diff --git a/.agents/skills/auto-qa/SKILL.md b/.agents/skills/auto-qa/SKILL.md index 80e0fbfda1a3..3b84f04d909a 100644 --- a/.agents/skills/auto-qa/SKILL.md +++ b/.agents/skills/auto-qa/SKILL.md @@ -1,12 +1,18 @@ --- name: auto-qa -description: "Continuously audit, live-test, and stress-test the current OpenClaw codebase across at least ten independently scoped subsystem lanes; reproduce and deduplicate genuine bugs, verify focused fixes, maintain an evidence-backed report, and prepare or land pull requests according to maintainer authorization and risk. Use for OpenClaw-wide autonomous QA, live-provider campaigns, gateway, CLI, web, plugin, native-app, scenario, packaging, and extended soak testing." +description: "Continuously audit, live-test, and stress-test the current OpenClaw codebase across at least ten independently scoped subsystem lanes; default to 100 independently verified, landed root-cause fixes, maintain an evidence-backed report, and announce each merged pull request immediately. Use for OpenClaw-wide autonomous QA, live-provider campaigns, gateway, CLI, web, plugin, native-app, scenario, packaging, and extended soak testing." --- # OpenClaw Auto QA Run a continuous, current-`main` OpenClaw product campaign. Treat a reviewer finding as a hypothesis, a passing test as evidence only for its actual head, and a merge as complete only when the canonical repository confirms it. Always prefer a clean, appropriately scoped root-cause refactor over a quick fix or smaller diff. Repair the actual root cause in its canonical owner; a patch is not acceptable when it leaves sibling paths, lifecycle invariants, or the defective abstraction intact. +## Set the campaign goal + +- Default to **100 distinct, independently verified, actually merged root-cause fixes** unless the operator explicitly specifies a different target. Count broken invariants, not pull requests, symptoms, proposed patches, skill updates, or merely passing tests. +- When the operator changes the target during an active campaign, update the existing report, numbered ledger, progress denominator, and live status in place. Preserve every previously verified landing; never reset the investigation or claim that an unfinished target has been reached. +- Exclude security-sensitive, persistent-state, protocol, public-contract, or otherwise review-required work from the accepted count until the appropriate owner explicitly authorizes and verifies its landing. + ## Prefer clean refactors over quick fixes - Identify the broken ownership boundary, abstraction, state transition, or dependency contract before choosing an implementation. Compare the canonical owner, callers, callees, and sibling paths; prefer the design that makes their shared invariant obvious and reliable. @@ -62,7 +68,7 @@ Read [references/live-proof-routing.md](references/live-proof-routing.md) before 6. Run a fresh `$autoreview` on the complete final refactor. Require the reviewer to compare owner boundaries and sibling implementations, confirm this is the best clean root-cause solution, and reject quick-fix residue even when tests pass. Resolve actionable findings; rerun review after any production, test, or head change. Personally read the latest ClawSweeper review, satisfy each applicable rank-up move with real evidence, and update the existing PR body before landing. 7. Check existing open PRs, current author counts, and the actual repository automation before publishing. Read both the current labeler and response policy; verify the authenticated author association, repository permission, account type, automation branch prefix, and actual override label. Apply only exemptions proved by that current policy, including eligible owners, maintainers, collaborators, bots or apps, approved automation branches, and explicit overrides. Never infer capacity from a truncated list or assume that one privileged role represents every exemption. Reuse and repair an existing candidate PR for the same cause. When a real cap applies, hold reviewed worktrees and finish or land existing verified work first. 8. Create a focused PR with the repository's actual template, canonical cause, user impact, frozen head, completed proof, and risk. Use only the current repo-native `scripts/pr` review, artifact, prepare, and merge workflow for authorized main landing. -9. Autonomously merge only when the user authorized it **and** the canonical root-cause refactor is individually reproduced, low-risk, independently reviewed, current-main-compatible, and has green required exact-head proof. Evaluate risk by ownership and behavioral impact, not by whether the diff is the smallest possible. Verify the resulting canonical merge SHA before incrementing the ledger. +9. Autonomously merge only when the user authorized it **and** the canonical root-cause refactor is individually reproduced, low-risk, independently reviewed, current-main-compatible, and has green required exact-head proof. Evaluate risk by ownership and behavioral impact, not by whether the diff is the smallest possible. Verify the resulting canonical merge SHA before incrementing the ledger. Immediately report each verified landing to the operator with its pull-request URL and one or two concise sentences describing the actual root causes fixed; do not batch, defer, or announce unmerged pull requests as landed. Prepare but **do not autonomously merge** security or authentication changes; SQLite integrity, migration, schema, or persistent-state changes; public configuration or plugin SDK compatibility changes; protocol changes; architectural or product decisions; broad high-impact refactors; uncertain diagnoses; or a candidate with pending, skipped, stale, conflicting, or failing proof. A bounded low-risk root-cause refactor may be autonomously landable when explicitly authorized; sensitive or uncertain impact is not. Mark higher-risk changes **user review required** and keep them outside the accepted-bug count. diff --git a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md index 9ce683ef9957..6f1b9a595781 100644 --- a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md +++ b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md @@ -1,6 +1,6 @@ --- name: openclaw-autonomous-issue-sweep -description: "Orchestrate 64 autonomous OpenClaw issue workers newest-to-oldest; find existing PRs, deeply investigate bugs, simplify or refactor, live-test, independently review, land verified fixes, close already-fixed issues, and add only meaningful new evidence." +description: "Orchestrate 64 autonomous OpenClaw issue workers newest-to-oldest with isolated issue worktrees and resource-bounded parallelism; investigate bugs, simplify or refactor, review, land verified fixes, close already-fixed issues, and add meaningful evidence." --- # OpenClaw Autonomous Issue Sweep @@ -17,9 +17,15 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. - Use full-history forks so every subagent inherits the orchestrator's model and **xhigh reasoning effort**. Never print, record, or disclose model identifiers; redact subprocess banners and diagnostics before reporting. -- Treat a request to run this workflow as authority to review, fix, refactor, - commit, push, create/update PRs, land eligible changes, comment, and close - issues individually. Do not ask for routine confirmation again. +- Begin every full-history child assignment with its explicit role and agent + identity, require inherited **xhigh reasoning effort**, and forbid + `create_goal`, visualizations, `spawn_agent`, or nested agents. Children + return evidence to the orchestrator; never downgrade their model or effort. +- Treat a request to run this workflow as authority to create lightweight, + issue-scoped isolated Git worktrees and `codex/issue-` branches, review, + fix, refactor, commit, push, create/update PRs, land eligible changes, + comment, and close issues individually. Do not ask for separate worktree or + routine-operation confirmation again. - Never treat sweep authority as permission to publish releases, bump protocol or SQLite schema versions, weaken security, break shipped compatibility, change another owner's protected product surface, or execute untrusted code @@ -36,30 +42,53 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. ## Coordinate 64 workers safely 1. Assign one subagent to maintain the live open-issue queue in descending - `createdAt` order, one to coordinate landing/proof capacity, and the rest to - issue investigations. Coordinator agents also investigate when idle. + `createdAt` order, one to coordinate landing/proof capacity, and no more + than **3** to live issue closures or other GitHub mutations. Assign the + remaining slots to issue investigations; idle coordinators also investigate. 2. Claim issues from the newest unclaimed end only; replenish workers as they finish. Parallel completions may arrive out of order, but never knowingly start an older unclaimed issue ahead of a newer available issue. 3. Deduplicate by canonical root cause, not merely by issue number. Let one owner fix a shared defect and link related issues/PRs to that outcome. -4. Freeze the reviewed source SHA for each wave. Designate a single fetch owner; - pause shared-ref refreshes while repo-native PR prepare/merge runs. -5. Never switch a shared checkout branch or edit it while sibling agents use it. - Use an existing agent-owned checkout, a repo-native isolated PR worktree, or - an explicitly user-authorized new worktree. Otherwise serialize write - access; parallel read-only investigations may continue. -6. Sample checkout/temp-volume free disk, CPU/load, memory pressure, process - count, operator-gateway health, actual worker count, and Octopool capacity - before each wave and periodically thereafter. Throttle expensive work for - sustained pressure or low disk; never kill unrelated operator processes. -7. Serialize merge operations and each Testbox lease. A lease has one owner and - one active command; never reclaim, sync, or change its head during a run. +4. Freeze the reviewed source SHA for each wave. Serialize only shared Git/ref + mutations: fetches, branch/ref changes, `git worktree add`/remove, PR + preparation and merges, and main-targeted pushes. Give each mutation a brief + coordinator-owned exclusive slot; do not hold it across coding, proof, + reviews, remote waits, or other independent issue work. +5. Give every independent root-cause fix its own isolated, issue-scoped + lightweight worktree and `codex/issue-` branch. Create it from the + frozen SHA, for example: + + ```bash + git worktree add -b "codex/issue-$issue_id" \ + "$campaign_worktrees/issue-$issue_id" "$frozen_main_sha" + ``` + + Reuse a repo-native isolated PR worktree when repairing an existing PR; + duplicate issues sharing one root cause share its single owner/worktree. + Share Git objects; do not clone the repository or install dependencies per + worktree merely for isolation. Never edit, switch, reset, or otherwise + mutate the shared checkout while sibling workers are active. Once isolated + worktrees exist, independent issue owners edit, inspect, and verify in + parallel within their own checkout. + +6. Keep all **64** inherited high-effort agents available, but distinguish idle + agents from active local tool users. Start with bounded waves of **4–8** + concurrently active code/test workers and continuously reduce or expand that + limit according to usable CPU/load, memory/swap pressure, checkout and temp + free disk, process count, operator-gateway health, and remote-pool capacity. + Reserve capacity for the operator; count heavyweight proof proportionally, + stop admitting new commands under sustained pressure, and resume in small + waves after recovery. Never kill unrelated operator processes. +7. Serialize merges and each Testbox lease, not independent worktree edits. A + lease has one owner and one active command; never reclaim, sync, or change + its head during a run. 8. Respect GitHub rate limits, active assignees, repository ownership, and existing contributor work. Do not auto-assign broad-discovery candidates. 9. Replace finished workers while the queue remains. Record actual active, - completed, failed, fixed, landed, closed, commented, and skipped counts; - never report launched or finished workers as still running. + parked, completed, failed, fixed, landed, verified-closed, queued-for-close, + commented, and skipped counts. Persist that campaign checkpoint for resumed + workers; never report launched, parked, or finished workers as still running. ## Conserve GitHub capacity and host resources @@ -79,10 +108,17 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. merge decisions, or a stale/contradictory cached result. Rate-limit and deduplicate worker requests instead of having 64 agents independently fetch the same issue, PR, author profile, or CI rollup. -- Keep disk, load, memory pressure, active lease IDs, provider trust class, - checkout ownership, and pool capacity in the orchestration ledger. Slow new - assignments, serialize builds/tests, clean only campaign-owned artifacts, - and offload heavy proof before resource pressure threatens the host. +- Keep disk, CPU/load, memory pressure, active lease IDs, provider trust class, + issue-worktree ownership, active local tool count, frozen heads, and pool + capacity in the orchestration ledger. Dynamically cap concurrent code/test + workers instead of serializing every independent fix. Pause or interrupt only + campaign-owned work under host pressure, preserve each issue's claim and + isolated checkout, then resume from that recorded state when capacity returns. + Offload heavy proof before resource pressure threatens the host. +- Worktree checkout and dependency use must respect free-disk headroom. Reuse + shared Git objects and existing trusted dependency installs where safe; route + dependency-missing or heavyweight proof to the selected remote box instead + of multiplying local installs across issue checkouts. - The parent may prewarm a trusted Crabbox/Testbox lease when a concrete heavy proof is imminent, then hand its verified lease ID and checkout ownership to one subagent at a time. Avoid speculative fleets, respect path-scoped lease @@ -154,6 +190,74 @@ Choose outcomes in this order: - Do not edit `CHANGELOG.md`; capture user impact, issue/PR references, and human credit in the PR body or commit message. +## Hard issue-closure gate + +An issue stays open unless every step below passes. Similar wording, adjacent +tests, merged PR dates, contributor suggestions, and confident review summaries +are not closure proof. + +1. Write down the reporter's exact **primary symptom**, desired user-visible + outcome, every separately affected surface, reported version/build SHA, and + all proposed alternatives. An optional mitigation or diagnostic suggestion + does not replace the reported primary outcome. +2. Personally trace both shipped and current behavior end to end: entry point, + canonical owner, caller, callee, dependency contract, sibling surfaces, and + existing tests. Reproduce the exact reported failure on the affected build + and prove the same user action succeeds on current `main`. Use a runnable + product or boundary-level regression; a nearby unit test, revised error text, + or an unexecuted source inspection is insufficient. +3. Prove Git ancestry rather than inferring it from dates: + + ```bash + git merge-base --is-ancestor "$fix_sha" "$current_main_sha" + git merge-base --is-ancestor "$fix_sha" "$reported_build_or_tag_sha" + git tag --contains "$fix_sha" + ``` + + The fix must be an ancestor of current `main`. Compare it against **each** + affected exact build/tag, account for diverged release branches, and identify + the first containing release when known. A merge before a release date does + not prove inclusion in that release. If the fix was already in an affected + build, assume the report still reproduces until a later causal fix is proved. + +4. Classify the candidate honestly: root-cause repair, mitigation, diagnostic + improvement, unsupported contract, workaround, or product decision. Never + close because a suggested fallback landed if the primary action still fails, + any reported surface remains broken, an owner hold exists, or documented + behavior requires an unresolved maintainer/security/product decision. +5. Require a **different, independent subagent with inherited xhigh reasoning** + to challenge the investigator's closure packet. The challenger personally + verifies the primary outcome, every affected surface, runtime owner and + contract, release ancestry, and before/after proof. The investigator cannot + self-approve; only a separate authorized closure coordinator may grant the + mutation after both reviewers agree. Any disagreement means **leave open**. +6. Immediately recheck live GitHub state, labels/owner holds, current `main`, + and exact proof. Do not close on stale state, an incomplete source map, an + indirect main-only test, changed wording without changed behavior, or any + unresolved facet. In **one sentence**, the closure comment must state the + exact fixed behavior, fix SHA/PR, first containing version when known, and + before/after evidence. +7. If a closure is challenged or an incorrectly closed issue is reopened, + **pause all closure mutations**. Audit earlier closures, correct the public + record, reopen proven mistakes, and resume only after explicit root + authorization. Continue safe investigation and verified code-fix work. + +Required evidence map: + +```text +Primary symptom -> expected outcome -> every reported surface -> affected build/tag +Entry -> caller -> canonical owner -> callee -> dependency -> sibling -> boundary proof +Fix SHA -> current-main ancestry -> each affected-build ancestry -> containing release +Affected-build failure -> current-main success -> independent challenge -> coordinator grant +``` + +Reject example: a remote command fails because its explicit working directory +does not exist on the target host. A merged change that only replaces a vague +spawn error with an accurate invalid-directory diagnostic is useful, but the +command still fails. If the primary expected outcome is successful execution, +leave the issue open; changing that explicit-directory contract may need an +owner decision. + ## Verify behavior and obtain two independent reviews For every non-trivial production change: @@ -222,9 +326,9 @@ moves:` item with real evidence or an explicit reason for skipping it. - Keep owner/security/auth/config/public-SDK/protocol/persistent-state/product decisions outside autonomous landing when the relevant guide requires owner judgment. Continue with the next issue instead of blocking the whole sweep. -- Close a fixed issue only after live rechecking its open state and matching - the original symptoms to current-main proof. Cite the merged PR/commit and - ask the reporter to reopen if it still reproduces on the current version. +- Close a fixed issue only after the complete **Hard issue-closure gate**, + independent challenger sign-off, coordinator grant, and fresh live recheck. + Cite the exact causal PR/commit and first containing release when known. - Never close merely because a repro is difficult, the report is inconvenient, the behavior might be intentional, or the PR is stale. Product-decision and won't-implement closures require maintainer judgment. @@ -235,6 +339,10 @@ moves:` item with real evidence or an explicit reason for skipping it. - Recheck live state immediately before every mutation; avoid redundant, speculative, noisy, or duplicate comments. Handle closures individually and follow repository limits on bulk operations. +- After verifying the canonical landed SHA and preserving contributor credit, + remove only that campaign-owned isolated worktree during a brief serialized + Git mutation slot. Delete its campaign-owned branch only when no unlanded + work depends on it; never prune unrelated worktrees, refs, or user files. ## Parent-thread reporting diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index f533c7dde338..85acf3351cad 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -154,8 +154,8 @@ jobs: deep_profile: "false" live: "false" managed_service: "true" - include_filters: "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:bundled-runtime-deps,scenario:agent-cold-warm-message" - expected_release_entries: "fresh-install:fresh,fresh-install:onboarded-user,bundled-runtime-deps:missing-plugin-index,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins" + include_filters: "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:agent-cold-warm-message" + expected_release_entries: "fresh-install:fresh,fresh-install:onboarded-user,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins" - lane: mock-deep-profile title: Kova mock provider deep profile auth: mock diff --git a/.github/workflows/qa-live-transports-convex.yml b/.github/workflows/qa-live-transports-convex.yml index ab657e69b296..943276d8d0e5 100644 --- a/.github/workflows/qa-live-transports-convex.yml +++ b/.github/workflows/qa-live-transports-convex.yml @@ -480,7 +480,47 @@ jobs: node-version: ${{ env.NODE_VERSION }} install-bun: "true" + - name: Resolve Buzz QA runner + id: resolve_buzz + env: + SELECTED_REVISION: ${{ needs.validate_selected_ref.outputs.selected_revision }} + shell: bash + run: | + set -euo pipefail + + output_dir=".artifacts/qa-e2e/buzz-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "${output_dir}" + echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT" + + OUTPUT_DIR="${output_dir}" node --input-type=module <<'NODE' + import fs from "node:fs"; + + const manifestPath = "extensions/buzz/openclaw.plugin.json"; + const manifest = fs.existsSync(manifestPath) + ? JSON.parse(fs.readFileSync(manifestPath, "utf8")) + : {}; + const available = + Array.isArray(manifest.qaRunners) && + manifest.qaRunners.some((runner) => runner?.commandName === "buzz"); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `available=${available}\n`); + if (!available) { + fs.writeFileSync( + `${process.env.OUTPUT_DIR}/skipped.json`, + `${JSON.stringify( + { + status: "skipped", + reason: "selected ref does not declare the Buzz QA runner", + revision: process.env.SELECTED_REVISION, + }, + null, + 2, + )}\n`, + ); + } + NODE + - name: Validate required Buzz QA credential env + if: steps.resolve_buzz.outputs.available == 'true' env: OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }} OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} @@ -496,11 +536,13 @@ jobs: done - name: Build private QA runtime + if: steps.resolve_buzz.outputs.available == 'true' env: NODE_OPTIONS: --max-old-space-size=12288 run: pnpm build - name: Run Buzz live lane + if: steps.resolve_buzz.outputs.available == 'true' id: run_lane shell: bash env: @@ -513,7 +555,7 @@ jobs: run: | set -euo pipefail - output_dir=".artifacts/qa-e2e/buzz-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + output_dir="${{ steps.resolve_buzz.outputs.output_dir }}" scenario_args=() if [[ -n "${INPUT_SCENARIO// }" ]]; then @@ -541,7 +583,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ inputs.expected_sha != '' && format('release-qa-live-buzz-{0}-{1}', inputs.expected_sha, github.run_attempt) || format('qa-live-buzz-{0}-{1}', github.run_id, github.run_attempt) }} - path: ${{ steps.run_lane.outputs.output_dir }} + path: ${{ steps.resolve_buzz.outputs.output_dir }} retention-days: 14 if-no-files-found: error diff --git a/.gitignore b/.gitignore index b6a2496a3a4e..4c28047aff32 100644 --- a/.gitignore +++ b/.gitignore @@ -131,99 +131,8 @@ USER.md # local QA evidence mirrors; CI publishes canonical Mantis files as Actions artifacts /mantis/ -# Local project-agent skill installs. Only repo-owned skills are visible by -# default; keep every tracked repo skill allowlisted for Git-aware sync tools. -.agents/skills/* -!.agents/skills/agent-transcript/ -!.agents/skills/agent-transcript/** -!.agents/skills/auto-qa/ -!.agents/skills/auto-qa/** -!.agents/skills/autoreview/ -!.agents/skills/autoreview/** -!.agents/skills/channel-message-flows/ -!.agents/skills/channel-message-flows/** -!.agents/skills/claw-score/ -!.agents/skills/claw-score/** -!.agents/skills/clawdtributor/ -!.agents/skills/clawdtributor/** -!.agents/skills/clawsweeper/ -!.agents/skills/clawsweeper/** -!.agents/skills/control-ui-e2e/ -!.agents/skills/control-ui-e2e/** -!.agents/skills/crabbox/ -!.agents/skills/crabbox/** -!.agents/skills/discord-clawd/ -!.agents/skills/discord-clawd/** -!.agents/skills/discord-user-post/ -!.agents/skills/discord-user-post/** -!.agents/skills/discrawl/ -!.agents/skills/discrawl/** -!.agents/skills/gitcrawl/ -!.agents/skills/gitcrawl/** -!.agents/skills/graincrawl/ -!.agents/skills/graincrawl/** -!.agents/skills/notcrawl/ -!.agents/skills/notcrawl/** -!.agents/skills/openclaw-changelog-update/ -!.agents/skills/openclaw-changelog-update/** -!.agents/skills/openclaw-ci-limits/ -!.agents/skills/openclaw-ci-limits/** -!.agents/skills/openclaw-debugging/ -!.agents/skills/openclaw-debugging/** -!.agents/skills/openclaw-docker-e2e-authoring/ -!.agents/skills/openclaw-docker-e2e-authoring/** -!.agents/skills/openclaw-ghsa-maintainer/ -!.agents/skills/openclaw-ghsa-maintainer/** -!.agents/skills/openclaw-landable-bug-sweep/ -!.agents/skills/openclaw-landable-bug-sweep/** -!.agents/skills/openclaw-live-updater/ -!.agents/skills/openclaw-live-updater/** -!.agents/skills/openclaw-parallels-smoke/ -!.agents/skills/openclaw-parallels-smoke/** -!.agents/skills/openclaw-pr-maintainer/ -!.agents/skills/openclaw-pr-maintainer/** -!.agents/skills/prototype-openclaw-tui/ -!.agents/skills/prototype-openclaw-tui/** -!.agents/skills/openclaw-qa-testing/ -!.agents/skills/openclaw-qa-testing/** -!.agents/skills/openclaw-refactor-docs/ -!.agents/skills/openclaw-refactor-docs/** -!.agents/skills/openclaw-secret-scanning-maintainer/ -!.agents/skills/openclaw-secret-scanning-maintainer/** -!.agents/skills/openclaw-small-bugfix-sweep/ -!.agents/skills/openclaw-small-bugfix-sweep/** -!.agents/skills/openclaw-test-heap-leaks/ -!.agents/skills/openclaw-test-heap-leaks/** -!.agents/skills/openclaw-test-performance/ -!.agents/skills/openclaw-test-performance/** -!.agents/skills/openclaw-testing/ -!.agents/skills/openclaw-testing/** -!.agents/skills/parallels-discord-roundtrip/ -!.agents/skills/parallels-discord-roundtrip/** -!.agents/skills/release-openclaw-announcement/ -!.agents/skills/release-openclaw-announcement/** -!.agents/skills/release-openclaw-ci/ -!.agents/skills/release-openclaw-ci/** -!.agents/skills/release-openclaw-mac/ -!.agents/skills/release-openclaw-mac/** -!.agents/skills/release-openclaw-maintainer/ -!.agents/skills/release-openclaw-maintainer/** -!.agents/skills/release-openclaw-nightly/ -!.agents/skills/release-openclaw-nightly/** -!.agents/skills/release-openclaw-plugin-testing/ -!.agents/skills/release-openclaw-plugin-testing/** -!.agents/skills/security-triage/ -!.agents/skills/security-triage/** -!.agents/skills/slacrawl/ -!.agents/skills/slacrawl/** -!.agents/skills/tag-duplicate-prs-issues/ -!.agents/skills/tag-duplicate-prs-issues/** -!.agents/skills/technical-documentation/ -!.agents/skills/technical-documentation/** -!.agents/skills/telegram-crabbox-e2e-proof/ -!.agents/skills/telegram-crabbox-e2e-proof/** -!.agents/skills/verify-release/ -!.agents/skills/verify-release/** +# Keep repository skills visible to Git-aware syncs; local-only skill ignores +# belong in .git/info/exclude. .agents/skills/**/*.orig .agents/skills/**/__pycache__/ .agents/skills/**/*.py[cod] diff --git a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt index 61e36d851e46..e5010b8e2a08 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt @@ -56,7 +56,8 @@ class WearRealtimeChannelRegistryTest { val secondClaim = checkNotNull(registry.claim("watch-a", "attempt-b")) withTimeout(2_000L) { - while (transport.closeCount(first) != 1 || synchronized(stoppedOwners) { stoppedOwners.size } != 1) { + transport.awaitClosed(first) + while (synchronized(stoppedOwners) { stoppedOwners.size } != 1) { kotlinx.coroutines.yield() } } @@ -294,7 +295,8 @@ class WearRealtimeChannelRegistryTest { registry.accept(reconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) transport.awaitOpened(reconnect) withTimeout(1_000L) { - while (transport.closeCount(active) != 1 || !transport.hasStartedReading(reconnect)) yield() + transport.awaitClosed(active) + while (!transport.hasStartedReading(reconnect)) yield() } val repeated = checkNotNull(registry.claim("watch-a", "attempt-a")) @@ -337,7 +339,8 @@ class WearRealtimeChannelRegistryTest { releaseWrite.countDown() withTimeout(1_000L) { send.await() } withTimeout(1_000L) { - while (transport.closeCount(active) != 1 || !transport.hasStartedReading(reconnect)) yield() + transport.awaitClosed(active) + while (!transport.hasStartedReading(reconnect)) yield() } assertTrue(registry.isCurrent(owner)) registry.close(owner) @@ -435,9 +438,7 @@ class WearRealtimeChannelRegistryTest { assertEquals(0, transport.closeCount(channel)) scope.cancel() releaseClose.complete(Unit) - withTimeout(1_000L) { - while (transport.closeCount(channel) != 1) yield() - } + withTimeout(1_000L) { transport.awaitClosed(channel) } } finally { releaseClose.complete(Unit) scope.cancel() @@ -483,9 +484,7 @@ class WearRealtimeChannelRegistryTest { val replacementOwner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner assertEquals(listOf(activeOwner), synchronized(stoppedOwners) { stoppedOwners.toList() }) assertTrue(registry.isCurrent(replacementOwner)) - withTimeout(1_000L) { - while (transport.closeCount(staleReconnect) != 1) yield() - } + withTimeout(1_000L) { transport.awaitClosed(staleReconnect) } registry.close(replacementOwner) } finally { releaseStop.complete(Unit) @@ -553,9 +552,7 @@ class WearRealtimeChannelRegistryTest { try { registry.accept(expired, appendAudio = { _, _ -> }, stopTalk = {}) transport.awaitOpened(expired) - withTimeout(1_000L) { - while (transport.closeCount(expired) != 1) yield() - } + withTimeout(1_000L) { transport.awaitClosed(expired) } val claim = async { registry.claim("watch-a", "attempt-a") } delay(100L) @@ -655,6 +652,7 @@ class WearRealtimeChannelRegistryTest { val releaseStop = CompletableDeferred() val active = FakeChannel("watch-a", "channel-a", "attempt-a") val reserved = FakeChannel("watch-a", "channel-b-reserved", "attempt-b") + val supersededReconnect = FakeChannel("watch-a", "channel-b-superseded", "attempt-b") val reconnect = FakeChannel("watch-a", "channel-b-reconnect", "attempt-b") val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> if (owner.attemptId == "attempt-a") { @@ -672,13 +670,18 @@ class WearRealtimeChannelRegistryTest { val replacementClaim = async { registry.claim("watch-a", "attempt-b") } withTimeout(1_000L) { stopStarted.await() } + registry.accept(supersededReconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(supersededReconnect) registry.accept(reconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) transport.awaitOpened(reconnect) + // The older reconnect cannot close until the newest channel is published in the registry. + withTimeout(1_000L) { transport.awaitClosed(supersededReconnect) } releaseStop.complete(Unit) val owner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner - assertEquals(3L, owner.channelGeneration) + assertEquals(4L, owner.channelGeneration) assertEquals(1, transport.closeCount(reserved)) + assertEquals(1, transport.closeCount(supersededReconnect)) assertEquals(0, transport.closeCount(reconnect)) assertTrue(registry.isCurrent(owner)) registry.close(owner) @@ -728,13 +731,9 @@ class WearRealtimeChannelRegistryTest { val owner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner withTimeout(1_000L) { - while ( - transport.closeCount(reserved) != 1 || - transport.closeCount(firstReconnect) != 1 || - !transport.hasStartedReading(latestReconnect) - ) { - yield() - } + transport.awaitClosed(reserved) + transport.awaitClosed(firstReconnect) + while (!transport.hasStartedReading(latestReconnect)) yield() } assertEquals("attempt-b", owner.attemptId) assertEquals(0, transport.closeCount(latestReconnect)) @@ -781,9 +780,7 @@ class WearRealtimeChannelRegistryTest { val cancelledClaim = async { registry.claim("watch-a", "attempt-b") } withTimeout(1_000L) { stopStarted.await() } cancelledClaim.cancelAndJoin() - withTimeout(1_000L) { - while (transport.closeCount(cancelled) != 1) yield() - } + withTimeout(1_000L) { transport.awaitClosed(cancelled) } registry.accept(retry, appendAudio = { _, _ -> }, stopTalk = stopTalk) transport.awaitOpened(retry) @@ -836,9 +833,7 @@ class WearRealtimeChannelRegistryTest { releaseFirst.complete(Unit) transport.awaitOpened(first) - withTimeout(1_000L) { - while (transport.closeCount(first) != 1) yield() - } + withTimeout(1_000L) { transport.awaitClosed(first) } val repeated = checkNotNull(registry.claim("watch-a", "attempt-b")) assertFalse(repeated.newlyAcquired) @@ -926,7 +921,7 @@ class WearRealtimeChannelRegistryTest { transport.awaitOpened(first) registry.accept(sameNodeExcess, appendAudio = { _, _ -> }, stopTalk = {}) - transport.awaitCloseStarted(sameNodeExcess) + transport.awaitClosed(sameNodeExcess) assertFalse(transport.wasOpened(sameNodeExcess)) assertEquals(1, transport.closeCount(sameNodeExcess)) @@ -934,7 +929,7 @@ class WearRealtimeChannelRegistryTest { transport.awaitOpened(secondNode) registry.accept(globalExcess, appendAudio = { _, _ -> }, stopTalk = {}) - transport.awaitCloseStarted(globalExcess) + transport.awaitClosed(globalExcess) assertFalse(transport.wasOpened(globalExcess)) assertEquals(1, transport.closeCount(globalExcess)) } finally { @@ -948,6 +943,7 @@ private class FakeChannelTransport : WearRealtimeChannelTransport { private val openGates = ConcurrentHashMap>() private val closeGates = ConcurrentHashMap>() private val closeStarted = ConcurrentHashMap>() + private val closeCompleted = ConcurrentHashMap>() private val closeCounts = ConcurrentHashMap() private val writeGates = ConcurrentHashMap() private val writeStarted = ConcurrentHashMap>() @@ -977,6 +973,7 @@ private class FakeChannelTransport : WearRealtimeChannelTransport { resources?.input?.close() resources?.output?.close() closeCounts.compute(channel) { _, count -> (count ?: 0) + 1 } + closeCompleted.computeIfAbsent(channel) { CompletableDeferred() }.complete(Unit) } suspend fun awaitOpened(channel: ChannelClient.Channel) { @@ -999,6 +996,10 @@ private class FakeChannelTransport : WearRealtimeChannelTransport { closeStarted.computeIfAbsent(channel) { CompletableDeferred() }.await() } + suspend fun awaitClosed(channel: ChannelClient.Channel) { + closeCompleted.computeIfAbsent(channel) { CompletableDeferred() }.await() + } + fun holdWrite(channel: ChannelClient.Channel): CountDownLatch = CountDownLatch(1).also { gate -> writeGates[channel] = gate diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 5b7fc1d99b87..025a6e632b97 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -11956,6 +11956,7 @@ public struct ModelChoice: Codable, Sendable { public let available: Bool? public let contextwindow: Int? public let reasoning: Bool? + public let supportstools: Bool? public let agentruntime: [String: AnyCodable]? public let apikeysupported: Bool? public let input: [AnyCodable]? @@ -11968,6 +11969,7 @@ public struct ModelChoice: Codable, Sendable { available: Bool? = nil, contextwindow: Int? = nil, reasoning: Bool? = nil, + supportstools: Bool? = nil, agentruntime: [String: AnyCodable]? = nil, apikeysupported: Bool? = nil, input: [AnyCodable]? = nil) @@ -11979,6 +11981,7 @@ public struct ModelChoice: Codable, Sendable { self.available = available self.contextwindow = contextwindow self.reasoning = reasoning + self.supportstools = supportstools self.agentruntime = agentruntime self.apikeysupported = apikeysupported self.input = input @@ -11992,6 +11995,7 @@ public struct ModelChoice: Codable, Sendable { case available case contextwindow = "contextWindow" case reasoning + case supportstools = "supportsTools" case agentruntime = "agentRuntime" case apikeysupported = "apiKeySupported" case input diff --git a/config/env-var-count-budget.txt b/config/env-var-count-budget.txt index e2c3614de3ce..509777186d26 100644 --- a/config/env-var-count-budget.txt +++ b/config/env-var-count-budget.txt @@ -1,3 +1,3 @@ # Distinct OPENCLAW_* names in production source under src, packages, and extensions. # Ratchet: lower this number when cleanup removes names; never raise it. -518 +517 diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index 07a5f7c31f7f..0b8dac707bac 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -1259,8 +1259,7 @@ Preferred generic helpers for new scenarios: - `waitForChannelReady` - `injectInboundMessage` - `injectOutboundMessage` -- `waitForTransportOutboundMessage` -- `waitForChannelOutboundMessage` +- `waitForOutboundMessage` - `waitForNoTransportOutbound` - `getTransportSnapshot` - `readTransportMessage` @@ -1269,10 +1268,10 @@ Preferred generic helpers for new scenarios: - `resetTransport` Compatibility aliases remain available for existing scenarios - -`waitForQaChannelReady`, `waitForOutboundMessage`, `waitForNoOutbound`, -`formatConversationTranscript`, `resetBus` - but new scenario authoring -should use the generic names. The aliases exist to avoid a flag-day -migration, not as the model going forward. +`waitForQaChannelReady`, `waitForNoOutbound`, `formatConversationTranscript`, +and `resetBus` - but new scenario authoring should use the generic names. +Use the canonical `waitForOutboundMessage` for outbound checks instead of +adding transport- or channel-specific outbound wait aliases. ## Reporting diff --git a/docs/install/updating.md b/docs/install/updating.md index 04d3996d5b4c..0b90a71f53b2 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -108,6 +108,12 @@ for an efficient source-server update: it restores tracked build outputs that `main` (or rebases a local server branch onto `origin/main`), installs dependencies, builds clean, and restarts the gateway. +Generated output roots such as `dist`, `dist-runtime`, and package-local +`dist` directories must be real directories. Builds refuse symbolic-link roots +before reading or mutating their contents so cleanup cannot affect the link +target. Replace an output-root symlink with a real directory before updating or +building a source checkout. + ```bash ssh you@server 'cd /path/to/openclaw && scripts/update-gateway.sh' ``` diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index d79d99948bae..e8a1162c8435 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description. ## Core npm package -66 plugins +64 plugins - **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint. @@ -85,8 +85,6 @@ Each entry lists the package, distribution route, and description. - **[document-extract](/plugins/reference/document-extract)** (`@openclaw/document-extract-plugin`) - included in OpenClaw. Extract text and fallback page images from local document attachments. -- **[duckduckgo](/plugins/reference/duckduckgo)** (`@openclaw/duckduckgo-plugin`) - included in OpenClaw. Adds web search provider support. - - **[elevenlabs](/plugins/reference/elevenlabs)** (`@openclaw/elevenlabs-speech`) - included in OpenClaw. Adds media understanding provider support. Adds realtime transcription provider support. Adds text-to-speech provider support. - **[fal](/plugins/reference/fal)** (`@openclaw/fal-provider`) - included in OpenClaw. Adds fal model provider support to OpenClaw. @@ -171,8 +169,6 @@ Each entry lists the package, distribution route, and description. - **[volcengine](/plugins/reference/volcengine)** (`@openclaw/volcengine-provider`) - included in OpenClaw. Adds Volcengine, Volcengine Plan model provider support to OpenClaw. -- **[voyage](/plugins/reference/voyage)** (`@openclaw/voyage-provider`) - included in OpenClaw. Adds memory embedding provider support. - - **[vydra](/plugins/reference/vydra)** (`@openclaw/vydra-provider`) - included in OpenClaw. Adds Vydra model provider support to OpenClaw. - **[web-readability](/plugins/reference/web-readability)** (`@openclaw/web-readability-plugin`) - included in OpenClaw. Extract readable article content from local HTML web fetch responses. @@ -187,7 +183,7 @@ Each entry lists the package, distribution route, and description. ## Official external packages -79 plugins +81 plugins - **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management. @@ -233,6 +229,8 @@ Each entry lists the package, distribution route, and description. - **[discord](/plugins/reference/discord)** (`@openclaw/discord`) - npm; ClawHub. OpenClaw Discord channel plugin for channels, DMs, commands, and app events. +- **[duckduckgo](/plugins/reference/duckduckgo)** (`@openclaw/duckduckgo-plugin`) - npm; ClawHub: `clawhub:@openclaw/duckduckgo-plugin`. Adds web search provider support. + - **[exa](/plugins/reference/exa)** (`@openclaw/exa-plugin`) - npm; ClawHub: `clawhub:@openclaw/exa-plugin`. Adds web search provider support. - **[featherless](/plugins/reference/featherless)** (`@openclaw/featherless-provider`) - npm; ClawHub: `clawhub:@openclaw/featherless-provider`. OpenClaw Featherless AI provider plugin. @@ -337,6 +335,8 @@ Each entry lists the package, distribution route, and description. - **[voice-call](/plugins/reference/voice-call)** (`@openclaw/voice-call`) - npm; ClawHub. OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls. +- **[voyage](/plugins/reference/voyage)** (`@openclaw/voyage-provider`) - npm; ClawHub: `clawhub:@openclaw/voyage-provider`. Adds memory embedding provider support. + - **[whatsapp](/plugins/reference/whatsapp)** (`@openclaw/whatsapp`) - ClawHub: `clawhub:@openclaw/whatsapp`; npm. OpenClaw WhatsApp channel plugin for WhatsApp Web chats. - **[zai](/plugins/reference/zai)** (`@openclaw/zai-provider`) - npm; ClawHub: `clawhub:@openclaw/zai-provider`. Adds Z.AI model provider support to OpenClaw. diff --git a/docs/plugins/reference/duckduckgo.md b/docs/plugins/reference/duckduckgo.md index 43769cbc1106..a74ffe70265d 100644 --- a/docs/plugins/reference/duckduckgo.md +++ b/docs/plugins/reference/duckduckgo.md @@ -12,7 +12,7 @@ Adds web search provider support. ## Distribution - Package: `@openclaw/duckduckgo-plugin` -- Install route: included in OpenClaw +- Install route: npm; ClawHub: `clawhub:@openclaw/duckduckgo-plugin` ## Surface diff --git a/docs/plugins/reference/voyage.md b/docs/plugins/reference/voyage.md index c4952f1d67fd..c4220ee48600 100644 --- a/docs/plugins/reference/voyage.md +++ b/docs/plugins/reference/voyage.md @@ -12,7 +12,7 @@ Adds memory embedding provider support. ## Distribution - Package: `@openclaw/voyage-provider` -- Install route: included in OpenClaw +- Install route: npm; ClawHub: `clawhub:@openclaw/voyage-provider` ## Surface diff --git a/docs/tools/duckduckgo-search.md b/docs/tools/duckduckgo-search.md index 09a81a242614..35a1182f19af 100644 --- a/docs/tools/duckduckgo-search.md +++ b/docs/tools/duckduckgo-search.md @@ -18,6 +18,12 @@ OpenClaw supports DuckDuckGo as a **key-free** `web_search` provider. No API key DuckDuckGo is never auto-selected, since auto-detection only considers providers with usable credentials. Set it explicitly: + + ```bash + openclaw plugins install @openclaw/duckduckgo-plugin + openclaw gateway restart + ``` + ```bash openclaw configure --section web diff --git a/extensions/amazon-bedrock/stream.runtime.test.ts b/extensions/amazon-bedrock/stream.runtime.test.ts index 114bbd539c39..cf5499377ac8 100644 --- a/extensions/amazon-bedrock/stream.runtime.test.ts +++ b/extensions/amazon-bedrock/stream.runtime.test.ts @@ -123,6 +123,74 @@ describe("Bedrock inbound image base64", () => { }); describe("Bedrock tool-result replay", () => { + it("replays unsupported audio attachments as their canonical text placeholder", () => { + const messages = testing.convertMessages( + { + messages: [ + { + role: "toolResult", + toolCallId: "call_audio", + toolName: "listen", + content: [{ type: "audio", mimeType: "audio/wav", data: "YXVkaW8=" }], + isError: false, + }, + ], + } as never, + bedrockModel({ input: ["text", "image"] }), + "none", + ); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: ConversationRole.USER, + content: [ + { + toolResult: { + toolUseId: "call_audio", + content: [{ text: "(see attached audio)" }], + }, + }, + ], + }); + }); + + it("preserves valid text and image attachments alongside unsupported audio", () => { + const messages = testing.convertMessages( + { + messages: [ + { + role: "toolResult", + toolCallId: "call_media", + toolName: "inspect", + content: [ + { type: "audio", mimeType: "audio/wav", data: "YXVkaW8=" }, + { type: "text", text: "actual tool output" }, + { type: "image", mimeType: "image/png", data: "aW1hZ2U=" }, + ], + isError: false, + }, + ], + } as never, + bedrockModel({ input: ["text", "image"] }), + "none", + ); + + expect(messages[0]).toMatchObject({ + role: ConversationRole.USER, + content: [ + { + toolResult: { + toolUseId: "call_media", + content: [ + { text: "actual tool output" }, + { image: { format: "png", source: { bytes: expect.any(Uint8Array) } } }, + ], + }, + }, + ], + }); + }); + it("drops payload-less image husks from consecutive tool results", () => { const messages = testing.convertMessages( { @@ -335,6 +403,61 @@ describe("Bedrock profile endpoint resolution", () => { }); describe("Bedrock stop reasons", () => { + it.each([ + { + name: "text", + events: [ + { contentBlockDelta: { contentBlockIndex: 0, delta: { text: "truncated response" } } }, + { contentBlockStop: { contentBlockIndex: 0 } }, + ], + contentType: "text", + }, + { + name: "tool call", + events: [ + { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: "call_lookup", name: "lookup" } }, + }, + }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"query":"partial"}' } }, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + ], + contentType: "toolCall", + }, + ])( + "reports truncated $name streams without a terminal messageStop", + async ({ events, contentType }) => { + vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + stream: streamEvents([{ messageStart: { role: ConversationRole.ASSISTANT } }, ...events]), + } as never); + + const stream = streamBedrockForTest(bedrockModel({}), { + messages: [{ role: "user", content: "Hello", timestamp: 0 }], + } as never); + const eventTypes: string[] = []; + for await (const event of stream) { + eventTypes.push(event.type); + } + const result = await stream.result(); + + expect(eventTypes.at(-1)).toBe("error"); + expect(eventTypes).not.toContain("done"); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("Bedrock stream ended before messageStop"); + expect(result.content).toEqual([expect.objectContaining({ type: contentType })]); + expect(result.content[0]).not.toHaveProperty("index"); + expect(result.content[0]).not.toHaveProperty("partialJson"); + }, + ); + it.each([ BedrockStopReason.CONTENT_FILTERED, BedrockStopReason.GUARDRAIL_INTERVENED, diff --git a/extensions/amazon-bedrock/stream.runtime.ts b/extensions/amazon-bedrock/stream.runtime.ts index 2137af2981c9..aa12bbc0ed7f 100644 --- a/extensions/amazon-bedrock/stream.runtime.ts +++ b/extensions/amazon-bedrock/stream.runtime.ts @@ -332,7 +332,7 @@ const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = } } - if (refusalBuffer && !sawMessageStop) { + if (!sawMessageStop) { throw new Error("Bedrock stream ended before messageStop"); } if (options.signal?.aborted) { @@ -812,7 +812,7 @@ function createBedrockToolResult(message: ToolResultMessage): ContentBlock.ToolR content.push({ text: sanitizeSurrogates(block.text) }); continue; } - if (describeToolResultMediaPlaceholder([block])) { + if (block.type === "image" && describeToolResultMediaPlaceholder([block])) { content.push({ image: createImageBlock(block.mimeType, block.data) }); } } @@ -820,7 +820,10 @@ function createBedrockToolResult(message: ToolResultMessage): ContentBlock.ToolR return { toolResult: { toolUseId: message.toolCallId, - content: content.length > 0 ? content : [{ text: "(no output)" }], + content: + content.length > 0 + ? content + : [{ text: describeToolResultMediaPlaceholder(message.content) ?? "(no output)" }], status: message.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, }, }; diff --git a/extensions/anthropic-vertex/index.test.ts b/extensions/anthropic-vertex/index.test.ts index 56d97a821ee5..b124d77751fc 100644 --- a/extensions/anthropic-vertex/index.test.ts +++ b/extensions/anthropic-vertex/index.test.ts @@ -102,6 +102,19 @@ describe("anthropic-vertex provider plugin", () => { expect(result.provider.models[4]?.thinkingLevelMap).toEqual({ xhigh: null, max: "max" }); }); + it.each([ + { region: "global", baseUrl: "https://aiplatform.googleapis.com" }, + { region: "us", baseUrl: "https://aiplatform.us.rep.googleapis.com" }, + { region: "eu", baseUrl: "https://aiplatform.eu.rep.googleapis.com" }, + { region: "us-east5", baseUrl: "https://us-east5-aiplatform.googleapis.com" }, + ])("publishes the SDK endpoint for the $region location", ({ region, baseUrl }) => { + expect( + buildAnthropicVertexProvider({ + env: { GOOGLE_CLOUD_LOCATION: region }, + }).baseUrl, + ).toBe(baseUrl); + }); + it.each(["global", "us", "eu"])("publishes Opus 5 for the %s endpoint", (region) => { const provider = buildAnthropicVertexProvider({ env: { GOOGLE_CLOUD_LOCATION: region }, @@ -194,7 +207,7 @@ describe("anthropic-vertex provider plugin", () => { name: "Claude Sonnet 5", api: "anthropic-messages", provider: "anthropic-vertex", - baseUrl: "https://us-aiplatform.googleapis.com", + baseUrl: "https://aiplatform.us.rep.googleapis.com", reasoning: true, input: ["text", "image"], contextWindow: 1_000_000, @@ -235,7 +248,7 @@ describe("anthropic-vertex provider plugin", () => { name: "Claude Opus 5", api: "anthropic-messages", provider: "anthropic-vertex", - baseUrl: "https://us-aiplatform.googleapis.com", + baseUrl: "https://aiplatform.us.rep.googleapis.com", reasoning: false, input: ["text"], cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, diff --git a/extensions/anthropic-vertex/provider-catalog.ts b/extensions/anthropic-vertex/provider-catalog.ts index e83408662305..da9bf0e506b7 100644 --- a/extensions/anthropic-vertex/provider-catalog.ts +++ b/extensions/anthropic-vertex/provider-catalog.ts @@ -238,7 +238,9 @@ export function buildAnthropicVertexProvider(params?: { const baseUrl = normalizeLowercaseStringOrEmpty(region) === "global" ? "https://aiplatform.googleapis.com" - : `https://${region}-aiplatform.googleapis.com`; + : region === "us" || region === "eu" + ? `https://aiplatform.${region}.rep.googleapis.com` + : `https://${region}-aiplatform.googleapis.com`; return { baseUrl, diff --git a/extensions/anthropic-vertex/region.test.ts b/extensions/anthropic-vertex/region.test.ts index 49a2ced78f94..30b8686039f1 100644 --- a/extensions/anthropic-vertex/region.test.ts +++ b/extensions/anthropic-vertex/region.test.ts @@ -25,6 +25,12 @@ describe("anthropic vertex region helpers", () => { ).toBe("europe-west4"); }); + it.each(["us", "eu"])("parses the %s multi-region Vertex endpoint", (region) => { + expect( + resolveAnthropicVertexRegionFromBaseUrl(`https://aiplatform.${region}.rep.googleapis.com`), + ).toBe(region); + }); + it("treats the global Vertex endpoint as global", () => { expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe( "global", diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index 00a56299ae8e..2cd043fba24a 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -589,6 +589,24 @@ describe("createAnthropicVertexStreamFn", () => { }); describe("createAnthropicVertexStreamFnForModel", () => { + it.each(["us", "eu"])("preserves the %s multi-region SDK endpoint", (region) => { + const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFnForModel( + { baseUrl: `https://aiplatform.${region}.rep.googleapis.com` }, + { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + deps, + ); + + void streamFn(makeModel({ id: "claude-sonnet-5", maxTokens: 128_000 }), { messages: [] }, {}); + + expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ + googleAuth: googleAuthClient, + projectId: "vertex-project", + region, + baseURL: `https://aiplatform.${region}.rep.googleapis.com/v1`, + }); + }); + it("derives project and region from the model and env", () => { const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFnForModel( diff --git a/extensions/browser/src/cli/browser-cli-manage.test.ts b/extensions/browser/src/cli/browser-cli-manage.test.ts index f6f8f5eaba7f..c3867f5d9438 100644 --- a/extensions/browser/src/cli/browser-cli-manage.test.ts +++ b/extensions/browser/src/cli/browser-cli-manage.test.ts @@ -1,5 +1,5 @@ // Browser tests cover browser cli manage plugin behavior. -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createBrowserManageProgram, getBrowserManageCallBrowserRequestMock, @@ -15,10 +15,26 @@ function lastRuntimeLog(): string { return value; } +function parseSingleRuntimeJson(): unknown { + const logs = getBrowserCliRuntimeCapture().runtimeLogs; + expect(logs).toHaveLength(1); + return JSON.parse(logs[0] ?? ""); +} + describe("browser manage output", () => { + let previousExitCode: typeof process.exitCode; + beforeEach(() => { + previousExitCode = process.exitCode; + process.exitCode = undefined; getBrowserManageCallBrowserRequestMock().mockClear(); getBrowserCliRuntimeCapture().resetRuntimeCapture(); + getBrowserCliRuntime().exit.mockClear(); + getBrowserCliRuntime().writeJson.mockClear(); + }); + + afterEach(() => { + process.exitCode = previousExitCode; }); it("shows chrome-mcp transport for existing-session status without fake CDP fields", async () => { @@ -524,6 +540,72 @@ describe("browser manage output", () => { expect(output).toContain("OK gateway: browser control endpoint reachable"); expect(output).toContain("OK graphics: software"); expect(output).toContain("OK tabs: 1 visible, use tab reference t1"); + expect(getBrowserCliRuntime().writeJson).not.toHaveBeenCalled(); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("prints one complete JSON browser doctor failure before setting exit status", async () => { + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => { + if (req.path === "/") { + return { + enabled: false, + profile: "openclaw", + transport: "cdp", + running: false, + }; + } + if (req.path === "/profiles") { + return { profiles: [] }; + } + return {}; + }); + + const program = createBrowserManageProgram(); + await program.parseAsync(["browser", "--json", "doctor"], { from: "user" }); + + expect(parseSingleRuntimeJson()).toEqual( + expect.objectContaining({ + ok: false, + checks: expect.arrayContaining([ + expect.objectContaining({ name: "gateway", ok: true }), + expect.objectContaining({ name: "plugin", ok: false }), + ]), + }), + ); + expect(getBrowserCliRuntimeCapture().runtimeErrors).toEqual([]); + expect(getBrowserCliRuntime().writeJson).toHaveBeenCalledTimes(1); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("prints one JSON browser doctor report and succeeds when every check passes", async () => { + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => { + if (req.path === "/") { + return { + enabled: true, + profile: "openclaw", + transport: "cdp", + running: true, + }; + } + if (req.path === "/profiles") { + return { profiles: [{ name: "openclaw", running: true }] }; + } + if (req.path === "/tabs") { + return { running: true, tabs: [] }; + } + return {}; + }); + + const program = createBrowserManageProgram(); + await program.parseAsync(["browser", "--json", "doctor"], { from: "user" }); + + expect(parseSingleRuntimeJson()).toMatchObject({ ok: true }); + expect(getBrowserCliRuntimeCapture().runtimeErrors).toEqual([]); + expect(getBrowserCliRuntime().writeJson).toHaveBeenCalledTimes(1); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); }); it("prints a readable browser doctor failure when gateway auth SecretRefs are unavailable", async () => { @@ -534,9 +616,7 @@ describe("browser manage output", () => { getBrowserManageCallBrowserRequestMock().mockRejectedValueOnce(error); const program = createBrowserManageProgram(); - await expect(program.parseAsync(["browser", "doctor"], { from: "user" })).rejects.toThrow( - "__exit__:1", - ); + await program.parseAsync(["browser", "doctor"], { from: "user" }); const output = lastRuntimeLog(); expect(output).toContain( @@ -544,5 +624,8 @@ describe("browser manage output", () => { ); expect(output).toContain("OPENCLAW_GATEWAY_TOKEN"); expect(output).not.toContain("GatewaySecretRefUnavailableError"); + expect(getBrowserCliRuntime().writeJson).not.toHaveBeenCalled(); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); diff --git a/extensions/browser/src/cli/browser-cli-manage.ts b/extensions/browser/src/cli/browser-cli-manage.ts index 0071eda3e73a..1ba6fd525eeb 100644 --- a/extensions/browser/src/cli/browser-cli-manage.ts +++ b/extensions/browser/src/cli/browser-cli-manage.ts @@ -413,12 +413,11 @@ export function registerBrowserManageCommands( const profile = parent?.browserProfile; await runBrowserCommand(async () => { const result = await runBrowserDoctor(parent, profile, opts.deep === true); - if (printJsonResult(parent, result)) { - return; + if (!printJsonResult(parent, result)) { + defaultRuntime.log(result.checks.map(formatDoctorLine).join("\n")); } - defaultRuntime.log(result.checks.map(formatDoctorLine).join("\n")); if (!result.ok) { - defaultRuntime.exit(1); + process.exitCode = 1; } }); }); diff --git a/extensions/copilot/src/tool-bridge.test.ts b/extensions/copilot/src/tool-bridge.test.ts index 09309e43f692..2a7555c2ca49 100644 --- a/extensions/copilot/src/tool-bridge.test.ts +++ b/extensions/copilot/src/tool-bridge.test.ts @@ -489,11 +489,15 @@ describe("createCopilotToolBridge", () => { it("forwards identity, owner/policy, and channel/routing fields from attemptParams", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); + const toolBindings = { + browser: { kind: "tab", tabId: 7, target: "host", profile: "chrome", targetId: "target-7" }, + }; await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { agentAccountId: "acct-1", + toolBindings, senderId: "sender-1", senderName: "Ada", senderUsername: "ada", @@ -529,6 +533,7 @@ describe("createCopilotToolBridge", () => { const opts = getOpts(); expect(opts).toMatchObject({ agentAccountId: "acct-1", + toolBindings, senderId: "sender-1", senderName: "Ada", senderUsername: "ada", diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index 2a499bf0da2a..8c54df88603e 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -378,6 +378,7 @@ function buildOpenClawCodingToolsOptions( elevated: a.bashElevated, }, messageProvider: a.messageProvider ?? a.messageChannel, + toolBindings: a.toolBindings, chatType: a.chatType, agentAccountId: a.agentAccountId, messageTo: a.messageTo, diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 0c9ced8f80a9..478017969bee 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -638,6 +638,98 @@ describe("discordOutbound", () => { expect(mediaOptions.reply).toEqual(testCase.expectedReplies[1]); }); + it("preserves the media delivery identity for captioned videos in regular channels", async () => { + const mediaReceipt = { + primaryPlatformMessageId: "video-1", + platformMessageIds: ["video-1"], + parts: [{ platformMessageId: "video-1", kind: "media", index: 0 }], + sentAt: 2, + }; + hoisted.sendMessageDiscordMock + .mockResolvedValueOnce({ + messageId: "caption-1", + channelId: "channel-1", + receipt: { + primaryPlatformMessageId: "caption-1", + platformMessageIds: ["caption-1"], + parts: [{ platformMessageId: "caption-1", kind: "text", index: 0 }], + sentAt: 1, + }, + }) + .mockResolvedValueOnce({ + messageId: "video-1", + channelId: "channel-1", + receipt: mediaReceipt, + }); + + const result = await discordOutbound.sendMedia?.({ + cfg: {}, + to: "channel:channel-1", + text: "rendered clip", + mediaUrl: "/tmp/render.mp4", + accountId: "default", + }); + + expect(result).toEqual({ + channel: "discord", + messageId: "video-1", + channelId: "channel-1", + receipt: mediaReceipt, + }); + }); + + it("keeps captioned video in the thread created by the forum starter", async () => { + hoisted.sendMessageDiscordMock + .mockResolvedValueOnce({ + messageId: "starter-1", + channelId: "thread-1", + receipt: { + threadId: "thread-1", + platformMessageIds: ["starter-1"], + parts: [{ platformMessageId: "starter-1", kind: "text", index: 0 }], + sentAt: 1, + }, + }) + .mockResolvedValueOnce({ + messageId: "video-1", + channelId: "thread-1", + receipt: { + platformMessageIds: ["video-1"], + parts: [{ platformMessageId: "video-1", kind: "media", index: 0 }], + sentAt: 2, + }, + }); + + const result = await discordOutbound.sendMedia?.({ + cfg: {}, + to: "channel:forum-1", + text: "rendered clip", + mediaUrl: "/tmp/render.mp4", + accountId: "default", + }); + + expect(mockCall(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 0)[0]).toBe( + "channel:forum-1", + ); + expect(mockCall(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1)[0]).toBe( + "channel:thread-1", + ); + expect(result).toMatchObject({ + channel: "discord", + messageId: "starter-1", + channelId: "thread-1", + receipt: { + primaryPlatformMessageId: "starter-1", + threadId: "thread-1", + platformMessageIds: ["starter-1", "video-1"], + parts: [ + { platformMessageId: "starter-1", kind: "text", index: 0, threadId: "thread-1" }, + { platformMessageId: "video-1", kind: "media", index: 1, threadId: "thread-1" }, + ], + }, + }); + }); + it("marks implicit first-mode media sends for first-chunk native replies only", async () => { await discordOutbound.sendMedia?.({ cfg: {}, diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index c77f0c60bd7f..0eaed7f22f43 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -1,6 +1,8 @@ // Discord plugin module implements outbound adapter behavior. -import type { OutboundIdentity } from "openclaw/plugin-sdk/channel-outbound"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound"; +import { + type OutboundIdentity, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-outbound"; import { attachChannelToResult, type ChannelOutboundAdapter, @@ -35,6 +37,7 @@ import { type DiscordVoiceSendFn, } from "./outbound-send-context.js"; import { resolveDiscordReplyReference } from "./reply-reference.js"; +import { createDiscordSendReceiptFromResults } from "./send.receipt.js"; export const DISCORD_TEXT_CHUNK_LIMIT = 2000; const loadDiscordThreadBindings = createLazyRuntimeModule( @@ -206,11 +209,26 @@ export const discordOutbound: ChannelOutboundAdapter = { mediaReadFile: ctx.mediaReadFile, }; if (ctx.text.trim() && ctx.mediaUrl && isLikelyDiscordVideoMedia(ctx.mediaUrl)) { - await send(target, ctx.text, options); - return await send(target, "", { + const captionResult = await send(target, ctx.text, options); + // Forum sends create their thread on the first message; the video belongs in that thread. + const mediaTarget = captionResult.receipt?.threadId + ? `channel:${captionResult.receipt.threadId}` + : target; + const mediaResult = await send(mediaTarget, "", { ...mediaOptions, reply: options.reply?.scope === "all" ? options.reply : undefined, }); + const threadId = captionResult.receipt?.threadId; + if (!threadId) { + return mediaResult; + } + return { + ...captionResult, + receipt: createDiscordSendReceiptFromResults({ + results: [captionResult, mediaResult], + threadId, + }), + }; } return await send(target, ctx.text, mediaOptions); }, diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index 3c9ff19ba050..4ad1f359b1bc 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -19,7 +19,11 @@ import { createReusableDiscordReplyReference, type DiscordReplyReference, } from "./reply-reference.js"; -import { createDiscordSendResult, type DiscordReceiptResultSource } from "./send.receipt.js"; +import { + createDiscordSendReceiptFromResults, + createDiscordSendResult, + type DiscordReceiptResultSource, +} from "./send.receipt.js"; import { buildDiscordMessageRequest, buildDiscordSendError, @@ -70,6 +74,8 @@ type DiscordSendOpts = { type DiscordClientRequest = ReturnType["request"]; const DEFAULT_DISCORD_MEDIA_MAX_MB = 100; +/** Discord's ChannelFlags.RequireTag is bit 4 on forum/media parent channels. */ +const DISCORD_FORUM_REQUIRE_TAG_FLAG = 1 << 4; type DiscordChannelMessageResult = DiscordReceiptResultSource; @@ -206,6 +212,11 @@ export async function sendMessageDiscord( const channel = await resolveDiscordChannel(rest, channelId); if (isForumLikeChannel(channel)) { + if (((channel.flags ?? 0) & DISCORD_FORUM_REQUIRE_TAG_FLAG) !== 0) { + throw new Error( + `Discord forum channel ${channelId} requires an applied tag; use thread-create with appliedTags, then send to the created thread.`, + ); + } const threadName = deriveForumThreadName(renderedText); const chunks = buildDiscordTextChunks(textWithMentions, { maxLinesPerMessage, @@ -267,18 +278,20 @@ export async function sendMessageDiscord( const messageId = threadRes.message?.id ?? threadId; const resultChannelId = threadRes.message?.channel_id ?? threadId; const remainingChunks = chunks.slice(1); - await opts.onDeliveryResult?.( - toDiscordSendResult( - { - id: messageId, - channel_id: resultChannelId, - }, - channelId, - { kind: "text", threadId }, - ), + const starterResult = toDiscordSendResult( + { + id: messageId, + channel_id: resultChannelId, + }, + channelId, + { kind: "text", threadId }, ); + const deliveredResults: DiscordSendResult[] = [starterResult]; + await opts.onDeliveryResult?.(starterResult); const reportThreadResult: DiscordSendProgress = async (result, kind) => { - await opts.onDeliveryResult?.(toDiscordSendResult(result, threadId, { kind, threadId })); + const deliveredResult = toDiscordSendResult(result, threadId, { kind, threadId }); + deliveredResults.push(deliveredResult); + await opts.onDeliveryResult?.(deliveredResult); }; try { @@ -346,14 +359,10 @@ export async function sendMessageDiscord( accountId: accountInfo.accountId, direction: "outbound", }); - return toDiscordSendResult( - { - id: messageId, - channel_id: resultChannelId, - }, - channelId, - { kind: opts.mediaUrl ? "media" : "text", threadId }, - ); + return { + ...starterResult, + receipt: createDiscordSendReceiptFromResults({ results: deliveredResults, threadId }), + }; } let result: DiscordChannelMessageResult; diff --git a/extensions/discord/src/send.receipt.ts b/extensions/discord/src/send.receipt.ts index 66006d928795..b3781980d33d 100644 --- a/extensions/discord/src/send.receipt.ts +++ b/extensions/discord/src/send.receipt.ts @@ -5,6 +5,7 @@ import { type MessageReceiptPartKind, type MessageReceiptSourceResult, } from "openclaw/plugin-sdk/channel-outbound"; +import { attachChannelToResults } from "openclaw/plugin-sdk/channel-send-result"; import type { DiscordReplyReference } from "./reply-reference.js"; import type { DiscordSendResult } from "./send.types.js"; @@ -14,6 +15,27 @@ export type DiscordReceiptResultSource = { platformMessageIds?: readonly string[]; }; +export function createDiscordSendReceiptFromResults(params: { + results: readonly DiscordSendResult[]; + threadId?: string; +}): MessageReceipt { + const receipt = createMessageReceiptFromOutboundResults({ + results: attachChannelToResults("discord", params.results), + threadId: params.threadId, + }); + return { + ...receipt, + parts: receipt.parts.map(({ platformMessageId, kind, threadId, replyToId, raw }, index) => ({ + platformMessageId, + kind, + index, + threadId, + replyToId, + raw, + })), + }; +} + export function createDiscordSendReceipt(params: { platformMessageIds: readonly string[]; channelId?: string; diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index 298fc01c1c0c..7f9c62e1ccc8 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -542,6 +542,24 @@ describe("sendMessageDiscord", () => { }); }); + it("explains how to create a forum thread when the parent requires an applied tag", async () => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ + type: ChannelType.GuildForum, + flags: 1 << 4, + available_tags: [{ id: "tag1", name: "Question", moderated: false }], + }); + + await expect( + sendMessageDiscord("channel:forum1", "Discussion topic", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }), + ).rejects.toThrow(/thread-create with appliedTags/); + expect(postMock).not.toHaveBeenCalled(); + }); + it("posts media as a follow-up message in forum channels", async () => { const { rest, postMock } = setupForumSend({ id: "media1", channel_id: "thread1" }); const res = await sendMessageDiscord("channel:forum1", "Topic", { @@ -554,9 +572,18 @@ describe("sendMessageDiscord", () => { expect(res.channelId).toBe("thread1"); expectRecordFields(res.receipt, "send receipt", { threadId: "thread1", - platformMessageIds: ["starter1"], + platformMessageIds: ["starter1", "media1"], }); - expectSingleReceiptPart(res.receipt, { platformMessageId: "starter1", kind: "media" }); + expect( + res.receipt.parts.map(({ platformMessageId, kind, index }) => ({ + platformMessageId, + kind, + index, + })), + ).toEqual([ + { platformMessageId: "starter1", kind: "text", index: 0 }, + { platformMessageId: "media1", kind: "media", index: 1 }, + ]); expectRestRoute(postMock, 0, Routes.threads("forum1")); expect(requireRestBody(postMock, 0)).toEqual({ name: "Topic", @@ -569,7 +596,7 @@ describe("sendMessageDiscord", () => { it("chunks long forum posts into follow-up messages", async () => { const { rest, postMock } = setupForumSend({ id: "msg2", channel_id: "thread1" }); const longText = "a".repeat(2001); - await sendMessageDiscord("channel:forum1", longText, { + const result = await sendMessageDiscord("channel:forum1", longText, { rest, token: "t", cfg: DISCORD_TEST_CFG, @@ -580,6 +607,11 @@ describe("sendMessageDiscord", () => { const secondBody = requireRestBody(postMock, 1) as { content?: string }; expect(firstBody?.message?.content).toHaveLength(2000); expect(secondBody?.content).toBe("a"); + expect(result.receipt.platformMessageIds).toEqual(["starter1", "msg2"]); + expect(result.receipt.parts.map(({ kind, index }) => ({ kind, index }))).toEqual([ + { kind: "text", index: 0 }, + { kind: "text", index: 1 }, + ]); }); it("starts DM when recipient is a user", async () => { diff --git a/extensions/duckduckgo/README.md b/extensions/duckduckgo/README.md new file mode 100644 index 000000000000..69c1e6fc290f --- /dev/null +++ b/extensions/duckduckgo/README.md @@ -0,0 +1,12 @@ +# OpenClaw DuckDuckGo Plugin + +Official OpenClaw plugin for DuckDuckGo web search. + +Install from OpenClaw: + +```bash +openclaw plugins install @openclaw/duckduckgo-plugin +openclaw gateway restart +``` + +See for setup and configuration. diff --git a/extensions/duckduckgo/index.ts b/extensions/duckduckgo/index.ts index dd9c7de695ab..faeb9c8f4fe8 100644 --- a/extensions/duckduckgo/index.ts +++ b/extensions/duckduckgo/index.ts @@ -5,7 +5,7 @@ import { createDuckDuckGoWebSearchProvider } from "./src/ddg-search-provider.js" export default definePluginEntry({ id: "duckduckgo", name: "DuckDuckGo Plugin", - description: "Bundled DuckDuckGo web search plugin", + description: "Official DuckDuckGo web search plugin", register(api) { api.registerWebSearchProvider(createDuckDuckGoWebSearchProvider()); }, diff --git a/extensions/duckduckgo/package.json b/extensions/duckduckgo/package.json index 815446daf91e..e6cfe81f0f60 100644 --- a/extensions/duckduckgo/package.json +++ b/extensions/duckduckgo/package.json @@ -1,8 +1,11 @@ { "name": "@openclaw/duckduckgo-plugin", "version": "2026.7.2", - "private": true, - "description": "OpenClaw DuckDuckGo plugin", + "description": "OpenClaw DuckDuckGo plugin.", + "repository": { + "type": "git", + "url": "https://github.com/openclaw/openclaw" + }, "type": "module", "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" @@ -10,6 +13,23 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "install": { + "clawhubSpec": "clawhub:@openclaw/duckduckgo-plugin", + "npmSpec": "@openclaw/duckduckgo-plugin", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + }, + "compat": { + "pluginApi": ">=2026.7.2" + }, + "build": { + "openclawVersion": "2026.7.2", + "bundledDist": false + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } } } diff --git a/extensions/file-transfer/src/tools/descriptors.ts b/extensions/file-transfer/src/tools/descriptors.ts index b7bb1ea9ab38..4c0675168252 100644 --- a/extensions/file-transfer/src/tools/descriptors.ts +++ b/extensions/file-transfer/src/tools/descriptors.ts @@ -8,9 +8,9 @@ type FileTransferToolDescriptor = Pick< "label" | "name" | "description" | "parameters" >; -// Stash fetched files in a non-TTL subdir so follow-up tool calls within -// the same turn can still reference them. -export const FILE_TRANSFER_SUBDIR = "file-transfer"; +// Keep fetched files in the managed tool-media namespace so sandboxed replies +// can attach them and follow-up file_write calls can reuse the media id. +export const FILE_TRANSFER_SUBDIR = "tool-file-transfer"; export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts index 656d5880122b..8fa3cd1a20dc 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import * as tar from "tar"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DIR_FETCH_HARD_MAX_BYTES, FILE_TRANSFER_SUBDIR } from "./descriptors.js"; let tmpRoot: string; @@ -37,12 +38,13 @@ async function createTarBuffer(params: { async function importTool(tarBuffer: Buffer) { const archivePath = path.join(tmpRoot, `archive-${randomUUID()}.tar.gz`); const appendFileTransferAudit = vi.fn(async () => undefined); + const saveMediaBuffer = vi.fn(async () => { + await fs.writeFile(archivePath, tarBuffer); + return { path: archivePath }; + }); vi.resetModules(); vi.doMock("openclaw/plugin-sdk/media-store", () => ({ - saveMediaBuffer: vi.fn(async () => { - await fs.writeFile(archivePath, tarBuffer); - return { path: archivePath }; - }), + saveMediaBuffer, })); vi.doMock("../shared/audit.js", () => ({ appendFileTransferAudit })); vi.doMock("./node-tool-invoke.js", () => ({ @@ -67,6 +69,7 @@ async function importTool(tarBuffer: Buffer) { return { archivePath, appendFileTransferAudit, + saveMediaBuffer, module: await import("./dir-fetch-tool.js"), }; } @@ -86,7 +89,7 @@ describe("dir.fetch archive extraction", () => { await fs.writeFile(path.join(sourceDir, "ok.txt"), "ok"); }, }); - const { appendFileTransferAudit, module } = await importTool(tarBuffer); + const { appendFileTransferAudit, module, saveMediaBuffer } = await importTool(tarBuffer); const result = await executeDirFetch(module); @@ -106,6 +109,12 @@ describe("dir.fetch archive extraction", () => { const localPath = (result.details as { files: Array<{ localPath: string }> }).files[0] ?.localPath; await expect(fs.readFile(localPath!, "utf8")).resolves.toBe("ok"); + expect(saveMediaBuffer).toHaveBeenCalledWith( + tarBuffer, + "application/gzip", + FILE_TRANSFER_SUBDIR, + DIR_FETCH_HARD_MAX_BYTES, + ); expect(appendFileTransferAudit).toHaveBeenLastCalledWith( expect.objectContaining({ decision: "allowed" }), ); diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.ts index 265d6369e4dc..9c196a41d0d5 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.ts @@ -169,7 +169,7 @@ export function createDirFetchTool(): AnyAgentTool { throw new Error("dir.fetch sha256 mismatch (integrity failure)"); } - // Save tarball under the file-transfer subdir (no 2-min TTL). + // Keep the tarball and extracted paths under the same managed tool namespace. const savedTar = await saveMediaBuffer( tarBuffer, "application/gzip", diff --git a/extensions/file-transfer/src/tools/file-fetch-tool.test.ts b/extensions/file-transfer/src/tools/file-fetch-tool.test.ts index 221872335c2f..c64bf4154b25 100644 --- a/extensions/file-transfer/src/tools/file-fetch-tool.test.ts +++ b/extensions/file-transfer/src/tools/file-fetch-tool.test.ts @@ -7,6 +7,7 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { FILE_TRANSFER_SUBDIR } from "./descriptors.js"; import { createFileFetchTool } from "./file-fetch-tool.js"; vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({ @@ -57,7 +58,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/report.md", + path: "/gateway/media/tool-file-transfer/report.md", size: Buffer.byteLength(fileText), contentType: "text/markdown", }); @@ -95,7 +96,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/bom.md", + path: "/gateway/media/tool-file-transfer/bom.md", size: originalBuffer.byteLength, contentType: "text/markdown", }); @@ -111,7 +112,7 @@ describe("file_fetch tool", () => { expect(saveMediaBuffer).toHaveBeenCalledWith( originalBuffer, "text/markdown", - expect.any(String), + FILE_TRANSFER_SUBDIR, expect.any(Number), ); const details = result.details as { sha256: string; size: number }; @@ -134,7 +135,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/empty.png", + path: "/gateway/media/tool-file-transfer/empty.png", size: 0, contentType: "image/png", }); @@ -148,7 +149,7 @@ describe("file_fetch tool", () => { expect(result.content[0]?.type).toBe("text"); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; expect(text).toContain("Fetched /tmp/empty.png"); - expect(text).toContain("saved at /gateway/media/file-transfer/empty.png"); + expect(text).toContain("saved at /gateway/media/tool-file-transfer/empty.png"); }); it("still inlines a non-empty image payload", async () => { @@ -167,7 +168,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/photo.png", + path: "/gateway/media/tool-file-transfer/photo.png", size: buffer.byteLength, contentType: "image/png", }); diff --git a/extensions/file-transfer/src/tools/file-write-tool.test.ts b/extensions/file-transfer/src/tools/file-write-tool.test.ts index add0520f4ee3..d34cb7177343 100644 --- a/extensions/file-transfer/src/tools/file-write-tool.test.ts +++ b/extensions/file-transfer/src/tools/file-write-tool.test.ts @@ -1,12 +1,14 @@ // File Transfer tests cover file write tool plugin behavior. +import crypto from "node:crypto"; import { callGatewayTool, listNodes, resolveNodeIdFromList, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readMediaBuffer } from "openclaw/plugin-sdk/media-store"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { humanSize } from "../shared/params.js"; -import { FILE_WRITE_HARD_MAX_BYTES } from "./descriptors.js"; +import { FILE_TRANSFER_SUBDIR, FILE_WRITE_HARD_MAX_BYTES } from "./descriptors.js"; import { createFileWriteTool } from "./file-write-tool.js"; vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({ @@ -101,4 +103,38 @@ describe("file_write tool", () => { expect(callGatewayTool).toHaveBeenCalledOnce(); }); + + it("reads file_fetch media from the shared managed tool namespace", async () => { + const buffer = Buffer.from("copied"); + vi.mocked(readMediaBuffer).mockResolvedValue({ + id: "media-1", + buffer, + path: "/gateway/media/tool-file-transfer/media-1.bin", + size: buffer.byteLength, + }); + vi.mocked(listNodes).mockResolvedValue([{ nodeId: "node-1", displayName: "Node 1" }]); + vi.mocked(resolveNodeIdFromList).mockReturnValue("node-1"); + vi.mocked(callGatewayTool).mockResolvedValue({ + payload: { + ok: true, + path: "/tmp/out.bin", + size: buffer.byteLength, + sha256: crypto.createHash("sha256").update(buffer).digest("hex"), + overwritten: false, + }, + }); + + const result = await createFileWriteTool().execute("tool-call-1", { + node: "node-1", + path: "/tmp/out.bin", + sourceMediaId: "media-1", + }); + + expect(readMediaBuffer).toHaveBeenCalledWith( + "media-1", + FILE_TRANSFER_SUBDIR, + FILE_WRITE_HARD_MAX_BYTES, + ); + expect(result.details).toMatchObject({ source: "media", size: buffer.byteLength }); + }); }); diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index bbf02f1d8450..a0d24561d81c 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -541,6 +541,61 @@ describe("google transport stream", () => { expect(result.content[2]).toHaveProperty("thoughtSignature", "Y2FsbF9zaWdfMQ=="); }); + it.each([ + { + provider: "google", + feedback: { blockReason: "SAFETY" }, + expectedCode: "SAFETY", + expectedMessage: "Google prompt blocked (SAFETY)", + }, + { + provider: "google", + feedback: {}, + expectedCode: "PROMPT_BLOCKED", + expectedMessage: "Google prompt blocked (PROMPT_BLOCKED)", + }, + { + provider: "google-vertex", + feedback: { + blockReason: "PROHIBITED_CONTENT", + blockReasonMessage: "Prompt violates provider safety policy", + }, + expectedCode: "PROHIBITED_CONTENT", + expectedMessage: + "Google prompt blocked (PROHIBITED_CONTENT): Prompt violates provider safety policy", + }, + { + provider: "google-vertex", + feedback: { blockReasonMessage: "Prompt violates provider safety policy" }, + expectedCode: "PROMPT_BLOCKED", + expectedMessage: + "Google prompt blocked (PROMPT_BLOCKED): Prompt violates provider safety policy", + }, + ])( + "surfaces blocked $provider prompts as typed stream errors", + async ({ provider, feedback, expectedCode, expectedMessage }) => { + guardedFetchMock.mockResolvedValueOnce(buildSseResponse([{ promptFeedback: feedback }])); + if (provider === "google-vertex") { + vi.stubEnv("GOOGLE_CLOUD_PROJECT", "vertex-project"); + vi.stubEnv("GOOGLE_CLOUD_LOCATION", "global"); + googleAuthGetAccessTokenMock.mockResolvedValueOnce("ya29.vertex-token"); + } + + const result = + provider === "google-vertex" + ? await runGoogleVertexStreamResult({ fetch: guardedFetchMock }) + : await runGeminiStreamResult({ options: { apiKey: "gemini-api-key" } }); + + expect(result).toMatchObject({ + stopReason: "error", + errorCode: expectedCode, + errorType: "google_prompt_blocked", + errorMessage: expectedMessage, + content: [], + }); + }, + ); + it("rotates Gemini LLM API keys when a pre-stream request is rate limited", async () => { vi.stubEnv("OPENCLAW_LIVE_GEMINI_KEY", ""); vi.stubEnv("GEMINI_API_KEYS", "gemini-key-2"); diff --git a/extensions/google/transport-stream.ts b/extensions/google/transport-stream.ts index 6dfa613fedba..aa3e3d248c84 100644 --- a/extensions/google/transport-stream.ts +++ b/extensions/google/transport-stream.ts @@ -130,6 +130,10 @@ const GOOGLE_VERTEX_DEFAULT_API_VERSION = "v1"; type GoogleSseChunk = { responseId?: string; + promptFeedback?: { + blockReason?: string; + blockReasonMessage?: string; + }; candidates?: Array<{ content?: { parts?: Array<{ @@ -1343,6 +1347,17 @@ function createGoogleTransportStreamFn(kind: CanonicalGoogleTransportApi): Strea output.responseId ||= chunk.responseId; updateUsage(output, model, chunk); const candidate = chunk.candidates?.[0]; + const promptFeedback = chunk.promptFeedback; + if (!candidate && promptFeedback) { + const blockReason = + normalizeOptionalString(promptFeedback.blockReason) ?? "PROMPT_BLOCKED"; + const blockMessage = normalizeOptionalString(promptFeedback.blockReasonMessage); + const message = `Google prompt blocked (${blockReason})${blockMessage ? `: ${blockMessage}` : ""}`; + throw Object.assign(new Error(message), { + code: blockReason, + type: "google_prompt_blocked", + }); + } if (candidate?.content?.parts) { for (const part of candidate.content.parts) { const hasThoughtSignature = diff --git a/extensions/irc/src/inbound.behavior.test.ts b/extensions/irc/src/inbound.behavior.test.ts index 7a6d4f8ca3e2..1c2c23fe5e49 100644 --- a/extensions/irc/src/inbound.behavior.test.ts +++ b/extensions/irc/src/inbound.behavior.test.ts @@ -334,6 +334,70 @@ describe("irc inbound behavior", () => { expect(ctx?.OriginatingTo).toBe("channel:#ops"); }); + it.each([ + { label: "ordinary nick", nick: "OpenClaw", text: "OpenClaw: hello", mentioned: true }, + { label: "ASCII case folding", nick: "OpenClaw", text: "openclaw: hello", mentioned: true }, + { label: "leading bracket", nick: "[Claw]", text: "[Claw]: hello", mentioned: true }, + { label: "trailing bracket", nick: "Claw]", text: "hello Claw],", mentioned: true }, + { label: "leading caret", nick: "^Claw", text: "^Claw, hello", mentioned: true }, + { label: "trailing hyphen", nick: "Claw-", text: "Claw-: hello", mentioned: true }, + { label: "escaped backslash", nick: "\\Claw", text: "\\Claw: hello", mentioned: true }, + { label: "embedded brackets", nick: "Claw[Ops]", text: "Claw[Ops]: hi", mentioned: true }, + { label: "RFC1459 opening bracket", nick: "[Claw", text: "{claw: hello", mentioned: true }, + { label: "RFC1459 opening brace", nick: "{Claw", text: "[claw: hello", mentioned: true }, + { label: "RFC1459 closing bracket", nick: "Claw]", text: "claw}: hello", mentioned: true }, + { label: "RFC1459 closing brace", nick: "Claw}", text: "claw]: hello", mentioned: true }, + { label: "RFC1459 backslash", nick: "\\Claw", text: "|claw: hello", mentioned: true }, + { label: "RFC1459 vertical bar", nick: "|Claw", text: "\\claw: hello", mentioned: true }, + { label: "RFC1459 caret", nick: "^Claw", text: "~claw: hello", mentioned: true }, + { label: "RFC1459 tilde", nick: "~Claw", text: "^claw: hello", mentioned: true }, + { label: "ordinary nick suffix", nick: "Claw", text: "Clawbot: hello", mentioned: false }, + { label: "ordinary nick prefix", nick: "Claw", text: "overClaw: hello", mentioned: false }, + { label: "IRC nick punctuation suffix", nick: "Claw", text: "Claw-bot: hi", mentioned: false }, + { label: "RFC1459 tilde nick suffix", nick: "Claw", text: "Claw~bot: hi", mentioned: false }, + { + label: "punctuated nick inside a longer nick", + nick: "[Claw]", + text: "prefix[Claw]: hello", + mentioned: false, + }, + ])( + "recognizes only complete IRC nickname mentions: $label", + async ({ nick, text, mentioned }) => { + const coreRuntime = createPluginRuntimeMock(); + const runtime = createRuntimeEnv(); + setIrcRuntime(coreRuntime as never); + + await handleIrcInbound({ + message: createMessage({ + target: "#ops", + isGroup: true, + text, + }), + account: createAccount({ + nick, + config: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groupAllowFrom: [], + groups: { + "#ops": { enabled: true, requireMention: true }, + }, + }, + }), + config: { channels: { irc: {} } } as CoreConfig, + runtime, + sendReply: vi.fn(async () => {}), + }); + + expect(coreRuntime.channel.inbound.dispatch).toHaveBeenCalledTimes(mentioned ? 1 : 0); + if (!mentioned) { + expect(runtime.log).toHaveBeenCalledWith("irc: drop channel #ops (missing-mention)"); + } + }, + ); + it("drops a spoofed sender for a host-less nick!user DM allowlist entry", async () => { const coreRuntime = createPluginRuntimeMock(); const runtime = createRuntimeEnv(); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 919630c59f6e..d15d01490293 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -81,6 +81,27 @@ const ircIngressIdentity = defineStableChannelIngressIdentity({ }); const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +// IRC nicknames permit punctuation, so ASCII word boundaries lose valid leading/trailing chars. +const IRC_NICK_CHARACTER = String.raw`[A-Za-z0-9_\-\[\]\\\x60^{}|~]`; +const IRC_RFC1459_CASE_EQUIVALENTS = new Map([ + ["[", "{"], + ["{", "["], + ["]", "}"], + ["}", "]"], + ["\\", "|"], + ["|", "\\"], + ["^", "~"], + ["~", "^"], +]); + +function buildIrcNickMentionPattern(value: string): string { + return Array.from(value, (character) => { + const equivalent = IRC_RFC1459_CASE_EQUIVALENTS.get(character); + return equivalent + ? `[${escapeIrcRegexLiteral(character)}${escapeIrcRegexLiteral(equivalent)}]` + : escapeIrcRegexLiteral(character); + }).join(""); +} function isBareNick(value: string): boolean { return !value.includes("!") && !value.includes("@"); @@ -266,7 +287,10 @@ export async function handleIrcInbound(params: { const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig); const mentionNick = connectedNick?.trim() || account.nick; const explicitMentionRegex = mentionNick - ? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i") + ? new RegExp( + `(? ({ })); vi.mock("./matrix/send.js", () => ({ + editMessageMatrix: vi.fn(), + reactMatrixMessage: vi.fn(), + resolveMatrixRoomId: vi.fn(), sendMessageMatrix: mocks.sendMessageMatrix, sendPollMatrix: vi.fn(), sendTypingMatrix: vi.fn(), @@ -65,6 +68,77 @@ describe("matrix channel message adapter", () => { expect(matrixPlugin.meta.markdownCapable).toBe(true); }); + it.each([ + { + name: "the current room with reply quoting disabled", + to: "room:!room:example", + replyToMode: "off" as const, + expectedThreadId: "$thread", + }, + { + name: "an equivalent room target prefix", + to: "matrix:channel:!room:example", + replyToMode: "all" as const, + expectedThreadId: "$thread", + }, + { + name: "a different room", + to: "room:!another:example", + replyToMode: "all" as const, + expectedThreadId: undefined, + }, + { + name: "a direct user target without proven room identity", + to: "user:@alice:example", + replyToMode: "all" as const, + expectedThreadId: undefined, + }, + ])("routes a native Matrix message action in $name", async (testCase) => { + const threading = matrixPlugin.threading; + const handleAction = matrixPlugin.actions?.handleAction; + if (!threading?.resolveAutoThreadId || !handleAction) { + throw new Error("Expected Matrix threaded message action adapters"); + } + const toolContext = { + currentChannelProvider: "matrix" as const, + currentChannelId: "room:!room:example", + currentThreadTs: "$thread", + currentMessageId: "$reply", + replyToMode: testCase.replyToMode, + hasRepliedRef: { value: true }, + }; + const threadId = threading.resolveAutoThreadId({ + cfg, + accountId: "default", + to: testCase.to, + toolContext, + replyToId: "$explicit-reply", + }); + + await handleAction({ + cfg, + channel: "matrix", + action: "send", + accountId: "default", + toolContext, + params: { + to: testCase.to, + message: "threaded native action", + replyTo: "$explicit-reply", + ...(threadId ? { threadId } : {}), + }, + }); + + expect(mocks.sendMessageMatrix).toHaveBeenCalledOnce(); + expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe(testCase.to); + expect(lastMatrixSendOptions()).toMatchObject({ + cfg, + accountId: "default", + replyToId: "$explicit-reply", + threadId: testCase.expectedThreadId, + }); + }); + beforeEach(() => { mocks.sendMessageMatrix.mockReset(); mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" }); diff --git a/extensions/matrix/src/channel.threading.test.ts b/extensions/matrix/src/channel.threading.test.ts new file mode 100644 index 000000000000..8e665d2372bf --- /dev/null +++ b/extensions/matrix/src/channel.threading.test.ts @@ -0,0 +1,159 @@ +// Matrix threading tests keep room-affinity coverage isolated from account/env fixtures. +import { describe, expect, it } from "vitest"; +import { matrixPlugin } from "./channel.js"; +import type { CoreConfig } from "./types.js"; + +function requireMatrixAutoThreadIdResolver() { + const resolveAutoThreadId = matrixPlugin.threading?.resolveAutoThreadId; + if (!resolveAutoThreadId) { + throw new Error("expected Matrix automatic thread resolver"); + } + return resolveAutoThreadId; +} + +function requireMatrixToolContextTargetMatcher() { + const matchesToolContextTarget = matrixPlugin.threading?.matchesToolContextTarget; + if (!matchesToolContextTarget) { + throw new Error("expected Matrix tool context target matcher"); + } + return matchesToolContextTarget; +} + +describe("matrix message-tool threading", () => { + it.each([ + { + name: "the exact current room", + currentChannelId: "room:!room:example.org", + target: "room:!room:example.org", + expected: true, + }, + { + name: "an equivalent Matrix room prefix", + currentChannelId: "matrix:room:!room:example.org", + target: "channel:!room:example.org", + expected: true, + }, + { + name: "a raw current room id", + currentChannelId: "!room:example.org", + target: "matrix:room:!room:example.org", + expected: true, + }, + { + name: "a different room", + currentChannelId: "room:!room:example.org", + target: "room:!another:example.org", + expected: false, + }, + { + name: "a room alias without verified room resolution", + currentChannelId: "room:!room:example.org", + target: "#room:example.org", + expected: false, + }, + { + name: "a direct user target without verified room identity", + currentChannelId: "room:!dm:example.org", + target: "user:@alice:example.org", + expected: false, + }, + { + name: "a room id with different case", + currentChannelId: "room:!Room:example.org", + target: "room:!room:example.org", + expected: false, + }, + { + name: "two user targets rather than a room", + currentChannelId: "user:@alice:example.org", + target: "user:@alice:example.org", + expected: false, + }, + ])("only matches $name by canonical Matrix room identity", (testCase) => { + const toolContext = { + currentChannelId: testCase.currentChannelId, + currentThreadTs: "$thread", + replyToMode: "off" as const, + }; + + expect( + requireMatrixToolContextTargetMatcher()({ + target: testCase.target, + toolContext, + }), + ).toBe(testCase.expected); + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: testCase.target, + toolContext, + }), + ).toBe(testCase.expected ? "$thread" : undefined); + }); + + it.each(["off", "first", "all", "batched"] as const)( + "preserves an existing Matrix room thread when replyToMode is %s", + (replyToMode) => { + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "room:!room:example.org", + replyToId: "$reply", + toolContext: { + currentChannelId: "matrix:room:!room:example.org", + currentThreadTs: "$thread", + replyToMode, + hasRepliedRef: { value: true }, + }, + }), + ).toBe("$thread"); + }, + ); + + it("does not infer a Matrix room thread without an existing thread root", () => { + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "room:!room:example.org", + toolContext: { currentChannelId: "room:!room:example.org" }, + }), + ).toBeUndefined(); + }); + + it("does not inherit Matrix room threads from another channel provider", () => { + const toolContext = { + currentChannelProvider: "slack" as const, + currentChannelId: "room:!room:example.org", + currentThreadTs: "$thread", + }; + + expect( + requireMatrixToolContextTargetMatcher()({ + target: "room:!room:example.org", + toolContext, + }), + ).toBe(false); + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "room:!room:example.org", + toolContext, + }), + ).toBeUndefined(); + }); + + it("does not infer Matrix DM room identity from a matching user messaging target", () => { + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "user:@alice:example.org", + toolContext: { + currentChannelProvider: "matrix", + currentChannelId: "room:!dm:example.org", + currentMessagingTarget: "user:@alice:example.org", + currentThreadTs: "$thread", + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 4722fc91afd6..546bef41bd5f 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -4,7 +4,10 @@ import { adaptScopedAccountAccessor, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; +import type { + ChannelDoctorAdapter, + ChannelThreadingToolContext, +} from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { createChannelMessageAdapterFromOutbound, @@ -339,6 +342,24 @@ function resolveMatrixDeliveryTarget(params: { return null; } +function matchesMatrixToolContextRoom(params: { + target: string; + toolContext: ChannelThreadingToolContext; +}): boolean { + const { toolContext } = params; + if (toolContext.currentChannelProvider && toolContext.currentChannelProvider !== "matrix") { + return false; + } + const currentTarget = toolContext.currentChannelId + ? resolveMatrixTargetIdentity(toolContext.currentChannelId) + : null; + const target = resolveMatrixTargetIdentity(params.target); + // A Matrix user target can select a different DM room; only verified room IDs may share threads. + return ( + currentTarget?.kind === "room" && target?.kind === "room" && currentTarget.id === target.id + ); +} + const matrixChannelOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkTextForOutbound, @@ -665,6 +686,14 @@ export const matrixPlugin: ChannelPlugin = ), }, threading: { + matchesToolContextTarget: matchesMatrixToolContextRoom, + resolveAutoThreadId: ({ to, toolContext }) => { + const threadId = normalizeOptionalString(toolContext?.currentThreadTs); + if (!threadId || !toolContext) { + return undefined; + } + return matchesMatrixToolContextRoom({ target: to, toolContext }) ? threadId : undefined; + }, resolveReplyToMode: createScopedAccountReplyToModeResolver< ReturnType >({ diff --git a/extensions/matrix/src/session-route.test.ts b/extensions/matrix/src/session-route.test.ts index 7c5292ffb587..6738c43a332c 100644 --- a/extensions/matrix/src/session-route.test.ts +++ b/extensions/matrix/src/session-route.test.ts @@ -296,6 +296,36 @@ describe("resolveMatrixOutboundSessionRoute", () => { expect(channelRoute.threadId).toBe("$RootEvent:Example.Org"); }); + it.each([ + { + name: "uses the Matrix thread root when replying to a child event", + threadId: "$ThreadRoot:Example.Org", + replyToId: "$ReplyChild:Example.Org", + expectedThreadId: "$ThreadRoot:Example.Org", + }, + { + name: "keeps reply-only session routing when no Matrix thread exists", + threadId: undefined, + replyToId: "$ReplyChild:Example.Org", + expectedThreadId: "$ReplyChild:Example.Org", + }, + ])("$name", ({ threadId, replyToId, expectedThreadId }) => { + const route = expectRoute( + resolveMatrixOutboundSessionRoute({ + cfg: {}, + agentId: "main", + target: "room:!ops:example.org", + threadId, + replyToId, + }), + ); + + expect(route.threadId).toBe(expectedThreadId); + expect(route.sessionKey).toBe( + `agent:main:matrix:channel:!ops:example.org:thread:${expectedThreadId}`, + ); + }); + it("does not claim room aliases as canonical inbound session ids", () => { const route = resolveMatrixOutboundSessionRoute({ cfg: {}, diff --git a/extensions/matrix/src/session-route.ts b/extensions/matrix/src/session-route.ts index c4006262f8ad..50269d116017 100644 --- a/extensions/matrix/src/session-route.ts +++ b/extensions/matrix/src/session-route.ts @@ -121,6 +121,8 @@ export function resolveMatrixOutboundSessionRoute(params: ChannelOutboundSession replyToId: params.replyToId, threadId: params.threadId, currentSessionKey: params.currentSessionKey, + // Matrix m.thread identifies the session; m.in_reply_to may name a different child event. + precedence: ["threadId", "replyToId", "currentSession"], normalizeThreadId: (threadId) => threadId, canRecoverCurrentThread: ({ route }) => route.peer.kind !== "direct" || (params.cfg.session?.dmScope ?? "main") !== "main", diff --git a/extensions/mistral/realtime-transcription-provider.test.ts b/extensions/mistral/realtime-transcription-provider.test.ts index 3490ba2a3afd..3d421c5bddd5 100644 --- a/extensions/mistral/realtime-transcription-provider.test.ts +++ b/extensions/mistral/realtime-transcription-provider.test.ts @@ -9,7 +9,10 @@ import { buildMistralRealtimeTranscriptionProvider } from "./realtime-transcript let cleanup: (() => Promise) | undefined; -async function createRealtimeServer(onRequest: (url: URL) => void) { +async function createRealtimeServer( + onRequest: (url: URL) => void, + transcriptionEvents: readonly Record[] = [], +) { const server = createServer(); const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); const clients = new Set(); @@ -20,6 +23,19 @@ async function createRealtimeServer(onRequest: (url: URL) => void) { ws.on("close", () => { clients.delete(ws); }); + ws.on("message", (data) => { + const bytes = Buffer.isBuffer(data) + ? data + : Array.isArray(data) + ? Buffer.concat(data) + : Buffer.from(data); + const message = JSON.parse(bytes.toString("utf8")) as { type?: unknown }; + if (message.type === "session.update") { + for (const event of transcriptionEvents) { + ws.send(JSON.stringify(event)); + } + } + }); ws.send(JSON.stringify({ type: "session.created" })); }); }); @@ -122,4 +138,203 @@ describe("buildMistralRealtimeTranscriptionProvider", () => { expect(requests[0]?.searchParams.get("model")).toBe("voxtral-mini-transcribe-realtime-2602"); expect(requests[0]?.searchParams.get("target_streaming_delay_ms")).toBe("800"); }); + + it.each([ + { + name: "delivers terminal-only transcription text", + events: [{ type: "transcription.done", text: "final transcript" }], + partials: [], + transcripts: ["final transcript"], + }, + { + name: "prefers corrected final text over streamed deltas", + events: [ + { type: "transcription.text.delta", text: "draft" }, + { type: "transcription.text.delta", text: " words" }, + { type: "transcription.done", text: "corrected final transcript" }, + ], + partials: ["draft", "draft words"], + transcripts: ["corrected final transcript"], + }, + { + name: "does not repeat an already emitted final segment", + events: [ + { type: "transcription.text.delta", text: "draft" }, + { type: "transcription.segment", text: "final transcript", start: 0, end: 1 }, + { type: "transcription.done", text: "final transcript" }, + ], + partials: ["draft"], + transcripts: ["final transcript"], + }, + { + name: "delivers identical text from independently timed segments", + events: [ + { type: "transcription.segment", text: "echo", start: 0, end: 1, speaker_id: "first" }, + { type: "transcription.segment", text: "echo", start: 1, end: 2, speaker_id: "second" }, + { type: "transcription.done", text: "echo echo" }, + ], + partials: [], + transcripts: ["echo", "echo"], + }, + { + name: "does not replay a terminal aggregate after multiple final segments", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.segment", text: "world", start: 1, end: 2 }, + { type: "transcription.done", text: "hello world" }, + ], + partials: [], + transcripts: ["hello", "world"], + }, + { + name: "flushes new deltas after a finalized segment without replaying its aggregate", + events: [ + { type: "transcription.text.delta", text: "hello" }, + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.text.delta", text: " new" }, + { type: "transcription.text.delta", text: " speech" }, + { type: "transcription.done", text: "hello new speech" }, + ], + partials: ["hello", " new", " new speech"], + transcripts: ["hello", " new speech"], + }, + { + name: "flushes new deltas after multiple finalized segments without replaying them", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.segment", text: "world", start: 1, end: 2 }, + { type: "transcription.text.delta", text: " again" }, + { type: "transcription.done", text: "hello world again" }, + ], + partials: [" again"], + transcripts: ["hello", "world", " again"], + }, + { + name: "does not finalize whitespace-only deltas after a final segment", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.text.delta", text: " \t" }, + { type: "transcription.done", text: "hello " }, + ], + partials: [" \t"], + transcripts: ["hello"], + }, + { + name: "does not finalize a punctuation-only period delta after a final segment", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.text.delta", text: "." }, + { type: "transcription.done", text: "hello." }, + ], + partials: ["."], + transcripts: ["hello"], + }, + { + name: "does not finalize a punctuation-only comma delta after a final segment", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.text.delta", text: ", " }, + { type: "transcription.done", text: "hello," }, + ], + partials: [", "], + transcripts: ["hello"], + }, + { + name: "preserves multilingual speech with leading punctuation after a final segment", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.text.delta", text: ", 你好" }, + { type: "transcription.done", text: "hello, 你好" }, + ], + partials: [", 你好"], + transcripts: ["hello", ", 你好"], + }, + { + name: "preserves numeric speech after a final segment", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.text.delta", text: " 42" }, + { type: "transcription.done", text: "hello 42" }, + ], + partials: [" 42"], + transcripts: ["hello", " 42"], + }, + { + name: "does not replay terminal suffixes after final segments already own the stream", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.segment", text: "world", start: 1, end: 2 }, + { type: "transcription.done", text: "hello world again" }, + ], + partials: [], + transcripts: ["hello", "world"], + }, + { + name: "does not replay terminal corrections after final segments already own the stream", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.segment", text: "world", start: 1, end: 2 }, + { type: "transcription.done", text: "hello corrected world" }, + ], + partials: [], + transcripts: ["hello", "world"], + }, + { + name: "does not turn terminal sentence punctuation into a transcript", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.segment", text: "world", start: 1, end: 2 }, + { type: "transcription.done", text: "hello world." }, + ], + partials: [], + transcripts: ["hello", "world"], + }, + { + name: "does not turn terminal separator punctuation into a transcript", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.segment", text: "world", start: 1, end: 2 }, + { type: "transcription.done", text: "hello, world" }, + ], + partials: [], + transcripts: ["hello", "world"], + }, + { + name: "does not replay terminal whitespace normalization as a transcript", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.segment", text: "world", start: 1, end: 2 }, + { type: "transcription.done", text: " hello\t\n world " }, + ], + partials: [], + transcripts: ["hello", "world"], + }, + { + name: "does not replay a rewritten terminal aggregate after final segments", + events: [ + { type: "transcription.segment", text: "hello", start: 0, end: 1 }, + { type: "transcription.segment", text: "world", start: 1, end: 2 }, + { type: "transcription.done", text: "greetings earth" }, + ], + partials: [], + transcripts: ["hello", "world"], + }, + ])("$name", async ({ events, partials, transcripts }) => { + const baseUrl = await createRealtimeServer(() => {}, events); + const onPartial = vi.fn(); + const onTranscript = vi.fn(); + const session = buildMistralRealtimeTranscriptionProvider().createSession({ + providerConfig: { apiKey: "fixture-value", baseUrl }, + onPartial, + onTranscript, + }); + + await session.connect(); + await vi.waitFor(() => { + expect(onTranscript.mock.calls.map(([text]) => text)).toEqual(transcripts); + expect(session.isConnected()).toBe(false); + }); + + expect(onPartial.mock.calls.map(([text]) => text)).toEqual(partials); + }); }); diff --git a/extensions/mistral/realtime-transcription-provider.ts b/extensions/mistral/realtime-transcription-provider.ts index 649c3db99394..23241838acfe 100644 --- a/extensions/mistral/realtime-transcription-provider.ts +++ b/extensions/mistral/realtime-transcription-provider.ts @@ -62,6 +62,7 @@ const MISTRAL_REALTIME_CLOSE_TIMEOUT_MS = 5_000; const MISTRAL_REALTIME_MAX_RECONNECT_ATTEMPTS = 5; const MISTRAL_REALTIME_RECONNECT_DELAY_MS = 1000; const MISTRAL_REALTIME_MAX_QUEUED_BYTES = 2 * 1024 * 1024; +const MISTRAL_REALTIME_SPEECH_CONTENT = /[\p{L}\p{N}]/u; function readNestedMistralConfig(rawConfig: RealtimeTranscriptionProviderConfig) { const raw = readRecord(rawConfig); @@ -165,6 +166,15 @@ function createMistralRealtimeTranscriptionSession( config: MistralRealtimeTranscriptionSessionConfig, ): RealtimeTranscriptionSession { let partialText = ""; + let hasFinalSegment = false; + + const emitFinalTranscript = (text: string, source: "segment" | "terminal" | "pending") => { + if (!text.trim() || (source === "pending" && !MISTRAL_REALTIME_SPEECH_CONTENT.test(text))) { + return; + } + hasFinalSegment ||= source === "segment"; + config.onTranscript?.(text); + }; const handleEvent = ( event: MistralRealtimeTranscriptionEvent, @@ -195,18 +205,22 @@ function createMistralRealtimeTranscriptionSession( } return; case "transcription.segment": - if (event.text) { - config.onTranscript?.(event.text); + if (event.text?.trim()) { + emitFinalTranscript(event.text, "segment"); partialText = ""; } return; - case "transcription.done": - if (partialText.trim()) { - config.onTranscript?.(partialText); - partialText = ""; - } + case "transcription.done": { + // Final segments already own completed speech; only later buffered + // speech deltas are new. Punctuation only completes an earlier final. + const source = hasFinalSegment ? "pending" : "terminal"; + const terminalText = + source === "pending" ? partialText : event.text?.trim() ? event.text : partialText; + emitFinalTranscript(terminalText, source); + partialText = ""; transport.closeNow(); return; + } case "error": config.onError?.(new Error(readErrorDetail(event))); diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 01926cd5444c..737546be70d1 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1747,7 +1747,7 @@ describe("createOllamaStreamFn streaming events", () => { ); }); - it("emits only done for tool-call-only responses (no text content)", async () => { + it("streams the complete lifecycle for tool-call-only responses", async () => { await withMockNdjsonFetch( [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}', @@ -1757,12 +1757,36 @@ describe("createOllamaStreamFn streaming events", () => { const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }); const events = await collectStreamEvents(stream); - // No text content means no start/text_start/text_delta/text_end events const types = events.map((e) => e.type); - expect(types).toEqual(["done"]); - const doneEvent = requireEntry(events, 0, "tool-call-only done event"); + expect(types).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events[1]).toMatchObject({ + type: "toolcall_start", + contentIndex: 0, + partial: { content: [{ type: "toolCall", name: "bash", arguments: {} }] }, + }); + expect(events[2]).toMatchObject({ + type: "toolcall_delta", + contentIndex: 0, + delta: '{"command":"ls"}', + }); + expect(events[3]).toMatchObject({ + type: "toolcall_end", + contentIndex: 0, + toolCall: { name: "bash", arguments: { command: "ls" } }, + }); + const doneEvent = requireEntry(events, 4, "tool-call-only done event"); if (doneEvent.type === "done") { expect(doneEvent.reason).toBe("toolUse"); + expect(doneEvent.message.content[0]).toMatchObject({ + type: "toolCall", + id: events[3]?.type === "toolcall_end" ? events[3].toolCall.id : undefined, + }); } }, ); @@ -1839,7 +1863,21 @@ describe("createOllamaStreamFn streaming events", () => { const events = await collectStreamEvents(stream); const types = events.map((e) => e.type); - expect(types).toEqual(["start", "text_start", "text_delta", "text_end", "done"]); + expect(types).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events[5]).toMatchObject({ + type: "toolcall_delta", + contentIndex: 1, + delta: '{"command":"ls"}', + }); const doneEvent = events.at(-1); if (doneEvent?.type === "done") { expect(doneEvent.reason).toBe("toolUse"); @@ -1848,6 +1886,104 @@ describe("createOllamaStreamFn streaming events", () => { ); }); + it("streams multiple native calls with stable provider ids across chunks", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"id":"call-read","function":{"name":"read","arguments":{"path":"/tmp/a"}}}]},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"id":"call-bash","function":{"name":"bash","arguments":"{\\"command\\":\\"ls\\"}"}}]},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + const toolCallEnds = events.filter((event) => event.type === "toolcall_end"); + expect(toolCallEnds).toMatchObject([ + { + contentIndex: 0, + toolCall: { id: "call-read", name: "read", arguments: { path: "/tmp/a" } }, + }, + { + contentIndex: 1, + toolCall: { id: "call-bash", name: "bash", arguments: { command: "ls" } }, + }, + ]); + expect(events.filter((event) => event.type === "toolcall_delta")).toMatchObject([ + { contentIndex: 0, delta: '{"path":"/tmp/a"}' }, + { contentIndex: 1, delta: '{"command":"ls"}' }, + ]); + expect(events.filter((event) => event.type === "toolcall_start")).toMatchObject([ + { partial: { content: [{ arguments: {} }] } }, + { + partial: { + content: [{ arguments: { path: "/tmp/a" } }, { arguments: {} }], + }, + }, + ]); + const done = events.at(-1); + if (done?.type !== "done") { + throw new Error("missing terminal Ollama message"); + } + expect(done.message.content).toMatchObject([ + { type: "toolCall", id: "call-read" }, + { type: "toolCall", id: "call-bash" }, + ]); + }, + ); + }); + + it("does not stream non-executable calls from a token-limited final chunk", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":true,"done_reason":"length"}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }), + ); + + expect(events.map((event) => event.type)).toEqual(["done"]); + expect(events[0]).toMatchObject({ + type: "done", + reason: "length", + message: { content: [], stopReason: "length" }, + }); + }, + ); + }); + + it("never exposes an intermediate native call invalidated by a later length terminal", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"done_reason":"length"}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }), + ); + + expect(events.map((event) => event.type)).toEqual(["done"]); + expect(events[0]).toMatchObject({ + type: "done", + reason: "length", + message: { content: [], stopReason: "length" }, + }); + }, + ); + }); + it("emits text_end as soon as Ollama switches from text to tool calls", async () => { const controlledFetch = createControlledNdjsonFetch(); fetchWithSsrFGuardMock.mockImplementation(controlledFetch.fetchImpl); @@ -1899,6 +2035,20 @@ describe("createOllamaStreamFn streaming events", () => { ); controlledFetch.close(); + const toolCallStartEvent = await nextEventWithin(iterator); + const toolCallDeltaEvent = await nextEventWithin(iterator); + const toolCallEndEvent = await nextEventWithin(iterator); + expect(toolCallStartEvent).not.toBe("timeout"); + expect(toolCallDeltaEvent).not.toBe("timeout"); + expect(toolCallEndEvent).not.toBe("timeout"); + expectIteratorEvent(toolCallStartEvent, { type: "toolcall_start", done: false }); + expectIteratorEvent(toolCallDeltaEvent, { + type: "toolcall_delta", + delta: '{"command":"ls"}', + done: false, + }); + expectIteratorEvent(toolCallEndEvent, { type: "toolcall_end", done: false }); + const doneEvent = await nextEventWithin(iterator); expect(doneEvent).not.toBe("timeout"); if (doneEvent !== "timeout" && doneEvent.done === false) { @@ -2343,7 +2493,14 @@ describe("createOllamaStreamFn streaming events", () => { }); const events = await collectStreamEvents(stream); - expect(events.map((e) => e.type)).toEqual(["done"]); + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(JSON.stringify(events)).not.toContain("I should think privately"); const doneEvent = events.at(-1); expect(doneEvent?.type).toBe("done"); if (doneEvent?.type === "done") { @@ -2360,6 +2517,84 @@ describe("createOllamaStreamFn streaming events", () => { }, ); }); + + it("flushes buffered visible Kimi text before streaming its native tool call", async () => { + await withMockNdjsonFetch( + [ + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":"Visible answer"},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":""},"done":true}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + model: { id: "kimi-k2.6:cloud", provider: "ollama" }, + }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events[2]).toMatchObject({ type: "text_delta", delta: "Visible answer" }); + expect(events[4]).toMatchObject({ type: "toolcall_start", contentIndex: 1 }); + expect(events[6]).toMatchObject({ type: "toolcall_end", contentIndex: 1 }); + expect(events.at(-1)).toMatchObject({ + type: "done", + message: { + content: [ + { type: "text", text: "Visible answer" }, + { type: "toolCall", name: "bash" }, + ], + }, + }); + }, + ); + }); + + it("does not reveal buffered Kimi reasoning for an empty tool-call chunk", async () => { + const hiddenPrefix = + "I should think privately and not leak this planning text in the answer. " + + "I need to keep deciding what to say next."; + await withMockNdjsonFetch( + [ + JSON.stringify({ + model: "kimi-k2.6:cloud", + created_at: "t", + message: { role: "assistant", content: hiddenPrefix }, + done: false, + }), + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[]},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":" ️ Visible answer"},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":""},"done":true}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + model: { id: "kimi-k2.6:cloud", provider: "ollama" }, + }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "done", + ]); + expect(events[2]).toMatchObject({ type: "text_delta", delta: "Visible answer" }); + expect(JSON.stringify(events)).not.toContain("I should think privately"); + }, + ); + }); }); describe("createOllamaStreamFn", () => { diff --git a/extensions/ollama/src/stream.test.ts b/extensions/ollama/src/stream.test.ts index e8aa0ec4532b..ebe011d5393f 100644 --- a/extensions/ollama/src/stream.test.ts +++ b/extensions/ollama/src/stream.test.ts @@ -325,9 +325,7 @@ describe("createOllamaStreamFn thinking events", () => { }; expect(done.reason).toBe("length"); expect(done.message?.stopReason).toBe("length"); - expect(done.message?.content).toEqual([ - expect.objectContaining({ type: "toolCall", name: "read" }), - ]); + expect(done.message?.content).toEqual([]); }); it("uses generic stream timeout for Ollama request timeout", async () => { diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 01ef98b8f216..508bc7edd9a3 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -1262,6 +1262,7 @@ function createRawOllamaStreamFn( let accumulatedThinking = ""; let suppressedThinking = ""; const accumulatedToolCalls: OllamaToolCall[] = []; + const streamedToolCalls: ToolCall[] = []; let finalResponse: OllamaChatResponse | undefined; let pendingFinalVisibleContent: string | undefined; const modelInfo = { @@ -1291,9 +1292,24 @@ function createRawOllamaStreamFn( if (accumulatedVisibleContent) { parts.push({ type: "text", text: accumulatedVisibleContent }); } + parts.push(...streamedToolCalls); return parts; }; + const ensureStreamStarted = () => { + if (streamStarted) { + return; + } + streamStarted = true; + const emptyPartial = buildStreamAssistantMessage({ + model: modelInfo, + content: [], + stopReason: "stop", + usage: buildUsageWithNoCost({}), + }); + stream.push({ type: "start", partial: emptyPartial }); + }; + const closeThinkingBlock = () => { if (!thinkingStarted || thinkingEnded) { return; @@ -1345,16 +1361,7 @@ function createRawOllamaStreamFn( closeThinkingBlock(); } - if (!streamStarted) { - streamStarted = true; - const emptyPartial = buildStreamAssistantMessage({ - model: modelInfo, - content: [], - stopReason: "stop", - usage: buildUsageWithNoCost({}), - }); - stream.push({ type: "start", partial: emptyPartial }); - } + ensureStreamStarted(); if (!textBlockStarted) { textBlockStarted = true; const partial = buildStreamAssistantMessage({ @@ -1392,16 +1399,7 @@ function createRawOllamaStreamFn( refreshTimeout?.(); const thinkingDelta = chunk.message?.thinking ?? chunk.message?.reasoning; if (thinkingDelta && shouldEmitThinking) { - if (!streamStarted) { - streamStarted = true; - const emptyPartial = buildStreamAssistantMessage({ - model: modelInfo, - content: [], - stopReason: "stop", - usage: buildUsageWithNoCost({}), - }); - stream.push({ type: "start", partial: emptyPartial }); - } + ensureStreamStarted(); if (!thinkingStarted) { thinkingStarted = true; const partial = buildStreamAssistantMessage({ @@ -1435,10 +1433,18 @@ function createRawOllamaStreamFn( accumulatedRawContent += rawDelta; flushVisibleText(resolveVisibleContent(false)); } - if (chunk.message?.tool_calls) { + if (chunk.message?.tool_calls?.length) { + // Kimi holds short visible prefixes until a terminal boundary; + // settle them now so later tool indices cannot overwrite text. + flushVisibleText(resolveVisibleContent(true)); closeThinkingBlock(); closeTextBlock(); - accumulatedToolCalls.push(...chunk.message.tool_calls); + for (const rawToolCall of chunk.message.tool_calls) { + // Ollama can report a length stop in a later chunk, so no call + // becomes executable until its authoritative terminal arrives. + const id = readOllamaToolCallId(rawToolCall.id) ?? `ollama_call_${randomUUID()}`; + accumulatedToolCalls.push({ ...rawToolCall, id }); + } } if (chunk.done) { pendingFinalVisibleContent = resolveVisibleContent(true); @@ -1473,7 +1479,11 @@ function createRawOllamaStreamFn( if (accumulatedThinking) { finalResponse.message.thinking = accumulatedThinking; } - if (accumulatedToolCalls.length > 0) { + if (finalResponse.done_reason === "length") { + // All consumers inspect terminal content, not only lifecycle events; + // a token-limit stop must never retain an executable-looking call. + delete finalResponse.message.tool_calls; + } else if (accumulatedToolCalls.length > 0) { finalResponse.message.tool_calls = accumulatedToolCalls; } @@ -1491,9 +1501,45 @@ function createRawOllamaStreamFn( closeThinkingBlock(); closeTextBlock(); + const reason = resolveOllamaStopReason(finalResponse); + if (reason === "toolUse") { + for (const completedToolCall of assistantMessage.content) { + if (completedToolCall.type !== "toolCall") { + continue; + } + ensureStreamStarted(); + const placeholder: ToolCall = { ...completedToolCall, arguments: {} }; + streamedToolCalls.push(placeholder); + const contentIndex = buildCurrentContent().length - 1; + const partial = () => + buildStreamAssistantMessage({ + model: modelInfo, + content: buildCurrentContent(), + stopReason: "stop", + usage: buildUsageWithNoCost({}), + }); + stream.push({ type: "toolcall_start", contentIndex, partial: partial() }); + // Replace the placeholder instead of mutating it: queued start + // snapshots must not see arguments before their delta arrives. + streamedToolCalls[streamedToolCalls.length - 1] = completedToolCall; + stream.push({ + type: "toolcall_delta", + contentIndex, + delta: JSON.stringify(completedToolCall.arguments), + partial: partial(), + }); + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: completedToolCall, + partial: partial(), + }); + } + } + stream.push({ type: "done", - reason: resolveOllamaStopReason(finalResponse), + reason, message: assistantMessage, }); } finally { diff --git a/extensions/openai/realtime-quicksilver-bridge.test.ts b/extensions/openai/realtime-quicksilver-bridge.test.ts index 91ceca298e94..6bcb03df22a6 100644 --- a/extensions/openai/realtime-quicksilver-bridge.test.ts +++ b/extensions/openai/realtime-quicksilver-bridge.test.ts @@ -209,6 +209,28 @@ describe("OpenAIQuicksilverVoiceBridge", () => { harness.bridge.close(); }); + it("discards audio closed before the first connection and reconnects fresh", async () => { + const harness = createHarness(); + + harness.bridge.sendAudio(Buffer.from("queued-before-connect")); + harness.bridge.close(); + harness.bridge.close(); + harness.bridge.sendAudio(Buffer.from("sent-after-close")); + + expect(harness.connections).toHaveLength(0); + expect(harness.onClose).not.toHaveBeenCalled(); + + await harness.bridge.connect(); + + expect( + sentEvents(harness.socket).filter((event) => event.type === "input_audio.append"), + ).toHaveLength(0); + + harness.bridge.close(); + expect(harness.onClose).toHaveBeenCalledOnce(); + expect(harness.onClose).toHaveBeenCalledWith("completed"); + }); + it("does not carry queued audio across terminal close and explicit reconnect", async () => { const sockets: FakeSocket[] = []; const bridge = new OpenAIQuicksilverVoiceBridge({ diff --git a/extensions/openai/realtime-quicksilver-bridge.ts b/extensions/openai/realtime-quicksilver-bridge.ts index e0dd6583d4c0..de8583cfcca1 100644 --- a/extensions/openai/realtime-quicksilver-bridge.ts +++ b/extensions/openai/realtime-quicksilver-bridge.ts @@ -361,10 +361,13 @@ export class OpenAIQuicksilverVoiceBridge implements RealtimeVoiceBridge { close(): void { const connection = this.connection; - if (!connection || !this.lifecycle.cancel()) { + if (!this.lifecycle.cancel()) { return; } this.resetTerminalState(); + if (!connection) { + return; + } if (this.socket?.readyState === WEBSOCKET_OPEN) { this.sendEvent({ type: "session.close" }); } diff --git a/extensions/openai/realtime-voice-lifecycle.test.ts b/extensions/openai/realtime-voice-lifecycle.test.ts index 85b044e94f2a..181077ee0ec9 100644 --- a/extensions/openai/realtime-voice-lifecycle.test.ts +++ b/extensions/openai/realtime-voice-lifecycle.test.ts @@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest"; import { OpenAIRealtimeVoiceLifecycle } from "./realtime-voice-lifecycle.js"; describe("OpenAIRealtimeVoiceLifecycle", () => { + it("terminalizes preconnect cancellation until an explicit fresh connection", () => { + const lifecycle = new OpenAIRealtimeVoiceLifecycle(); + + expect(lifecycle.phase()).toBe("idle"); + expect(lifecycle.cancel()).toBe(true); + expect(lifecycle.phase()).toBe("terminal"); + expect(lifecycle.cancel()).toBe(false); + + const connection = lifecycle.connect(); + expect(lifecycle.phase()).toBe("connecting"); + expect(lifecycle.ready(connection)).toBe(true); + expect(lifecycle.phase()).toBe("ready"); + }); + it("moves a connection from connecting to ready", () => { const lifecycle = new OpenAIRealtimeVoiceLifecycle(); const connection = lifecycle.connect(); diff --git a/extensions/openai/realtime-voice-lifecycle.ts b/extensions/openai/realtime-voice-lifecycle.ts index a436a5ac92af..1bfe25ef5556 100644 --- a/extensions/openai/realtime-voice-lifecycle.ts +++ b/extensions/openai/realtime-voice-lifecycle.ts @@ -1,4 +1,9 @@ -type OpenAIRealtimeVoiceLifecyclePhase = "connecting" | "ready" | "retry-wait" | "terminal"; +type OpenAIRealtimeVoiceLifecyclePhase = + | "idle" + | "connecting" + | "ready" + | "retry-wait" + | "terminal"; type OpenAIRealtimeVoiceTerminalOutcome = "completed" | "error"; @@ -7,20 +12,29 @@ export type OpenAIRealtimeVoiceConnection = Readonly<{ signal: AbortSignal; }>; -type OpenAIRealtimeVoiceLifecycleState = { +type OpenAIRealtimeVoiceIdleState = { + phase: "idle" | "terminal"; + terminalOutcome?: "completed"; +}; + +type OpenAIRealtimeVoiceConnectionState = { connection: OpenAIRealtimeVoiceConnection; controller: AbortController; - phase: OpenAIRealtimeVoiceLifecyclePhase; + phase: Exclude; retryAttempts: number; terminalOutcome?: OpenAIRealtimeVoiceTerminalOutcome; terminalNotified: boolean; }; export class OpenAIRealtimeVoiceLifecycle { - private state: OpenAIRealtimeVoiceLifecycleState | undefined; + private state: OpenAIRealtimeVoiceIdleState | OpenAIRealtimeVoiceConnectionState = { + phase: "idle", + }; connect(): OpenAIRealtimeVoiceConnection { - this.state?.controller.abort(new Error("OpenAI realtime voice connection replaced")); + if ("controller" in this.state) { + this.state.controller.abort(new Error("OpenAI realtime voice connection replaced")); + } const controller = new AbortController(); const connection = this.createConnection(controller); this.state = { @@ -72,9 +86,16 @@ export class OpenAIRealtimeVoiceLifecycle { cancel(): boolean { const state = this.state; - if (!state || state.terminalOutcome) { + if (state.phase === "terminal") { return false; } + if (!("controller" in state)) { + this.state = { + phase: "terminal", + terminalOutcome: "completed", + }; + return true; + } state.phase = "terminal"; state.terminalOutcome = "completed"; state.controller.abort(new Error("OpenAI realtime voice session canceled")); @@ -125,8 +146,8 @@ export class OpenAIRealtimeVoiceLifecycle { return this.state?.phase === "ready"; } - phase(): OpenAIRealtimeVoiceLifecyclePhase | undefined { - return this.state?.phase; + phase(): OpenAIRealtimeVoiceLifecyclePhase { + return this.state.phase; } terminalOutcome( @@ -141,7 +162,9 @@ export class OpenAIRealtimeVoiceLifecycle { private currentState( connection: OpenAIRealtimeVoiceConnection, - ): OpenAIRealtimeVoiceLifecycleState | undefined { - return this.state?.connection.id === connection.id ? this.state : undefined; + ): OpenAIRealtimeVoiceConnectionState | undefined { + return "connection" in this.state && this.state.connection.id === connection.id + ? this.state + : undefined; } } diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 701d9d32c2a1..e57091cab983 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; const INTERNAL_REALTIME_VOICE_PROVIDER = Symbol.for("openclaw.internal.realtime-voice-provider.v1"); +const OPENAI_REALTIME_REJECTED_KEY_MESSAGE = + "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source"; function readInternalRealtimeVoiceProviderApi(provider: object) { return Reflect.get(provider, INTERNAL_REALTIME_VOICE_PROVIDER) as { @@ -1549,6 +1551,43 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { bridge.close(); }); + it("discards audio closed before the first connection and reconnects fresh", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const onClose = vi.fn(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onAudio: vi.fn(), + onClearAudio: vi.fn(), + onClose, + }); + + bridge.sendAudio(Buffer.from("queued-before-connect")); + bridge.close(); + bridge.close(); + bridge.sendAudio(Buffer.from("sent-after-close")); + + expect(FakeWebSocket.instances).toHaveLength(0); + expect(onClose).not.toHaveBeenCalled(); + + const connecting = bridge.connect(); + const socket = FakeWebSocket.instances[0]; + if (!socket) { + throw new Error("expected bridge to connect"); + } + socket.readyState = FakeWebSocket.OPEN; + socket.emit("open"); + socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + await connecting; + + expect( + parseSent(socket).filter((event) => event.type === "input_audio_buffer.append"), + ).toHaveLength(0); + + bridge.close(); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("completed"); + }); + it("does not carry queued audio across terminal close and explicit reconnect", async () => { const provider = buildOpenAIRealtimeVoiceProvider(); const bridge = provider.createBridge({ @@ -2114,7 +2153,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { Buffer.from( JSON.stringify({ type: "error", - error: { message: "Incorrect API key provided" }, + error: { message: "Incorrect API key provided: sk-proj-***" }, }), ), ); @@ -2123,18 +2162,105 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { Buffer.from( JSON.stringify({ type: "error", - error: { message: "Incorrect API key provided" }, + error: { message: "Incorrect API key provided: sk-proj-***" }, }), ), ); - await expect(connecting).rejects.toThrow("Incorrect API key provided"); + await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); expect(onError).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); expect(socket.closed).toBe(true); expect(bridge.isConnected()).toBe(false); }); + it("normalizes structured direct OpenAI startup auth errors", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + const connecting = bridge.connect(); + const socket = FakeWebSocket.instances[0]; + if (!socket) { + throw new Error("expected bridge to create a websocket"); + } + + socket.readyState = FakeWebSocket.OPEN; + socket.emit("open"); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + code: "invalid_api_key", + message: "Invalid API key", + }, + }), + ), + ); + + await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); + expect(bridge.isConnected()).toBe(false); + }); + + it("normalizes direct OpenAI socket handshake auth errors", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + const connecting = bridge.connect(); + const socket = FakeWebSocket.instances[0]; + if (!socket) { + throw new Error("expected bridge to create a websocket"); + } + + socket.emit("error", new Error("Unexpected server response: 401")); + + await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); + expect(bridge.isConnected()).toBe(false); + }); + + it.each([ + [ + "Azure deployment", + { + apiKey: "sk-test", // pragma: allowlist secret + azureEndpoint: "https://example.openai.azure.com", + azureDeployment: "realtime-prod", + }, + ], + [ + "custom endpoint", + { + apiKey: "sk-test", // pragma: allowlist secret + azureEndpoint: "https://realtime-proxy.example.com", + }, + ], + ])("preserves %s startup auth errors", async (_label, providerConfig) => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + providerConfig, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + const connecting = bridge.connect(); + const socket = FakeWebSocket.instances[0]; + if (!socket) { + throw new Error("expected bridge to create a websocket"); + } + + socket.emit("error", new Error("Unexpected server response: 401")); + + await expect(connecting).rejects.toThrow("Unexpected server response: 401"); + expect(bridge.isConnected()).toBe(false); + }); + it("keeps a retried connection ready after delayed startup failure close", async () => { const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); @@ -2163,7 +2289,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ), ); - await expect(failedConnect).rejects.toThrow("Incorrect API key provided"); + await expect(failedConnect).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); expect(failedSocket.deferredClose).toBeDefined(); const retryConnect = bridge.connect(); diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index 68f13eb388c7..678ea0aac0c9 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -306,6 +306,30 @@ const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/; const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5000; const resolvedKeychainSecretRefCache = new Map(); +function isDirectOpenAIRealtimeWebSocketUrl(value: string): boolean { + try { + return new URL(value).hostname === "api.openai.com"; + } catch { + return false; + } +} + +function isOpenAIRealtimeStartupAuthFailure(error: unknown): boolean { + const record = + typeof error === "object" && error !== null ? (error as Record) : undefined; + const status = record?.status ?? record?.statusCode; + const rawCode = record?.code ?? record?.errorCode; + const code = typeof rawCode === "string" ? rawCode.toLowerCase() : ""; + const message = readRealtimeErrorDetail(error).toLowerCase(); + return ( + status === 401 || + code === "invalid_api_key" || + message.includes("invalid_api_key") || + message.includes("incorrect api key provided") || + message.includes("unexpected server response: 401") + ); +} + function resolveKeychainSecretRef(value: string): string | undefined { const trimmed = value.trim(); const match = KEYCHAIN_SECRET_REF_RE.exec(trimmed); @@ -734,10 +758,13 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { close(): void { const connection = this.connection; - if (!connection || !this.lifecycle.cancel()) { + if (!this.lifecycle.cancel()) { return; } this.resetTerminalState(); + if (!connection) { + return; + } const ws = this.ws; this.ws = null; ws?.close(1000, "Bridge closed"); @@ -880,7 +907,14 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { try { const event = JSON.parse(data.toString()) as RealtimeEvent; if (event.type === "error" && !reachedReady) { - rejectStartup(new Error(readRealtimeErrorDetail(event.error))); + // Only direct OpenAI auth failures get bounded remediation. Azure, + // custom endpoints, and non-auth startup details remain provider-owned. + rejectStartup( + isDirectOpenAIRealtimeWebSocketUrl(url) && + isOpenAIRealtimeStartupAuthFailure(event.error) + ? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED) + : new Error(readRealtimeErrorDetail(event.error)), + ); return; } this.handleEvent(event, lifecycleConnection); @@ -914,7 +948,13 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { }, }); if (!reachedReady) { - rejectStartup(error instanceof Error ? error : new Error(String(error))); + const startupError = error instanceof Error ? error : new Error(String(error)); + rejectStartup( + isDirectOpenAIRealtimeWebSocketUrl(url) && + isOpenAIRealtimeStartupAuthFailure(startupError) + ? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED) + : startupError, + ); return; } this.config.onError?.(error instanceof Error ? error : new Error(String(error))); diff --git a/extensions/qa-channel/src/channel-actions.ts b/extensions/qa-channel/src/channel-actions.ts index 7761d0f2c957..16ace743ce75 100644 --- a/extensions/qa-channel/src/channel-actions.ts +++ b/extensions/qa-channel/src/channel-actions.ts @@ -163,6 +163,9 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = { // QA evidence must not validate a host target while the bus acts on a // foreign immutable message owner. assertQaMessageMatchesTarget(message, target); + if (message.deleted) { + throw new Error(`qa-channel message was deleted: ${message.id}`); + } return message; }; diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 0220a9a0ad81..4fce75457e4e 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -706,6 +706,121 @@ describe("qa-channel plugin", () => { } }); + it("keeps deleted messages out of channel actions and makes reactions idempotent", async () => { + installQaChannelTestRegistry(); + const state = createQaBusState(); + const bus = await startQaBusServer({ state }); + + try { + const cfg = createQaChannelConfig({ baseUrl: bus.baseUrl }); + const handleAction = requireQaActionHandler(); + const live = state.addOutboundMessage({ to: "channel:qa-room", text: "needle live" }); + const deleted = state.addOutboundMessage({ to: "channel:qa-room", text: "needle deleted" }); + const actionContext = { + channel: "qa-channel" as const, + cfg, + accountId: "default", + }; + const reactionParams = { + to: "channel:qa-room", + messageId: deleted.id, + emoji: "eyes", + }; + + await handleAction({ ...actionContext, action: "react", params: reactionParams }); + const cursorAfterReaction = state.getSnapshot().cursor; + await handleAction({ ...actionContext, action: "react", params: reactionParams }); + expect(state.getSnapshot().cursor).toBe(cursorAfterReaction); + expect(state.readMessage({ messageId: deleted.id }).reactions).toHaveLength(1); + + await handleAction({ + ...actionContext, + action: "delete", + params: { to: "channel:qa-room", messageId: deleted.id }, + }); + + for (const action of ["read", "reactions", "react", "edit", "delete"] as const) { + await expect( + handleAction({ + ...actionContext, + action, + params: { + to: "channel:qa-room", + messageId: deleted.id, + ...(action === "react" ? { emoji: "eyes" } : {}), + ...(action === "edit" ? { text: "edited after deletion" } : {}), + }, + }), + ).rejects.toThrow("qa-channel message was deleted"); + } + + const result = await handleAction({ + ...actionContext, + action: "search", + params: { query: "needle", channelId: "qa-room" }, + }); + const payload = extractToolPayload(result) as { messages: Array<{ id: string }> }; + expect(payload.messages.map((message) => message.id)).toEqual([live.id]); + expect(state.readMessage({ messageId: deleted.id }).deleted).toBe(true); + } finally { + await bus.stop(); + } + }); + + it("rejects thread replies outside the owning account and conversation", async () => { + installQaChannelTestRegistry(); + const state = createQaBusState(); + const bus = await startQaBusServer({ state }); + + try { + const cfg = { + channels: { + "qa-channel": { + baseUrl: bus.baseUrl, + accounts: { other: { baseUrl: bus.baseUrl } }, + }, + }, + }; + const handleAction = requireQaActionHandler(); + const thread = state.createThread({ conversationId: "qa-room", title: "Owned thread" }); + + for (const attempt of [ + { accountId: "other", channelId: "qa-room" }, + { accountId: "default", channelId: "other-room" }, + ]) { + await expect( + handleAction({ + channel: "qa-channel", + action: "thread-reply", + cfg, + accountId: attempt.accountId, + params: { + channelId: attempt.channelId, + threadId: thread.id, + text: "foreign reply", + }, + }), + ).rejects.toThrow("qa-bus thread not found in selected account and conversation"); + } + expect(state.getSnapshot().messages).toEqual([]); + expect(state.getSnapshot().conversations).toEqual([ + { accountId: "default", id: "qa-room", kind: "channel" }, + ]); + + const result = await handleAction({ + channel: "qa-channel", + action: "thread-reply", + cfg, + accountId: "default", + params: { channelId: "qa-room", threadId: thread.id, text: "owned reply" }, + }); + const payload = extractToolPayload(result) as { message: { threadId: string } }; + expect(payload.message.threadId).toBe(thread.id); + } finally { + await bus.stop(); + } + }); + it("binds message-id actions and searches to the selected account and conversation", async () => { installQaChannelTestRegistry(); const state = createQaBusState(); diff --git a/extensions/qa-lab/src/bus-queries.ts b/extensions/qa-lab/src/bus-queries.ts index 28a787c80996..28d3e978edf2 100644 --- a/extensions/qa-lab/src/bus-queries.ts +++ b/extensions/qa-lab/src/bus-queries.ts @@ -115,7 +115,7 @@ export function searchQaBusMessages(params: { const limit = Math.max(1, Math.min(params.input.limit ?? 20, 100)); const query = normalizeOptionalLowercaseString(params.input.query); return Array.from(params.messages.values()) - .filter((message) => message.accountId === accountId) + .filter((message) => message.accountId === accountId && !message.deleted) .filter((message) => params.input.conversationId !== undefined ? message.conversation.id === params.input.conversationId diff --git a/extensions/qa-lab/src/bus-state.test.ts b/extensions/qa-lab/src/bus-state.test.ts index 425f8573b5fc..5ae1b2bd5c41 100644 --- a/extensions/qa-lab/src/bus-state.test.ts +++ b/extensions/qa-lab/src/bus-state.test.ts @@ -113,6 +113,110 @@ describe("qa-bus state", () => { expect(typeof snapshot.messages[0]?.reactions[0]?.timestamp).toBe("number"); }); + it("keeps deleted messages inspectable but removes them from mutations and search", () => { + const state = createQaBusState(); + const live = state.addOutboundMessage({ to: "channel:qa-room", text: "needle live" }); + const deleted = state.addOutboundMessage({ to: "channel:qa-room", text: "needle deleted" }); + + state.deleteMessage({ messageId: deleted.id }); + const cursorAfterDelete = state.getSnapshot().cursor; + + expect(state.readMessage({ messageId: deleted.id }).deleted).toBe(true); + expect(state.getSnapshot().messages.map((message) => message.id)).toEqual([ + live.id, + deleted.id, + ]); + expect(state.searchMessages({ query: "needle", limit: 1 })).toEqual([ + expect.objectContaining({ id: live.id }), + ]); + + expect(() => + state.editMessage({ messageId: deleted.id, text: "edited after deletion" }), + ).toThrow("qa-bus message was deleted"); + expect(() => state.reactToMessage({ messageId: deleted.id, emoji: "eyes" })).toThrow( + "qa-bus message was deleted", + ); + expect(() => state.deleteMessage({ messageId: deleted.id })).toThrow( + "qa-bus message was deleted", + ); + expect(state.getSnapshot().cursor).toBe(cursorAfterDelete); + }); + + it("adds each sender and emoji reaction at most once", () => { + const state = createQaBusState(); + const message = state.addOutboundMessage({ to: "channel:qa-room", text: "react once" }); + + state.reactToMessage({ messageId: message.id, emoji: "eyes", senderId: " alice " }); + const cursorAfterReaction = state.getSnapshot().cursor; + + const repeated = state.reactToMessage({ + messageId: message.id, + emoji: "eyes", + senderId: "alice", + }); + expect(repeated.reactions).toHaveLength(1); + expect(state.getSnapshot().cursor).toBe(cursorAfterReaction); + + state.reactToMessage({ messageId: message.id, emoji: "eyes", senderId: "bob" }); + state.reactToMessage({ messageId: message.id, emoji: "wave", senderId: "alice" }); + expect(state.readMessage({ messageId: message.id }).reactions).toEqual([ + expect.objectContaining({ emoji: "eyes", senderId: "alice" }), + expect.objectContaining({ emoji: "eyes", senderId: "bob" }), + expect.objectContaining({ emoji: "wave", senderId: "alice" }), + ]); + expect(state.getSnapshot().cursor).toBe(cursorAfterReaction + 2); + }); + + it("keeps owned threads scoped to their account, channel, and conversation", () => { + const state = createQaBusState(); + const thread = state.createThread({ + accountId: "account-a", + conversationId: "qa-room", + title: "Owned thread", + }); + const originalSnapshot = state.getSnapshot(); + + expect(() => + state.addOutboundMessage({ + accountId: "account-b", + to: `thread:qa-room/${thread.id}`, + text: "cross-account reply", + }), + ).toThrow("qa-bus thread not found in selected account and conversation"); + expect(() => + state.addOutboundMessage({ + accountId: "account-a", + to: `thread:other-room/${thread.id}`, + text: "wrong-room reply", + }), + ).toThrow("qa-bus thread not found in selected account and conversation"); + for (const kind of ["direct", "group"] as const) { + expect(() => + state.addInboundMessage({ + accountId: "account-a", + conversation: { id: "qa-room", kind }, + senderId: "alice", + text: "wrong-kind reply", + threadId: thread.id, + }), + ).toThrow("qa-bus thread not found in selected account and conversation"); + } + expect(state.getSnapshot()).toEqual(originalSnapshot); + + const reply = state.addOutboundMessage({ + accountId: "account-a", + to: `thread:qa-room/${thread.id}`, + text: "owned reply", + }); + const external = state.addOutboundMessage({ + accountId: "account-b", + to: "thread:other-room/external-thread", + text: "externally observed reply", + }); + expect(reply.threadId).toBe(thread.id); + expect(external.threadId).toBe("external-thread"); + }); + it("rejects cross-account message reads and mutations", () => { const state = createQaBusState(); const message = state.addOutboundMessage({ diff --git a/extensions/qa-lab/src/bus-state.ts b/extensions/qa-lab/src/bus-state.ts index ffcb227197ca..f28fe1eed097 100644 --- a/extensions/qa-lab/src/bus-state.ts +++ b/extensions/qa-lab/src/bus-state.ts @@ -122,6 +122,16 @@ export function createQaBusState() { return created; }; + const requireActiveMessageForAccount = ( + input: Pick, + ): QaBusMessage => { + const message = requireQaBusMessageForAccount({ messages, input }); + if (message.deleted) { + throw new Error(`qa-bus message was deleted: ${input.messageId}`); + } + return message; + }; + const createMessage = (params: { direction: QaBusMessage["direction"]; accountId: string; @@ -137,6 +147,17 @@ export function createQaBusState() { nativeCommand?: QaBusInboundMessageInput["nativeCommand"]; toolCalls?: QaBusToolCall[]; }): QaBusMessage => { + const thread = params.threadId ? threads.get(params.threadId) : undefined; + if ( + thread && + (thread.accountId !== params.accountId || + thread.conversationId !== params.conversation.id || + params.conversation.kind !== "channel") + ) { + // Unknown ids can represent externally observed threads; owned records + // must never cross account, conversation, or channel-kind boundaries. + throw new Error("qa-bus thread not found in selected account and conversation"); + } const storedConversation = ensureConversation(params.accountId, params.conversation); const toolCalls = sanitizeQaBusToolCalls(params.toolCalls); const message: QaBusMessage = { @@ -257,12 +278,20 @@ export function createQaBusState() { }, reactToMessage(input: QaBusReactToMessageInput) { const accountId = normalizeAccountId(input.accountId); - const message = requireQaBusMessageForAccount({ messages, input }); + const message = requireActiveMessageForAccount(input); const reaction = { emoji: input.emoji, senderId: input.senderId?.trim() || DEFAULT_BOT_ID, timestamp: input.timestamp ?? Date.now(), }; + if ( + message.reactions.some( + (existing) => + existing.emoji === reaction.emoji && existing.senderId === reaction.senderId, + ) + ) { + return cloneMessage(message); + } message.reactions.push(reaction); pushEvent({ kind: "reaction-added", @@ -275,7 +304,7 @@ export function createQaBusState() { }, editMessage(input: QaBusEditMessageInput) { const accountId = normalizeAccountId(input.accountId); - const message = requireQaBusMessageForAccount({ messages, input }); + const message = requireActiveMessageForAccount(input); message.text = input.text; message.editedAt = input.timestamp ?? Date.now(); pushEvent({ @@ -287,7 +316,7 @@ export function createQaBusState() { }, deleteMessage(input: QaBusDeleteMessageInput) { const accountId = normalizeAccountId(input.accountId); - const message = requireQaBusMessageForAccount({ messages, input }); + const message = requireActiveMessageForAccount(input); message.deleted = true; pushEvent({ kind: "message-deleted", diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index bd0eac4708f4..f0a7392ef79d 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -314,12 +314,12 @@ describe("qa cli runtime", () => { watchUrl: "http://127.0.0.1:43124", }); runQaMultipass.mockResolvedValue({ - outputDir: "/tmp/multipass", - reportPath: "/tmp/multipass/qa-suite-report.md", - summaryPath: "/tmp/multipass/qa-suite-summary.json", - hostLogPath: "/tmp/multipass/multipass-host.log", - bootstrapLogPath: "/tmp/multipass/multipass-guest-bootstrap.log", - guestScriptPath: "/tmp/multipass/multipass-guest-run.sh", + outputDir: suiteArtifactsDir, + reportPath: suiteReportPath, + summaryPath: suiteSummaryPath, + hostLogPath: path.join(suiteArtifactsDir, "multipass-host.log"), + bootstrapLogPath: path.join(suiteArtifactsDir, "multipass-guest-bootstrap.log"), + guestScriptPath: path.join(suiteArtifactsDir, "multipass-guest-run.sh"), vmName: "openclaw-qa-test", scenarioIds: ["channel-chat-baseline"], }); @@ -464,9 +464,7 @@ describe("qa cli runtime", () => { } }); - it("keeps direct-suite zero-work validation disabled with --allow-failures", async () => { - const priorExitCode = process.exitCode; - process.exitCode = undefined; + it("rejects direct-suite zero-work summaries even with --allow-failures", async () => { const optionalScenario = { name: "Runtime tool fixture — image_generate", status: "skip" as const, @@ -490,14 +488,108 @@ describe("qa cli runtime", () => { }), ); - try { - await runQaSuiteCommand({ repoRoot: "/tmp/openclaw-repo", allowFailures: true }); - expect(process.exitCode).toBeUndefined(); - } finally { - process.exitCode = priorExitCode; - } + await expect( + runQaSuiteCommand({ repoRoot: "/tmp/openclaw-repo", allowFailures: true }), + ).rejects.toThrow("did not include any executed scenarios"); }); + it.each([ + { runner: "host" as const, summary: "missing" as const, expected: "Could not read QA summary" }, + { + runner: "host" as const, + summary: "malformed" as const, + expected: "Could not parse QA summary", + }, + { + runner: "multipass" as const, + summary: "missing" as const, + expected: "Could not read QA summary", + }, + { + runner: "multipass" as const, + summary: "malformed" as const, + expected: "Could not parse QA summary", + }, + { + runner: "multipass" as const, + summary: "zero-work" as const, + expected: "did not include any executed scenarios", + }, + ...(["host", "flow", "multipass"] as const).flatMap((runner) => [ + { + runner, + summary: "required-skip" as const, + expected: "did not include any executed scenarios", + }, + { + runner, + summary: "blocked" as const, + expected: "did not include any executed scenarios", + }, + ]), + ])( + "rejects $summary $runner summaries even with --allow-failures", + async ({ runner, summary, expected }) => { + if (summary === "missing") { + await fs.rm(suiteSummaryPath); + } else if (summary === "malformed") { + await fs.writeFile(suiteSummaryPath, "{not-json", "utf8"); + } else if (summary === "zero-work") { + await fs.writeFile( + suiteSummaryPath, + JSON.stringify({ + counts: { total: 0, passed: 0, failed: 0, skipped: 0 }, + scenarios: [], + }), + "utf8", + ); + } else { + await fs.writeFile( + suiteSummaryPath, + JSON.stringify({ + counts: { + total: 1, + passed: 0, + failed: 0, + skipped: summary === "required-skip" ? 1 : 0, + }, + scenarios: [ + { + name: "Required channel scenario", + status: summary === "required-skip" ? "skip" : "blocked", + details: "Required transport unavailable", + }, + ], + }), + "utf8", + ); + } + if (runner === "host" || runner === "flow") { + runQaSuite.mockResolvedValueOnce( + runner === "flow" + ? flowSuiteRuntimeResult({ + reportPath: suiteReportPath, + summaryPath: suiteSummaryPath, + }) + : unifiedSuiteRuntimeResult({ + outputDir: suiteArtifactsDir, + reportPath: suiteReportPath, + summaryPath: suiteSummaryPath, + evidencePath: suiteEvidencePath, + }), + ); + } + + await expect( + runQaSuiteCommand({ + repoRoot: "/tmp/openclaw-repo", + ...(runner === "multipass" ? { runner } : {}), + allowFailures: true, + }), + ).rejects.toThrow(expected); + }, + ); + it("rejects host-only resource options for Playwright scenarios", async () => { await expect( runQaSuiteCommand({ diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index 8168ca123e77..5ab0fe1b0a45 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -368,6 +368,7 @@ async function runQaParityPreflight(params: { process.stdout.write(`QA parity preflight summary: ${result.summaryPath}\n`); const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( result.summaryPath, + { requireExecutedScenario: params.allowFailures === true }, ); if (blockingScenarioCount > 0) { if (params.allowFailures === true) { @@ -978,19 +979,18 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) { process.stdout.write(`QA Multipass summary: ${result.summaryPath}\n`); process.stdout.write(`QA Multipass host log: ${result.hostLogPath}\n`); process.stdout.write(`QA Multipass bootstrap log: ${result.bootstrapLogPath}\n`); - if (!allowFailures) { - const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( - result.summaryPath, - { - optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ - scenarioIds, - explicitScenarioSelection: opts.explicitScenarioSelection, - }), - }, - ); - if (blockingScenarioCount > 0) { - process.exitCode = 1; - } + const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( + result.summaryPath, + { + optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ + scenarioIds, + explicitScenarioSelection: opts.explicitScenarioSelection, + }), + requireExecutedScenario: allowFailures, + }, + ); + if (!allowFailures && blockingScenarioCount > 0) { + process.exitCode = 1; } return result; } @@ -1051,19 +1051,18 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) { process.stdout.write(`QA suite report: ${result.reportPath}\n`); process.stdout.write(`QA suite evidence: ${result.evidencePath}\n`); process.stdout.write(`QA suite summary: ${result.summaryPath}\n`); - if (!allowFailures) { - const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( - result.summaryPath, - { - optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ - scenarioIds, - explicitScenarioSelection: opts.explicitScenarioSelection, - }), - }, - ); - if (blockingScenarioCount > 0) { - process.exitCode = 1; - } + const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( + result.summaryPath, + { + optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ + scenarioIds, + explicitScenarioSelection: opts.explicitScenarioSelection, + }), + requireExecutedScenario: allowFailures, + }, + ); + if (!allowFailures && blockingScenarioCount > 0) { + process.exitCode = 1; } return result; } @@ -1080,6 +1079,7 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) { scenarioIds, explicitScenarioSelection: opts.explicitScenarioSelection, }), + requireExecutedScenario: allowFailures, }, ); if (!allowFailures && blockingScenarioCount > 0) { diff --git a/extensions/qa-lab/src/crabline-transport.ts b/extensions/qa-lab/src/crabline-transport.ts index 43a30d6472cf..b5b7fe9ad918 100644 --- a/extensions/qa-lab/src/crabline-transport.ts +++ b/extensions/qa-lab/src/crabline-transport.ts @@ -399,6 +399,7 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter { }; this.waitForOutboundSequence = async (input) => await waitForQaTransportOutboundSequence({ + accountId: this.accountId, input, readEvents: () => this.#state.getOutboundEvents(), }); diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts index 39bb142fb031..c5a71ff2257b 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts @@ -60,6 +60,8 @@ describe("Telegram live QA scenario gate", () => { summaryPath, JSON.stringify({ counts: { + total: 1, + passed: status === "pass" ? 1 : 0, failed: status === "fail" ? 1 : 0, skipped: status === "skip" || status === "skipped" ? 1 : 0, }, @@ -76,6 +78,7 @@ describe("Telegram live QA scenario gate", () => { delete process.env[SUT_COMMAND_ENV]; tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-qa-telegram-gate-")); summaryPath = path.join(tempRoot, "qa-suite-summary.json"); + writeSummary("pass"); mocks.resolveTelegramQaScenarioIds.mockReturnValue(["channel-canary"]); mocks.runQaFlowSuiteFromRuntime.mockResolvedValue({ reportPath: ".artifacts/qa-e2e/telegram/qa-suite-report.md", @@ -121,7 +124,8 @@ describe("Telegram live QA scenario gate", () => { expect(process.exitCode).toBeUndefined(); }); - it("does not read the summary when failures are explicitly allowed", async () => { + it("permits genuinely executed failed scenarios when failures are explicitly allowed", async () => { + writeSummary("fail"); await runQaTelegramSuite({ repoRoot: "/repo", providerMode: "mock-openai", @@ -131,6 +135,43 @@ describe("Telegram live QA scenario gate", () => { expect(process.exitCode).toBeUndefined(); }); + it.each([ + { summary: "missing", expected: "Could not read QA summary" }, + { summary: "malformed", expected: "Could not parse QA summary" }, + { summary: "zero-work", expected: "did not include any executed scenarios" }, + { summary: "required-skip", expected: "did not include any executed scenarios" }, + { summary: "blocked", expected: "did not include any executed scenarios" }, + ])( + "rejects $summary Telegram summaries even with --allow-failures", + async ({ summary, expected }) => { + if (summary === "missing") { + rmSync(summaryPath); + } else if (summary === "malformed") { + writeFileSync(summaryPath, "{not-json", "utf8"); + } else if (summary === "zero-work") { + writeFileSync( + summaryPath, + JSON.stringify({ + counts: { total: 0, passed: 0, failed: 0, skipped: 0 }, + scenarios: [], + }), + "utf8", + ); + } else { + writeSummary(summary === "required-skip" ? "skip" : "blocked"); + } + + await expect( + runQaTelegramSuite({ + repoRoot: "/repo", + providerMode: "mock-openai", + allowFailures: true, + }), + ).rejects.toThrow(expected); + expect(process.exitCode).toBeUndefined(); + }, + ); + it("lists only scenarios accepted by its flow runner", async () => { const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true); mocks.listTelegramQaScenarios.mockReturnValue([ diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts index 99011ebab2bf..4754c1d7eed1 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts @@ -199,13 +199,12 @@ export async function runQaTelegramSuite(opts: TelegramQaSuiteOptions) { report: result.reportPath, summary: result.summaryPath, }); - if (!runOptions.allowFailures) { - const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( - result.summaryPath, - ); - if (blockingScenarioCount > 0) { - process.exitCode = 1; - } + const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( + result.summaryPath, + { requireExecutedScenario: runOptions.allowFailures === true }, + ); + if (!runOptions.allowFailures && blockingScenarioCount > 0) { + process.exitCode = 1; } return result; } diff --git a/extensions/qa-lab/src/qa-channel-transport.test.ts b/extensions/qa-lab/src/qa-channel-transport.test.ts index 6e9c96eaf350..0fd339af19cf 100644 --- a/extensions/qa-lab/src/qa-channel-transport.test.ts +++ b/extensions/qa-lab/src/qa-channel-transport.test.ts @@ -158,6 +158,70 @@ describe("qa channel transport", () => { expect(transport.state.getSnapshot().messages).toEqual([]); }); + it("keeps outbound verdicts bound to the selected account", async () => { + const state = createQaBusState(); + const transport = createQaChannelTransport(state); + const conversation = { id: "alice", kind: "direct" as const }; + + state.addOutboundMessage({ + accountId: "other", + to: "dm:alice", + text: "QA-ACCOUNT-OK", + }); + state.addOutboundMessage({ + accountId: "other", + to: "dm:alice", + text: "⚠️ agent failed before reply: foreign account failure", + }); + const expected = state.addOutboundMessage({ + accountId: "default", + to: "dm:alice", + text: "QA-ACCOUNT-OK", + }); + + await expect( + transport.waitForOutbound({ conversation, textIncludes: "QA-ACCOUNT-OK", timeoutMs: 50 }), + ).resolves.toMatchObject({ accountId: "default", id: expected.id }); + }); + + it("does not accept deleted previews as visible outbound replies", async () => { + const state = createQaBusState(); + const transport = createQaChannelTransport(state); + const preview = state.addOutboundMessage({ + to: "dm:alice", + text: "QA-VISIBLE-FINAL-OK", + }); + state.deleteMessage({ messageId: preview.id }); + const final = state.addOutboundMessage({ + to: "dm:alice", + text: "QA-VISIBLE-FINAL-OK", + }); + + await expect( + transport.waitForOutbound({ + conversation: { id: "alice", kind: "direct" }, + textIncludes: "QA-VISIBLE-FINAL-OK", + timeoutMs: 50, + }), + ).resolves.toMatchObject({ id: final.id }); + }); + + it("ignores another account's failure while waiting for a condition", async () => { + const state = createQaBusState(); + const transport = createQaChannelTransport(state); + + await expect( + transport.waitForCondition(async () => { + state.addOutboundMessage({ + accountId: "other", + to: "dm:alice", + text: "⚠️ agent failed before reply: foreign account failure", + }); + return "owned condition completed"; + }, 50), + ).resolves.toBe("owned condition completed"); + }); + it("injects native commands with transport metadata", async () => { const transport = createQaChannelTransport(createQaBusState()); diff --git a/extensions/qa-lab/src/qa-channel-transport.ts b/extensions/qa-lab/src/qa-channel-transport.ts index 5f4a1d5915ce..be1af5f0ec41 100644 --- a/extensions/qa-lab/src/qa-channel-transport.ts +++ b/extensions/qa-lab/src/qa-channel-transport.ts @@ -184,6 +184,7 @@ class QaChannelTransport extends QaStateBackedTransportAdapter { } async waitForOutboundSequence(input: QaTransportOutboundSequenceMatch) { return await waitForQaTransportOutboundSequence({ + accountId: this.accountId, input, readEvents: () => this.state.getSnapshot().events, }); diff --git a/extensions/qa-lab/src/qa-transport.test.ts b/extensions/qa-lab/src/qa-transport.test.ts index b81c43669fbd..2c09f6545752 100644 --- a/extensions/qa-lab/src/qa-transport.test.ts +++ b/extensions/qa-lab/src/qa-transport.test.ts @@ -69,6 +69,7 @@ describe("waitForQaTransportOutboundSequence", () => { await expect( waitForQaTransportOutboundSequence({ + accountId: "default", input: { conversationId: "qa-room", finalSettleMs: 0, @@ -104,6 +105,7 @@ describe("waitForQaTransportOutboundSequence", () => { await expect( waitForQaTransportOutboundSequence({ + accountId: "default", input: { conversationId: "alice", finalSettleMs: 20, @@ -132,6 +134,7 @@ describe("waitForQaTransportOutboundSequence", () => { await expect( waitForQaTransportOutboundSequence({ + accountId: "default", input: { conversationId: "alice", finalSettleMs: 0, @@ -143,4 +146,63 @@ describe("waitForQaTransportOutboundSequence", () => { }), ).rejects.toThrow("timed out after 20ms"); }); + + it("ignores foreign-account and inbound edit events when proving a final reply", async () => { + const state = createQaBusState(); + const expected = state.addOutboundMessage({ + accountId: "default", + to: "dm:alice", + text: "owned preview", + }); + state.editMessage({ + accountId: "default", + messageId: expected.id, + text: "final marker", + }); + + const foreign = state.addOutboundMessage({ + accountId: "other", + to: "dm:alice", + text: "foreign preview", + }); + state.editMessage({ + accountId: "other", + messageId: foreign.id, + text: "final marker", + }); + + const inbound = state.addInboundMessage({ + accountId: "default", + conversation: { id: "alice", kind: "direct" }, + senderId: "alice", + text: "inbound original", + }); + state.editMessage({ + accountId: "default", + messageId: inbound.id, + text: "inbound preview", + }); + state.editMessage({ + accountId: "default", + messageId: inbound.id, + text: "final marker", + }); + + await expect( + waitForQaTransportOutboundSequence({ + accountId: "default", + input: { + conversationId: "alice", + finalSettleMs: 0, + finalTextIncludes: "final marker", + minimumPreviewEvents: 1, + timeoutMs: 50, + }, + readEvents: () => state.getSnapshot().events, + }), + ).resolves.toMatchObject({ + events: [{ kind: "sent" }, { kind: "edited" }], + final: { accountId: "default", direction: "outbound", id: expected.id }, + }); + }); }); diff --git a/extensions/qa-lab/src/qa-transport.ts b/extensions/qa-lab/src/qa-transport.ts index 924c21894391..0f99f4c77035 100644 --- a/extensions/qa-lab/src/qa-transport.ts +++ b/extensions/qa-lab/src/qa-transport.ts @@ -61,6 +61,7 @@ export type QaTransportState = { type QaTransportFailureCursorSpace = "all" | "outbound"; type QaTransportFailureAssertionOptions = { + accountId?: string; sinceIndex?: number; cursorSpace?: QaTransportFailureCursorSpace; }; @@ -142,7 +143,9 @@ export function findFailureOutboundMessage( .slice(options?.sinceIndex ?? 0); return observedMessages.find( (message) => - message.direction === "outbound" && Boolean(extractQaFailureReplyText(message.text)), + message.direction === "outbound" && + (!options?.accountId || message.accountId === options.accountId) && + Boolean(extractQaFailureReplyText(message.text)), ); } @@ -156,7 +159,7 @@ function assertNoFailureReplies( } } -function createFailureAwareTransportWaitForCondition(state: QaTransportState) { +function createFailureAwareTransportWaitForCondition(state: QaTransportState, accountId: string) { return async function waitForTransportCondition( check: () => T | Promise | null | undefined, timeoutMs = 15_000, @@ -166,11 +169,13 @@ function createFailureAwareTransportWaitForCondition(state: QaTransportState) { return await waitForQaTransportCondition( async () => { assertNoFailureReplies(state, { + accountId, sinceIndex, cursorSpace: "all", }); const value = await check(); assertNoFailureReplies(state, { + accountId, sinceIndex, cursorSpace: "all", }); @@ -227,7 +232,10 @@ export abstract class QaStateBackedTransportAdapter implements QaTransportAdapte this.supportedActions = params.supportedActions ?? []; this.state = params.state; this.assertTransportHealthy = params.assertTransportHealthy ?? (() => undefined); - const waitForCondition = createFailureAwareTransportWaitForCondition(this.state); + const waitForCondition = createFailureAwareTransportWaitForCondition( + this.state, + this.accountId, + ); this.waitForCondition = async (check, timeoutMs, intervalMs) => await waitForCondition( async () => { @@ -274,6 +282,7 @@ export abstract class QaStateBackedTransportAdapter implements QaTransportAdapte await sleep(quietMs); this.assertTransportHealthy(); assertNoFailureReplies(this.state, { + accountId: this.accountId, sinceIndex: input.sinceIndex, cursorSpace: "outbound", }); @@ -288,10 +297,14 @@ export abstract class QaStateBackedTransportAdapter implements QaTransportAdapte return await waitForQaTransportCondition(() => { this.assertTransportHealthy(); assertNoFailureReplies(this.state, { + accountId: this.accountId, sinceIndex: input.sinceIndex, cursorSpace: "outbound", }); return this.outboundSince(input.sinceIndex).find((message) => { + if (message.deleted) { + return false; + } if (input.conversation && message.conversation.id !== input.conversation.id) { return false; } @@ -313,7 +326,8 @@ export abstract class QaStateBackedTransportAdapter implements QaTransportAdapte return this.state .getSnapshot() .messages.filter((message) => message.direction === "outbound") - .slice(sinceIndex); + .slice(sinceIndex) + .filter((message) => message.accountId === this.accountId); } } @@ -349,6 +363,7 @@ export function createQaStateBackedTransportAdapter( params.waitForOutboundSequence ?? (async (input: QaTransportOutboundSequenceMatch) => await waitForQaTransportOutboundSequence({ + accountId: params.accountId, input, readEvents: () => { params.assertTransportHealthy?.(); @@ -387,6 +402,7 @@ function isQaTransportOutboundEvent( } export async function waitForQaTransportOutboundSequence(params: { + accountId: string; input: QaTransportOutboundSequenceMatch; readEvents: () => | readonly (QaBusEvent | QaTransportOutboundEvent)[] @@ -404,6 +420,9 @@ export async function waitForQaTransportOutboundSequence(params: { ) .filter((event): event is QaTransportOutboundEvent => event !== null) .filter(({ message }) => { + if (message.accountId !== params.accountId || message.direction !== "outbound") { + return false; + } if ( params.input.conversationId && message.conversation.id !== params.input.conversationId @@ -414,7 +433,9 @@ export async function waitForQaTransportOutboundSequence(params: { }); const finalIndex = events.findLastIndex( ({ kind, message }) => - kind !== "deleted" && message.text.includes(params.input.finalTextIncludes), + kind !== "deleted" && + !message.deleted && + message.text.includes(params.input.finalTextIncludes), ); if (finalIndex < 0) { return undefined; @@ -428,6 +449,7 @@ export async function waitForQaTransportOutboundSequence(params: { if ( !latest || latest.kind === "deleted" || + latest.message.deleted || !latest.message.text.includes(params.input.finalTextIncludes) ) { stableCursor = null; diff --git a/extensions/qa-lab/src/scenario-runtime-api.test.ts b/extensions/qa-lab/src/scenario-runtime-api.test.ts index cab38ebac02f..86ec01053c13 100644 --- a/extensions/qa-lab/src/scenario-runtime-api.test.ts +++ b/extensions/qa-lab/src/scenario-runtime-api.test.ts @@ -20,8 +20,6 @@ function createDeps(overrides?: Partial): QaScenarioRunti randomUUID, runScenario: fn, waitForOutboundMessage: fn, - waitForTransportOutboundMessage: fn, - waitForChannelOutboundMessage: fn, waitForNoOutbound: fn, waitForNoTransportOutbound: fn, recentOutboundSummary: fn, diff --git a/extensions/qa-lab/src/scenario-runtime-api.ts b/extensions/qa-lab/src/scenario-runtime-api.ts index 46e1f84b47b4..f51f99a49524 100644 --- a/extensions/qa-lab/src/scenario-runtime-api.ts +++ b/extensions/qa-lab/src/scenario-runtime-api.ts @@ -32,8 +32,6 @@ type QaScenarioRuntimeDeps = { randomUUID: () => string; runScenario: QaScenarioRuntimeFunction; waitForOutboundMessage: QaScenarioRuntimeFunction; - waitForTransportOutboundMessage: QaScenarioRuntimeFunction; - waitForChannelOutboundMessage: QaScenarioRuntimeFunction; waitForNoOutbound: QaScenarioRuntimeFunction; waitForNoTransportOutbound: QaScenarioRuntimeFunction; recentOutboundSummary: QaScenarioRuntimeFunction; @@ -127,8 +125,6 @@ type QaScenarioRuntimeApi< runScenario: TDeps["runScenario"]; waitForCondition: TEnv["transport"]["waitForCondition"]; waitForOutboundMessage: TDeps["waitForOutboundMessage"]; - waitForTransportOutboundMessage: TDeps["waitForTransportOutboundMessage"]; - waitForChannelOutboundMessage: TDeps["waitForChannelOutboundMessage"]; waitForNoOutbound: TDeps["waitForNoOutbound"]; waitForNoTransportOutbound: TDeps["waitForNoTransportOutbound"]; recentOutboundSummary: TDeps["recentOutboundSummary"]; @@ -240,8 +236,6 @@ export function createQaScenarioRuntimeApi< runScenario: params.deps.runScenario, waitForCondition: transport.waitForCondition, waitForOutboundMessage: params.deps.waitForOutboundMessage, - waitForTransportOutboundMessage: params.deps.waitForTransportOutboundMessage, - waitForChannelOutboundMessage: params.deps.waitForChannelOutboundMessage, waitForNoOutbound: params.deps.waitForNoOutbound, waitForNoTransportOutbound: params.deps.waitForNoTransportOutbound, recentOutboundSummary: params.deps.recentOutboundSummary, diff --git a/extensions/qa-lab/src/self-check.test.ts b/extensions/qa-lab/src/self-check.test.ts index 0e1121bf2924..1aff1be8e461 100644 --- a/extensions/qa-lab/src/self-check.test.ts +++ b/extensions/qa-lab/src/self-check.test.ts @@ -125,6 +125,13 @@ describe("createQaSelfCheckScenario", () => { "thread:qa-room/thread-1", "thread:qa-room/thread-1", ]); - expect(state.searchMessages({ query: "inside thread" }).at(-1)?.deleted).toBe(true); + const deletedMessage = state.getSnapshot().messages.find((message) => message.deleted); + if (!deletedMessage) { + throw new Error("self-check did not preserve its deleted message tombstone"); + } + expect(state.readMessage({ messageId: deletedMessage.id }).deleted).toBe(true); + expect( + state.searchMessages({ query: "inside thread" }).map((message) => message.id), + ).not.toContain(deletedMessage.id); }); }); diff --git a/extensions/qa-lab/src/suite-runtime-flow.test.ts b/extensions/qa-lab/src/suite-runtime-flow.test.ts index 59c2ce5476f7..4251e755f8f8 100644 --- a/extensions/qa-lab/src/suite-runtime-flow.test.ts +++ b/extensions/qa-lab/src/suite-runtime-flow.test.ts @@ -4,8 +4,6 @@ import { describe, expect, it, vi } from "vitest"; const createQaScenarioRuntimeApi = vi.hoisted(() => vi.fn()); const runScenarioFlow = vi.hoisted(() => vi.fn(async (params: { api: unknown }) => params.api)); const waitForOutboundMessage = vi.hoisted(() => vi.fn()); -const waitForTransportOutboundMessage = vi.hoisted(() => vi.fn()); -const waitForChannelOutboundMessage = vi.hoisted(() => vi.fn()); const waitForNoOutbound = vi.hoisted(() => vi.fn()); const waitForNoTransportOutbound = vi.hoisted(() => vi.fn()); const recentOutboundSummary = vi.hoisted(() => vi.fn()); @@ -73,8 +71,6 @@ vi.mock("./scenario-flow-runner.js", () => ({ vi.mock("./suite-runtime-transport.js", () => ({ waitForOutboundMessage, - waitForTransportOutboundMessage, - waitForChannelOutboundMessage, waitForNoOutbound, waitForNoTransportOutbound, recentOutboundSummary, @@ -303,7 +299,15 @@ describe("qa suite runtime flow", () => { expect(call.scenario).toBe(scenario); expect(call.deps.runScenario).toBe(runScenario); expect(call.deps.waitForQaChannelReady).toBe(waitForQaChannelReady); - expect(call.deps.waitForOutboundMessage).toBe(waitForOutboundMessage); + expect(call.deps.waitForOutboundMessage).toBeTypeOf("function"); + const outboundPredicate = vi.fn(); + call.deps.waitForOutboundMessage(env.transport.state, outboundPredicate, 123); + expect(waitForOutboundMessage).toHaveBeenCalledWith( + env.transport.state, + outboundPredicate, + 123, + { accountId: "qa-channel" }, + ); expect(call.deps.markGatewayLogCursor()).toBe(0); expect(() => call.deps.assertNoGatewayLogSentinels()).not.toThrow(); expect(call.deps.readSessionTranscriptSummary).toBe(readSessionTranscriptSummary); diff --git a/extensions/qa-lab/src/suite-runtime-flow.ts b/extensions/qa-lab/src/suite-runtime-flow.ts index 64710f0cc43f..33c48300f2a5 100644 --- a/extensions/qa-lab/src/suite-runtime-flow.ts +++ b/extensions/qa-lab/src/suite-runtime-flow.ts @@ -71,11 +71,9 @@ import { formatTransportTranscript, readTransportTranscript, recentOutboundSummary, - waitForChannelOutboundMessage, waitForNoOutbound, waitForNoTransportOutbound, waitForOutboundMessage, - waitForTransportOutboundMessage, } from "./suite-runtime-transport.js"; import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js"; import { @@ -201,15 +199,23 @@ type QaSuiteScenarioFlowApiParams = QaSuiteScenarioDepsParams & { }; function createQaSuiteScenarioDeps(params: QaSuiteScenarioDepsParams) { + const waitForAccountOutboundMessage: typeof waitForOutboundMessage = ( + state, + predicate, + timeoutMs, + options, + ) => + waitForOutboundMessage(state, predicate, timeoutMs, { + ...options, + accountId: params.env.transport.accountId, + }); return { fs, path, sleep, randomUUID, runScenario: params.runScenario, - waitForOutboundMessage, - waitForTransportOutboundMessage, - waitForChannelOutboundMessage, + waitForOutboundMessage: waitForAccountOutboundMessage, waitForNoOutbound, waitForNoTransportOutbound, recentOutboundSummary, diff --git a/extensions/qa-lab/src/suite-runtime-transport.test.ts b/extensions/qa-lab/src/suite-runtime-transport.test.ts index a3eaf0b02a5c..1f35220a3e5f 100644 --- a/extensions/qa-lab/src/suite-runtime-transport.test.ts +++ b/extensions/qa-lab/src/suite-runtime-transport.test.ts @@ -8,7 +8,6 @@ import { readTransportTranscript, waitForNoOutbound, waitForOutboundMessage, - waitForTransportOutboundMessage, } from "./suite-runtime-transport.js"; describe("qa suite transport helpers", () => { @@ -124,6 +123,48 @@ describe("qa suite transport helpers", () => { ); }); + it("waits for a live final instead of accepting a deleted matching preview", async () => { + const state = createQaBusState(); + const preview = state.addOutboundMessage({ + to: "dm:qa-operator", + text: "QA-VISIBLE-FINAL-OK", + }); + state.deleteMessage({ messageId: preview.id }); + const final = state.addOutboundMessage({ + to: "dm:qa-operator", + text: "QA-VISIBLE-FINAL-OK", + }); + + await expect( + waitForOutboundMessage(state, (message) => message.text.includes("QA-VISIBLE-FINAL-OK"), 50), + ).resolves.toMatchObject({ id: final.id }); + }); + + it("filters foreign account replies and failures from account-scoped waits", async () => { + const state = createQaBusState(); + state.addOutboundMessage({ + accountId: "other", + to: "dm:qa-operator", + text: "QA-ACCOUNT-OK", + }); + state.addOutboundMessage({ + accountId: "other", + to: "dm:qa-operator", + text: "⚠️ agent failed before reply: foreign account failure", + }); + const expected = state.addOutboundMessage({ + accountId: "default", + to: "dm:qa-operator", + text: "QA-ACCOUNT-OK", + }); + + await expect( + waitForOutboundMessage(state, (message) => message.text.includes("QA-ACCOUNT-OK"), 50, { + accountId: "default", + }), + ).resolves.toMatchObject({ accountId: "default", id: expected.id }); + }); + it("fails raw scenario waitForCondition calls when a classified failure reply arrives", async () => { const state = createQaBusState(); const waitForCondition = createQaChannelTransport(state).waitForCondition; @@ -236,22 +277,27 @@ describe("qa suite transport helpers", () => { expect(formatted).toContain("ASSISTANT OpenClaw QA: working on it"); }); - it("waits for outbound replies through the generic transport alias", async () => { + it("applies account filtering after the global outbound cursor", async () => { const state = createQaBusState(); - const pending = waitForTransportOutboundMessage( - state, - (candidate) => candidate.conversation.id === "qa-operator" && candidate.text.includes("done"), - 5_000, - ); - state.addOutboundMessage({ + accountId: "other", + to: "dm:qa-operator", + text: "previous account reply", + }); + const sinceIndex = state + .getSnapshot() + .messages.filter((message) => message.direction === "outbound").length; + const expected = state.addOutboundMessage({ + accountId: "default", to: "dm:qa-operator", text: "done", - senderId: "openclaw", - senderName: "OpenClaw QA", }); - const message = await pending; - expect(message.text).toBe("done"); + await expect( + waitForOutboundMessage(state, (candidate) => candidate.text === "done", 50, { + accountId: "default", + sinceIndex, + }), + ).resolves.toMatchObject({ accountId: "default", id: expected.id }); }); }); diff --git a/extensions/qa-lab/src/suite-runtime-transport.ts b/extensions/qa-lab/src/suite-runtime-transport.ts index b4276c0e7c47..1458e9ea8d84 100644 --- a/extensions/qa-lab/src/suite-runtime-transport.ts +++ b/extensions/qa-lab/src/suite-runtime-transport.ts @@ -14,7 +14,7 @@ type WaitForNoOutboundOptions = { function findFailureOutboundMessage( state: QaTransportState, - options?: { sinceIndex?: number; cursorSpace?: "all" | "outbound" }, + options?: { accountId?: string; sinceIndex?: number; cursorSpace?: "all" | "outbound" }, ) { return findTransportFailureOutboundMessage(state, options); } @@ -23,7 +23,7 @@ async function waitForOutboundMessage( state: QaTransportState, predicate: (message: QaBusMessage) => boolean, timeoutMs = 15_000, - options?: { sinceIndex?: number }, + options?: { accountId?: string; sinceIndex?: number }, ) { return await waitForQaTransportCondition(() => { const failureMessage = findFailureOutboundMessage(state, options); @@ -34,7 +34,12 @@ async function waitForOutboundMessage( .getSnapshot() .messages.filter((message: QaBusMessage) => message.direction === "outbound") .slice(options?.sinceIndex ?? 0) - .find(predicate); + .find( + (message) => + !message.deleted && + (!options?.accountId || message.accountId === options.accountId) && + predicate(message), + ); if (!match) { return undefined; } @@ -136,22 +141,6 @@ function formatConversationTranscript( return formatTransportTranscript(state, params); } -async function waitForTransportOutboundMessage( - state: QaTransportState, - predicate: (message: QaBusMessage) => boolean, - timeoutMs?: number, -) { - return await waitForOutboundMessage(state, predicate, timeoutMs); -} - -async function waitForChannelOutboundMessage( - state: QaTransportState, - predicate: (message: QaBusMessage) => boolean, - timeoutMs?: number, -) { - return await waitForTransportOutboundMessage(state, predicate, timeoutMs); -} - async function waitForNoTransportOutbound( state: QaTransportState, timeoutMs = 1_200, @@ -165,9 +154,7 @@ export { formatTransportTranscript, readTransportTranscript, recentOutboundSummary, - waitForChannelOutboundMessage, waitForNoOutbound, waitForNoTransportOutbound, waitForOutboundMessage, - waitForTransportOutboundMessage, }; diff --git a/extensions/qa-lab/src/suite-summary.test.ts b/extensions/qa-lab/src/suite-summary.test.ts index cb32550a119f..9abcbf5fc279 100644 --- a/extensions/qa-lab/src/suite-summary.test.ts +++ b/extensions/qa-lab/src/suite-summary.test.ts @@ -94,6 +94,60 @@ describe("qa suite summary helpers", () => { ).resolves.toBe(1); }); + it.each([ + { + name: "required skip", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 1 }, + scenarios: [{ name: "required scenario", status: "skip" }], + }, + }, + { + name: "required skipped", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 1 }, + scenarios: [{ name: "required scenario", status: "skipped" }], + }, + }, + { + name: "blocked scenario", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 0 }, + scenarios: [{ name: "required scenario", status: "blocked" }], + }, + }, + { + name: "blocked evidence", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 0 }, + entries: [{ result: { status: "blocked" } }], + }, + }, + ])("requires a completed scenario before tolerating $name", async ({ summary }) => { + await expect( + readSummary(summary, (summaryPath) => + readQaSuiteFailedOrSkippedScenarioCountFromFile(summaryPath, { + requireExecutedScenario: true, + }), + ), + ).rejects.toThrow("did not include any executed scenarios"); + }); + + it("still permits a genuinely executed failed scenario in failure-tolerant gates", async () => { + await expect( + readSummary( + { + counts: { total: 1, passed: 0, failed: 1, skipped: 0 }, + scenarios: [{ name: "required scenario", status: "fail" }], + }, + (summaryPath) => + readQaSuiteFailedOrSkippedScenarioCountFromFile(summaryPath, { + requireExecutedScenario: true, + }), + ), + ).resolves.toBe(1); + }); + it("rejects a suite containing only catalog-confirmed report-only skips", async () => { await expect( readSummary( diff --git a/extensions/qa-lab/src/suite-summary.ts b/extensions/qa-lab/src/suite-summary.ts index 22a873b93bcb..25c7e44d0a08 100644 --- a/extensions/qa-lab/src/suite-summary.ts +++ b/extensions/qa-lab/src/suite-summary.ts @@ -118,6 +118,7 @@ function assertQaSuiteSummaryHasExecutedScenarios( summaryPath: string, errorCode: "summary_failure_count_missing" | "summary_blocking_count_missing", optionalScenarioNames?: ReadonlySet, + requireExecutedScenario = false, ): void { if (!summary || typeof summary !== "object") { return; @@ -137,14 +138,15 @@ function assertQaSuiteSummaryHasExecutedScenarios( const entries = Array.isArray(payload.entries) ? (payload.entries as QaEvidenceEntryStatus[]) : undefined; - const hasExecutedScenario = + const hasCompletedScenario = scenarios?.some((scenario) => scenario.status === "pass" || scenario.status === "fail") === true || entries?.some((entry) => entry.result?.status === "pass" || entry.result?.status === "fail") === true || (passed ?? 0) > 0 || - (failed ?? 0) > 0 || - (total !== null && total > 0 && (skipped === null || total > skipped)); + (failed ?? 0) > 0; + const hasExecutedScenario = + hasCompletedScenario || (total !== null && total > 0 && (skipped === null || total > skipped)); const hasBlockingNonOptionalSkip = errorCode === "summary_blocking_count_missing" && scenarios?.some( @@ -169,6 +171,8 @@ function assertQaSuiteSummaryHasExecutedScenarios( if ( total === 0 || scenarios?.length === 0 || + // A tolerated blocking result cannot authenticate a campaign that never completed a scenario. + (requireExecutedScenario && !hasCompletedScenario) || (!hasExecutedScenario && !hasBlockingUnknownOrFailedScenario && !hasBlockingNonOptionalSkip && @@ -309,7 +313,7 @@ export async function readQaSuiteFailedScenarioCountFromFile(summaryPath: string export async function readQaSuiteFailedOrSkippedScenarioCountFromFile( summaryPath: string, - options?: { optionalScenarioNames?: ReadonlySet }, + options?: { optionalScenarioNames?: ReadonlySet; requireExecutedScenario?: boolean }, ): Promise { const payload = await readQaSuiteSummaryFile(summaryPath); assertQaSuiteSummaryHasExecutedScenarios( @@ -317,6 +321,7 @@ export async function readQaSuiteFailedOrSkippedScenarioCountFromFile( summaryPath, "summary_blocking_count_missing", options?.optionalScenarioNames, + options?.requireExecutedScenario, ); const blockingScenarioCount = readQaSuiteFailedOrSkippedScenarioCountFromSummary(payload); if (blockingScenarioCount !== null) { diff --git a/extensions/qa-lab/src/test-file-scenario-runner.test.ts b/extensions/qa-lab/src/test-file-scenario-runner.test.ts index c7bc85230128..2daa843720fc 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.test.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.test.ts @@ -258,7 +258,7 @@ describe("qa test file scenario runner", () => { "sends a chat turn through the GUI", ], ]); - expect(commands.map((command) => command.timeoutMs)).toEqual([undefined, undefined]); + expect(commands.map((command) => command.timeoutMs)).toEqual([1_800_000, 1_800_000]); const evidence = validateQaEvidenceSummaryJson( JSON.parse(await fs.readFile(result.evidencePath, "utf8")), ); @@ -366,7 +366,7 @@ describe("qa test file scenario runner", () => { )}`, ], ]); - expect(commands.map((command) => command.timeoutMs)).toEqual([undefined]); + expect(commands.map((command) => command.timeoutMs)).toEqual([1_800_000]); const evidence = validateQaEvidenceSummaryJson( JSON.parse(await fs.readFile(result.evidencePath, "utf8")), ); @@ -885,6 +885,78 @@ describe("qa test file scenario runner", () => { expect(commands.map((command) => command.timeoutMs)).toEqual([3 * 60 * 60_000]); }); + it.each([ + { executionKind: "vitest" as const, commandCount: 1 }, + { executionKind: "playwright" as const, commandCount: 2 }, + ])( + "applies the resolved command timeout to every $executionKind subprocess", + async ({ commandCount, executionKind }) => { + const repoRoot = await makeTempRepo(`qa-${executionKind}-command-timeout-`); + const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", `scenario-${executionKind}`); + const commands: QaScenarioCommandExecution[] = []; + + await runQaTestFileScenarios({ + repoRoot, + outputDir, + providerMode: "mock-openai", + primaryModel: "mock-openai/gpt-5.6-luna", + scenarios: [ + makeTestFileScenario( + executionKind, + executionKind === "playwright" + ? "ui/src/e2e/chat-flow.e2e.test.ts" + : "extensions/qa-lab/src/coverage-report.test.ts", + ), + ], + commandTimeoutMs: 321, + runCommand: async (command) => { + commands.push(command); + await writeNativeVitestReport(command, { passed: 1 }); + return { exitCode: 0, stdout: "native pass\n", stderr: "" }; + }, + }); + + expect(commands).toHaveLength(commandCount); + expect(commands.map((command) => command.timeoutMs)).toEqual( + Array.from({ length: commandCount }, () => 321), + ); + }, + ); + + it.each(["vitest", "playwright"] as const)( + "terminates a hanging $executionKind subprocess with failure evidence", + async (executionKind) => { + const repoRoot = await makeTempRepo(`qa-${executionKind}-hung-command-`); + const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", `scenario-${executionKind}`); + const result = await runQaTestFileScenarios({ + repoRoot, + outputDir, + providerMode: "mock-openai", + primaryModel: "mock-openai/gpt-5.6-luna", + scenarios: [ + makeTestFileScenario( + executionKind, + executionKind === "playwright" + ? "ui/src/e2e/chat-flow.e2e.test.ts" + : "extensions/qa-lab/src/coverage-report.test.ts", + ), + ], + commandTimeoutMs: 100, + runCommand: (execution) => + runQaScenarioCommandLifecycle({ + ...execution, + args: ["-e", "setInterval(() => {}, 1_000)"], + }), + }); + + expect(result.results[0]).toMatchObject({ + failureMessage: expect.stringContaining("timed out after 100ms"), + status: "fail", + }); + expect(result.evidence.entries[0]?.result.status).toBe("fail"); + }, + ); + describe.skipIf(process.platform === "win32")("script timeout process groups", () => { const commandTimeoutMs = 1_500; let descendantPid: number | undefined; diff --git a/extensions/qa-lab/src/test-file-scenario-runner.ts b/extensions/qa-lab/src/test-file-scenario-runner.ts index 152ba80291e4..5b6ae5264665 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.ts @@ -295,13 +295,13 @@ async function runScenarioCommandSteps(params: { const timeoutMs = params.scenario.execution.kind === "script" ? (params.scenario.execution.timeoutMs ?? params.commandTimeoutMs) - : undefined; + : params.commandTimeoutMs; const result = await params.runCommand({ command: step.command, args: step.args, cwd: params.repoRoot, env: params.env, - ...(timeoutMs === undefined ? {} : { timeoutMs }), + timeoutMs, }); if (result.stdout) { logChunks.push(result.stdout); diff --git a/extensions/qa-lab/web/src/app.browser.test.ts b/extensions/qa-lab/web/src/app.browser.test.ts index 47ed732933c4..0ab4dd5c950d 100644 --- a/extensions/qa-lab/web/src/app.browser.test.ts +++ b/extensions/qa-lab/web/src/app.browser.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Bootstrap, RunnerSelection } from "./ui-types.js"; +import type { Bootstrap, RunnerSelection, Snapshot } from "./ui-types.js"; const httpMock = vi.hoisted(() => { class QaLabHttpError extends Error { @@ -103,14 +103,17 @@ function createBootstrap(selection: RunnerSelection): Bootstrap { }; } -async function mountRunner(selection: RunnerSelection) { +async function mountRunner( + selection: RunnerSelection, + snapshot: Snapshot = { conversations: [], events: [], messages: [], threads: [] }, +) { let bootstrap = createBootstrap(selection); httpMock.getJson.mockImplementation(async (url: string) => { if (url === "/api/bootstrap") { return bootstrap; } if (url === "/api/state") { - return { conversations: [], events: [], messages: [], threads: [] }; + return snapshot; } if (url === "/api/report") { return { report: null }; @@ -195,6 +198,66 @@ afterEach(() => { }); describe("QA Lab runner browser interactions", () => { + it("sends group conversation messages from the interactive chat composer", async () => { + const root = await mountRunner( + { + alternateModel: "mock-openai/gpt-5.6-luna-alt", + channel: null, + channelDriver: "qa-channel", + evidenceMode: "full", + fastMode: false, + primaryModel: "mock-openai/gpt-5.6-luna", + profile: "all", + providerMode: "mock-openai", + runtimePair: null, + runtimePairLane: null, + scenarioIds: ["dm-chat-baseline"], + }, + { + conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }], + events: [], + messages: [], + threads: [ + { + accountId: "default", + conversationId: "qa-room", + id: "owned-thread", + title: "Owned thread", + }, + ], + }, + ); + httpMock.postJson.mockResolvedValue({ message: { id: "group-message" } }); + + root.querySelector("[data-thread-select='owned-thread']")?.click(); + selectValue(root, "#conversation-kind", "group"); + const conversationInput = root.querySelector("#conversation-id"); + if (!conversationInput) { + throw new Error("missing group conversation input"); + } + conversationInput.value = "qa-group"; + conversationInput.dispatchEvent(new Event("input", { bubbles: true })); + const composer = root.querySelector("#composer-text"); + if (!composer) { + throw new Error("missing group message composer"); + } + composer.value = "hello group"; + composer.dispatchEvent(new Event("input", { bubbles: true })); + root.querySelector("[data-action='send']")?.click(); + + await vi.waitFor(() => expect(httpMock.postJson).toHaveBeenCalledTimes(1)); + expect(httpMock.postJson).toHaveBeenCalledWith( + "/api/inbound/message", + expect.objectContaining({ + accountId: "default", + conversation: { id: "qa-group", kind: "group", title: "qa-group" }, + text: "hello group", + }), + ); + const submittedPayload = httpMock.postJson.mock.calls[0]?.[1] as Record; + expect(submittedPayload).not.toHaveProperty("threadId"); + }); + it("keeps scenario rows from collapsing inside the scrolling list", async () => { const root = await mountRunner({ alternateModel: "mock-openai/gpt-5.6-luna-alt", diff --git a/extensions/qa-lab/web/src/app.ts b/extensions/qa-lab/web/src/app.ts index c89a0382a019..0ff300627144 100644 --- a/extensions/qa-lab/web/src/app.ts +++ b/extensions/qa-lab/web/src/app.ts @@ -579,23 +579,29 @@ export async function createQaLabApp(root: HTMLDivElement) { state.selectedConversationKey, ); const accountId = selectedConversation?.accountId ?? "default"; + const selectedThreadId = + selectedConversation?.id === conversationId && + selectedConversation.kind === state.composer.conversationKind + ? state.selectedThreadId + : null; await postJson("/api/inbound/message", { accountId, conversation: { id: conversationId, kind: state.composer.conversationKind, - ...(state.composer.conversationKind === "channel" ? { title: conversationId } : {}), + ...(state.composer.conversationKind !== "direct" ? { title: conversationId } : {}), }, senderId: state.composer.senderId.trim() || "alice", senderName: state.composer.senderName.trim() || undefined, text, - ...(state.selectedThreadId ? { threadId: state.selectedThreadId } : {}), + ...(selectedThreadId ? { threadId: selectedThreadId } : {}), }); state.selectedConversationKey = conversationSelectionKey({ accountId, id: conversationId, kind: state.composer.conversationKind, }); + state.selectedThreadId = selectedThreadId; state.composer.text = ""; chatScrollLocked = true; await refresh(); @@ -1737,8 +1743,9 @@ export async function createQaLabApp(root: HTMLDivElement) { /* Composer form */ root.querySelector("#conversation-kind")?.addEventListener("change", (e) => { + const selectedKind = (e.currentTarget as HTMLSelectElement).value; state.composer.conversationKind = - (e.currentTarget as HTMLSelectElement).value === "channel" ? "channel" : "direct"; + selectedKind === "channel" || selectedKind === "group" ? selectedKind : "direct"; }); root.querySelector("#conversation-id")?.addEventListener("input", (e) => { state.composer.conversationId = (e.currentTarget as HTMLInputElement).value; diff --git a/extensions/qa-lab/web/src/ui-render-content.ts b/extensions/qa-lab/web/src/ui-render-content.ts index 27807ed69faf..8f4d83b02dd3 100644 --- a/extensions/qa-lab/web/src/ui-render-content.ts +++ b/extensions/qa-lab/web/src/ui-render-content.ts @@ -69,6 +69,11 @@ function deriveSelectedThread(state: UiState): string | null { function filteredMessages(state: UiState) { const messages = state.snapshot?.messages ?? []; + const selectedConversationThreadIds = new Set( + (state.snapshot?.threads ?? []) + .filter((thread) => threadConversationSelectionKey(thread) === state.selectedConversationKey) + .map((thread) => thread.id), + ); return messages.filter((message) => { if ( state.selectedConversationKey && @@ -76,10 +81,12 @@ function filteredMessages(state: UiState) { ) { return false; } - if (state.selectedThreadId && message.threadId !== state.selectedThreadId) { - return false; + if (state.selectedThreadId) { + return message.threadId === state.selectedThreadId; } - return true; + // External thread ids have no sidebar record, even when the conversation + // also owns navigable threads, so keep their messages in the root view. + return !message.threadId || !selectedConversationThreadIds.has(message.threadId); }); } @@ -88,18 +95,28 @@ function formatConversationLabel( conversations: Conversation[], ): string { const label = conversation.title || conversation.id; - const hasAccountCollision = conversations.some( + const sidebarCollisions = conversations.filter( (candidate) => - candidate.accountId !== conversation.accountId && - candidate.kind === conversation.kind && - candidate.id === conversation.id, + candidate !== conversation && + candidate.id === conversation.id && + (candidate.kind === "direct") === (conversation.kind === "direct"), ); - return hasAccountCollision ? `${label} (${conversation.accountId})` : label; + const hasAccountCollision = sidebarCollisions.some( + (candidate) => candidate.accountId !== conversation.accountId, + ); + const hasKindCollision = sidebarCollisions.some( + (candidate) => candidate.kind !== conversation.kind, + ); + const disambiguators = [ + ...(hasKindCollision ? [conversation.kind] : []), + ...(hasAccountCollision ? [conversation.accountId] : []), + ]; + return disambiguators.length > 0 ? `${label} (${disambiguators.join(", ")})` : label; } export function renderChatView(state: UiState): string { const conversations = state.snapshot?.conversations ?? []; - const channels = conversations.filter((c) => c.kind === "channel"); + const channels = conversations.filter((c) => c.kind === "channel" || c.kind === "group"); const dms = conversations.filter((c) => c.kind === "direct"); const threads = (state.snapshot?.threads ?? []).filter( (thread) => @@ -205,6 +222,7 @@ export function renderChatView(state: UiState): string { as diff --git a/extensions/qa-lab/web/src/ui-render.test.ts b/extensions/qa-lab/web/src/ui-render.test.ts index 8052fb327677..ca85a0b2dde8 100644 --- a/extensions/qa-lab/web/src/ui-render.test.ts +++ b/extensions/qa-lab/web/src/ui-render.test.ts @@ -158,6 +158,172 @@ describe("QA Lab UI evidence render", () => { expect(html).toContain( `data-conversation-key="${selectedConversationKey.replaceAll('"', """)}"`, ); + + const crossAccountKindHtml = renderQaLabUi( + evidenceState({ + activeTab: "chat", + snapshot: { + conversations: [ + { accountId: "account-a", id: "shared", kind: "group" }, + { accountId: "account-b", id: "shared", kind: "channel" }, + ], + events: [], + messages: [], + threads: [], + }, + }), + ); + expect(crossAccountKindHtml).toContain("shared (group, account-a)"); + expect(crossAccountKindHtml).toContain("shared (channel, account-b)"); + }); + + it("shows group conversations in the sidebar and composer without leaking same-id rooms", () => { + const selectedConversationKey = JSON.stringify(["account-a", "group", "shared"]); + const html = renderQaLabUi( + evidenceState({ + activeTab: "chat", + selectedConversationKey, + composer: { + conversationId: "shared", + conversationKind: "group", + senderId: "alice", + senderName: "Alice", + text: "", + }, + snapshot: { + conversations: [ + { accountId: "account-a", id: "shared", kind: "group" }, + { accountId: "account-b", id: "shared", kind: "group" }, + { accountId: "account-a", id: "shared", kind: "channel" }, + { accountId: "account-a", id: "shared", kind: "direct" }, + ], + events: [], + messages: [ + { + accountId: "account-a", + conversation: { id: "shared", kind: "group" }, + direction: "inbound", + id: "selected-group-message", + reactions: [], + senderId: "alice", + text: "selected group message", + timestamp: 1, + }, + { + accountId: "account-b", + conversation: { id: "shared", kind: "group" }, + direction: "inbound", + id: "foreign-group-message", + reactions: [], + senderId: "bob", + text: "foreign group message", + timestamp: 2, + }, + { + accountId: "account-a", + conversation: { id: "shared", kind: "channel" }, + direction: "outbound", + id: "same-id-channel-message", + reactions: [], + senderId: "openclaw", + text: "same-id channel message", + timestamp: 3, + }, + ], + threads: [], + }, + }), + ); + + expect(html).toContain("shared (group, account-a)"); + expect(html).toContain("shared (group, account-b)"); + expect(html).toContain("shared (channel, account-a)"); + expect(html).toContain("selected group message"); + expect(html).not.toContain("foreign group message"); + expect(html).not.toContain("same-id channel message"); + expect(html).toContain(''); + expect(html).toContain( + `data-conversation-key="${selectedConversationKey.replaceAll('"', """)}"`, + ); + }); + + it("keeps thread replies out of the root timeline when thread navigation exists", () => { + const selectedConversationKey = JSON.stringify(["default", "channel", "qa-room"]); + const snapshot: NonNullable = { + conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }], + events: [], + messages: [ + { + accountId: "default", + conversation: { id: "qa-room", kind: "channel" }, + direction: "outbound", + id: "root-message", + reactions: [], + senderId: "openclaw", + text: "root timeline message", + timestamp: 1, + }, + { + accountId: "default", + conversation: { id: "qa-room", kind: "channel" }, + direction: "outbound", + id: "thread-message", + reactions: [], + senderId: "openclaw", + text: "thread-only reply", + threadId: "owned-thread", + timestamp: 2, + }, + { + accountId: "default", + conversation: { id: "qa-room", kind: "channel" }, + direction: "outbound", + id: "external-thread-message", + reactions: [], + senderId: "openclaw", + text: "externally observed thread reply", + threadId: "external-thread", + timestamp: 3, + }, + ], + threads: [ + { + accountId: "default", + conversationId: "qa-room", + id: "owned-thread", + title: "Owned thread", + }, + ], + }; + + const rootHtml = renderQaLabUi( + evidenceState({ activeTab: "chat", selectedConversationKey, snapshot }), + ); + expect(rootHtml).toContain("Main timeline"); + expect(rootHtml).toContain("root timeline message"); + expect(rootHtml).not.toContain("thread-only reply"); + expect(rootHtml).toContain("externally observed thread reply"); + + const threadHtml = renderQaLabUi( + evidenceState({ + activeTab: "chat", + selectedConversationKey, + selectedThreadId: "owned-thread", + snapshot, + }), + ); + expect(threadHtml).not.toContain("root timeline message"); + expect(threadHtml).toContain("thread-only reply"); + expect(threadHtml).not.toContain("externally observed thread reply"); + + const externalThreadHtml = renderQaLabUi( + evidenceState({ + activeTab: "chat", + selectedConversationKey, + snapshot: { ...snapshot, threads: [] }, + }), + ); + expect(externalThreadHtml).toContain("thread-only reply"); }); it("renders capture startup commands without personal home paths", () => { diff --git a/extensions/qa-lab/web/src/ui-types.ts b/extensions/qa-lab/web/src/ui-types.ts index 04ee7759af51..5c86096abecf 100644 --- a/extensions/qa-lab/web/src/ui-types.ts +++ b/extensions/qa-lab/web/src/ui-types.ts @@ -18,7 +18,7 @@ import type { export type Conversation = { accountId: string; id: string; - kind: "direct" | "channel"; + kind: "direct" | "channel" | "group"; title?: string; }; @@ -371,7 +371,7 @@ export type UiState = { runnerDraftDirty: boolean; runnerPlanOverride: RunnerResolvedPlan | null; composer: { - conversationKind: "direct" | "channel"; + conversationKind: "direct" | "channel" | "group"; conversationId: string; senderId: string; senderName: string; diff --git a/extensions/slack/src/account-inspect.test.ts b/extensions/slack/src/account-inspect.test.ts index f9724ce6973f..038d3c015e1f 100644 --- a/extensions/slack/src/account-inspect.test.ts +++ b/extensions/slack/src/account-inspect.test.ts @@ -1,8 +1,16 @@ // Slack tests cover account inspection and credential status reporting. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { describe, expect, it } from "vitest"; +import { isSlackPluginAccountConfigured } from "./account-configured.js"; import { inspectSlackAccount } from "./account-inspect.js"; +function isInspectedSlackAccountUsable(account: ReturnType): boolean { + return isSlackPluginAccountConfigured({ + ...account, + identity: account.identity ?? "bot", + }); +} + describe("inspectSlackAccount", () => { it("reports user-token source and status for a configured user identity", () => { const account = inspectSlackAccount({ @@ -107,6 +115,63 @@ describe("inspectSlackAccount", () => { expect(account).toMatchObject({ botTokenSource: "config", botTokenStatus: "configured_unavailable", + configured: true, }); + expect(isInspectedSlackAccountUsable(account)).toBe(false); + }); + + it("keeps a healthy bot identity configured when its optional user token is unavailable", () => { + const account = inspectSlackAccount({ + cfg: { + channels: { + slack: { + botToken: "test-bot-token", + appToken: "test-app-token", + userToken: { + source: "env", + provider: "default", + id: "OPENCLAW_TEST_MISSING_OPTIONAL_SLACK_USER_TOKEN", + }, + }, + }, + } as OpenClawConfig, + envBotToken: "", + envAppToken: "", + envUserToken: "", + }); + + expect(account).toMatchObject({ + configured: true, + botTokenStatus: "available", + appTokenStatus: "available", + userTokenStatus: "configured_unavailable", + }); + expect(isInspectedSlackAccountUsable(account)).toBe(true); + }); + + it("keeps incomplete required credentials unconfigured even when another token is unavailable", () => { + const account = inspectSlackAccount({ + cfg: { + channels: { + slack: { + botToken: { + source: "env", + provider: "default", + id: "OPENCLAW_TEST_MISSING_REQUIRED_SLACK_BOT_TOKEN", + }, + }, + }, + } as OpenClawConfig, + envBotToken: "", + envAppToken: "", + envUserToken: "", + }); + + expect(account).toMatchObject({ + configured: false, + botTokenStatus: "configured_unavailable", + appTokenStatus: "missing", + }); + expect(isInspectedSlackAccountUsable(account)).toBe(false); }); }); diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index e3cb36cf0b71..22f348f7b2f0 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -34,6 +34,7 @@ import { } from "./bot-message.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; import { + ensureTelegramMessageProcessingResult, getTelegramSpooledReplayDeferredParticipant, isTelegramSpooledReplayUpdate, runWithTelegramUpdateProcessingFrame, @@ -186,6 +187,10 @@ export function createTelegramBotCore( try { const { result } = await runWithTelegramUpdateProcessingFrame(async () => { await next(); + if (!getTelegramSpooledReplayDeferredParticipant()) { + // Accepted synchronous updates need one terminal fact at their middleware owner. + ensureTelegramMessageProcessingResult({ kind: "completed" }); + } }); const deferredWork = getTelegramSpooledReplayDeferredParticipant(); if (deferredWork) { diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 6c6986eb5d7e..14e6d0d63190 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -732,14 +732,14 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(turnPlan?.record?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); }); - it("records a completed outcome after a native slash command", async () => { + it("leaves native-command outcomes to the update middleware owner", async () => { const { handler } = registerAndResolveStatusHandler({ cfg: {} }); const { result } = await runWithTelegramUpdateProcessingFrame(async () => { await handler(createTelegramPrivateCommandContext()); }); - expect(result).toEqual({ kind: "completed" }); + expect(result).toBeUndefined(); }); it("preserves every argument on native queue command turns", async () => { diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 08b5d5d5c71c..a288c8071d8d 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -75,10 +75,7 @@ import { syncTelegramMenuCommands as syncTelegramMenuCommandsRuntime, type TelegramMenuCommand, } from "./bot-native-command-menu.js"; -import { - recordTelegramMessageProcessingResult, - type TelegramMessageProcessingResult, -} from "./bot-processing-outcome.js"; +import type { TelegramMessageProcessingResult } from "./bot-processing-outcome.js"; import type { TelegramUpdateKeyContext } from "./bot-updates.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { @@ -126,19 +123,6 @@ const activeTelegramCodexLoginFlows = new Map(); type TelegramNativeCommandContext = Context & { match?: string }; -function registerTelegramNativeCommandHandler( - bot: Bot, - command: string, - handler: (ctx: TelegramNativeCommandContext) => Promise, -): void { - bot.command(command, async (ctx: TelegramNativeCommandContext) => { - await handler(ctx); - // Native commands bypass processMessage, so their terminal outcome must be - // recorded here for every built-in, plugin, and direct-delivery branch. - recordTelegramMessageProcessingResult({ kind: "completed" }); - }); -} - type TelegramChunkMode = ReturnType< typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").resolveChunkMode >; @@ -1227,7 +1211,7 @@ export const registerTelegramNativeCommands = ({ if (commandsToRegister.length > 0 || pluginCatalog.commands.length > 0) { for (const command of nativeCommands) { const normalizedCommandName = normalizeTelegramCommandName(command.name); - registerTelegramNativeCommandHandler(bot, normalizedCommandName, async (ctx) => { + bot.command(normalizedCommandName, async (ctx: TelegramNativeCommandContext) => { const msg = ctx.message; if (!msg) { return; @@ -1817,7 +1801,7 @@ export const registerTelegramNativeCommands = ({ } for (const pluginCommand of pluginCatalog.commands) { - registerTelegramNativeCommandHandler(bot, pluginCommand.command, async (ctx) => { + bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { const msg = ctx.message; if (!msg) { return; diff --git a/extensions/telegram/src/bot-processing-outcome.test.ts b/extensions/telegram/src/bot-processing-outcome.test.ts new file mode 100644 index 000000000000..ecdb3a77fca8 --- /dev/null +++ b/extensions/telegram/src/bot-processing-outcome.test.ts @@ -0,0 +1,46 @@ +/** Verifies Telegram update outcomes stay attached to their durable ingress owner. */ +import { describe, expect, it } from "vitest"; +import { + ensureTelegramMessageProcessingResult, + recordTelegramMessageProcessingResult, + runWithTelegramUpdateProcessingFrame, +} from "./bot-processing-outcome.js"; + +describe("Telegram update processing outcomes", () => { + it("reuses the ingress outcome frame across nested bot middleware", async () => { + const outer = await runWithTelegramUpdateProcessingFrame(async () => { + const inner = await runWithTelegramUpdateProcessingFrame(async () => { + ensureTelegramMessageProcessingResult({ kind: "completed" }); + return "middleware-finished"; + }); + + expect(inner).toEqual({ value: "middleware-finished", result: { kind: "completed" } }); + return "update-finished"; + }); + + expect(outer).toEqual({ value: "update-finished", result: { kind: "completed" } }); + }); + + it.each([ + { kind: "skipped" as const }, + { kind: "failed-retryable" as const, error: new Error("retry") }, + ])( + "does not replace an explicit $kind disposition with middleware completion", + async (expected) => { + const { result } = await runWithTelegramUpdateProcessingFrame(async () => { + recordTelegramMessageProcessingResult(expected); + ensureTelegramMessageProcessingResult({ kind: "completed" }); + }); + + expect(result).toBe(expected); + }, + ); + + it("keeps deferred owners outcome-free until their participant settles", async () => { + const { result } = await runWithTelegramUpdateProcessingFrame(async () => { + await runWithTelegramUpdateProcessingFrame(async () => {}); + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/extensions/telegram/src/bot-processing-outcome.ts b/extensions/telegram/src/bot-processing-outcome.ts index 3a7262b9328a..52e7ca7833ca 100644 --- a/extensions/telegram/src/bot-processing-outcome.ts +++ b/extensions/telegram/src/bot-processing-outcome.ts @@ -56,11 +56,23 @@ export class TelegramSpooledReplayProcessingError extends Error { export async function runWithTelegramUpdateProcessingFrame( fn: () => Promise, ): Promise<{ value: T; result?: TelegramMessageProcessingResult }> { - const frame: TelegramUpdateProcessingFrame = {}; - const value = await telegramUpdateProcessingFrames.run(frame, fn); + const inheritedFrame = telegramUpdateProcessingFrames.getStore(); + // Durable ingress owns the outer frame; bot middleware must update that same fact. + const frame = inheritedFrame ?? {}; + const value = inheritedFrame ? await fn() : await telegramUpdateProcessingFrames.run(frame, fn); return frame.result ? { value, result: frame.result } : { value }; } +/** Records a default only when a handler has not already chosen its terminal disposition. */ +export function ensureTelegramMessageProcessingResult( + result: TelegramMessageProcessingResult, +): void { + const frame = telegramUpdateProcessingFrames.getStore(); + if (frame && !frame.result) { + frame.result = result; + } +} + export function recordTelegramMessageProcessingResult( result: TelegramMessageProcessingResult, ): void { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 8cdcd7ebb204..2534a85af090 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -84,6 +84,7 @@ const { createTelegramSpooledReplayDeferredParticipant, recordTelegramMessageProcessingResult, runWithTelegramSpooledReplayUpdate, + runWithTelegramUpdateProcessingFrame, TelegramSpooledReplayProcessingError, } = await import("./bot-processing-outcome.js"); const { TELEGRAM_RICH_TEXT_LIMIT } = await import("./rich-message.js"); @@ -2797,6 +2798,28 @@ describe("createTelegramBot", () => { expect(onUpdateId.mock.calls.map((call) => call[0])).toEqual([102]); }); + + it("records synchronous update completion on the shared ingress frame", async () => { + const { run: runMiddlewareChain } = setupUpdateOffsetTracker({ lastUpdateId: 150 }); + + const { result } = await runWithTelegramUpdateProcessingFrame(async () => { + await runMiddlewareChain({ update: { update_id: 151 } }, async () => {}); + }); + + expect(result).toEqual({ kind: "completed" }); + }); + + it("preserves an intentionally skipped update through middleware completion", async () => { + const { run: runMiddlewareChain } = setupUpdateOffsetTracker({ lastUpdateId: 160 }); + + const { result } = await runWithTelegramUpdateProcessingFrame(async () => { + await runMiddlewareChain({ update: { update_id: 161 } }, async () => { + recordTelegramMessageProcessingResult({ kind: "skipped" }); + }); + }); + + expect(result).toEqual({ kind: "skipped" }); + }); it("logs and swallows update watermark persistence failures", async () => { const onUpdateId = vi.fn().mockRejectedValueOnce(new Error("disk boom")); const runtime = { diff --git a/extensions/telegram/src/outbound-message-context.ts b/extensions/telegram/src/outbound-message-context.ts index 3d0a57acb178..4ae88f0176e7 100644 --- a/extensions/telegram/src/outbound-message-context.ts +++ b/extensions/telegram/src/outbound-message-context.ts @@ -136,6 +136,8 @@ export async function recordOutboundMessageForPromptContext(params: { successfulSendThread?: TelegramThreadSpec; promptContextTimestampMs?: number; promptContextProjection?: TelegramPromptContextProjection; + /** Edits refresh an existing cache entry without inserting another self-history turn. */ + recordGroupHistory?: boolean; }): Promise { try { const providerGeneralTopicId = @@ -169,14 +171,16 @@ export async function recordOutboundMessageForPromptContext(params: { ...(providerObservedThreadId !== undefined ? { providerObservedThreadId } : {}), ...(messageThreadId !== undefined ? { threadId: messageThreadId } : {}), }); - const timestamp = resolveOutboundCacheMessageTimestamp(cacheMessage); - outboundGroupHistoryRecorders.get(params.account.accountId)?.({ - chatId: params.chatId, - messageId: params.messageId, - text: params.text ?? cacheMessage.text ?? cacheMessage.caption, - ...(messageThreadId !== undefined ? { messageThreadId } : {}), - ...(timestamp !== undefined ? { timestamp } : {}), - }); + if (params.recordGroupHistory !== false) { + const timestamp = resolveOutboundCacheMessageTimestamp(cacheMessage); + outboundGroupHistoryRecorders.get(params.account.accountId)?.({ + chatId: params.chatId, + messageId: params.messageId, + text: params.text ?? cacheMessage.text ?? cacheMessage.caption, + ...(messageThreadId !== undefined ? { messageThreadId } : {}), + ...(timestamp !== undefined ? { timestamp } : {}), + }); + } return true; } catch (error) { logVerbose(`telegram: failed to record outbound message context: ${String(error)}`); diff --git a/extensions/telegram/src/rich-message.ts b/extensions/telegram/src/rich-message.ts index f3f149ecb84a..237bb4406147 100644 --- a/extensions/telegram/src/rich-message.ts +++ b/extensions/telegram/src/rich-message.ts @@ -2,6 +2,7 @@ import type { Bot } from "grammy"; import type { ForceReply, InlineKeyboardMarkup, + LinkPreviewOptions, Message, ReplyKeyboardMarkup, ReplyKeyboardRemove, @@ -82,6 +83,7 @@ export type TelegramEditRichMessageTextParams = { message_id?: number; inline_message_id?: string; rich_message: TelegramInputRichMessage; + link_preview_options?: LinkPreviewOptions; reply_markup?: InlineKeyboardMarkup; }; diff --git a/extensions/telegram/src/send-edit.ts b/extensions/telegram/src/send-edit.ts index 25876985a632..8c2fc49d3337 100644 --- a/extensions/telegram/src/send-edit.ts +++ b/extensions/telegram/src/send-edit.ts @@ -4,6 +4,10 @@ import type { TelegramInlineButtons } from "./button-types.js"; import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js"; import { buildInlineKeyboard } from "./inline-keyboard.js"; import { isRecoverableTelegramNetworkError, isTelegramServerError } from "./network-errors.js"; +import { + recordOutboundMessageForPromptContext, + type TelegramOutboundPromptContextMessage, +} from "./outbound-message-context.js"; import { buildTelegramRichMarkdownPlan, getTelegramRichRawApi, @@ -26,6 +30,7 @@ import { import { prepareTelegramOutbound } from "./send-outbound.js"; import type { OpenClawConfig } from "./send.runtime.js"; import { resolveMarkdownTableMode } from "./send.runtime.js"; +import { resolveTelegramBotUserIdFromToken } from "./token.js"; type TelegramEditMessageTextParams = Parameters[3]; type TelegramEditMessageCaptionParams = Parameters< @@ -148,6 +153,7 @@ async function editMessageTelegramWithContext( ) => request(fn, label, shouldLog ? { shouldLog } : undefined); const textMode = opts.textMode ?? "markdown"; + const linkPreviewEnabled = opts.linkPreview ?? account.config.linkPreview ?? true; // Caller-authored HTML edits keep legacy parse_mode HTML semantics too. const useRichMessages = account.config.richMessages === true && textMode !== "html"; const tableMode = resolveMarkdownTableMode({ @@ -161,7 +167,7 @@ async function editMessageTelegramWithContext( const richRawApi = useRichMessages ? getTelegramRichRawApi(api) : undefined; const richMessagePlan = useRichMessages ? buildTelegramRichMarkdownPlan(text, { - skipEntityDetection: opts.linkPreview === false, + skipEntityDetection: !linkPreviewEnabled, tableMode, }) : undefined; @@ -177,14 +183,14 @@ async function editMessageTelegramWithContext( const textEditParams: TelegramEditMessageTextParams = { parse_mode: "HTML", }; - if (opts.linkPreview === false) { + if (!linkPreviewEnabled) { textEditParams.link_preview_options = { is_disabled: true }; } if (replyMarkup !== undefined) { textEditParams.reply_markup = replyMarkup; } const plainTextParams: TelegramEditMessageTextParams = {}; - if (opts.linkPreview === false) { + if (!linkPreviewEnabled) { plainTextParams.link_preview_options = { is_disabled: true }; } if (replyMarkup !== undefined) { @@ -206,8 +212,13 @@ async function editMessageTelegramWithContext( const performTextEdit = () => { if (richRawApi && richMessagePlan) { - const richEditParams: Pick = - replyMarkup === undefined ? {} : { reply_markup: replyMarkup }; + const richEditParams: Pick< + TelegramEditRichMessageTextParams, + "link_preview_options" | "reply_markup" + > = { + ...(linkPreviewEnabled ? {} : { link_preview_options: { is_disabled: true } }), + ...(replyMarkup === undefined ? {} : { reply_markup: replyMarkup }), + }; warnTelegramRichBlocksDegradations({ context: "editMessage", reasons: richMessagePlan.degradationReasons, @@ -282,16 +293,17 @@ async function editMessageTelegramWithContext( ), }); + let editedMessage: TelegramOutboundPromptContextMessage | true | undefined; try { const editMode = opts.editMode ?? "text"; if (editMode === "caption") { - await performCaptionEdit(); + editedMessage = await performCaptionEdit(); } else { try { - await performTextEdit(); + editedMessage = await performTextEdit(); } catch (err) { if (editMode === "auto" && isTelegramMessageHasNoTextError(err)) { - await performCaptionEdit(); + editedMessage = await performCaptionEdit(); } else { throw err; } @@ -305,6 +317,22 @@ async function editMessageTelegramWithContext( } } + if (editedMessage && editedMessage !== true && typeof editedMessage.message_id === "number") { + const botUserId = resolveTelegramBotUserIdFromToken(opts.token || account.token); + await recordOutboundMessageForPromptContext({ + cfg, + account, + chatId, + message: editedMessage, + messageId: editedMessage.message_id, + recordGroupHistory: false, + ...(botUserId !== undefined ? { botUserId } : {}), + ...(editedMessage.message_thread_id !== undefined + ? { messageThreadId: editedMessage.message_thread_id } + : {}), + }); + } + logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`); return { ok: true, messageId: String(messageId), chatId }; } diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index ae293c76c199..58d2491168c7 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -10,12 +10,17 @@ import { import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { markdownToTelegramHtml, telegramHtmlToPlainTextFallback } from "./format.js"; +import { + recordTelegramGroupHistoryEntry, + selectTelegramGroupHistoryAfterLastSelf, +} from "./group-history-window.js"; import { buildTelegramConversationContext, createTelegramMessageCache, hasProviderObservedTelegramThreadBinding, resolveTelegramMessageCacheScope, } from "./message-cache.js"; +import { registerTelegramOutboundGroupHistoryRecorder } from "./outbound-message-context.js"; import { createTelegramPromptContextProjectionCursor } from "./prompt-context-projection.js"; import { inputRichBlocksToPlainText, type InputRichBlock } from "./rich-block-model.js"; import { setTelegramRuntime } from "./runtime.js"; @@ -4583,6 +4588,198 @@ describe("editMessageTelegram", () => { ); expect(botRawApi.editMessageText).not.toHaveBeenCalled(); }); + + it.each([ + { + name: "inherits the disabled account default", + accountLinkPreview: false, + linkPreview: undefined, + expectedDisabled: true, + }, + { + name: "lets an explicit enabled value override the account default", + accountLinkPreview: false, + linkPreview: true, + expectedDisabled: false, + }, + { + name: "lets an explicit disabled value override the account default", + accountLinkPreview: true, + linkPreview: false, + expectedDisabled: true, + }, + ])("$name for edited Telegram messages", async (testCase) => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "https://example.com", { + token: "tok", + cfg: { channels: { telegram: { linkPreview: testCase.accountLinkPreview } } }, + ...(testCase.linkPreview !== undefined ? { linkPreview: testCase.linkPreview } : {}), + }); + + const params = requireRecord( + firstMockCall(botApi.editMessageText, "editMessageText preview call")[3], + "edited Telegram preview params", + ); + if (testCase.expectedDisabled) { + expect(params.link_preview_options).toEqual({ is_disabled: true }); + } else { + expect(params).not.toHaveProperty("link_preview_options"); + } + }); + + it("preserves disabled previews when editing rich Telegram messages", async () => { + botRawApi.editMessageText.mockResolvedValue({ + message_id: 1, + chat: { id: "123", type: "private" }, + text: "https://example.com", + }); + + await editMessageTelegram("123", 1, "https://example.com", { + token: "tok", + cfg: { channels: { telegram: { richMessages: true } } }, + linkPreview: false, + }); + + expect(botRawApi.editMessageText).toHaveBeenCalledWith( + expect.objectContaining({ + chat_id: "123", + message_id: 1, + link_preview_options: { is_disabled: true }, + }), + ); + }); + + it.each([ + { name: "text", editMode: "text" as const, field: "text" as const }, + { name: "caption", editMode: "caption" as const, field: "caption" as const }, + ])("refreshes cached $name from Telegram's authoritative edit response", async (testCase) => { + const storePath = `/tmp/openclaw-telegram-edited-context-${process.pid}-${Date.now()}-${testCase.name}.json`; + const cfg = { session: { store: storePath } }; + const chat = { id: -100123, type: "supergroup" as const, title: "Ops" }; + const cache = createTelegramMessageCache({ + scope: resolveTelegramMessageCacheScope(storePath), + }); + await cache.record({ + accountId: "default", + chatId: chat.id, + threadId: 77, + msg: { + chat, + message_id: 902, + message_thread_id: 77, + date: 1_779_394_740, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + [testCase.field]: "outdated content", + }, + }); + const editedMessage = { + chat, + message_id: 902, + message_thread_id: 77, + date: 1_779_394_740, + edit_date: 1_779_394_750, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + [testCase.field]: "authoritative edited content", + }; + if (testCase.editMode === "caption") { + botApi.editMessageCaption.mockResolvedValue(editedMessage); + } else { + botApi.editMessageText.mockResolvedValue(editedMessage); + } + + await editMessageTelegram(chat.id, 902, "authoritative edited content", { + token: "42:test-token", + cfg, + editMode: testCase.editMode, + }); + + const cached = await cache.get({ + accountId: "default", + chatId: chat.id, + messageId: "902", + }); + expect(cached?.body).toBe("authoritative edited content"); + expect(hasProviderObservedTelegramThreadBinding(cached, 77)).toBe(true); + }); + + it("refreshes edited group messages without duplicating self history or hiding later replies", async () => { + const storePath = `/tmp/openclaw-telegram-edit-history-${process.pid}-${Date.now()}.json`; + const cfg = { session: { store: storePath } }; + const chat = { id: -100123, type: "supergroup" as const, title: "Ops" }; + const historyKey = `${chat.id}:topic:77`; + const groupHistory = new Map< + string, + Array<{ sender: string; body: string; messageId: string; timestamp: number }> + >(); + recordTelegramGroupHistoryEntry({ + historyMap: groupHistory, + historyKey, + limit: 50, + entry: { + sender: "OpenClaw (you)", + body: "original response", + messageId: "902", + timestamp: 1_779_394_740_000, + }, + }); + recordTelegramGroupHistoryEntry({ + historyMap: groupHistory, + historyKey, + limit: 50, + entry: { + sender: "Teammate", + body: "context that must remain visible", + messageId: "903", + timestamp: 1_779_394_741_000, + }, + }); + const unregister = registerTelegramOutboundGroupHistoryRecorder({ + accountId: "default", + recorder: (record) => + recordTelegramGroupHistoryEntry({ + historyMap: groupHistory, + historyKey, + limit: 50, + entry: { + sender: "OpenClaw (you)", + body: record.text ?? "", + messageId: String(record.messageId), + timestamp: record.timestamp ?? 0, + }, + }), + }); + botApi.editMessageText.mockResolvedValue({ + chat, + message_id: 902, + message_thread_id: 77, + date: 1_779_394_740, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + text: "authoritative edited response", + }); + + try { + await editMessageTelegram(chat.id, 902, "authoritative edited response", { + token: "42:test-token", + cfg, + }); + } finally { + unregister(); + } + + const entries = groupHistory.get(historyKey) ?? []; + expect(entries.map((entry) => entry.messageId)).toEqual(["902", "903"]); + expect(selectTelegramGroupHistoryAfterLastSelf(entries)).toEqual([ + expect.objectContaining({ + sender: "Teammate", + body: "context that must remain visible", + }), + ]); + const cached = await createTelegramMessageCache({ + scope: resolveTelegramMessageCacheScope(storePath), + }).get({ accountId: "default", chatId: chat.id, messageId: "902" }); + expect(cached?.body).toBe("authoritative edited response"); + }); }); describe("sendPollTelegram", () => { diff --git a/extensions/telegram/src/telegram-ingress-drain-factory.test.ts b/extensions/telegram/src/telegram-ingress-drain-factory.test.ts new file mode 100644 index 000000000000..25f68d3fea36 --- /dev/null +++ b/extensions/telegram/src/telegram-ingress-drain-factory.test.ts @@ -0,0 +1,112 @@ +/** Verifies the grammY-to-durable-ingress terminal outcome handoff. */ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + ensureTelegramMessageProcessingResult, + recordTelegramMessageProcessingResult, + runWithTelegramUpdateProcessingFrame, + type TelegramMessageProcessingResult, +} from "./bot-processing-outcome.js"; +import type { TelegramIngressDrainLifecycle } from "./telegram-ingress-drain.js"; + +const mocks = vi.hoisted(() => ({ + createTelegramIngressMonitor: vi.fn((params: unknown) => params), + openTelegramIngressQueue: vi.fn(() => ({ kind: "test-queue" })), + resolveTelegramAdoptionStallTimeoutMs: vi.fn(() => 5_000), +})); + +vi.mock("./telegram-ingress-drain.js", () => ({ + createTelegramIngressMonitor: mocks.createTelegramIngressMonitor, + resolveTelegramAdoptionStallTimeoutMs: mocks.resolveTelegramAdoptionStallTimeoutMs, +})); + +vi.mock("./telegram-ingress-spool.js", () => ({ + openTelegramIngressQueue: mocks.openTelegramIngressQueue, +})); + +const { createTelegramTransportIngressMonitor } = + await import("./telegram-ingress-drain-factory.js"); + +type CapturedMonitor = { + dispatch: ( + update: unknown, + lifecycle: TelegramIngressDrainLifecycle, + ) => Promise; +}; + +describe("Telegram transport ingress outcome handoff", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { kind: "completed" as const }, + { kind: "skipped" as const }, + { kind: "failed-retryable" as const, error: new Error("retry the update") }, + ])( + "returns the middleware-owned $kind outcome despite grammY returning void", + async (outcome) => { + const bot = { + handleUpdate: vi.fn(async () => { + await runWithTelegramUpdateProcessingFrame(async () => { + recordTelegramMessageProcessingResult(outcome); + }); + }), + }; + createTelegramTransportIngressMonitor({ + spoolDir: "/tmp/telegram-ingress-proof", + bot, + cfg: {} as OpenClawConfig, + accountId: "default", + }); + const monitor = mocks.createTelegramIngressMonitor.mock.calls[0]?.[0] as CapturedMonitor; + const update = { update_id: 123 }; + + await expect(monitor.dispatch(update, {} as TelegramIngressDrainLifecycle)).resolves.toBe( + outcome, + ); + expect(bot.handleUpdate).toHaveBeenCalledWith(update); + }, + ); + + it("does not invent an outcome for deferred participant ownership", async () => { + const bot = { + handleUpdate: vi.fn(async () => { + await runWithTelegramUpdateProcessingFrame(async () => {}); + }), + }; + createTelegramTransportIngressMonitor({ + spoolDir: "/tmp/telegram-ingress-proof", + bot, + cfg: {} as OpenClawConfig, + accountId: "default", + }); + const monitor = mocks.createTelegramIngressMonitor.mock.calls[0]?.[0] as CapturedMonitor; + + await expect( + monitor.dispatch({ update_id: 124 }, {} as TelegramIngressDrainLifecycle), + ).resolves.toBeUndefined(); + }); + + it("keeps an existing explicit skip when middleware applies its completion default", async () => { + const bot = { + handleUpdate: vi.fn(async () => { + await runWithTelegramUpdateProcessingFrame(async () => { + recordTelegramMessageProcessingResult({ kind: "skipped" }); + ensureTelegramMessageProcessingResult({ kind: "completed" }); + }); + }), + }; + createTelegramTransportIngressMonitor({ + spoolDir: "/tmp/telegram-ingress-proof", + bot, + cfg: {} as OpenClawConfig, + accountId: "default", + }); + const monitor = mocks.createTelegramIngressMonitor.mock.calls[0]?.[0] as CapturedMonitor; + + await expect( + monitor.dispatch({ update_id: 125 }, {} as TelegramIngressDrainLifecycle), + ).resolves.toEqual({ kind: "skipped" }); + }); +}); diff --git a/extensions/telegram/src/telegram-ingress-drain-factory.ts b/extensions/telegram/src/telegram-ingress-drain-factory.ts index 5e84a6d483a8..2339a5c0ea9e 100644 --- a/extensions/telegram/src/telegram-ingress-drain-factory.ts +++ b/extensions/telegram/src/telegram-ingress-drain-factory.ts @@ -1,7 +1,10 @@ // Telegram plugin module builds transport-shared durable ingress monitors. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { TelegramBotInfo } from "./bot-info.js"; -import type { TelegramMessageProcessingResult } from "./bot-processing-outcome.js"; +import { + runWithTelegramUpdateProcessingFrame, + type TelegramMessageProcessingResult, +} from "./bot-processing-outcome.js"; import { createTelegramIngressMonitor, resolveTelegramAdoptionStallTimeoutMs, @@ -60,9 +63,12 @@ export function createTelegramTransportIngressMonitor( if (params.dispatchUpdate) { return await params.dispatchUpdate(update, lifecycle); } - // Lifecycle is also on the spooled ALS frame (runWithTelegramSpooledReplayUpdate). - // bot-message merges it into turnAdoptionLifecycle for complete-at-adoption. - await params.bot.handleUpdate(update as never); + // grammY returns void, so carry its middleware-owned outcome back to durable ingress. + // The spooled lifecycle remains on its existing frame for complete-at-adoption. + const { result } = await runWithTelegramUpdateProcessingFrame(async () => { + await params.bot.handleUpdate(update as never); + }); + return result; }, }); } diff --git a/extensions/voyage/README.md b/extensions/voyage/README.md new file mode 100644 index 000000000000..37b074cb890e --- /dev/null +++ b/extensions/voyage/README.md @@ -0,0 +1,14 @@ +# OpenClaw Voyage Provider + +Official OpenClaw memory embedding provider plugin for Voyage AI. + +Install from OpenClaw: + +```bash +openclaw plugins install @openclaw/voyage-provider +openclaw gateway restart +``` + +Set `VOYAGE_API_KEY`, then configure memory search with `provider: "voyage"`. +See for setup and +configuration. diff --git a/extensions/voyage/index.ts b/extensions/voyage/index.ts index 8fadd002c3b1..bfdb7048e033 100644 --- a/extensions/voyage/index.ts +++ b/extensions/voyage/index.ts @@ -5,7 +5,7 @@ import { voyageMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter export default definePluginEntry({ id: "voyage", name: "Voyage Embeddings", - description: "Bundled Voyage memory embedding provider plugin", + description: "Voyage memory embedding provider plugin", register(api) { api.registerMemoryEmbeddingProvider(voyageMemoryEmbeddingProviderAdapter); }, diff --git a/extensions/voyage/package.json b/extensions/voyage/package.json index 20569589e823..e7db3033e9ed 100644 --- a/extensions/voyage/package.json +++ b/extensions/voyage/package.json @@ -1,8 +1,11 @@ { "name": "@openclaw/voyage-provider", "version": "2026.7.2", - "private": true, - "description": "OpenClaw Voyage embedding provider plugin", + "description": "OpenClaw Voyage embedding provider plugin.", + "repository": { + "type": "git", + "url": "https://github.com/openclaw/openclaw" + }, "type": "module", "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" @@ -10,6 +13,23 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "install": { + "clawhubSpec": "clawhub:@openclaw/voyage-provider", + "npmSpec": "@openclaw/voyage-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + }, + "compat": { + "pluginApi": ">=2026.7.2" + }, + "build": { + "openclawVersion": "2026.7.2", + "bundledDist": false + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } } } diff --git a/extensions/whatsapp/src/inbound/extract.test.ts b/extensions/whatsapp/src/inbound/extract.test.ts index 484e621bbd0d..7e5a7b8a6566 100644 --- a/extensions/whatsapp/src/inbound/extract.test.ts +++ b/extensions/whatsapp/src/inbound/extract.test.ts @@ -1,7 +1,12 @@ // Whatsapp tests cover extract plugin behavior. import type { proto } from "baileys"; import { describe, expect, it } from "vitest"; -import { describeReplyContext, extractMentionedJids, hasInboundUserContent } from "./extract.js"; +import { + describeReplyContext, + extractMentionedJids, + extractText, + hasInboundUserContent, +} from "./extract.js"; describe("extractMentionedJids", () => { const botJid = "5511999999999@s.whatsapp.net"; @@ -153,6 +158,121 @@ describe("describeReplyContext", () => { }); }); +describe("extractText", () => { + it.each([ + { + name: "button display text", + message: { + buttonsResponseMessage: { selectedButtonId: "yes", selectedDisplayText: "Yes" }, + }, + expected: "Yes", + }, + { + name: "button identifier when display text is unavailable", + message: { buttonsResponseMessage: { selectedButtonId: "yes" } }, + expected: "yes", + }, + { + name: "button identifier when display text is blank", + message: { + buttonsResponseMessage: { selectedButtonId: "yes", selectedDisplayText: " " }, + }, + expected: "yes", + }, + { + name: "list selection title", + message: { + listResponseMessage: { title: "Option A", singleSelectReply: { selectedRowId: "a" } }, + }, + expected: "Option A", + }, + { + name: "list row identifier when its title is unavailable", + message: { listResponseMessage: { singleSelectReply: { selectedRowId: "a" } } }, + expected: "a", + }, + { + name: "template button display text", + message: { + templateButtonReplyMessage: { selectedId: "button-1", selectedDisplayText: "Confirm" }, + }, + expected: "Confirm", + }, + { + name: "template button identifier when display text is unavailable", + message: { templateButtonReplyMessage: { selectedId: "button-1" } }, + expected: "button-1", + }, + { + name: "interactive response body", + message: { + interactiveResponseMessage: { + body: { text: "Continue" }, + nativeFlowResponseMessage: { name: "single_select", paramsJson: "{}" }, + }, + }, + expected: "Continue", + }, + { + name: "native-flow selection title when the interactive body is unavailable", + message: { + interactiveResponseMessage: { + nativeFlowResponseMessage: { + name: "single_select", + paramsJson: '{"id":"shipping-express","title":"Express shipping"}', + }, + }, + }, + expected: "Express shipping", + }, + { + name: "native-flow selection identifier when its title is unavailable", + message: { + interactiveResponseMessage: { + nativeFlowResponseMessage: { + name: "single_select", + paramsJson: '{"id":"shipping-express"}', + }, + }, + }, + expected: "shipping-express", + }, + { + name: "ephemeral button response", + message: { + ephemeralMessage: { + message: { + buttonsResponseMessage: { selectedButtonId: "ok", selectedDisplayText: "OK" }, + }, + }, + }, + expected: "OK", + }, + ])("preserves $name as inbound message text", ({ message, expected }) => { + expect(extractText(message as proto.IMessage)).toBe(expected); + }); + + it("ignores malformed native-flow response JSON", () => { + expect( + extractText({ + interactiveResponseMessage: { + nativeFlowResponseMessage: { name: "single_select", paramsJson: "{" }, + }, + } as proto.IMessage), + ).toBeUndefined(); + }); + + it("ignores non-record native-flow response JSON", () => { + expect( + extractText({ + interactiveResponseMessage: { + nativeFlowResponseMessage: { name: "single_select", paramsJson: "[]" }, + }, + } as proto.IMessage), + ).toBeUndefined(); + }); +}); + describe("hasInboundUserContent", () => { it("returns true for plain text conversation", () => { expect(hasInboundUserContent({ conversation: "hello" })).toBe(true); diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts index 5ea7312c3c75..2b0a45d7c580 100644 --- a/extensions/whatsapp/src/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -7,7 +7,7 @@ import { type NormalizedLocation, } from "openclaw/plugin-sdk/channel-inbound"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveComparableIdentity, type WhatsAppReplyContext } from "../identity.js"; import { jidToE164 } from "../text-runtime.js"; import { parseVcard } from "../vcard.js"; @@ -136,6 +136,26 @@ export function extractMentionedJids(rawMessage: proto.IMessage | undefined): st return uniqueStrings(flattened); } +function extractNativeFlowResponseText( + response: proto.Message.IInteractiveResponseMessage | null | undefined, +): string | undefined { + const paramsJson = response?.nativeFlowResponseMessage?.paramsJson; + if (!paramsJson) { + return undefined; + } + try { + const params: unknown = JSON.parse(paramsJson); + if (!isRecord(params)) { + return undefined; + } + return [params.title, params.id].find( + (value): value is string => typeof value === "string" && Boolean(value.trim()), + ); + } catch { + return undefined; + } +} + export function extractText(rawMessage: proto.IMessage | undefined): string | undefined { const message = unwrapMessage(rawMessage); if (!message) { @@ -161,6 +181,19 @@ export function extractText(rawMessage: proto.IMessage | undefined): string | un if (caption?.trim()) { return caption.trim(); } + const interactiveSelection = [ + candidate.buttonsResponseMessage?.selectedDisplayText, + candidate.buttonsResponseMessage?.selectedButtonId, + candidate.listResponseMessage?.title, + candidate.listResponseMessage?.singleSelectReply?.selectedRowId, + candidate.templateButtonReplyMessage?.selectedDisplayText, + candidate.templateButtonReplyMessage?.selectedId, + candidate.interactiveResponseMessage?.body?.text, + extractNativeFlowResponseText(candidate.interactiveResponseMessage), + ].find((value) => Boolean(value?.trim())); + if (interactiveSelection) { + return interactiveSelection.trim(); + } } const contactPlaceholder = extractContactPlaceholder(message) ?? diff --git a/extensions/whatsapp/src/inbound/send-api.test.ts b/extensions/whatsapp/src/inbound/send-api.test.ts index 852d4dd01c10..feff6a7e6253 100644 --- a/extensions/whatsapp/src/inbound/send-api.test.ts +++ b/extensions/whatsapp/src/inbound/send-api.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { AnyMessageContent, MiscMessageGenerationOptions, WAMessage } from "baileys"; import { listMessageReceiptPlatformIds } from "openclaw/plugin-sdk/channel-outbound"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { prepareWhatsAppOutboundMedia } from "../outbound-media-contract.js"; import { resolveWhatsAppOutboundMentions } from "./outbound-mentions.js"; import { createWebSendApi } from "./send-api.js"; import type { WhatsAppSendResult } from "./send-result.js"; @@ -329,6 +330,25 @@ describe("createWebSendApi", () => { }); }); + it.each([ + { kind: "image", contentType: " Image/PNG; charset=binary ", mimetype: "image/png" }, + { kind: "video", contentType: " Video/MP4; charset=binary ", mimetype: "video/mp4" }, + ])( + "preserves the native $kind payload after canonicalizing mixed-case media MIME", + async ({ kind, contentType, mimetype }) => { + const payload = Buffer.from(kind); + const media = await prepareWhatsAppOutboundMedia({ buffer: payload, contentType }); + + await api.sendMessage("+1555", "cap", media.buffer, media.mimetype); + + expectSendContentFields(0, { + [kind]: payload, + caption: "cap", + mimetype, + }); + }, + ); + it("prepopulates image thumbnails and dimensions before Baileys media upload", async () => { const payload = Buffer.from("img"); const thumbnail = Buffer.from("thumb"); diff --git a/extensions/whatsapp/src/outbound-media-contract.ts b/extensions/whatsapp/src/outbound-media-contract.ts index 3d487bf308a9..1d62c30c7d63 100644 --- a/extensions/whatsapp/src/outbound-media-contract.ts +++ b/extensions/whatsapp/src/outbound-media-contract.ts @@ -152,8 +152,8 @@ function normalizeWhatsAppLoadedMedia( const normalizedContentType = normalizeMimeType(media.contentType); const resolvedContentType = !normalizedContentType || normalizedContentType === "application/octet-stream" - ? (filenameMimeType ?? media.contentType) - : media.contentType; + ? (filenameMimeType ?? normalizedContentType) + : normalizedContentType; const kind = inferWhatsAppMediaKind(media, resolvedContentType); // Match the existing URL/filename voice rule used by the transcode decision; // otherwise native .ogg/.opus uploads carry an inconsistent payload MIME. diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index a2746251a5f3..00102bb500d5 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -142,6 +142,40 @@ describe("web outbound", () => { expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined); }); + it.each([ + { name: "text", mediaUrl: undefined }, + { name: "media", mediaUrl: "/tmp/pic.jpg" }, + ])("still sends $name when composing presence fails", async ({ mediaUrl }) => { + const mediaBuffer = Buffer.from("img"); + if (mediaUrl) { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: mediaBuffer, + contentType: "image/jpeg", + kind: "image", + }); + } + sendComposingTo.mockRejectedValueOnce(new Error("presence update unavailable")); + + await expect( + sendMessageWhatsApp("+1555", "hi", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + ...(mediaUrl ? { mediaUrl } : {}), + }), + ).resolves.toEqual({ + messageId: "msg123", + toJid: "1555@s.whatsapp.net", + }); + + expect(sendComposingTo).toHaveBeenCalledWith("+1555"); + expect(sendMessage).toHaveBeenCalledWith( + "+1555", + "hi", + mediaUrl ? mediaBuffer : undefined, + mediaUrl ? "image/jpeg" : undefined, + ); + }); + it("re-chunks after WhatsApp marker expansion", async () => { const onDeliveryResult = vi.fn(); await sendMessageWhatsApp("+1555", Array.from({ length: 8 }, () => "`x`").join(" "), { @@ -375,7 +409,7 @@ describe("web outbound", () => { expect(sendMessage).toHaveBeenNthCalledWith(2, "+1555", "voice note", undefined, undefined); }); - it("normalizes MIME parameters when inferring media kind", async () => { + it("normalizes MIME parameters before handing media to the socket transport", async () => { const buf = Buffer.from("image"); loadWebMediaMock.mockResolvedValueOnce({ buffer: buf, @@ -388,12 +422,7 @@ describe("web outbound", () => { mediaUrl: "/tmp/image.png", }); - expect(sendMessage).toHaveBeenLastCalledWith( - "+1555", - "caption", - buf, - " Image/PNG; charset=binary ", - ); + expect(sendMessage).toHaveBeenLastCalledWith("+1555", "caption", buf, "image/png"); }); it("reports the accepted voice send before a caption failure", async () => { diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 660c448ea601..5ec2906e13bc 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -240,7 +240,15 @@ export async function sendMessageWhatsApp( logger.info({ jid: redactedJid, hasMedia }, "sending message"); if (!isWhatsAppNewsletterJid(jid)) { await active.assertSendReady?.(to); - await active.sendComposingTo(to); + try { + await active.sendComposingTo(to); + } catch (err) { + // Typing is optional; a failed chatstate update must not block the actual message. + logger.warn( + { err: String(err), jid: redactedJid }, + "failed to send composing presence; continuing message delivery", + ); + } } const hasExplicitAccountId = Boolean(options.accountId?.trim()); const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; diff --git a/package.json b/package.json index 1fd62961b564..28b0fed71bc7 100644 --- a/package.json +++ b/package.json @@ -261,6 +261,7 @@ "!dist/extensions/diffs/**", "!dist/extensions/diffs-language-pack/**", "!dist/extensions/discord/**", + "!dist/extensions/duckduckgo/**", "!dist/extensions/exa/**", "!dist/extensions/feishu/**", "!dist/extensions/featherless/**", @@ -315,6 +316,7 @@ "!dist/extensions/venice/**", "!dist/extensions/vercel-ai-gateway/**", "!dist/extensions/voice-call/**", + "!dist/extensions/voyage/**", "!dist/extensions/whatsapp/**", "!dist/extensions/zai/**", "!dist/extensions/zalo/**", diff --git a/packages/ai/src/providers/google-shared.test.ts b/packages/ai/src/providers/google-shared.test.ts index cff0cb2b1e4e..9847051bb17f 100644 --- a/packages/ai/src/providers/google-shared.test.ts +++ b/packages/ai/src/providers/google-shared.test.ts @@ -264,6 +264,66 @@ describe("consumeGoogleGenerateContentStream", () => { }); describe("runGoogleGenerateContentLifecycle", () => { + it.each([ + { api: "google-generative-ai", blockReason: "SAFETY" }, + { api: "google-generative-ai", blockReason: undefined }, + { api: "google-vertex", blockReason: "SAFETY" }, + { api: "google-vertex", blockReason: undefined }, + ] as const)( + "surfaces blocked $api prompts as typed stream errors when blockReason is $blockReason", + async ({ api, blockReason }) => { + const targetModel = { + ...model, + api, + provider: api === "google-vertex" ? "google-vertex" : "google", + } satisfies Model<"google-generative-ai" | "google-vertex">; + const output: AssistantMessage = { + ...createOutput(), + api: targetModel.api, + provider: targetModel.provider, + }; + const stream = new AssistantMessageEventStream(); + + await runGoogleGenerateContentLifecycle({ + stream, + model: targetModel, + output, + createClient: () => ({ + models: { + generateContentStream: async () => + chunks([ + { + promptFeedback: { + ...(blockReason ? { blockReason } : {}), + blockReasonMessage: "Prompt violates provider safety policy", + }, + usageMetadata: { + promptTokenCount: 12, + cachedContentTokenCount: 2, + totalTokenCount: 12, + }, + } as GenerateContentResponse, + ]), + }, + }), + buildParams: () => ({ model: targetModel.id, contents: [] }), + nextToolCallId: () => "call_1", + }); + + const result = await stream.result(); + const expectedBlockReason = blockReason ?? "PROMPT_BLOCKED"; + expect(result).toMatchObject({ + stopReason: "error", + errorCode: expectedBlockReason, + errorType: "google_prompt_blocked", + errorMessage: `Google prompt blocked (${expectedBlockReason}): Prompt violates provider safety policy`, + content: [], + usage: { input: 10, cacheRead: 2, totalTokens: 12 }, + }); + expect(result.usage.cost.total).toBeGreaterThan(0); + }, + ); + it("surfaces HTTP response body text from Google-compatible errors", async () => { const output = createOutput(); const stream = new AssistantMessageEventStream(); diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 154e87b6ed3e..70ea56736230 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -779,7 +779,38 @@ export async function consumeGoogleGenerateContentStream { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + "https://aiplatform.googleapis.com", + "https://us-east5-aiplatform.googleapis.com", + "https://aiplatform.us.rep.googleapis.com", + "https://aiplatform.eu.rep.googleapis.com", + ])("preserves env-configured long retention for the official %s endpoint", (baseUrl) => { + vi.stubEnv("OPENCLAW_CACHE_RETENTION", "long"); + + expect(resolveAnthropicEphemeralCacheControl(baseUrl, undefined)).toEqual({ + type: "ephemeral", + ttl: "1h", + }); + }); + + it("keeps env-configured long retention restricted for custom proxy endpoints", () => { + vi.stubEnv("OPENCLAW_CACHE_RETENTION", "long"); + + expect( + resolveAnthropicEphemeralCacheControl("https://proxy.example.test/vertex", undefined), + ).toEqual({ type: "ephemeral" }); + }); + + it("preserves explicitly configured long retention for custom proxy endpoints", () => { + expect( + resolveAnthropicEphemeralCacheControl("https://proxy.example.test/vertex", "long"), + ).toEqual({ type: "ephemeral", ttl: "1h" }); + }); +}); diff --git a/packages/ai/src/transports/anthropic-payload-policy.ts b/packages/ai/src/transports/anthropic-payload-policy.ts index 265b1dfa1633..f17a1f4c67b0 100644 --- a/packages/ai/src/transports/anthropic-payload-policy.ts +++ b/packages/ai/src/transports/anthropic-payload-policy.ts @@ -56,6 +56,8 @@ function isLongTtlEligibleEndpoint(baseUrl: string | undefined): boolean { return ( hostname === "api.anthropic.com" || hostname === "aiplatform.googleapis.com" || + hostname === "aiplatform.us.rep.googleapis.com" || + hostname === "aiplatform.eu.rep.googleapis.com" || hostname.endsWith("-aiplatform.googleapis.com") ); } diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index f5e2f5a9e45c..46a03dc47173 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -37,6 +37,7 @@ export const ModelChoiceSchema = closedObject({ available: Type.Optional(Type.Boolean()), contextWindow: Type.Optional(Type.Integer({ minimum: 1 })), reasoning: Type.Optional(Type.Boolean()), + supportsTools: Type.Optional(Type.Boolean()), agentRuntime: Type.Optional(GatewayAgentRuntimeSchema), apiKeySupported: Type.Optional(Type.Boolean()), input: Type.Optional( diff --git a/qa/scenarios/channels/telegram-queue-invalid-mode.yaml b/qa/scenarios/channels/telegram-queue-invalid-mode.yaml index 796f2c114d6a..018c7ee25f92 100644 --- a/qa/scenarios/channels/telegram-queue-invalid-mode.yaml +++ b/qa/scenarios/channels/telegram-queue-invalid-mode.yaml @@ -1,4 +1,4 @@ -title: Telegram native queue command rejects ordinary prompt text +title: Telegram native settings commands reject prose as command arguments scenario: id: telegram-queue-invalid-mode @@ -9,14 +9,17 @@ scenario: - telegram.built-in-commands regressionRefs: - openclaw/openclaw#116688 - objective: Verify a native Telegram queue command with ordinary trailing text returns its queue-mode validation error without invoking the model or synthesizing a model-failure fallback. + objective: Verify native Telegram settings commands validate their complete arguments without invoking the model or synthesizing a model-failure fallback. successCriteria: - Telegram accepts the native queue command with its complete trailing argument text. - The reply identifies the invalid queue mode and lists supported queue modes. + - Valid queue settings followed by unexpected prose produce an explicit argument error. + - Native thinking commands preserve and reject invalid multiword arguments. - The reply never blames the model, and a mock provider receives no request for the command. codeRefs: - extensions/telegram/src/bot-native-commands.ts - src/auto-reply/reply/get-reply-directives.ts + - src/auto-reply/reply/directive-handling.native.ts - src/auto-reply/reply/directive-handling.queue-validation.ts execution: kind: flow @@ -67,3 +70,48 @@ flow: message: expr: "`native queue validation unexpectedly invoked the model ${String(scenarioRequests.length)} time(s)`" detailsExpr: reply.text + + - name: valid native queue options reject unexpected trailing prose + actions: + - set: queueStartIndex + value: + expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length" + - sendInbound: + conversation: { id: telegram-command-room, kind: channel } + senderId: qa-command-operator + senderName: QA Command Operator + text: /queue collect please help + nativeCommand: { name: queue } + - waitForOutbound: + conversation: { id: telegram-command-room, kind: channel } + sinceIndex: { ref: queueStartIndex } + textIncludes: Unexpected argument "please" for /queue. + timeoutMs: 60000 + saveAs: queueTrailingReply + detailsExpr: queueTrailingReply.text + + - name: native thinking commands preserve invalid multiword arguments + actions: + - set: thinkStartIndex + value: + expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length" + - sendInbound: + conversation: { id: telegram-command-room, kind: channel } + senderId: qa-command-operator + senderName: QA Command Operator + text: /think about my deployment plan + nativeCommand: { name: think } + - waitForOutbound: + conversation: { id: telegram-command-room, kind: channel } + sinceIndex: { ref: thinkStartIndex } + textIncludes: Unrecognized thinking level "about". + timeoutMs: 60000 + saveAs: thinkReply + - set: allScenarioRequests + value: + expr: "env.mock ? await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBefore}`) : []" + - assert: + expr: "!env.mock || allScenarioRequests.length === 0" + message: + expr: "`native settings validation unexpectedly invoked the model ${String(allScenarioRequests.length)} time(s)`" + detailsExpr: thinkReply.text diff --git a/scripts/bundled-plugin-assets.mjs b/scripts/bundled-plugin-assets.mjs index 1a7b45535fbe..9147bd94f1b1 100644 --- a/scripts/bundled-plugin-assets.mjs +++ b/scripts/bundled-plugin-assets.mjs @@ -6,6 +6,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { runManagedCommand } from "./lib/managed-child-process.mjs"; +import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -128,6 +129,9 @@ export async function runBundledPluginAssetHooks(options = {}) { console.log(`No bundled plugin asset ${phase} hooks${scope}; skipping.`); return; } + if (phase === "copy") { + assertRealOutputRoot(path.join(options.rootDir ?? rootDir, "dist")); + } for (const hook of hooks) { console.log(`[${hook.pluginId}] ${phase}: ${hook.command}`); diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 41d52b291acb..090d38ae5ac8 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -7,6 +7,7 @@ import { NON_PACKAGED_BUNDLED_PLUGIN_DIRS, } from "./lib/bundled-plugin-build-entries.mjs"; import { shouldBuildBundledCluster } from "./lib/optional-bundled-clusters.mjs"; +import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { mergeGeneratedChannelConfigs, readGeneratedBundledChannelConfigs, @@ -246,6 +247,9 @@ export function copyBundledPluginMetadata(params = {}) { if (!fs.existsSync(extensionsRoot)) { return; } + // Fail closed before any dist/extensions removal: a symlinked dist root + // would redirect recursive deletes into the link target. + assertRealOutputRoot(path.join(repoRoot, "dist")); const buildablePluginDirs = new Set( collectBundledPluginBuildEntries({ cwd: repoRoot, env }).map((entry) => entry.id), diff --git a/scripts/copy-export-html-templates.ts b/scripts/copy-export-html-templates.ts index 6bb084da6952..f745b24acbcb 100644 --- a/scripts/copy-export-html-templates.ts +++ b/scripts/copy-export-html-templates.ts @@ -5,20 +5,17 @@ import fs from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts"; +import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; const context = resolveBuildCopyContext(import.meta.url); -const exportHtmlSrcDir = path.join( - context.projectRoot, - "src", - "auto-reply", - "reply", - "export-html", -); -const exportHtmlDistDir = path.join(context.projectRoot, "dist", "export-html"); - -function copyExportHtmlTemplates() { +export function copyExportHtmlTemplates(params: { projectRoot?: string } = {}) { + const projectRoot = params.projectRoot ?? context.projectRoot; + const exportHtmlSrcDir = path.join(projectRoot, "src", "auto-reply", "reply", "export-html"); + const exportHtmlDistDir = path.join(projectRoot, "dist", "export-html"); + assertRealOutputRoot(path.join(projectRoot, "dist")); if (!fs.existsSync(exportHtmlSrcDir)) { console.warn(`${context.prefix} Source directory not found:`, exportHtmlSrcDir); return; @@ -52,4 +49,6 @@ function copyExportHtmlTemplates() { console.log(`${context.prefix} Copied ${copiedCount} export-html assets.`); } -copyExportHtmlTemplates(); +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + copyExportHtmlTemplates(); +} diff --git a/scripts/lib/official-external-plugin-catalog.json b/scripts/lib/official-external-plugin-catalog.json index ba03884d00c9..05663e8b269a 100644 --- a/scripts/lib/official-external-plugin-catalog.json +++ b/scripts/lib/official-external-plugin-catalog.json @@ -169,6 +169,46 @@ } } }, + { + "name": "@openclaw/duckduckgo-plugin", + "description": "OpenClaw DuckDuckGo plugin.", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "duckduckgo", + "label": "DuckDuckGo" + }, + "contracts": { + "webSearchProviders": [ + "duckduckgo" + ] + }, + "webSearchProviders": [ + { + "id": "duckduckgo", + "label": "DuckDuckGo Search (experimental)", + "hint": "Free web search fallback with no API key required", + "onboardingScopes": [ + "text-inference" + ], + "requiresCredential": false, + "envVars": [], + "placeholder": "(no key needed)", + "signupUrl": "https://duckduckgo.com/", + "docsUrl": "https://docs.openclaw.ai/tools/duckduckgo-search", + "credentialPath": "", + "autoDetectOrder": 100 + } + ], + "install": { + "clawhubSpec": "clawhub:@openclaw/duckduckgo-plugin", + "npmSpec": "@openclaw/duckduckgo-plugin", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + } + } + }, { "name": "@openclaw/exa-plugin", "description": "OpenClaw Exa plugin.", diff --git a/scripts/lib/official-external-provider-catalog.json b/scripts/lib/official-external-provider-catalog.json index 882145a98922..9388b8164643 100644 --- a/scripts/lib/official-external-provider-catalog.json +++ b/scripts/lib/official-external-provider-catalog.json @@ -1708,6 +1708,42 @@ } } }, + { + "name": "@openclaw/voyage-provider", + "description": "OpenClaw Voyage embedding provider plugin.", + "source": "official", + "kind": "provider", + "openclaw": { + "plugin": { + "id": "voyage", + "label": "Voyage" + }, + "providers": [ + { + "id": "voyage", + "name": "Voyage", + "docs": "/reference/memory-config", + "categories": [ + "cloud" + ], + "envVars": [ + "VOYAGE_API_KEY" + ] + } + ], + "contracts": { + "memoryEmbeddingProviders": [ + "voyage" + ] + }, + "install": { + "clawhubSpec": "clawhub:@openclaw/voyage-provider", + "npmSpec": "@openclaw/voyage-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + } + } + }, { "name": "@openclaw/stepfun-provider", "description": "OpenClaw StepFun provider plugin.", diff --git a/scripts/lib/output-root-guard.d.mts b/scripts/lib/output-root-guard.d.mts new file mode 100644 index 000000000000..b384b1f0477a --- /dev/null +++ b/scripts/lib/output-root-guard.d.mts @@ -0,0 +1,4 @@ +export function assertRealOutputRoot( + rootPath: string, + params?: { fs?: typeof import("node:fs") }, +): void; diff --git a/scripts/lib/output-root-guard.mjs b/scripts/lib/output-root-guard.mjs new file mode 100644 index 000000000000..8427ccc1af49 --- /dev/null +++ b/scripts/lib/output-root-guard.mjs @@ -0,0 +1,28 @@ +// Fail-closed output-root guard shared by build and postbuild mutators. +import fs from "node:fs"; + +/** + * Throws when a generated output root is a symbolic link. readdir/rm through a + * symlinked root rewrites the link target — observed deleting a live gateway + * build tree — so recursive cleanup and replacement must fail closed here. + */ +export function assertRealOutputRoot(rootPath, params = {}) { + const fsImpl = params.fs ?? fs; + let stat; + try { + stat = fsImpl.lstatSync(rootPath); + } catch (error) { + // Missing roots are normal on a first build; the build recreates them. + if (error?.code === "ENOENT") { + return; + } + throw error; + } + if (!stat.isSymbolicLink()) { + return; + } + throw new Error( + `Build output root "${rootPath}" is a symbolic link; refusing to mutate it. ` + + `Remove the symlink or replace it with a real directory before building.`, + ); +} diff --git a/scripts/lib/plugin-npm-runtime-build.mjs b/scripts/lib/plugin-npm-runtime-build.mjs index 3fa93aaf731e..0381cc17c062 100644 --- a/scripts/lib/plugin-npm-runtime-build.mjs +++ b/scripts/lib/plugin-npm-runtime-build.mjs @@ -7,6 +7,7 @@ import { collectPluginSourceEntries, collectTopLevelPublicSurfaceEntries, } from "./bundled-plugin-build-entries.mjs"; +import { assertRealOutputRoot } from "./output-root-guard.mjs"; import { listMissingPackageStaticAssetSources, runPackageAssetBuild, @@ -352,6 +353,7 @@ export async function buildPluginNpmRuntime(params) { return null; } + assertRealOutputRoot(plan.outDir); fs.rmSync(plan.outDir, { recursive: true, force: true }); await build({ clean: false, diff --git a/scripts/prune-docker-plugin-dist.mjs b/scripts/prune-docker-plugin-dist.mjs index 87573924f5c1..9a53e1d66c62 100644 --- a/scripts/prune-docker-plugin-dist.mjs +++ b/scripts/prune-docker-plugin-dist.mjs @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { collectRootPackageExcludedExtensionDirs } from "./lib/bundled-plugin-build-entries.mjs"; +import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; const RUNTIME_DEPENDENCY_FIELDS = ["dependencies", "optionalDependencies"]; @@ -177,6 +178,11 @@ export function pruneDockerPluginDist(params = {}) { ); const removed = []; + // The removals below recurse into dist/ and dist-runtime/ plugin trees; + // refuse to follow a symlinked output root into its target. + assertRealOutputRoot(path.join(repoRoot, "dist")); + assertRealOutputRoot(path.join(repoRoot, "dist-runtime")); + removed.push(...pruneNodeModulesForOmittedPlugins(repoRoot, bundledPluginDir, omittedPluginIds)); for (const pluginId of [...omittedPluginIds].toSorted((left, right) => diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 29b1bd87f987..760d98404044 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import { fileURLToPath, pathToFileURL } from "node:url"; import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs"; +import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { escapeRegExp } from "./lib/regexp.mjs"; import { copyStaticExtensionAssets, @@ -337,6 +338,9 @@ export function writeStableRootRuntimeAliases(params = {}) { const rootDir = params.rootDir ?? ROOT; const distDir = path.join(rootDir, "dist"); const fsImpl = params.fs ?? fs; + // Alias rewrites delete files under dist; fail closed on a symlinked root + // so a stale alias removal cannot land inside the link target. + assertRealOutputRoot(distDir, { fs: fsImpl }); const candidatesByAlias = collectStableRootRuntimeAliasCandidates({ distDir, fs: fsImpl }); for (const [aliasFileName, candidates] of candidatesByAlias) { @@ -544,6 +548,12 @@ function shouldCopyStaticExtensionAssets(params) { * Runs every runtime postbuild phase after the main dist build. */ export function runRuntimePostBuild(params = {}) { + const rootDir = params.rootDir ?? params.cwd ?? params.repoRoot ?? ROOT; + const fsImpl = params.fs ?? fs; + // Postbuild phases share both roots. Validate the whole mutation set before + // any phase runs so a later unsafe root cannot leave earlier output changed. + assertRealOutputRoot(path.join(rootDir, "dist"), { fs: fsImpl }); + assertRealOutputRoot(path.join(rootDir, "dist-runtime"), { fs: fsImpl }); const timingsSetting = params.timings ?? process.env.OPENCLAW_RUNTIME_POSTBUILD_TIMINGS; const timingsEnabled = timingsSetting !== "0" && timingsSetting !== false; // Per-phase lines are debug detail; default output is one summary line so a diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index 15172ddcdffd..e74e08a13956 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; function relativeSymlinkTarget(sourcePath, targetPath) { @@ -322,6 +323,8 @@ export function stageBundledPluginRuntime(params = {}) { const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); const distRoot = path.join(repoRoot, "dist"); const runtimeRoot = path.join(repoRoot, "dist-runtime"); + assertRealOutputRoot(distRoot); + assertRealOutputRoot(runtimeRoot); const distExtensionsRoot = path.join(distRoot, "extensions"); const runtimeExtensionsRoot = path.join(runtimeRoot, "extensions"); diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 9b514656641b..121aeef0b107 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1442,6 +1442,8 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "scripts/lib/plugin-npm-runtime-build.mjs", ["test/scripts/plugin-npm-runtime-build-args.test.ts", "test/plugin-npm-runtime-build.test.ts"], ], + ["scripts/lib/output-root-guard.mjs", ["test/scripts/output-root-guard.test.ts"]], + ["scripts/lib/output-root-guard.d.mts", ["test/scripts/output-root-guard.test.ts"]], [ "scripts/lib/npm-publish-plan.mjs", [ @@ -2252,6 +2254,8 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/write-package-dist-inventory.ts", ["test/scripts/test-install-sh-docker.test.ts"]], ["scripts/e2e/cron-mcp-cleanup-seed.ts", ["test/scripts/docker-e2e-seeds.test.ts"]], ["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]], + ["scripts/copy-export-html-templates.ts", ["test/scripts/copy-export-html-templates.test.ts"]], + ["scripts/ui.js", ["test/scripts/ui.test.ts"]], ["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]], ["scripts/build-discord-activity-sdk.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]], ["scripts/build-diffs-viewer-runtime.mjs", ["test/scripts/build-diffs-viewer-runtime.test.ts"]], diff --git a/scripts/tsdown-build.d.mts b/scripts/tsdown-build.d.mts index 326aa2eabdd1..9e62aa8fedf2 100644 --- a/scripts/tsdown-build.d.mts +++ b/scripts/tsdown-build.d.mts @@ -9,6 +9,10 @@ export function cleanTsdownOutputRoots(params?: { roots?: string[]; }): void; export function pruneStaleRootChunkFiles(params?: Record): void; +export function pruneStaleRuntimeSymlinks(params?: { + cwd?: string; + fs?: typeof import("node:fs"); +}): void; export function listTsdownOutputRoots(): string[]; export function resolveTsdownCleanOutputRoots(args?: string[]): string[]; export function pruneUntrackedGeneratedSourceDeclarations(params?: Record): number; diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 09bc058e0fe4..c6c7bdfa0404 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -8,6 +8,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; +import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { TSDOWN_PACKAGE_CONFIG_GROUP, TSDOWN_UNIFIED_CONFIG_GROUP, @@ -75,13 +76,18 @@ function removeDistPluginNodeModulesSymlinks(rootDir) { } } -function pruneStaleRuntimeSymlinks() { - const cwd = process.cwd(); +export function pruneStaleRuntimeSymlinks(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const fsImpl = params.fs ?? fs; + const distRoot = path.join(cwd, "dist"); + const distRuntimeRoot = path.join(cwd, "dist-runtime"); + assertRealOutputRoot(distRoot, { fs: fsImpl }); + assertRealOutputRoot(distRuntimeRoot, { fs: fsImpl }); // runtime-postbuild stages plugin-owned node_modules into dist/ and links the // dist-runtime overlay back to that tree. Remove only those symlinks up front // so tsdown's clean step cannot traverse stale runtime overlays on rebuilds. - removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist")); - removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist-runtime")); + removeDistPluginNodeModulesSymlinks(distRoot); + removeDistPluginNodeModulesSymlinks(distRuntimeRoot); } /** @@ -92,6 +98,12 @@ export function cleanTsdownOutputRoots(params = {}) { const fsImpl = params.fs ?? fs; const env = params.env ?? process.env; const roots = params.roots ?? listTsdownOutputRoots(); + const rootPaths = roots.map((root) => path.join(cwd, root)); + // Validate the complete mutation set before traversing protected children or + // cleaning any earlier root; otherwise a later symlink can leave a partial build. + for (const rootPath of rootPaths) { + assertRealOutputRoot(rootPath, { fs: fsImpl }); + } const protectedDeclarationPaths = env[RUN_NODE_SKIP_DTS_BUILD_ENV] === "1" ? listExistingDeclarationOutputPaths({ @@ -104,8 +116,7 @@ export function cleanTsdownOutputRoots(params = {}) { ...protectedDeclarationPaths, ...listExistingPreservedOutputPaths({ cwd, env, fs: fsImpl }), ]); - for (const root of roots) { - const rootPath = path.join(cwd, root); + for (const rootPath of rootPaths) { try { if (hasProtectedChild({ rootPath, protectedPaths })) { cleanOutputRootExcept(rootPath, protectedPaths, fsImpl); @@ -203,6 +214,9 @@ export function pruneStaleRootChunkFiles(params = {}) { const cwd = params.cwd ?? process.cwd(); const fsImpl = params.fs ?? fs; const roots = listTsdownOutputRoots({ cwd, fs: fsImpl }).map((root) => path.join(cwd, root)); + for (const root of roots) { + assertRealOutputRoot(root, { fs: fsImpl }); + } for (const root of roots) { let entries; try { diff --git a/scripts/ui.js b/scripts/ui.js index b14949a48ad2..a37a0dcd46da 100644 --- a/scripts/ui.js +++ b/scripts/ui.js @@ -5,6 +5,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { resolvePnpmRunner } from "./pnpm-runner.mjs"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; @@ -301,6 +302,10 @@ function resolveScriptAction(action) { return null; } +export function assertUiBuildOutputRoot(params = {}) { + assertRealOutputRoot(path.join(params.rootDir ?? repoRoot, "dist"), { fs: params.fs ?? fs }); +} + export function main(argv = process.argv.slice(2)) { const [action, ...rest] = argv; if (!action) { @@ -313,6 +318,9 @@ export function main(argv = process.argv.slice(2)) { usage(); process.exit(2); } + if (action === "build") { + assertUiBuildOutputRoot(); + } if (process.env.OPENCLAW_BUILD_ALL_NO_PNPM === "1" && action === "build") { run(process.execPath, [path.join(repoRoot, "node_modules/vite/bin/vite.js"), "build", ...rest]); diff --git a/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts b/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts index 45d09631cad5..c3bf8e7d6103 100644 --- a/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts +++ b/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts @@ -237,14 +237,18 @@ describe("runEmbeddedAgent before_agent_reply seam", () => { }, ); - it("forwards one-shot auxiliary-run flags into the embedded attempt", async () => { + it("forwards one-shot auxiliary-run flags and tool bindings into the embedded attempt", async () => { // Auxiliary-run flags are request-scoped; they must pass through to the // first attempt without becoming persistent session settings. + const toolBindings = { + browser: { kind: "tab", tabId: 7, target: "host", profile: "chrome", targetId: "target-7" }, + }; mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult()); await runEmbeddedAgent({ ...overflowBaseRunParams, trigger: "user", + toolBindings, disableTrajectory: true, modelRun: true, promptMode: "none", @@ -254,6 +258,7 @@ describe("runEmbeddedAgent before_agent_reply seam", () => { expect(attemptParams.disableTrajectory).toBe(true); expect(attemptParams.modelRun).toBe(true); expect(attemptParams.promptMode).toBe("none"); + expect(attemptParams).toMatchObject({ toolBindings }); }); it("forwards one-shot bundle MCP cleanup into the embedded attempt", async () => { diff --git a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts index af866fe7ac50..b08aea516277 100644 --- a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts @@ -25,6 +25,7 @@ import { resolveOpenClawReferencePaths } from "../../docs-path.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { prepareAgentMemoryPrompt } from "../../memory-prompt-prepare.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; +import { buildModelToolsUnavailablePrompt } from "../../model-tool-support.js"; import { buildProjectMemoryWriteInstruction, prepareProjectMemoryBootstrap, @@ -65,6 +66,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { getProviderRuntimeHandle: () => ProviderRuntimePluginHandle; isRawModelRun: boolean; markStage: (name: string) => void; + modelToolsEnabled: boolean; proactiveSubagentOrchestration: boolean; sandbox?: SandboxContext; sandboxSessionKey: string; @@ -273,6 +275,14 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { const projectMemoryWriteInstruction = buildProjectMemoryWriteInstruction( attempt.preparedModelRuntime?.projectKey, ); + const extraSystemPrompt = + [ + attempt.extraSystemPrompt, + projectMemoryWriteInstruction, + buildModelToolsUnavailablePrompt(params.modelToolsEnabled), + ] + .filter((value): value is string => Boolean(value)) + .join("\n\n") || undefined; const attemptSystemPrompt = buildAttemptSystemPrompt({ isRawModelRun: params.isRawModelRun, @@ -287,9 +297,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { workspaceDir: params.effectiveWorkspace, defaultThinkLevel: attempt.thinkLevel, reasoningLevel: attempt.reasoningLevel ?? "off", - extraSystemPrompt: projectMemoryWriteInstruction - ? [attempt.extraSystemPrompt, projectMemoryWriteInstruction].filter(Boolean).join("\n\n") - : attempt.extraSystemPrompt, + extraSystemPrompt, ownerNumbers: attempt.ownerNumbers, reasoningTagHint, heartbeatPrompt, diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts index c30d400f03e9..06f4e1a7c4cc 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts @@ -744,7 +744,8 @@ vi.mock("../../model-auth.js", () => ({ resolveModelAuthMode: () => undefined, })); -vi.mock("../../model-tool-support.js", () => ({ +vi.mock("../../model-tool-support.js", async (importOriginal) => ({ + ...(await importOriginal()), supportsModelTools: (...args: unknown[]) => hoisted.supportsModelToolsMock(...args), })); diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 1155d41528bd..96d512902376 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -292,6 +292,7 @@ export async function runEmbeddedAttempt( getProviderRuntimeHandle, isRawModelRun, markStage: (name) => prepStages.mark(name), + modelToolsEnabled: toolsEnabled, proactiveSubagentOrchestration, sandbox: sandbox ?? undefined, sandboxSessionKey, diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index d5bced61a11c..c3975d917e57 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -190,6 +190,7 @@ export async function dispatchEmbeddedRunAttempt(input: { messageChannel: params.messageChannel, messageProvider: params.messageProvider, clientCaps: params.clientCaps, + toolBindings: params.toolBindings, chatType: params.chatType, agentAccountId: params.agentAccountId, messageTo: params.messageTo, diff --git a/src/agents/main-session-recovery-lifecycle.ts b/src/agents/main-session-recovery-lifecycle.ts index 5fda42d1e9d3..cd3fd3ddf70a 100644 --- a/src/agents/main-session-recovery-lifecycle.ts +++ b/src/agents/main-session-recovery-lifecycle.ts @@ -113,6 +113,18 @@ export function projectMainSessionRecoveryLifecycle(params: { ) : runs; if (settlesRecovery) { + if ( + matchesFence && + lifecycleGeneration !== params.currentLifecycleGeneration && + remaining?.some( + (run) => + run.runId === runId && run.lifecycleGeneration === params.currentLifecycleGeneration, + ) + ) { + // Older generations share the live owner's run id. Consume only their + // fence; recording that id as terminal would also tombstone its replacement. + return { action: "apply", patch: { restartRecoveryRuns: remaining } }; + } const foregroundClaims = params.entry?.mainRestartRecovery?.foregroundClaims; const foregroundOwnerClaimId = runId && diff --git a/src/agents/main-session-recovery-run-ownership.test.ts b/src/agents/main-session-recovery-run-ownership.test.ts index e41f6d77adf7..b4a2f947f2a5 100644 --- a/src/agents/main-session-recovery-run-ownership.test.ts +++ b/src/agents/main-session-recovery-run-ownership.test.ts @@ -2,12 +2,16 @@ import { describe, expect, it } from "vitest"; import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; import { projectMainSessionRecoveryLifecycle } from "./main-session-recovery-lifecycle.js"; -function recoveryEntry(params?: { hasCurrentOwner?: boolean }): SessionEntry { +function recoveryEntry(params?: { + hasCurrentOwner?: boolean; + ownsDelivery?: boolean; +}): SessionEntry { return { sessionId: "session-1", updatedAt: 100, status: "running", abortedLastRun: false, + ...(params?.ownsDelivery ? { restartRecoveryDeliveryRunId: "recovery" } : {}), restartRecoveryRuns: [ { runId: "recovery", lifecycleGeneration: "generation-old" }, { runId: "recovery", lifecycleGeneration: "generation-current" }, @@ -52,11 +56,14 @@ describe("main-session recovery run ownership", () => { }); }); - it("does not let an older same-id terminal settle its replacement generation", () => { + it.each([ + { name: "admitted delivery owner", params: { ownsDelivery: true } }, + { name: "foreground owner", params: { hasCurrentOwner: true, ownsDelivery: true } }, + ])("does not let an older same-id terminal settle or tombstone its $name", ({ params }) => { expect( projectMainSessionRecoveryLifecycle({ currentLifecycleGeneration: "generation-current", - entry: recoveryEntry({ hasCurrentOwner: true }), + entry: recoveryEntry(params), event: { runId: "recovery", lifecycleGeneration: "generation-old", @@ -68,8 +75,25 @@ describe("main-session recovery run ownership", () => { action: "apply", patch: { restartRecoveryRuns: [{ runId: "recovery", lifecycleGeneration: "generation-current" }], - restartRecoveryTerminalRunIds: ["recovery"], }, }); }); + + it("suppresses a duplicate older terminal after its fence has been consumed", () => { + const entry = recoveryEntry({ ownsDelivery: true }); + entry.restartRecoveryRuns = [{ runId: "recovery", lifecycleGeneration: "generation-current" }]; + + expect( + projectMainSessionRecoveryLifecycle({ + currentLifecycleGeneration: "generation-current", + entry, + event: { + runId: "recovery", + lifecycleGeneration: "generation-old", + data: { phase: "end" }, + }, + snapshotPatch: { status: "done", abortedLastRun: false }, + }), + ).toEqual({ action: "suppress" }); + }); }); diff --git a/src/agents/model-tool-support.test.ts b/src/agents/model-tool-support.test.ts index d7f7b2db6e7b..1e3c39f4b5c0 100644 --- a/src/agents/model-tool-support.test.ts +++ b/src/agents/model-tool-support.test.ts @@ -1,6 +1,6 @@ // Documents model tool-support compatibility defaults. import { describe, expect, it } from "vitest"; -import { supportsModelTools } from "./model-tool-support.js"; +import { buildModelToolsUnavailablePrompt, supportsModelTools } from "./model-tool-support.js"; describe("supportsModelTools", () => { it("defaults to true when the model has no compat override", () => { @@ -15,3 +15,13 @@ describe("supportsModelTools", () => { expect(supportsModelTools({ compat: { supportsTools: false } } as never)).toBe(false); }); }); + +describe("buildModelToolsUnavailablePrompt", () => { + it("tells chat-only models not to invent tool-backed work", () => { + expect(buildModelToolsUnavailablePrompt(true)).toBeUndefined(); + expect(buildModelToolsUnavailablePrompt(false)).toContain( + "Do not claim that you ran commands, read or wrote files, browsed the web, generated media", + ); + expect(buildModelToolsUnavailablePrompt(false)).toContain("switch to a tool-capable model"); + }); +}); diff --git a/src/agents/model-tool-support.ts b/src/agents/model-tool-support.ts index 742907d8cf38..2f0b57da3e2a 100644 --- a/src/agents/model-tool-support.ts +++ b/src/agents/model-tool-support.ts @@ -4,6 +4,9 @@ * Provider catalogs can opt a model out via `compat.supportsTools === false`; * absent metadata remains permissive for older catalog entries. */ +const MODEL_TOOLS_UNAVAILABLE_PROMPT = + "## Tool availability\n\nThis model cannot use tools in this run. Do not claim that you ran commands, read or wrote files, browsed the web, generated media, or performed any other tool-backed action. If a request requires tools, say they are unavailable in this chat and ask the user to switch to a tool-capable model."; + /** Returns whether a catalog model should be offered tool calls. */ export function supportsModelTools(model: { compat?: unknown }): boolean { const compat = @@ -12,3 +15,8 @@ export function supportsModelTools(model: { compat?: unknown }): boolean { : undefined; return compat?.supportsTools !== false; } + +/** Builds the bounded honesty guard for models that explicitly disable tools. */ +export function buildModelToolsUnavailablePrompt(modelToolsEnabled: boolean): string | undefined { + return modelToolsEnabled ? undefined : MODEL_TOOLS_UNAVAILABLE_PROMPT; +} diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index 102d9bfd8272..79d5f940feeb 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -41,6 +41,7 @@ async function withManagedMediaRoot(run: (ctx: { stateDir: string }) => Promi try { return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { await fs.mkdir(path.join(stateDir, "media", "outbound"), { recursive: true }); + await fs.mkdir(path.join(stateDir, "media", "tool-file-transfer"), { recursive: true }); await fs.mkdir(path.join(stateDir, "media", "tool-image-generation"), { recursive: true }); return await run({ stateDir }); }); @@ -242,6 +243,10 @@ describe("resolveSandboxedMediaSource", () => { name: "managed outbound media", relative: path.join("media", "outbound", "reply.png"), }, + { + name: "managed file-transfer tool media", + relative: path.join("media", "tool-file-transfer", "fetched.png"), + }, { name: "managed tool media", relative: path.join("media", "tool-image-generation", "generated.png"), @@ -475,47 +480,90 @@ describe("resolveSandboxedMediaSource", () => { ); }); - it("rejects symlinked managed media paths escaping the managed media root", async () => { - if (process.platform === "win32") { - return; - } - await withManagedMediaRoot(async ({ stateDir }) => { - await withSandboxRoot(async (sandboxDir) => { + it.each(["outbound", "tool-file-transfer"])( + "rejects symlinked managed media paths escaping the %s root", + async (subdir) => { + if (process.platform === "win32") { + return; + } + await withManagedMediaRoot(async ({ stateDir }) => { + await withSandboxRoot(async (sandboxDir) => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-media-outside-")); + const outsideFile = path.join(outsideDir, "secret.png"); + const symlinkPath = path.join(stateDir, "media", subdir, "linked-secret.png"); + try { + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.symlink(outsideFile, symlinkPath); + + await expectSandboxRejection(symlinkPath, sandboxDir, /managed media root|symlink/i); + } finally { + await fs.rm(symlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + }, + ); + + it.each(["outbound", "tool-file-transfer"])( + "rejects checked managed media symlinks escaping the %s root", + async (subdir) => { + if (process.platform === "win32") { + return; + } + await withManagedMediaRoot(async ({ stateDir }) => { const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-media-outside-")); const outsideFile = path.join(outsideDir, "secret.png"); - const symlinkPath = path.join(stateDir, "media", "outbound", "linked-secret.png"); + const symlinkPath = path.join(stateDir, "media", subdir, "linked-secret.png"); try { await fs.writeFile(outsideFile, "secret", "utf8"); await fs.symlink(outsideFile, symlinkPath); - await expectSandboxRejection(symlinkPath, sandboxDir, /managed media root|symlink/i); + await expect(resolveAllowedManagedMediaPath(symlinkPath)).rejects.toThrow( + /managed media root|symlink/i, + ); } finally { await fs.rm(symlinkPath, { force: true }); await fs.rm(outsideDir, { recursive: true, force: true }); } }); - }); - }); + }, + ); - it("rejects checked managed media symlinks escaping the managed media root", async () => { + it("rejects hardlinked file-transfer media that aliases a file outside managed media", async () => { if (process.platform === "win32") { return; } await withManagedMediaRoot(async ({ stateDir }) => { - const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-media-outside-")); - const outsideFile = path.join(outsideDir, "secret.png"); - const symlinkPath = path.join(stateDir, "media", "outbound", "linked-secret.png"); - try { - await fs.writeFile(outsideFile, "secret", "utf8"); - await fs.symlink(outsideFile, symlinkPath); - - await expect(resolveAllowedManagedMediaPath(symlinkPath)).rejects.toThrow( - /managed media root|symlink/i, + await withSandboxRoot(async (sandboxDir) => { + const outsideDir = await fs.mkdtemp( + path.join(path.dirname(stateDir), "managed-media-hardlink-outside-"), ); - } finally { - await fs.rm(symlinkPath, { force: true }); - await fs.rm(outsideDir, { recursive: true, force: true }); - } + const outsideFile = path.join(outsideDir, "secret.png"); + const hardlinkPath = path.join( + stateDir, + "media", + "tool-file-transfer", + "linked-secret.png", + ); + try { + await fs.writeFile(outsideFile, "secret", "utf8"); + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + + await expect(resolveAllowedManagedMediaPath(hardlinkPath)).rejects.toThrow(/hard.?link/i); + await expectSandboxRejection(hardlinkPath, sandboxDir, /hard.?link|managed media root/i); + } finally { + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); }); }); diff --git a/src/auto-reply/commands-registry.runtime.ts b/src/auto-reply/commands-registry.runtime.ts index b9832d0556a5..4c35c7515da1 100644 --- a/src/auto-reply/commands-registry.runtime.ts +++ b/src/auto-reply/commands-registry.runtime.ts @@ -1,2 +1,6 @@ /** Runtime facade for command-registry helpers used across lazy boundaries. */ -export { listChatCommands, normalizeCommandBody } from "./commands-registry.js"; +export { + findCommandByNativeName, + listChatCommands, + normalizeCommandBody, +} from "./commands-registry.js"; diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts index d673404ca6ef..fa0de295f682 100644 --- a/src/auto-reply/reply.directive.parse.test.ts +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -352,3 +352,90 @@ describe("level directive preserves message text after an invalid level", () => expect(res.rawLevel).toBe("maybe"); }); }); + +describe("native directive commands own their complete argument boundary", () => { + it.each([ + { + command: "think" as const, + body: "/think about my deployment plan", + rawKey: "rawThinkLevel" as const, + invalidArgument: "about", + trailingArguments: "my deployment plan", + }, + { + command: "verbose" as const, + body: "/verbose explain quantum computing", + rawKey: "rawVerboseLevel" as const, + invalidArgument: "explain", + trailingArguments: "quantum computing", + }, + { + command: "trace" as const, + body: "/trace banana please", + rawKey: "rawTraceLevel" as const, + invalidArgument: "banana", + trailingArguments: "please", + }, + { + command: "fast" as const, + body: "/fast bananas please", + rawKey: "rawFastMode" as const, + invalidArgument: "bananas", + trailingArguments: "please", + }, + { + command: "reasoning" as const, + body: "/reasoning nonsense please", + rawKey: "rawReasoningLevel" as const, + invalidArgument: "nonsense", + trailingArguments: "please", + }, + { + command: "elevated" as const, + body: "/elevated perhaps explain", + rawKey: "rawElevatedLevel" as const, + invalidArgument: "perhaps", + trailingArguments: "explain", + }, + ])( + "preserves the invalid first argument for native /$command", + ({ body, command, invalidArgument, rawKey, trailingArguments }) => { + const parsed = parseInlineDirectives(body, { nativeCommand: command }); + + expect(parsed[rawKey]).toBe(invalidArgument); + expect(parsed.cleaned).toBe(trailingArguments); + expect(parsed.nativeCommand).toEqual({ + name: command, + unconsumedArguments: trailingArguments, + }); + }, + ); + + it("retains unexpected arguments after a valid native queue mode", () => { + const parsed = parseInlineDirectives("/queue collect please help", { + nativeCommand: "queue", + }); + + expect(parsed.queueMode).toBe("collect"); + expect(parsed.nativeCommand).toEqual({ + name: "queue", + unconsumedArguments: "please help", + }); + }); + + it("does not interpret another directive inside native command arguments", () => { + const parsed = parseInlineDirectives("/queue /think high", { nativeCommand: "queue" }); + + expect(parsed.hasQueueDirective).toBe(true); + expect(parsed.rawQueueMode).toBe("/think"); + expect(parsed.hasThinkDirective).toBe(false); + }); + + it("preserves the existing prose interpretation for ordinary inline directives", () => { + const parsed = parseInlineDirectives("/think about my deployment plan"); + + expect(parsed.rawThinkLevel).toBeUndefined(); + expect(parsed.nativeCommand).toBeUndefined(); + expect(parsed.cleaned).toBe("about my deployment plan"); + }); +}); diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 01a286de97f0..08e64cdea39c 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -467,7 +467,6 @@ describe("runReplyAgent active steering", () => { expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(taskTyping.startTypingLoop).toHaveBeenCalledOnce(); - expect(taskTyping.refreshTypingTtl).toHaveBeenCalledOnce(); expect(taskTyping.cleanup).not.toHaveBeenCalled(); expect(typing.cleanup).toHaveBeenCalledOnce(); diff --git a/src/auto-reply/reply/directive-handling.directive-only.ts b/src/auto-reply/reply/directive-handling.directive-only.ts index 644516e17f66..f26ed88c884f 100644 --- a/src/auto-reply/reply/directive-handling.directive-only.ts +++ b/src/auto-reply/reply/directive-handling.directive-only.ts @@ -27,6 +27,10 @@ export function isDirectiveOnly(params: { ) { return false; } + // Native arguments belong to their command even when the inline parser leaves invalid prose. + if (directives.nativeCommand) { + return true; + } const stripped = stripStructuralPrefixes(cleanedBody ?? ""); // Group mentions are routing syntax, not meaningful agent body text. const noMentions = isGroup ? stripMentions(stripped, ctx, cfg, agentId) : stripped; diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 36a51a18cc08..1fd616649fbf 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -37,6 +37,7 @@ import { } from "./directive-handling.model-runtime.js"; import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; import { maybeHandleModelDirectiveInfo } from "./directive-handling.model.js"; +import { maybeHandleUnexpectedNativeDirectiveArguments } from "./directive-handling.native.js"; import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js"; import { @@ -348,6 +349,10 @@ export async function handleDirectiveOnly( text: "Exec node requires a value.", }; } + const unexpectedExecArguments = maybeHandleUnexpectedNativeDirectiveArguments(directives); + if (unexpectedExecArguments) { + return unexpectedExecArguments; + } if (!directives.hasExecOptions) { const execDefaults = resolveExecDefaults({ cfg: params.cfg, @@ -375,6 +380,11 @@ export async function handleDirectiveOnly( return queueAck; } + const unexpectedNativeArguments = maybeHandleUnexpectedNativeDirectiveArguments(directives); + if (unexpectedNativeArguments) { + return unexpectedNativeArguments; + } + if ( directives.hasThinkDirective && directives.thinkLevel && diff --git a/src/auto-reply/reply/directive-handling.native.ts b/src/auto-reply/reply/directive-handling.native.ts new file mode 100644 index 000000000000..50d7196b0b18 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.native.ts @@ -0,0 +1,20 @@ +/** Validates argument boundaries for explicitly invoked native session directives. */ +import type { ReplyPayload } from "../types.js"; +import type { InlineDirectives } from "./directive-handling.parse.js"; + +/** Rejects prose left over after canonical command-specific validation succeeds. */ +export function maybeHandleUnexpectedNativeDirectiveArguments( + directives: InlineDirectives, +): ReplyPayload | undefined { + const nativeCommand = directives.nativeCommand; + const unconsumedArguments = nativeCommand?.unconsumedArguments; + if (!nativeCommand || !unconsumedArguments) { + return undefined; + } + + // One token is enough to explain the rejected boundary without echoing an unbounded prompt. + const unexpectedArgument = unconsumedArguments.split(/\s+/, 1)[0] ?? unconsumedArguments; + return { + text: `Unexpected argument "${unexpectedArgument}" for /${nativeCommand.name}.`, + }; +} diff --git a/src/auto-reply/reply/directive-handling.parse.ts b/src/auto-reply/reply/directive-handling.parse.ts index f759ead51ed7..1d13767a15de 100644 --- a/src/auto-reply/reply/directive-handling.parse.ts +++ b/src/auto-reply/reply/directive-handling.parse.ts @@ -23,9 +23,40 @@ import { import { extractQueueDirective } from "./queue/directive.js"; import type { QueueDropPolicy, QueueMode } from "./queue/types.js"; +const NATIVE_REPLY_DIRECTIVE_COMMANDS = { + think: true, + verbose: true, + trace: true, + fast: true, + reasoning: true, + elevated: true, + exec: true, + model: true, + queue: true, +} as const; + +/** Canonical command-registry keys that share the session-directive execution pipeline. */ +type NativeReplyDirectiveCommand = keyof typeof NATIVE_REPLY_DIRECTIVE_COMMANDS; + +/** Resolves a registered command key without inferring directive ownership from slash text. */ +export function resolveNativeReplyDirectiveCommand( + commandKey: string | undefined, +): NativeReplyDirectiveCommand | undefined { + return commandKey && Object.hasOwn(NATIVE_REPLY_DIRECTIVE_COMMANDS, commandKey) + ? (commandKey as NativeReplyDirectiveCommand) + : undefined; +} + +type NativeDirectiveInvocation = { + name: NativeReplyDirectiveCommand; + unconsumedArguments?: string; +}; + /** Parsed inline directives removed from a user message before agent execution. */ export type InlineDirectives = { cleaned: string; + /** Explicit native command ownership prevents prose-oriented inline cleanup from eating args. */ + nativeCommand?: NativeDirectiveInvocation; hasThinkDirective: boolean; thinkLevel?: ThinkLevel; rawThinkLevel?: string; @@ -85,38 +116,58 @@ export function parseInlineDirectives( modelAliases?: string[]; disableElevated?: boolean; allowStatusDirective?: boolean; + nativeCommand?: NativeReplyDirectiveCommand; }, ): InlineDirectives { + const nativeCommand = options?.nativeCommand; + const parseScopedDirective = ( + currentBody: string, + commandName: NativeReplyDirectiveCommand, + extract: (value: string) => T, + ): T => + !nativeCommand || nativeCommand === commandName + ? extract(currentBody) + : ({ cleaned: currentBody, hasDirective: false } as T); const { cleaned: thinkCleaned, thinkLevel, rawLevel: rawThinkLevel, hasDirective: hasThinkDirective, - } = extractThinkDirective(body); + } = parseScopedDirective(body, "think", (value) => + extractThinkDirective(value, { strict: nativeCommand === "think" }), + ); const { cleaned: verboseCleaned, verboseLevel, rawLevel: rawVerboseLevel, hasDirective: hasVerboseDirective, - } = extractVerboseDirective(thinkCleaned); + } = parseScopedDirective(thinkCleaned, "verbose", (value) => + extractVerboseDirective(value, { strict: nativeCommand === "verbose" }), + ); const { cleaned: traceCleaned, traceLevel, rawLevel: rawTraceLevel, hasDirective: hasTraceDirective, - } = extractTraceDirective(verboseCleaned); + } = parseScopedDirective(verboseCleaned, "trace", (value) => + extractTraceDirective(value, { strict: nativeCommand === "trace" }), + ); const { cleaned: fastCleaned, fastMode, rawLevel: rawFastMode, hasDirective: hasFastDirective, - } = extractFastDirective(traceCleaned); + } = parseScopedDirective(traceCleaned, "fast", (value) => + extractFastDirective(value, { strict: nativeCommand === "fast" }), + ); const { cleaned: reasoningCleaned, reasoningLevel, rawLevel: rawReasoningLevel, hasDirective: hasReasoningDirective, - } = extractReasoningDirective(fastCleaned); + } = parseScopedDirective(fastCleaned, "reasoning", (value) => + extractReasoningDirective(value, { strict: nativeCommand === "reasoning" }), + ); const { cleaned: elevatedCleaned, elevatedLevel, @@ -129,7 +180,9 @@ export function parseInlineDirectives( rawLevel: undefined, hasDirective: false, } - : extractElevatedDirective(reasoningCleaned); + : parseScopedDirective(reasoningCleaned, "elevated", (value) => + extractElevatedDirective(value, { strict: nativeCommand === "elevated" }), + ); const { cleaned: execCleaned, execHost, @@ -146,8 +199,8 @@ export function parseInlineDirectives( invalidAsk: invalidExecAsk, invalidNode: invalidExecNode, hasDirective: hasExecDirective, - } = extractExecDirective(elevatedCleaned); - const allowStatusDirective = options?.allowStatusDirective !== false; + } = parseScopedDirective(elevatedCleaned, "exec", extractExecDirective); + const allowStatusDirective = options?.allowStatusDirective !== false && !nativeCommand; const { cleaned: statusCleaned, hasDirective: hasStatusDirective } = allowStatusDirective ? extractStatusDirective(execCleaned) : { cleaned: execCleaned, hasDirective: false }; @@ -157,9 +210,11 @@ export function parseInlineDirectives( rawProfile, rawRuntime, hasDirective: hasModelDirective, - } = extractModelDirective(statusCleaned, { - aliases: options?.modelAliases, - }); + } = parseScopedDirective(statusCleaned, "model", (value) => + extractModelDirective(value, { + aliases: options?.modelAliases, + }), + ); const { cleaned: queueCleaned, queueMode, @@ -173,7 +228,7 @@ export function parseInlineDirectives( rawDrop, hasDirective: hasQueueDirective, hasOptions: hasQueueOptions, - } = extractQueueDirective(modelCleaned); + } = parseScopedDirective(modelCleaned, "queue", extractQueueDirective); const hasAnyDirective = hasThinkDirective || hasVerboseDirective || @@ -188,6 +243,14 @@ export function parseInlineDirectives( // Later directives see text cleaned by earlier directives; preserve that ordering. return { cleaned: hasAnyDirective ? queueCleaned : body.trim(), + ...(nativeCommand && hasAnyDirective + ? { + nativeCommand: { + name: nativeCommand, + ...(queueCleaned ? { unconsumedArguments: queueCleaned } : {}), + }, + } + : {}), hasThinkDirective, thinkLevel, rawThinkLevel, diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index 6d814cca5b30..03565d3279eb 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -21,6 +21,10 @@ type ExtractedLevel = { hasDirective: boolean; }; +type LevelDirectiveParseOptions = { + strict?: boolean; +}; + const compileDirectivePattern = (names: readonly string[], suffix = ""): RegExp => { const namePattern = names.map(escapeRegExp).join("|"); return new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)${suffix}`, "i"); @@ -38,6 +42,7 @@ const matchLevelDirective = ( body: string, pattern: RegExp, normalize: (raw?: string) => unknown, + options?: LevelDirectiveParseOptions, ): { start: number; end: number; rawLevel?: string } | null => { const match = body.match(pattern); if (!match || match.index === undefined) { @@ -56,13 +61,16 @@ const matchLevelDirective = ( } } const argStart = i; - while (i < body.length && /[A-Za-z-]/.test(body.charAt(i))) { + while ( + i < body.length && + (options?.strict ? !/\s/.test(body.charAt(i)) : /[A-Za-z-]/.test(body.charAt(i))) + ) { i += 1; } const candidate = i > argStart ? body.slice(argStart, i) : undefined; if ( candidate !== undefined && - (normalize(candidate) !== undefined || body.slice(i).trim().length === 0) + (options?.strict || normalize(candidate) !== undefined || body.slice(i).trim().length === 0) ) { return { start, end: i, rawLevel: candidate }; } @@ -73,8 +81,9 @@ const extractLevelDirective = ( body: string, pattern: RegExp, normalize: (raw?: string) => T | undefined, + options?: LevelDirectiveParseOptions, ): ExtractedLevel => { - const match = matchLevelDirective(body, pattern, normalize); + const match = matchLevelDirective(body, pattern, normalize, options); if (!match) { return { cleaned: body.trim(), hasDirective: false }; } @@ -106,7 +115,10 @@ const extractSimpleDirective = ( }; }; -export function extractThinkDirective(body?: string): { +export function extractThinkDirective( + body?: string, + options?: LevelDirectiveParseOptions, +): { cleaned: string; thinkLevel?: ThinkLevel; rawLevel?: string; @@ -115,7 +127,12 @@ export function extractThinkDirective(body?: string): { if (!body) { return { cleaned: "", hasDirective: false }; } - const extracted = extractLevelDirective(body, THINK_DIRECTIVE_PATTERN, normalizeThinkLevel); + const extracted = extractLevelDirective( + body, + THINK_DIRECTIVE_PATTERN, + normalizeThinkLevel, + options, + ); return { cleaned: extracted.cleaned, thinkLevel: extracted.level, @@ -124,7 +141,10 @@ export function extractThinkDirective(body?: string): { }; } -export function extractVerboseDirective(body?: string): { +export function extractVerboseDirective( + body?: string, + options?: LevelDirectiveParseOptions, +): { cleaned: string; verboseLevel?: VerboseLevel; rawLevel?: string; @@ -133,7 +153,12 @@ export function extractVerboseDirective(body?: string): { if (!body) { return { cleaned: "", hasDirective: false }; } - const extracted = extractLevelDirective(body, VERBOSE_DIRECTIVE_PATTERN, normalizeVerboseLevel); + const extracted = extractLevelDirective( + body, + VERBOSE_DIRECTIVE_PATTERN, + normalizeVerboseLevel, + options, + ); return { cleaned: extracted.cleaned, verboseLevel: extracted.level, @@ -142,7 +167,10 @@ export function extractVerboseDirective(body?: string): { }; } -export function extractTraceDirective(body?: string): { +export function extractTraceDirective( + body?: string, + options?: LevelDirectiveParseOptions, +): { cleaned: string; traceLevel?: TraceLevel; rawLevel?: string; @@ -151,7 +179,12 @@ export function extractTraceDirective(body?: string): { if (!body) { return { cleaned: "", hasDirective: false }; } - const extracted = extractLevelDirective(body, TRACE_DIRECTIVE_PATTERN, normalizeTraceLevel); + const extracted = extractLevelDirective( + body, + TRACE_DIRECTIVE_PATTERN, + normalizeTraceLevel, + options, + ); return { cleaned: extracted.cleaned, traceLevel: extracted.level, @@ -160,7 +193,10 @@ export function extractTraceDirective(body?: string): { }; } -export function extractFastDirective(body?: string): { +export function extractFastDirective( + body?: string, + options?: LevelDirectiveParseOptions, +): { cleaned: string; fastMode?: FastMode; rawLevel?: string; @@ -169,7 +205,7 @@ export function extractFastDirective(body?: string): { if (!body) { return { cleaned: "", hasDirective: false }; } - const extracted = extractLevelDirective(body, FAST_DIRECTIVE_PATTERN, normalizeFastMode); + const extracted = extractLevelDirective(body, FAST_DIRECTIVE_PATTERN, normalizeFastMode, options); return { cleaned: extracted.cleaned, fastMode: extracted.level, @@ -178,7 +214,10 @@ export function extractFastDirective(body?: string): { }; } -export function extractElevatedDirective(body?: string): { +export function extractElevatedDirective( + body?: string, + options?: LevelDirectiveParseOptions, +): { cleaned: string; elevatedLevel?: ElevatedLevel; rawLevel?: string; @@ -187,7 +226,12 @@ export function extractElevatedDirective(body?: string): { if (!body) { return { cleaned: "", hasDirective: false }; } - const extracted = extractLevelDirective(body, ELEVATED_DIRECTIVE_PATTERN, normalizeElevatedLevel); + const extracted = extractLevelDirective( + body, + ELEVATED_DIRECTIVE_PATTERN, + normalizeElevatedLevel, + options, + ); return { cleaned: extracted.cleaned, elevatedLevel: extracted.level, @@ -196,7 +240,10 @@ export function extractElevatedDirective(body?: string): { }; } -export function extractReasoningDirective(body?: string): { +export function extractReasoningDirective( + body?: string, + options?: LevelDirectiveParseOptions, +): { cleaned: string; reasoningLevel?: ReasoningLevel; rawLevel?: string; @@ -209,6 +256,7 @@ export function extractReasoningDirective(body?: string): { body, REASONING_DIRECTIVE_PATTERN, normalizeReasoningLevel, + options, ); return { cleaned: extracted.cleaned, diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 05d412e3a047..610acb3aad26 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -14,6 +14,7 @@ import type { CommandContext } from "./commands-types.js"; import { isDirectiveOnly } from "./directive-handling.directive-only.js"; import { resolveModelRuntimeDirective } from "./directive-handling.model-runtime.js"; import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; +import { maybeHandleUnexpectedNativeDirectiveArguments } from "./directive-handling.native.js"; import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import { clearInlineDirectives } from "./get-reply-directives-utils.js"; @@ -315,6 +316,15 @@ export async function applyInlineDirectiveOverrides(params: { senderIsOwner: command.senderIsOwner, }; + // Model-only directives have a focused persistence service; reject leftovers before that mutation. + if (directives.nativeCommand?.name === "model") { + const unexpectedNativeArguments = maybeHandleUnexpectedNativeDirectiveArguments(directives); + if (unexpectedNativeArguments) { + typing.cleanup(); + return { kind: "reply", reply: unexpectedNativeArguments }; + } + } + if ( isDirectiveOnly({ directives, diff --git a/src/auto-reply/reply/get-reply-directives-utils.ts b/src/auto-reply/reply/get-reply-directives-utils.ts index 2ad150b2b9e7..c1a2a6e7e9c1 100644 --- a/src/auto-reply/reply/get-reply-directives-utils.ts +++ b/src/auto-reply/reply/get-reply-directives-utils.ts @@ -22,6 +22,7 @@ const CLEARED_EXEC_FIELDS = { export function clearInlineDirectives(cleaned: string): InlineDirectives { return { cleaned, + nativeCommand: undefined, hasThinkDirective: false, thinkLevel: undefined, rawThinkLevel: undefined, diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index e78d10860048..a58e740ca9c8 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -36,8 +36,11 @@ import { import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { resolveBlockStreamingChunking } from "./block-streaming.js"; import { buildCommandContext } from "./commands-context.js"; -import { type InlineDirectives, parseInlineDirectives } from "./directive-handling.parse.js"; -import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js"; +import { + type InlineDirectives, + parseInlineDirectives, + resolveNativeReplyDirectiveCommand, +} from "./directive-handling.parse.js"; import { reserveSkillCommandNames, resolveConfiguredDirectiveAliases, @@ -273,29 +276,24 @@ export async function resolveReplyDirectives(params: { (alias) => !reservedCommands.has(normalizeLowercaseStringOrEmpty(alias)), ); const allowStatusDirective = allowTextCommands && command.isAuthorizedSender; + const commandTurn = resolveCommandTurnContext(ctx); + const nativeDirectiveCommand = + command.isAuthorizedSender && isNativeCommandTurn(commandTurn) && commandTurn.commandName + ? resolveNativeReplyDirectiveCommand( + (await loadCommandsRegistry()).findCommandByNativeName( + commandTurn.commandName, + command.channel, + { + includeBundledChannelFallback: false, + }, + )?.key, + ) + : undefined; let parsedDirectives = parseInlineDirectives(commandText, { modelAliases: configuredAliases, allowStatusDirective, + nativeCommand: nativeDirectiveCommand, }); - const commandTurn = resolveCommandTurnContext(ctx); - if ( - command.isAuthorizedSender && - isNativeCommandTurn(commandTurn) && - commandTurn.commandName === "queue" && - parsedDirectives.hasQueueDirective - ) { - // Native command arguments belong to the command, not to an inline prompt; - // validate them before mixed-text cleanup can erase an invalid queue mode. - const queueReply = maybeHandleQueueDirective({ - directives: parsedDirectives, - cfg, - channel: command.channel, - sessionEntry: targetSessionEntry, - }); - if (queueReply) { - return { kind: "reply", reply: markCommandReplyForDelivery(queueReply) }; - } - } const hasInlineStatus = parsedDirectives.hasStatusDirective && parsedDirectives.cleaned.trim().length > 0; if (hasInlineStatus) { @@ -329,7 +327,7 @@ export async function resolveReplyDirectives(params: { parsedDirectives.hasExecDirective || parsedDirectives.hasModelDirective || parsedDirectives.hasQueueDirective; - if (hasInlineDirective) { + if (hasInlineDirective && !parsedDirectives.nativeCommand) { const stripped = stripStructuralPrefixes(parsedDirectives.cleaned); const noMentions = isGroup ? stripMentions(stripped, ctx, cfg, agentId) : stripped; if (noMentions.trim().length > 0) { diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.test.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.test.ts index 9ea0f8f1dfc4..cc405e65fee0 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.test.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.test.ts @@ -40,13 +40,13 @@ const createTypingController = (): TypingController => ({ describe("maybeResolveNativeSlashCommandFastReply", () => { beforeEach(() => { + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); handleCommandsMock.mockReset(); }); - it("returns native queue validation instead of discarding trailing command arguments", async () => { + async function resolveNativeDirectiveCommand(body: string) { handleCommandsMock.mockResolvedValue({ shouldContinue: true }); - - const body = "/queue Can you diagnose this?"; + const commandName = body.slice(1).split(/\s+/, 1)[0] ?? ""; const typing = createTypingController(); const result = await maybeResolveNativeSlashCommandFastReply({ ctx: buildTestCtx({ @@ -58,19 +58,20 @@ describe("maybeResolveNativeSlashCommandFastReply", () => { CommandAuthorized: true, Provider: "telegram", Surface: "telegram", + GatewayClientScopes: ["operator.admin"], SessionKey: "telegram:slash:123", CommandTargetSessionKey: "agent:main:telegram:123", CommandTurn: { kind: "native", source: "native", authorized: true, - commandName: "queue", + commandName, body, }, }), cfg: markCompleteReplyConfig({ session: { - store: path.join(tempDirs.make("openclaw-native-queue-"), "sessions.json"), + store: path.join(tempDirs.make("openclaw-native-directive-"), "sessions.json"), }, } as OpenClawConfig), agentId: "main", @@ -86,6 +87,12 @@ describe("maybeResolveNativeSlashCommandFastReply", () => { typing, }); + return { result, typing }; + } + + it("returns native queue validation instead of discarding trailing command arguments", async () => { + const { result, typing } = await resolveNativeDirectiveCommand("/queue Can you diagnose this?"); + expect(result).toEqual({ handled: true, reply: expect.objectContaining({ @@ -96,6 +103,69 @@ describe("maybeResolveNativeSlashCommandFastReply", () => { expect(typing.cleanup).toHaveBeenCalledOnce(); }); + it.each([ + { + command: "/think about my deployment plan", + expected: 'Unrecognized thinking level "about".', + }, + { + command: "/verbose explain quantum computing", + expected: 'Unrecognized verbose level "explain".', + }, + { + command: "/trace banana please", + expected: 'Unrecognized trace level "banana".', + }, + { + command: "/fast bananas please", + expected: 'Unrecognized fast mode "bananas".', + }, + { + command: "/reasoning nonsense please", + expected: 'Unrecognized reasoning level "nonsense".', + }, + ])("validates every native directive argument: $command", async ({ command, expected }) => { + const { result } = await resolveNativeDirectiveCommand(command); + + expect(result).toEqual({ + handled: true, + reply: expect.objectContaining({ text: expect.stringContaining(expected) }), + }); + }); + + it.each([ + { command: "/queue collect please help", expected: 'Unexpected argument "please" for /queue.' }, + { command: "/think high please", expected: 'Unexpected argument "please" for /think.' }, + { command: "/verbose on please", expected: 'Unexpected argument "please" for /verbose.' }, + { command: "/fast on please", expected: 'Unexpected argument "please" for /fast.' }, + { + command: "/reasoning on please", + expected: 'Unexpected argument "please" for /reasoning.', + }, + { command: "/exec host=node please", expected: 'Unexpected argument "please" for /exec.' }, + ])( + "rejects trailing prose instead of dropping native command $command", + async ({ command, expected }) => { + const { result } = await resolveNativeDirectiveCommand(command); + + expect(result).toEqual({ + handled: true, + reply: expect.objectContaining({ text: expected }), + }); + }, + ); + + it("keeps recognized native settings commands isolated from nested directives", async () => { + const { result } = await resolveNativeDirectiveCommand("/queue /think high"); + + expect(result).toEqual({ + handled: true, + reply: expect.objectContaining({ + text: expect.stringContaining('Unrecognized queue mode "/think"'), + }), + }); + }); + it("marks native /compact terminal replies for delivery under message_tool_only (#90185)", async () => { handleCommandsMock.mockResolvedValueOnce({ shouldContinue: false, diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index 7be2415d6dd0..f075c04972d6 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -339,7 +339,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { skillFilter: params.skillFilter, }); if (directiveResult.kind === "reply") { - params.typing.cleanup(); + // The canonical directive owner already finalizes typing for every terminal reply. return { handled: true, reply: markCommandReplyForDelivery(directiveResult.reply) }; } diff --git a/src/auto-reply/reply/reply-run-typing.ts b/src/auto-reply/reply/reply-run-typing.ts index f2ca73edb83f..5e6457c308e3 100644 --- a/src/auto-reply/reply/reply-run-typing.ts +++ b/src/auto-reply/reply/reply-run-typing.ts @@ -25,12 +25,10 @@ export function bindReplyOperationTyping( export async function refreshReplyOperationTyping( operation: ReplyOperation, options: { startIfIdle: boolean }, -): Promise { +): Promise { const typing = typingByReplyOperation.get(operation); if (!typing || operation.result || (!options.startIfIdle && !typing.isActive())) { - return false; + return; } await typing.startTypingLoop(); - typing.refreshTypingTtl(); - return true; } diff --git a/src/cli/config-cli-path.ts b/src/cli/config-cli-path.ts index 86fb16ee15e4..9af8986610c9 100644 --- a/src/cli/config-cli-path.ts +++ b/src/cli/config-cli-path.ts @@ -69,9 +69,10 @@ function parsePath(raw: string): PathSegment[] { const ch = trimmed[i]; if (ch === "\\") { const next = trimmed[i + 1]; - if (next) { - current += next; + if (next === undefined) { + throw new Error(`Invalid path (trailing escape): ${raw}`); } + current += next; i += 2; continue; } diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 8d00c3f9d0db..857f123b8812 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -3346,6 +3346,31 @@ describe("config cli", () => { args: ["config", "set", "gateway.[port]", "23456"], error: "Invalid path (empty segment): gateway.[port]", }, + { + name: "rejects a trailing escape for config get before reading another key", + args: ["config", "get", "gateway.port\\"], + error: "Invalid path (trailing escape): gateway.port\\", + }, + { + name: "rejects a trailing escape for config set before writing another key", + args: ["config", "set", "gateway.port\\", "23456"], + error: "Invalid path (trailing escape): gateway.port\\", + }, + { + name: "rejects a trailing escape for config unset before deleting another key", + args: ["config", "unset", "gateway.port\\"], + error: "Invalid path (trailing escape): gateway.port\\", + }, + { + name: "rejects a trailing escape for batch config set before writing another key", + args: [ + "config", + "set", + "--batch-json", + JSON.stringify([{ path: "gateway.port\\", value: 23456 }]), + ], + error: "Invalid path (trailing escape): gateway.port\\", + }, ])("$name", async ({ args, error, list }) => { if (list) { const resolved = { agents: { list } } as unknown as OpenClawConfig; @@ -3358,6 +3383,15 @@ describe("config cli", () => { expect(mockWriteConfigFile).not.toHaveBeenCalled(); }); + it.each(["gateway.port\\", "gateway.port\\ "])( + "rejects a trailing escape in shared config path %s", + (configPath) => { + expect(() => parseConfigSetPath(configPath)).toThrow( + `Invalid path (trailing escape): ${configPath}`, + ); + }, + ); + it.each([ "agents.list[0]id", "agents.list[0] id", @@ -3396,6 +3430,12 @@ describe("config cli", () => { ["agents.list[0].id", ["agents", "list", "0", "id"]], ["agents.list[0][1]", ["agents", "list", "0", "1"]], ["[0]", ["0"]], + [" gateway.port ", ["gateway", "port"]], + ["channels.discord.guilds.prod\\.guild", ["channels", "discord", "guilds", "prod.guild"]], + [ + "channels.discord.guilds.prod\\\\.channels", + ["channels", "discord", "guilds", "prod\\", "channels"], + ], ])("preserves valid bracket path %s", (configPath, expected) => { expect(parseConfigSetPath(configPath)).toEqual(expected); }); diff --git a/src/cli/plugins-location-bridges.test.ts b/src/cli/plugins-location-bridges.test.ts index d76637285bfc..f6c1a75a78bb 100644 --- a/src/cli/plugins-location-bridges.test.ts +++ b/src/cli/plugins-location-bridges.test.ts @@ -165,12 +165,14 @@ describe("listPersistedBundledPluginLocationBridges", () => { }); it.each([ - ["synthetic", "@openclaw/synthetic-provider"], - ["teams-meetings", "@openclaw/teams-meetings"], - ["zoom-meetings", "@openclaw/zoom-meetings"], - ])( - "externalizes the shipped bundled %s plugin while preserving default enablement", - async (pluginId, npmSpec) => { + ["duckduckgo", "@openclaw/duckduckgo-plugin", false], + ["synthetic", "@openclaw/synthetic-provider", true], + ["teams-meetings", "@openclaw/teams-meetings", true], + ["voyage", "@openclaw/voyage-provider", true], + ["zoom-meetings", "@openclaw/zoom-meetings", true], + ] as const)( + "externalizes the shipped bundled %s plugin using official install metadata", + async (pluginId, npmSpec, enabledByDefault) => { readPersistedInstalledPluginIndexMock.mockResolvedValue( makeIndex({ pluginId, @@ -180,7 +182,7 @@ describe("listPersistedBundledPluginLocationBridges", () => { rootDir: `/app/dist/extensions/${pluginId}`, origin: "bundled", enabled: true, - enabledByDefault: true, + ...(enabledByDefault ? { enabledByDefault: true } : {}), startup: startupInfo, compat: [], packageInstall: { @@ -197,7 +199,7 @@ describe("listPersistedBundledPluginLocationBridges", () => { preferredSource: "npm", npmSpec, clawhubSpec: `clawhub:${npmSpec}`, - enabledByDefault: true, + ...(enabledByDefault ? { enabledByDefault: true } : {}), }, ]); }, diff --git a/src/commands/agents.providers.test.ts b/src/commands/agents.providers.test.ts index 3130969c735a..2dcb885bef52 100644 --- a/src/commands/agents.providers.test.ts +++ b/src/commands/agents.providers.test.ts @@ -101,14 +101,14 @@ describe("buildProviderStatusIndex", () => { expect(status?.name).toBe("Work"); }); - it("records accounts that throw during read-only resolution as not configured", async () => { + it("keeps unresolved configured SecretRef accounts visible without exposing their refs", async () => { const plugin = { id: "quietchat", meta: { label: "QuietChat" }, config: { listAccountIds: () => ["default"], resolveAccount: () => { - throw new Error("unresolved SecretRef"); + throw new Error("unresolved SecretRef: PRIVATE_PROVIDER_TOKEN"); }, }, status: {}, @@ -123,13 +123,161 @@ describe("buildProviderStatusIndex", () => { "quietchat:default", { provider: "quietchat", + providerLabel: "QuietChat", accountId: "default", - state: "not configured", - configured: false, + state: "configured unavailable", + configured: true, + visibleInConfiguredLists: true, }, ], ]), ); + const statuses = await buildProviderStatusIndex({} as OpenClawConfig); + expect( + listProvidersForAgent({ + summaryIsDefault: true, + cfg: {} as OpenClawConfig, + bindings: [], + providerStatus: statuses, + providerMetadata: buildProviderSummaryMetadataIndex({} as OpenClawConfig), + }), + ).toEqual(["QuietChat default: configured unavailable"]); + expect(JSON.stringify([...statuses.values()])).not.toContain("PRIVATE_PROVIDER_TOKEN"); + }); + + it("keeps configured-but-unavailable Telegram-style accounts in default agent output", async () => { + const account = { + accountId: "default", + enabled: true, + configured: true, + tokenStatus: "configured_unavailable" as const, + }; + const plugin = { + id: "telegram", + meta: { label: "Telegram" }, + config: { + listAccountIds: () => ["default"], + inspectAccount: () => account, + resolveAccount: () => account, + describeAccount: () => ({ + accountId: "default", + enabled: true, + configured: true, + tokenStatus: "configured_unavailable" as const, + }), + isConfigured: () => false, + }, + status: {}, + } as never; + const cfg = { + channels: { + telegram: { + enabled: true, + tokenFile: "/nonexistent/token", + }, + }, + } as OpenClawConfig; + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]); + + const statuses = await buildProviderStatusIndex(cfg); + + expect(statuses.get("telegram:default")).toMatchObject({ + configured: true, + state: "configured unavailable", + }); + expect( + listProvidersForAgent({ + summaryIsDefault: true, + cfg, + bindings: [], + providerStatus: statuses, + providerMetadata: buildProviderSummaryMetadataIndex(cfg), + }), + ).toEqual(["Telegram default: configured unavailable"]); + }); + + it("does not mark a healthy Slack account unavailable for an optional unresolved user token", async () => { + const account = { + accountId: "default", + enabled: true, + configured: true, + botTokenStatus: "available" as const, + appTokenStatus: "available" as const, + userTokenStatus: "configured_unavailable" as const, + }; + const plugin = { + id: "slack", + meta: { label: "Slack" }, + config: { + listAccountIds: () => ["default"], + inspectAccount: () => account, + resolveAccount: () => account, + describeAccount: () => ({ accountId: "default", enabled: true, configured: true }), + isConfigured: () => true, + }, + status: {}, + } as never; + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]); + + expect( + (await buildProviderStatusIndex({} as OpenClawConfig)).get("slack:default"), + ).toMatchObject({ configured: true, state: "configured" }); + }); + + it("does not treat an incomplete Slack account as configured when a required token is missing", async () => { + const account = { + accountId: "default", + enabled: true, + configured: false, + botTokenStatus: "configured_unavailable" as const, + appTokenStatus: "missing" as const, + userTokenStatus: "missing" as const, + }; + const plugin = { + id: "slack", + meta: { label: "Slack" }, + config: { + listAccountIds: () => ["default"], + inspectAccount: () => account, + resolveAccount: () => account, + describeAccount: () => ({ accountId: "default", enabled: true, configured: true }), + isConfigured: () => false, + }, + status: {}, + } as never; + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]); + + expect( + (await buildProviderStatusIndex({} as OpenClawConfig)).get("slack:default"), + ).toMatchObject({ configured: false, state: "not configured" }); + }); + + it("keeps a fully configured Slack account visible when a required token is unavailable", async () => { + const account = { + accountId: "default", + enabled: true, + configured: true, + botTokenStatus: "configured_unavailable" as const, + appTokenStatus: "available" as const, + userTokenStatus: "missing" as const, + }; + const plugin = { + id: "slack", + meta: { label: "Slack" }, + config: { + listAccountIds: () => ["default"], + inspectAccount: () => account, + resolveAccount: () => account, + describeAccount: () => ({ accountId: "default", enabled: true, configured: false }), + isConfigured: () => false, + }, + status: {}, + } as never; + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]); + + expect( + (await buildProviderStatusIndex({} as OpenClawConfig)).get("slack:default"), + ).toMatchObject({ configured: true, state: "configured unavailable" }); }); it("does not inspect linkage for an unconfigured account", async () => { diff --git a/src/commands/agents.providers.ts b/src/commands/agents.providers.ts index 8df35b093b74..3bf7ef3557f9 100644 --- a/src/commands/agents.providers.ts +++ b/src/commands/agents.providers.ts @@ -1,5 +1,6 @@ // Provider/account summary helpers for `openclaw agents list`. import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { hasConfiguredUnavailableCredentialStatus } from "../channels/account-snapshot-fields.js"; import { isChannelVisibleInConfiguredLists } from "../channels/plugins/exposure.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { normalizeChannelId } from "../channels/plugins/index.js"; @@ -22,7 +23,14 @@ type ProviderAccountStatus = { providerLabel?: string; accountId: string; name?: string; - state: "linked" | "not linked" | "configured" | "not configured" | "enabled" | "disabled"; + state: + | "linked" + | "not linked" + | "configured" + | "configured unavailable" + | "not configured" + | "enabled" + | "disabled"; enabled?: boolean; configured?: boolean; visibleInConfiguredLists?: boolean; @@ -155,9 +163,11 @@ export async function buildProviderStatusIndex( } map.set(providerAccountKey(plugin.id, accountId), { provider: plugin.id, + providerLabel: plugin.meta.label, accountId, - state: "not configured", - configured: false, + state: "configured unavailable", + configured: true, + visibleInConfiguredLists: isChannelVisibleInConfiguredLists(plugin.meta), }); continue; } @@ -175,6 +185,18 @@ export async function buildProviderStatusIndex( : snapshot?.configured; const resolvedEnabled = typeof enabled === "boolean" ? enabled : true; const resolvedConfigured = typeof configured === "boolean" ? configured : true; + const inspectedConfigured = (account as { configured?: unknown }).configured; + const configuredIntent = + typeof inspectedConfigured === "boolean" + ? inspectedConfigured + : snapshot?.configured === true; + // Provider inspection owns which credentials are required. Only an account whose owner + // reports complete configured intent but no usable runtime credentials is unavailable. + const configuredUnavailable = + !resolvedConfigured && + configuredIntent && + (hasConfiguredUnavailableCredentialStatus(snapshot) || + hasConfiguredUnavailableCredentialStatus(account)); const linkState = resolvedConfigured && plugin.config.isLinked ? await plugin.config.isLinked(account, cfg) @@ -186,14 +208,16 @@ export async function buildProviderStatusIndex( configured: resolvedConfigured, enabled: resolvedEnabled, }); - const state = projectChannelAccountDisplayState( - resolveChannelAccountState({ - enabled: resolvedEnabled, - configured: resolvedConfigured, - linked, - }), - fallbackState, - ); + const state = configuredUnavailable + ? "configured unavailable" + : projectChannelAccountDisplayState( + resolveChannelAccountState({ + enabled: resolvedEnabled, + configured: resolvedConfigured, + linked, + }), + fallbackState, + ); const name = snapshot?.name ?? (account as { name?: string }).name; map.set(providerAccountKey(plugin.id, accountId), { provider: plugin.id, @@ -202,7 +226,7 @@ export async function buildProviderStatusIndex( name, state, enabled, - configured, + configured: configuredUnavailable || configured, visibleInConfiguredLists: isChannelVisibleInConfiguredLists(plugin.meta), }); } diff --git a/src/commands/doctor-gateway-health.test.ts b/src/commands/doctor-gateway-health.test.ts index f4db3a74892f..d77d5e348668 100644 --- a/src/commands/doctor-gateway-health.test.ts +++ b/src/commands/doctor-gateway-health.test.ts @@ -82,11 +82,51 @@ describe("checkGatewayHealth", () => { method: "channels.status", params: { probe: true, timeoutMs: 5000 }, timeoutMs: 6000, + config: cfg, }); expect(runtime.error).not.toHaveBeenCalled(); expect(note.mock.calls.map(([, title]) => title)).not.toContain("OpenClaw version mismatch"); }); + it("reports failed channel diagnostics without marking a reachable gateway unhealthy", async () => { + callGateway + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce(new Error("channel probe timed out")); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + await expect( + checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }), + ).resolves.toEqual({ authenticated: true, healthOk: true, status: { ok: true } }); + + expect(note).toHaveBeenCalledWith( + [ + "Channel status probe failed: channel probe timed out", + "Retry: openclaw channels status --probe", + ].join("\n"), + "Channel warnings", + ); + expect(runtime.error).not.toHaveBeenCalled(); + }); + + it("redacts credentials and terminal controls in channel probe failures", async () => { + const token = "sk-abcdefghijklmnopqrstuv"; + callGateway + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce( + new Error(`\u001B[31mchannel probe failed\nAuthorization: Bearer ${token}`), + ); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + await checkGatewayHealth({ runtime: runtime as never, cfg }); + + const [message, title] = note.mock.calls.at(-1) ?? []; + expect(title).toBe("Channel warnings"); + expect(message).toContain("channel probe failed\\nAuthorization: Bearer"); + expect(message).not.toContain(token); + expect(message).not.toContain("\u001B"); + expect(message.split("\n")).toHaveLength(2); + }); + it("notes CLI and gateway version mismatch when the gateway reports another runtime version", async () => { callGateway.mockResolvedValueOnce({ runtimeVersion: "2026.4.23" }).mockResolvedValueOnce({}); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts index f449e3c4221c..6b97b74dd9be 100644 --- a/src/commands/doctor-gateway-health.ts +++ b/src/commands/doctor-gateway-health.ts @@ -1,5 +1,7 @@ /** Gateway health probes used by doctor before deeper daemon and memory diagnostics. */ import { note } from "../../packages/terminal-core/src/note.js"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { formatCliCommand } from "../cli/command-format.js"; import { probeGatewayStatus } from "../cli/daemon-cli/probe.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -115,6 +117,7 @@ export async function checkGatewayHealth(params: { method: "channels.status", params: { probe: true, timeoutMs: 5000 }, timeoutMs: 6000, + config: params.cfg, }); const issues = collectChannelStatusIssues(statusLocal); if (issues.length > 0) { @@ -130,8 +133,14 @@ export async function checkGatewayHealth(params: { "Channel warnings", ); } - } catch { - // ignore: doctor already reported gateway health + } catch (error) { + note( + [ + `Channel status probe failed: ${sanitizeTerminalText(formatErrorMessage(error))}`, + `Retry: ${formatCliCommand("openclaw channels status --probe")}`, + ].join("\n"), + "Channel warnings", + ); } return { healthOk, authenticated: true, status }; } catch (err) { diff --git a/src/commands/doctor/cron/index.test.ts b/src/commands/doctor/cron/index.test.ts index 8a461ac1a088..520ddb5adee4 100644 --- a/src/commands/doctor/cron/index.test.ts +++ b/src/commands/doctor/cron/index.test.ts @@ -15,6 +15,7 @@ import { import { cronStoreKey } from "../../../cron/store/key.js"; import { readCronTaskRunHistoryPage } from "../../../cron/task-run-history.js"; import { runOpenClawStateWriteTransaction } from "../../../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../../../state/openclaw-state-db.paths.js"; import { withRestoredMocks } from "../../../test-utils/vitest-spies.js"; import { collectLegacyCronStoreHealthFindings, @@ -246,7 +247,7 @@ describe("collectLegacyCronStoreHealthFindings", () => { expect.objectContaining({ checkId: "core/doctor/legacy-cron-store", severity: "warning", - path: storePath, + path: resolveOpenClawStateSqlitePath(), requirement: "legacy-notify-fallback", }), ]), @@ -294,6 +295,21 @@ describe("collectLegacyCronStoreHealthFindings", () => { await expect(readPersistedJobs(storePath)).resolves.toEqual([]); }); + it("attributes SQLite-only cron findings to the canonical state database", async () => { + const storePath = await makeTempStorePath(); + vi.stubEnv("OPENCLAW_STATE_DIR", path.dirname(path.dirname(storePath))); + await writeCurrentCronStore(storePath, [createCurrentCronJob({ notify: true })]); + + const findings = await collectLegacyCronStoreHealthFindings({ cfg: {} }); + + expect(findings).toEqual([ + expect.objectContaining({ + path: resolveOpenClawStateSqlitePath(), + requirement: "legacy-notify-fallback", + }), + ]); + }); + it("returns no findings for an already-normalized empty cron store", async () => { const storePath = await makeTempStorePath(); await writeCurrentCronStore(storePath, []); @@ -389,6 +405,11 @@ describe("maybeRepairLegacyCronStore", () => { }, state: {}, }, + createCurrentCronJob({ + id: "disabled-pinned", + enabled: false, + payload: { kind: "agentTurn", message: "Dormant job", model: "ollama/qwen3" }, + }), ]); const prompter = makePrompter(true); @@ -410,6 +431,8 @@ describe("maybeRepairLegacyCronStore", () => { expectNoteContaining("2 jobs set `payload.model`", "Cron"); expectNoteContaining("Provider namespaces: anthropic=1, openai=1", "Cron"); expectNoteContaining("2 jobs use a different model than `agents.defaults.model`", "Cron"); + expectNoNoteContaining("ollama", "Cron"); + expectNoNoteContaining("jobs.json", "Cron"); const jobs = await readPersistedJobs(storePath); const job = requirePersistedJob(jobs, 0); @@ -1400,6 +1423,7 @@ describe("maybeRepairLegacyCronStore", () => { expectNoNoteContaining("Legacy cron job storage detected", "Cron"); expectNoteContaining("Cron store issues detected", "Cron"); expectNoteContaining("1 job still uses legacy", "Cron"); + expectNoNoteContaining("jobs.json", "Cron"); }); it("advises on isolated shell-prompt jobs without a non-actionable --fix repair note (#94655)", async () => { diff --git a/src/commands/doctor/cron/index.ts b/src/commands/doctor/cron/index.ts index b74657524d31..48b7b18bbb28 100644 --- a/src/commands/doctor/cron/index.ts +++ b/src/commands/doctor/cron/index.ts @@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { loadCronQuarantineFile, resolveCronJobsStorePath } from "../../../cron/store.js"; import type { HealthFinding } from "../../../flows/health-checks.js"; import { formatErrorMessage as errorMessage } from "../../../infra/errors.js"; +import { resolveOpenClawStateSqlitePath } from "../../../state/openclaw-state-db.paths.js"; import { shortenHomePath } from "../../../utils.js"; import type { DoctorPrompter, DoctorOptions } from "../../doctor-prompter.js"; import { countStaleDreamingJobs } from "./dreaming-payload-migration.js"; @@ -189,12 +190,13 @@ export async function collectLegacyCronStoreHealthFindings(params: { return findings; } + const sqliteStorePath = resolveOpenClawStateSqlitePath(); const normalized = normalizeStoredCronJobs(rawJobs); for (const line of formatLegacyIssuePreview(normalized.issues)) { findings.push( legacyCronStoreFinding({ message: line.replace(/^- /u, ""), - path: storePath, + path: sqliteStorePath, requirement: "legacy-cron-store-shape", }), ); @@ -215,7 +217,7 @@ export async function collectLegacyCronStoreHealthFindings(params: { findings.push( legacyCronStoreFinding({ message: `${pluralize(names.length, "tool-bearing automation")} ${description}.`, - path: storePath, + path: sqliteStorePath, requirement, fixHint: `Review with ${formatCliCommand("openclaw automations list")} and reauthorize with ${formatCliCommand("openclaw automations edit --tools ")}.`, }), @@ -227,7 +229,7 @@ export async function collectLegacyCronStoreHealthFindings(params: { findings.push( legacyCronStoreFinding({ message: `${pluralize(sqliteProjectionBackfillCount, "SQLite cron row")} will be backfilled from stored config JSON into split columns.`, - path: storePath, + path: sqliteStorePath, requirement: "sqlite-projection-backfill", }), ); @@ -238,7 +240,7 @@ export async function collectLegacyCronStoreHealthFindings(params: { findings.push( legacyCronStoreFinding({ message: `${pluralize(notifyCount, "job")} still uses legacy notify webhook fallback.`, - path: storePath, + path: sqliteStorePath, requirement: "legacy-notify-fallback", }), ); @@ -249,7 +251,7 @@ export async function collectLegacyCronStoreHealthFindings(params: { findings.push( legacyCronStoreFinding({ message: `${pluralize(dreamingStaleCount, "managed dreaming job")} still has the legacy heartbeat-coupled shape.`, - path: storePath, + path: sqliteStorePath, requirement: "legacy-dreaming-payload", }), ); @@ -352,8 +354,8 @@ export async function maybeRepairLegacyCronStore(params: { noteLegacyCronRepairResult(await applyLegacyCronStoreRepair({ cfg: params.cfg, state })); return; } - noteCronModelOverrides({ cfg: params.cfg, jobs: rawJobs, storePath }); - noteCronDeliveryTargetAdvisory({ cfg: params.cfg, jobs: rawJobs, storePath }); + noteCronModelOverrides({ cfg: params.cfg, jobs: rawJobs }); + noteCronDeliveryTargetAdvisory({ cfg: params.cfg, jobs: rawJobs }); const inFlightCount = countInFlightCronJobs(rawJobs); if (inFlightCount > 0) { @@ -436,7 +438,7 @@ export async function maybeRepairLegacyCronStore(params: { const noteHeading = legacyStoreDetected ? `Legacy cron job storage detected at ${shortenHomePath(storePath)}.` - : `Cron store issues detected at ${shortenHomePath(storePath)}.`; + : `Cron store issues detected at ${shortenHomePath(resolveOpenClawStateSqlitePath())}.`; note( [ diff --git a/src/commands/doctor/cron/warnings.test.ts b/src/commands/doctor/cron/warnings.test.ts index 72df7a8922ef..316f035dd7d0 100644 --- a/src/commands/doctor/cron/warnings.test.ts +++ b/src/commands/doctor/cron/warnings.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { collectLegacyWhatsAppCrontabHealthWarning, noteCronDeliveryTargetAdvisory, + noteCronModelOverrides, } from "./warnings.js"; const mocks = vi.hoisted(() => ({ @@ -21,8 +22,6 @@ afterEach(() => { vi.clearAllMocks(); }); -const STORE_PATH = "/tmp/openclaw/cron/jobs.sqlite"; - function job(overrides: Record): Record { return { id: "job", schedule: "0 * * * *", ...overrides }; } @@ -34,7 +33,6 @@ function availableChannels(...ids: string[]) { function collectCronDeliveryTargetAdvisory(params: { jobs: Array>; - storePath: string; resolveAvailableChannelIds: () => string[]; }): string | null { mocks.note.mockClear(); @@ -44,17 +42,38 @@ function collectCronDeliveryTargetAdvisory(params: { noteCronDeliveryTargetAdvisory({ cfg: {}, jobs: params.jobs, - storePath: params.storePath, }); const body = mocks.note.mock.calls.at(-1)?.[0]; return typeof body === "string" ? body : null; } +describe("noteCronModelOverrides", () => { + it("describes enabled overrides without claiming a specific backing store", () => { + noteCronModelOverrides({ + cfg: {}, + jobs: [job({ enabled: true, payload: { kind: "agentTurn", model: "ollama/qwen3" } })], + }); + + expect(mocks.note).toHaveBeenCalledWith( + expect.stringMatching(/^Automation model overrides detected\.\n/u), + "Cron", + ); + }); + + it("does not warn for disabled model-pinned jobs", () => { + noteCronModelOverrides({ + cfg: {}, + jobs: [job({ enabled: false, payload: { kind: "agentTurn", model: "ollama/qwen3" } })], + }); + + expect(mocks.note).not.toHaveBeenCalled(); + }); +}); + describe("collectCronDeliveryTargetAdvisory", () => { it("advises when a concrete delivery channel has no active plugin", () => { const advisory = collectCronDeliveryTargetAdvisory({ jobs: [job({ id: "report", delivery: { mode: "announce", channel: "missing-channel" } })], - storePath: STORE_PATH, resolveAvailableChannelIds: availableChannels("slack", "telegram"), }); expect(advisory).not.toBeNull(); @@ -68,7 +87,6 @@ describe("collectCronDeliveryTargetAdvisory", () => { // Omitting `mode` defaults to announce, so a bare channel still counts as a concrete target. const advisory = collectCronDeliveryTargetAdvisory({ jobs: [job({ delivery: { channel: "slack" } })], - storePath: STORE_PATH, resolveAvailableChannelIds: availableChannels("slack", "telegram"), }); expect(advisory).toBeNull(); @@ -78,7 +96,6 @@ describe("collectCronDeliveryTargetAdvisory", () => { // "gchat" canonicalizes to "googlechat"; an alias target must not look unavailable. const advisory = collectCronDeliveryTargetAdvisory({ jobs: [job({ delivery: { mode: "announce", channel: "gchat" } })], - storePath: STORE_PATH, resolveAvailableChannelIds: availableChannels("googlechat"), }); expect(advisory).toBeNull(); @@ -92,7 +109,6 @@ describe("collectCronDeliveryTargetAdvisory", () => { const resolve = availableChannels("slack"); const advisory = collectCronDeliveryTargetAdvisory({ jobs: [job({ delivery })], - storePath: STORE_PATH, resolveAvailableChannelIds: resolve, }); expect(advisory).toBeNull(); @@ -105,7 +121,6 @@ describe("collectCronDeliveryTargetAdvisory", () => { }); const advisory = collectCronDeliveryTargetAdvisory({ jobs: [job({ id: "implicit" }), job({ id: "weblike", delivery: { mode: "webhook" } })], - storePath: STORE_PATH, resolveAvailableChannelIds: resolve, }); expect(advisory).toBeNull(); @@ -121,7 +136,6 @@ describe("collectCronDeliveryTargetAdvisory", () => { delivery: { mode: "announce", channel: "missing-channel" }, }), ], - storePath: STORE_PATH, resolveAvailableChannelIds: resolve, }); expect(advisory).toBeNull(); @@ -131,7 +145,6 @@ describe("collectCronDeliveryTargetAdvisory", () => { it("flags a concrete target even when no channels are active (only channel removed)", () => { const advisory = collectCronDeliveryTargetAdvisory({ jobs: [job({ id: "report", delivery: { mode: "announce", channel: "slack" } })], - storePath: STORE_PATH, resolveAvailableChannelIds: availableChannels(), }); expect(advisory).toContain("Channels: slack=1"); @@ -146,7 +159,6 @@ describe("collectCronDeliveryTargetAdvisory", () => { job({ id: "g3", delivery: { mode: "announce", channel: "ghost-b" } }), job({ id: "g4", delivery: { mode: "announce", channel: "ghost-b" } }), ], - storePath: STORE_PATH, resolveAvailableChannelIds: availableChannels("slack"), }); expect(advisory).toContain("4 jobs announce"); @@ -168,7 +180,6 @@ describe("collectCronDeliveryTargetAdvisory", () => { }), job({ id: undefined, name: undefined, delivery: { mode: "announce", channel: "ghost" } }), ], - storePath: STORE_PATH, resolveAvailableChannelIds: availableChannels("slack"), }); expect(advisory).toContain("Nightly digest -> ghost"); diff --git a/src/commands/doctor/cron/warnings.ts b/src/commands/doctor/cron/warnings.ts index fa41a8baeb7c..cdee3d6dadbc 100644 --- a/src/commands/doctor/cron/warnings.ts +++ b/src/commands/doctor/cron/warnings.ts @@ -1,4 +1,5 @@ // Doctor cron warnings for model overrides and stale WhatsApp crontab health scripts. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "../../../../packages/normalization-core/src/string-coerce.js"; import { note } from "../../../../packages/terminal-core/src/note.js"; import { normalizeChatChannelId } from "../../../channels/ids.js"; @@ -9,7 +10,6 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { resolveCronDeliveryPlan } from "../../../cron/delivery-plan.js"; import type { CronJob } from "../../../cron/types.js"; import { runExec } from "../../../process/exec.js"; -import { shortenHomePath } from "../../../utils.js"; type CrontabReader = () => Promise<{ stdout?: unknown; stderr?: unknown }>; @@ -53,12 +53,6 @@ function normalizeModelMismatchKey(value: unknown): string | undefined { return normalizeModelRef(value) ?? normalizeOptionalString(value)?.toLowerCase(); } -function getRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function formatSortedCounts(counts: Map): string { return [...counts.entries()] .toSorted(([left], [right]) => left.localeCompare(right)) @@ -70,7 +64,6 @@ function formatSortedCounts(counts: Map): string { export function noteCronModelOverrides(params: { cfg: OpenClawConfig; jobs: Array>; - storePath: string; }) { const defaultModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model); const defaultKey = normalizeModelMismatchKey(defaultModel); @@ -80,7 +73,10 @@ export function noteCronModelOverrides(params: { let mismatchCount = 0; for (const rawJob of params.jobs) { - const payload = getRecord(rawJob.payload); + if (rawJob.enabled === false) { + continue; + } + const payload = isRecord(rawJob.payload) ? rawJob.payload : undefined; const kind = normalizeOptionalString(payload?.kind)?.toLowerCase(); if (kind && kind !== "agentturn") { continue; @@ -108,7 +104,7 @@ export function noteCronModelOverrides(params: { } const lines = [ - `Automation model overrides detected at ${shortenHomePath(params.storePath)}.`, + "Automation model overrides detected.", `- ${pluralize(overrideCount, "job")} set \`payload.model\` and will not inherit \`agents.defaults.model\`${defaultModel ? ` (${defaultModel})` : ""}`, `- Provider namespaces: ${formatSortedCounts(providerCounts)}`, ]; @@ -145,7 +141,7 @@ function listConcreteCronDeliveryTargets( } // Only an explicit delivery object pins a concrete channel; without one the plan resolves // to the pseudo "last" route decided at run time, which doctor cannot validate ahead of time. - if (!getRecord(job.delivery)) { + if (!isRecord(job.delivery)) { continue; } const plan = resolveCronDeliveryPlan(job as unknown as CronJob); @@ -168,7 +164,6 @@ function listConcreteCronDeliveryTargets( */ function collectCronDeliveryTargetAdvisory(params: { jobs: Array>; - storePath: string; resolveAvailableChannelIds: () => Iterable; }): string | null { const concreteTargets = listConcreteCronDeliveryTargets(params.jobs); @@ -206,7 +201,7 @@ function collectCronDeliveryTargetAdvisory(params: { } return [ - `Automation delivery targets unavailable channels at ${shortenHomePath(params.storePath)}.`, + "Automation delivery targets unavailable channels.", `- ${pluralize(unavailableCount, "job")} ${unavailableCount === 1 ? "announces" : "announce"} to a channel whose plugin is not active; the next scheduled run will fail to deliver`, `- Channels: ${formatSortedCounts(channelCounts)}`, `- Examples: ${examples.join(", ")}`, @@ -218,13 +213,11 @@ function collectCronDeliveryTargetAdvisory(params: { export function noteCronDeliveryTargetAdvisory(params: { cfg: OpenClawConfig; jobs: Array>; - storePath: string; }): void { let advisory: string | null; try { advisory = collectCronDeliveryTargetAdvisory({ jobs: params.jobs, - storePath: params.storePath, // Mirror the doctor channel lookup: setup-fallback materializes configured channels even // when no gateway is running, so configured targets are not mistaken for unavailable ones. resolveAvailableChannelIds: () => diff --git a/src/commands/status-json-runtime.test.ts b/src/commands/status-json-runtime.test.ts index 42c9009db352..d9f60d218908 100644 --- a/src/commands/status-json-runtime.test.ts +++ b/src/commands/status-json-runtime.test.ts @@ -153,11 +153,11 @@ describe("status-json-runtime", () => { expect(payloadInput.pluginCompatibility).toBeUndefined(); }); - it("suppresses health errors when requested", async () => { + it("preserves failed deep health probes in nonthrowing JSON output", async () => { mocks.resolveStatusRuntimeSnapshot.mockResolvedValueOnce({ securityAudit: undefined, usage: undefined, - health: undefined, + health: { error: "gateway health probe timed out" }, lastHeartbeat: { status: "ok" }, gatewayService: { label: "LaunchAgent" }, nodeService: { label: "node" }, @@ -173,7 +173,7 @@ describe("status-json-runtime", () => { expect(mocks.buildStatusJsonPayload).toHaveBeenCalledOnce(); const payloadInput = requireStatusPayloadInput(); expect(payloadInput.surface.gatewayProbeAuth).toStrictEqual({ token: "tok" }); - expect(payloadInput.health).toBeUndefined(); + expect(payloadInput.health).toEqual({ error: "gateway health probe timed out" }); expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({ config: { update: { channel: "stable" }, gateway: {} }, sourceConfig: { gateway: {} }, diff --git a/src/commands/status-json.test.ts b/src/commands/status-json.test.ts index 402a79a2825d..d43ed811ab59 100644 --- a/src/commands/status-json.test.ts +++ b/src/commands/status-json.test.ts @@ -144,8 +144,18 @@ describe("statusJsonCommand", () => { expect(payload).not.toHaveProperty("securityAudit"); }); - it("includes security audit details only when --all is requested", async () => { + it("includes security audit and plugin compatibility details when --all is requested", async () => { const { runtime, logs } = createRuntimeCapture(); + const compatibilityNotice = { + pluginId: "legacy-plugin", + code: "hook-only", + severity: "warn", + message: "plugin registers only legacy hooks", + }; + mocks.scanStatusJsonFast.mockResolvedValueOnce({ + ...createScanResult(), + pluginCompatibility: [compatibilityNotice], + }); await statusJsonCommand({ all: true }, runtime); @@ -183,6 +193,34 @@ describe("statusJsonCommand", () => { summary: { critical: 1, warn: 0, info: 0 }, findings: [], }, + pluginCompatibility: { + count: 1, + warnings: [compatibilityNotice], + }, }); }); + + it("reports deep gateway probe failures and runs the documented security audit", async () => { + const { runtime, logs } = createRuntimeCapture(); + mocks.scanStatusJsonFast.mockResolvedValueOnce({ + ...createScanResult(), + gatewayReachable: true, + }); + mocks.callGateway.mockImplementation(async (params: { method?: string }) => { + if (params.method === "health") { + throw new Error("gateway health probe timed out"); + } + return null; + }); + + await statusJsonCommand({ deep: true }, runtime); + + expect(mocks.runSecurityAudit).toHaveBeenCalledOnce(); + const payload = JSON.parse(logs[0] ?? "{}") as { + health?: { error?: string }; + securityAudit?: { summary?: { critical?: number } }; + }; + expect(payload.health).toEqual({ error: "Error: gateway health probe timed out" }); + expect(payload.securityAudit?.summary?.critical).toBe(1); + }); }); diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts index a23654631623..a6b4ceed116b 100644 --- a/src/commands/status-json.ts +++ b/src/commands/status-json.ts @@ -19,8 +19,8 @@ export async function statusJsonCommand( opts, runtime, scanStatusJsonFast, - // `--all` is the opt-in path for heavier security audit fields in JSON output. - includeSecurityAudit: opts.all === true, + includeSecurityAudit: opts.all === true || opts.deep === true, + includePluginCompatibility: opts.all === true, suppressHealthErrors: true, }); } diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index 7110a40c1d8f..1760b9abc06f 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -409,4 +409,34 @@ describe("status-runtime-shared", () => { plugins: [{ id: "telegram" }], }); }); + + it("keeps failed deep health probes visible in nonthrowing status snapshots", async () => { + mocks.callGateway.mockRejectedValueOnce(new Error("gateway health probe timed out")); + + await expect( + resolveStatusRuntimeSnapshot({ + config: { gateway: {} }, + sourceConfig: { gateway: {} }, + deep: true, + gatewayReachable: true, + suppressHealthErrors: true, + }), + ).resolves.toMatchObject({ + health: { error: "Error: gateway health probe timed out" }, + lastHeartbeat: { ok: true }, + }); + }); + + it("does not suppress failed deep health probes for text status", async () => { + mocks.callGateway.mockRejectedValueOnce(new Error("gateway health probe timed out")); + + await expect( + resolveStatusRuntimeSnapshot({ + config: { gateway: {} }, + sourceConfig: { gateway: {} }, + deep: true, + gatewayReachable: true, + }), + ).rejects.toThrow("gateway health probe timed out"); + }); }); diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index 9f003eb47dd4..fcfc62ee1a58 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -235,6 +235,7 @@ export async function resolveStatusServiceSummaries(timeoutMs?: number) { type StatusUsageSummary = Awaited>; type StatusGatewayHealth = Awaited>; +type StatusGatewayHealthResult = StatusGatewayHealth | { error: string }; type StatusLastHeartbeat = Awaited>; type StatusGatewayServiceSummary = Awaited>; type StatusNodeServiceSummary = Awaited>; @@ -262,12 +263,13 @@ async function resolveStatusRuntimeDetails(params: { config: params.config, }) : undefined; + // JSON status remains nonthrowing, but requested probe failures must stay visible. const health = params.deep ? params.suppressHealthErrors ? await resolveGatewayHealthSummary({ config: params.config, timeoutMs: params.timeoutMs, - }).catch(() => undefined) + }).catch((error: unknown) => ({ error: String(error) })) : await resolveGatewayHealthSummary({ config: params.config, timeoutMs: params.timeoutMs, @@ -291,7 +293,7 @@ async function resolveStatusRuntimeDetails(params: { }; return result satisfies { usage?: StatusUsageSummary; - health?: StatusGatewayHealth; + health?: StatusGatewayHealthResult; lastHeartbeat: StatusLastHeartbeat; gatewayService: StatusGatewayServiceSummary; nodeService: StatusNodeServiceSummary; @@ -342,7 +344,7 @@ export async function resolveStatusRuntimeSnapshot(params: { } satisfies { securityAudit?: StatusSecurityAudit; usage?: StatusUsageSummary; - health?: StatusGatewayHealth; + health?: StatusGatewayHealthResult; lastHeartbeat: StatusLastHeartbeat; gatewayService: StatusGatewayServiceSummary; nodeService: StatusNodeServiceSummary; diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 9dc24ca75eb0..9e2a26bbcd99 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -141,8 +141,8 @@ export async function statusCommand( await runStatusJsonCommand({ opts, runtime, - includeSecurityAudit: opts.all === true, - includePluginCompatibility: true, + includeSecurityAudit: opts.all === true || opts.deep === true, + includePluginCompatibility: opts.all === true, suppressHealthErrors: true, scanStatusJsonFast: async (scanOpts, runtimeForScan) => await loadStatusScanFastJsonModule().then(({ scanStatusJsonFast }) => @@ -226,6 +226,11 @@ export async function statusCommand( ), }); + // Structured probe failures belong to nonthrowing JSON; text status keeps failures loud. + if (health && "error" in health) { + throw new Error(health.error); + } + const rich = true; const { buildStatusUpdateSurface, diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index 526977006122..0616e502194c 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -135,6 +135,23 @@ describe("scanStatusJsonFast", () => { expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled(); }); + it("collects actual plugin compatibility warnings when full JSON status is requested", async () => { + const notice = { + pluginId: "legacy-plugin", + code: "hook-only", + severity: "warn", + message: "plugin registers only legacy hooks", + }; + mocks.buildPluginCompatibilityNotices.mockReturnValue([notice]); + + const result = await scanStatusJsonFast({ all: true }, {} as never); + + expect(mocks.buildPluginCompatibilityNotices).toHaveBeenCalledWith({ + config: createStatusMemorySearchConfig(), + }); + expect(result.pluginCompatibility).toEqual([notice]); + }); + it("keeps default fast JSON update scans local-only", async () => { mocks.hasConfiguredChannels.mockReturnValue(true); diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 6b8b0b3292d0..93f18b2bd979 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -13,6 +13,9 @@ import type { StatusScanResult } from "./status.scan-result.ts"; const statusScanMemoryModuleLoader = createLazyImportLoader( () => import("./status.scan-memory.js"), ); +const statusScanPluginStatusModuleLoader = createLazyImportLoader( + () => import("../plugins/status.js"), +); const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); const STATUS_JSON_CHANNEL_ENV_PREFIXES = GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter( @@ -105,13 +108,20 @@ export async function scanStatusJsonWithPolicy( includeLocalStatusRpcFallback: policy.includeLocalStatusRpcFallback, gatewayProbeTimeoutMs: policy.gatewayProbeTimeoutMs, }); + const pluginCompatibility = opts.all + ? await statusScanPluginStatusModuleLoader + .load() + .then(({ buildPluginCompatibilitySnapshotNotices }) => + buildPluginCompatibilitySnapshotNotices({ config: overview.cfg }), + ) + : []; return await executeStatusScanFromOverview({ overview, runtime, resolveMemory: policy.resolveMemory, channelIssues: [], channels: { rows: [], details: [] }, - pluginCompatibility: [], + pluginCompatibility, }); } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index a46981581dec..3eec60f21ce6 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -767,8 +767,8 @@ vi.mock("../plugins/status.js", () => ({ })); vi.mock("./status.scan.fast-json.js", () => ({ - scanStatusJsonFast: vi.fn(async () => - createMockStatusScanResult({ includePluginCompatibility: false }), + scanStatusJsonFast: vi.fn(async (opts: { all?: boolean }) => + createMockStatusScanResult({ includePluginCompatibility: opts.all === true }), ), })); @@ -1011,7 +1011,7 @@ describe("statusCommand", () => { (runtime.error as Mock<(...args: unknown[]) => void>).mockClear(); }); - it("prints JSON and includes security audit only when all is requested", async () => { + it("prints JSON and includes full diagnostics only when all is requested", async () => { mocks.buildPluginCompatibilityNotices.mockReturnValue([ createCompatibilityNotice({ pluginId: "legacy-plugin", code: "hook-only" }), ]); @@ -1034,10 +1034,7 @@ describe("statusCommand", () => { expect(payload.securityAudit).toBeUndefined(); expect(payload.gatewayService.label).toBe("LaunchAgent"); expect(payload.nodeService.label).toBe("LaunchAgent"); - expect(payload.pluginCompatibility).toEqual({ - count: 0, - warnings: [], - }); + expect(payload.pluginCompatibility).toBeUndefined(); expect(payload.tasks.total).toBe(0); expect(payload.tasks.active).toBe(0); expect(payload.tasks.byStatus.queued).toBe(0); @@ -1050,6 +1047,10 @@ describe("statusCommand", () => { const allPayload = JSON.parse(getRuntimeLog(0)); expect(allPayload.securityAudit.summary.critical).toBe(1); expect(allPayload.securityAudit.summary.warn).toBe(1); + expect(allPayload.pluginCompatibility).toEqual({ + count: 1, + warnings: [createCompatibilityNotice({ pluginId: "legacy-plugin", code: "hook-only" })], + }); const auditParams = mocks.runSecurityAudit.mock.calls[0]?.[0]; expect(auditParams?.includeFilesystem).toBe(true); expect(auditParams?.includeChannelSecurity).toBe(true); @@ -1092,6 +1093,13 @@ describe("statusCommand", () => { expect(mocks.runSecurityAudit).not.toHaveBeenCalled(); }); + it("includes the security audit for deep JSON status", async () => { + await statusCommand({ json: true, deep: true }, runtime as never); + + expect(mocks.runSecurityAudit).toHaveBeenCalledOnce(); + expect(JSON.parse(getRuntimeLog(0)).securityAudit.summary.critical).toBe(1); + }); + it("passes deep mode through to the text status scan", async () => { const { scanStatus } = await import("./status.scan.js"); vi.mocked(scanStatus).mockClear(); diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 250d18b1953a..5f9b9a34ffbd 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -14,6 +14,7 @@ type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatCh type FormatChannelSelectionLine = typeof import("../channels/registry.js").formatChannelSelectionLine; type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured; +type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").ChannelSetupPlugin; type NoteChannelPrimerChannels = Parameters< typeof import("./channel-setup.status.js").noteChannelPrimer >[1]; @@ -260,6 +261,111 @@ describe("resolveChannelSetupSelectionContributions", () => { ]); }); + it.each(["rejected status check", "synchronous status check", "adapter resolution"] as const)( + "keeps healthy channels selectable after a %s failure", + async (failurePoint) => { + const installedPlugins = [ + { + id: "matrix", + meta: makeMeta("matrix", "Matrix"), + capabilities: { chatTypes: [] }, + config: {} as ChannelSetupPlugin["config"], + }, + { + id: "telegram", + meta: makeMeta("telegram", "Telegram"), + capabilities: { chatTypes: [] }, + config: {} as ChannelSetupPlugin["config"], + }, + ] satisfies ChannelSetupPlugin[]; + listChatChannels.mockReturnValue([ + makeMeta("matrix", "Matrix"), + makeMeta("telegram", "Telegram"), + ]); + isChannelConfigured.mockImplementation((_, channelId) => channelId === "matrix"); + + const failure = new Error("lazy Matrix setup module unavailable"); + const summary = await collectChannelStatus({ + cfg: {} as never, + accountOverrides: {}, + installedPlugins, + resolveAdapter: (channel) => { + if (channel === "matrix" && failurePoint === "adapter resolution") { + throw failure; + } + return { + channel, + getStatus: + channel === "matrix" + ? failurePoint === "synchronous status check" + ? () => { + throw failure; + } + : async () => { + throw failure; + } + : async () => ({ + channel: "telegram", + configured: true, + statusLines: ["Telegram: configured"], + selectionHint: "configured", + quickstartScore: 5, + }), + } as never; + }, + }); + + expect(summary.statusByChannel.get("matrix")).toEqual({ + channel: "matrix", + configured: true, + statusLines: ["Matrix: status unavailable (lazy Matrix setup module unavailable)"], + selectionHint: "status unavailable", + }); + expect(summary.statusByChannel.get("telegram")).toEqual({ + channel: "telegram", + configured: true, + statusLines: ["Telegram: configured"], + selectionHint: "configured", + quickstartScore: 5, + }); + expect(summary.statusLines).toEqual([ + "Matrix: status unavailable (lazy Matrix setup module unavailable)", + "Telegram: configured", + ]); + }, + ); + + it("redacts credentials and terminal controls in failed channel status checks", async () => { + const token = "sk-abcdefghijklmnopqrstuv"; + const summary = await collectChannelStatus({ + cfg: {} as never, + accountOverrides: {}, + installedPlugins: [ + { + id: "matrix", + meta: makeMeta("matrix", "Matrix"), + capabilities: { chatTypes: [] }, + config: {} as ChannelSetupPlugin["config"], + }, + ], + resolveAdapter: (channel) => + ({ + channel, + getStatus: async () => { + throw new Error(`\u001B[31mloader failed\nAuthorization: Bearer ${token}`); + }, + }) as never, + }); + + const statusLine = summary.statusLines[0]; + expect(statusLine).toContain( + "Matrix: status unavailable (loader failed\\nAuthorization: Bearer", + ); + expect(statusLine).not.toContain(token); + expect(statusLine).not.toContain("\u001B"); + expect(statusLine).not.toContain("\n"); + }); + it("localizes channel status note labels", async () => { listChatChannels.mockReturnValue([ makeMeta("discord", "Discord"), diff --git a/src/flows/channel-setup.status.ts b/src/flows/channel-setup.status.ts index cf4116a9fc24..964a20d4a94a 100644 --- a/src/flows/channel-setup.status.ts +++ b/src/flows/channel-setup.status.ts @@ -20,6 +20,7 @@ import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-s import type { ChannelChoice } from "../commands/onboard-types.js"; import { isChannelConfigured } from "../config/channel-configured.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { findBundledPluginSourceInMap, resolveBundledPluginSources, @@ -355,22 +356,35 @@ export async function collectChannelStatus(params: { resolveChannelSetupWizardAdapterForPlugin( installedPlugins.find((plugin) => plugin.id === channel), )); - const statusEntries = await Promise.all( - installedPlugins.flatMap((plugin) => { - if (!shouldShowChannelInSetup(plugin.meta)) { - return []; - } - const adapter = resolveAdapter(plugin.id); - if (!adapter) { - return []; - } - return adapter.getStatus({ - cfg: params.cfg, - options: params.options, - accountOverrides: params.accountOverrides, - }); - }), - ); + const statusEntries = ( + await Promise.all( + installedPlugins + .filter((plugin) => shouldShowChannelInSetup(plugin.meta)) + .map(async (plugin): Promise => { + try { + const adapter = resolveAdapter(plugin.id); + if (!adapter) { + return undefined; + } + return await adapter.getStatus({ + cfg: params.cfg, + options: params.options, + accountOverrides: params.accountOverrides, + }); + } catch (error) { + const detail = formatSetupFreeText(formatErrorMessage(error)); + return { + channel: plugin.id, + configured: isChannelConfigured(params.cfg, plugin.id), + statusLines: [ + `${formatSetupSelectionLabel(plugin.meta.label, plugin.id)}: status unavailable (${detail})`, + ], + selectionHint: "status unavailable", + }; + } + }), + ) + ).filter((status): status is ChannelSetupStatus => status !== undefined); const statusByChannel = new Map( statusEntries.map((entry: ChannelSetupStatus) => [entry.channel, entry]), ); diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 9b5941d76c9d..ecd75339299b 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -60,7 +60,7 @@ type ModelsListView = ModelCatalogBrowseView; type ModelsListEntry = Pick< ModelCatalogEntry, "alias" | "contextWindow" | "id" | "input" | "name" | "provider" | "reasoning" -> & { available?: boolean }; +> & { available?: boolean; supportsTools?: boolean }; type ModelsListEntryWithCapabilities = ModelsListEntry & { agentRuntime?: GatewayAgentRuntime; apiKeySupported?: boolean; @@ -96,6 +96,9 @@ function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry { ...(entry.alias ? { alias: entry.alias } : {}), ...(contextWindow ? { contextWindow } : {}), ...(typeof entry.reasoning === "boolean" ? { reasoning: entry.reasoning } : {}), + ...(typeof entry.compat?.supportsTools === "boolean" + ? { supportsTools: entry.compat.supportsTools } + : {}), }; } diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index f6396a97f59b..90aed1058314 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -92,6 +92,7 @@ type ModelCatalogRpcEntry = { contextWindow?: number; input?: string[]; reasoning?: boolean; + supportsTools?: boolean; agentRuntime?: GatewayAgentRuntime; }; @@ -179,6 +180,7 @@ type ConfiguredProviderModelFixture = { name: string; alias: string; contextWindow: number; + supportsTools?: boolean; }; const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) => ({ @@ -200,6 +202,9 @@ const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) = id: params.modelId, name: params.name, contextWindow: params.contextWindow, + ...(params.supportsTools === undefined + ? {} + : { compat: { supportsTools: params.supportsTools } }), }, ], }, @@ -213,6 +218,7 @@ const expectedConfiguredProviderModel = (params: ConfiguredProviderModelFixture) alias: params.alias, provider: params.provider, contextWindow: params.contextWindow, + ...(params.supportsTools === undefined ? {} : { supportsTools: params.supportsTools }), }); describe("gateway server models + voicewake", () => { @@ -362,6 +368,9 @@ describe("gateway server models + voicewake", () => { if (expected.contextWindow !== undefined) { expect(models[0]?.contextWindow).toBe(expected.contextWindow); } + if (expected.supportsTools !== undefined) { + expect(models[0]?.supportsTools).toBe(expected.supportsTools); + } }; test( @@ -757,6 +766,7 @@ describe("gateway server models + voicewake", () => { name: "Kimi K2.5 (Configured)", alias: "Kimi K2.5 (NVIDIA)", contextWindow: 32_000, + supportsTools: false, }, }, { diff --git a/src/gateway/session-lifecycle-state.test.ts b/src/gateway/session-lifecycle-state.test.ts index 8c40dfbb691f..c47958d24738 100644 --- a/src/gateway/session-lifecycle-state.test.ts +++ b/src/gateway/session-lifecycle-state.test.ts @@ -434,6 +434,45 @@ describe("session lifecycle state", () => { expect(persisted.mainRestartRecovery).toBeUndefined(); }); + it("keeps an active recovery when an older same-run terminal arrives", async () => { + const lifecycleGeneration = getAgentEventLifecycleGeneration(); + const persisted = await persistLifecycle( + { + sessionId: "session-id", + updatedAt: 1_000, + startedAt: 1_050, + status: "running", + abortedLastRun: false, + restartRecoveryDeliveryRunId: "recovery-run", + restartRecoveryRuns: [ + { runId: "recovery-run", lifecycleGeneration: "pre-restart" }, + { runId: "recovery-run", lifecycleGeneration }, + ], + mainRestartRecovery: { + cycleId: "cycle-1", + revision: 5, + chargedAttempts: 2, + }, + }, + { + ts: 2_000, + sessionId: "session-id", + runId: "recovery-run", + lifecycleGeneration: "pre-restart", + data: { phase: "end", endedAt: 1_800 }, + }, + ); + + expect(persisted).toMatchObject({ + status: "running", + abortedLastRun: false, + restartRecoveryDeliveryRunId: "recovery-run", + restartRecoveryRuns: [{ runId: "recovery-run", lifecycleGeneration }], + mainRestartRecovery: { cycleId: "cycle-1" }, + }); + expect(persisted.restartRecoveryTerminalRunIds).toBeUndefined(); + }); + it("does not settle a foreground owner from a stale lifecycle generation", async () => { const persisted = await persistLifecycle( { diff --git a/src/infra/channels-status-issues.test.ts b/src/infra/channels-status-issues.test.ts index 4f6a1d8e0bcf..0ac7df43e573 100644 --- a/src/infra/channels-status-issues.test.ts +++ b/src/infra/channels-status-issues.test.ts @@ -143,6 +143,31 @@ describe("collectChannelStatusIssues", () => { }); }); + it("reports stopped configured accounts without treating unknown runtime state as stopped", () => { + mocks.listChannelPlugins.mockReturnValue([createPlugin("discord")]); + + expect( + collectChannelStatusIssues({ + channelAccounts: { + discord: [ + { accountId: "stopped", enabled: true, configured: true, running: false }, + { accountId: "unknown", enabled: true, configured: true }, + { accountId: "disabled", enabled: false, configured: true, running: false }, + { accountId: "unconfigured", enabled: true, configured: false, running: false }, + ], + }, + }), + ).toEqual([ + { + channel: "discord", + accountId: "stopped", + kind: "runtime", + message: "Channel is enabled and configured, but its runtime is not running.", + fix: "restart the channel or gateway", + }, + ]); + }); + it("reports dead ingress even while a restart is pending", () => { mocks.listChannelPlugins.mockReturnValue([createPlugin("slack")]); diff --git a/src/infra/channels-status-issues.ts b/src/infra/channels-status-issues.ts index 71c455aadff8..29f3c9064339 100644 --- a/src/infra/channels-status-issues.ts +++ b/src/infra/channels-status-issues.ts @@ -63,36 +63,35 @@ function collectGenericRuntimeStatusIssues( if (health.healthy) { continue; } - if (health.reason === "disconnected") { - issues.push({ - channel, - accountId, - kind: "runtime", - message: "Channel reports running, but the runtime is disconnected.", - fix: "restart the channel or gateway", - }); - continue; - } - if (health.reason === "stale-socket") { - issues.push({ - channel, - accountId, - kind: "runtime", - message: - "Channel reports connected, but transport activity is stale; inbound delivery may be broken.", - fix: "restart the channel or gateway", - }); - continue; - } - if (health.reason === "stuck") { - issues.push({ - channel, - accountId, - kind: "runtime", - message: "Channel runtime appears stuck with stale run activity.", - fix: "restart the channel or gateway", - }); + let message: string; + switch (health.reason) { + case "not-running": + // Older status snapshots can omit running; absence is not a stopped runtime. + if (account.running !== false) { + continue; + } + message = "Channel is enabled and configured, but its runtime is not running."; + break; + case "disconnected": + message = "Channel reports running, but the runtime is disconnected."; + break; + case "stale-socket": + message = + "Channel reports connected, but transport activity is stale; inbound delivery may be broken."; + break; + case "stuck": + message = "Channel runtime appears stuck with stale run activity."; + break; + default: + continue; } + issues.push({ + channel, + accountId, + kind: "runtime", + message, + fix: "restart the channel or gateway", + }); } return issues; } diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index abfbca81ed1d..c2f608d88a97 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -2,6 +2,7 @@ // interactive blocks, mirror text, and suppressed relay status payloads. import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { describe, expect, it } from "vitest"; +import { markInboundContextLabel } from "../../auto-reply/reply/inbound-context-marker.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { typedCases } from "../../test-utils/typed-cases.js"; import { @@ -53,6 +54,43 @@ describe("normalizeReplyPayloadsForDelivery", () => { ]); }); + it("strips leading echoed inbound metadata before parsing reply directives", () => { + const text = [ + markInboundContextLabel("Location:"), + "```json", + '{"latitude":51.5072,"longitude":-0.1276}', + "```", + "", + markInboundContextLabel("Plugin context:"), + "```json", + '{"source":"example","payload":{"mode":"test"}}', + "```", + "", + "[[reply_to: 123]] Visible reply", + ].join("\n"); + + expect(normalizeReplyPayloadsForDelivery([{ text }])).toMatchObject([ + { + text: "Visible reply", + replyToId: "123", + replyToTag: true, + }, + ]); + }); + + it("preserves marked metadata examples after visible reply text", () => { + const text = [ + "Here is the metadata format:", + "", + markInboundContextLabel("Location:"), + "```json", + '{"latitude":51.5072,"longitude":-0.1276}', + "```", + ].join("\n"); + + expect(normalizeReplyPayloadsForDelivery([{ text }])).toMatchObject([{ text }]); + }); + it("strips unsupported citation control markers from reply payload text", () => { const payloads: ReplyPayload[] = [{ text: "v2026.5.20 release note citeturn2view0" }]; @@ -691,6 +729,51 @@ describe("OutboundPayloadPlan projections", () => { }, ]); }); + + it.each([ + { + name: "a MEDIA directive", + attachment: "MEDIA:https://example.com/config.png", + extractMarkdownImages: false, + }, + { + name: "an extracted Markdown image", + attachment: "![chart](https://example.com/config.png)", + extractMarkdownImages: true, + }, + ])("preserves formatted reply text when extracting $name", (testCase) => { + const visibleText = [ + "Here is the config.", + "", + "```yaml", + "server:", + " host: 0.0.0.0", + " ports:", + " - 80", + "```", + "", + "The service is ready.", + ].join("\n"); + const [planned] = createOutboundPayloadPlan( + [{ text: `${visibleText}\n\n${testCase.attachment}` }], + { extractMarkdownImages: testCase.extractMarkdownImages }, + ); + + expect(planned?.payload.text).toBe(visibleText); + expect(planned?.payload.mediaUrls).toEqual(["https://example.com/config.png"]); + }); + + it("preserves canonical code fences when reply directives and media share a payload", () => { + const code = ["```python", "value = 'a b'", "``` not a close", "other = 'c d'", "```"].join( + "\n", + ); + const [planned] = createOutboundPayloadPlan([ + { text: `[[reply_to_current]]\n${code}\nMEDIA:https://example.com/config.png` }, + ]); + + expect(planned?.payload.text).toBe(code); + expect(planned?.payload.mediaUrls).toEqual(["https://example.com/config.png"]); + }); }); describe("formatOutboundPayloadLog", () => { diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 04a21b6d1379..889eca89ec66 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -10,6 +10,7 @@ import { isRenderablePayload, shouldSuppressReasoningPayload, } from "../../auto-reply/reply/reply-payloads.js"; +import { stripLeadingInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -231,7 +232,7 @@ function createOutboundPayloadPlanEntry( if (shouldSuppressReasoningPayload(payload)) { return null; } - const parsed = parseReplyDirectives(payload.text ?? "", { + const parsed = parseReplyDirectives(stripLeadingInboundMetadata(payload.text ?? ""), { extractMarkdownImages: context.extractMarkdownImages, }); const explicitMediaUrls = payload.mediaUrls ?? parsed.mediaUrls; diff --git a/src/infra/state-migrations.channel-pairing.test.ts b/src/infra/state-migrations.channel-pairing.test.ts index db7d3edf762d..ba33d2a7d11a 100644 --- a/src/infra/state-migrations.channel-pairing.test.ts +++ b/src/infra/state-migrations.channel-pairing.test.ts @@ -180,6 +180,27 @@ describe("legacy channel pairing state migration", () => { expect(readChannelPairingStateSnapshot("telegram", env).allowFrom).toEqual({}); }); + it("ignores invalid account candidates while resolving scoped filenames", async () => { + const { env, sourceDir } = await createFixture(); + const filePath = path.join(sourceDir, "telegram-alerts-allowFrom.json"); + writeJson(filePath, { version: 1, allowFrom: ["1003"] }); + + const detected = detectLegacyChannelPairingState({ + sourceDir, + configuredAccountIds: { telegram: ["*", "alerts"] }, + }); + const result = migrateLegacyChannelPairingState({ detected, env }); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual([ + "Migrated 1 telegram/alerts allowFrom entry → shared SQLite state", + ]); + expect(fs.existsSync(filePath)).toBe(false); + expect(readChannelPairingStateSnapshot("telegram", env).allowFrom).toEqual({ + alerts: ["1003"], + }); + }); + it("does not infer default accounts for external channels", async () => { const { env, sourceDir } = await createFixture(); const filePath = path.join(sourceDir, "custom-channel-default-allowFrom.json"); diff --git a/src/infra/state-migrations.channel-pairing.ts b/src/infra/state-migrations.channel-pairing.ts index 4c89e13fd87e..d7f2c24e421c 100644 --- a/src/infra/state-migrations.channel-pairing.ts +++ b/src/infra/state-migrations.channel-pairing.ts @@ -105,9 +105,15 @@ function parseAllowFromFilename( continue; } const accountKey = stem.slice(channel.length + 1); - const matchingAccountIds = (accountIds[channel] ?? []).filter( - (accountId) => safeAccountKey(accountId) === accountKey, - ); + const matchingAccountIds = (accountIds[channel] ?? []).filter((accountId) => { + try { + return safeAccountKey(accountId) === accountKey; + } catch { + // One invalid configured candidate must not abort every legacy migration. + // With no valid match, the source remains in place as unresolved below. + return false; + } + }); if (matchingAccountIds.length === 1 && matchingAccountIds[0]) { targets.push({ channel: channel as PairingChannel, accountId: matchingAccountIds[0] }); } else if (matchingAccountIds.length > 1) { diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index ef11d05a8f71..1e95605478d3 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -307,6 +307,14 @@ function resolveDoctorStateMigrationAgentId(cfg: OpenClawConfig): string { } } +function resolveConcreteBindingAccountId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const accountId = value.trim(); + return accountId && accountId !== "*" ? accountId : undefined; +} + export async function detectLegacyStateMigrations(params: { cfg: OpenClawConfig; pluginDoctorConfig?: OpenClawConfig; @@ -547,11 +555,13 @@ export async function detectLegacyStateMigrations(params: { ...(typeof channelConfig?.defaultAccount === "string" ? [channelConfig.defaultAccount] : []), - ...(params.cfg.bindings ?? []).flatMap((binding) => - binding.match?.channel === channelId && typeof binding.match.accountId === "string" - ? [binding.match.accountId] - : [], - ), + ...(params.cfg.bindings ?? []).flatMap((binding) => { + const accountId = + binding.match?.channel === channelId + ? resolveConcreteBindingAccountId(binding.match.accountId) + : undefined; + return accountId ? [accountId] : []; + }), ]; return [ channelId, @@ -568,10 +578,11 @@ export async function detectLegacyStateMigrations(params: { (binding) => normalizeAgentId(binding.agentId) === targetAgentId && binding.match?.channel === channelId && - typeof binding.match.accountId === "string", + resolveConcreteBindingAccountId(binding.match.accountId) !== undefined, )?.match.accountId; - if (typeof boundAccountId === "string" && boundAccountId.trim()) { - return [[channelId, boundAccountId.trim()]]; + const concreteBoundAccountId = resolveConcreteBindingAccountId(boundAccountId); + if (concreteBoundAccountId) { + return [[channelId, concreteBoundAccountId]]; } const defaultAccount = value && typeof value === "object" && !Array.isArray(value) diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index de486353af1b..9560ab7d2bb6 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -691,6 +691,45 @@ describe("state migrations", () => { detectionCase = { ...detected, stateDir, env }; }); + it("does not treat wildcard route bindings as pairing account ids", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + cfg.bindings = [ + { + agentId: "worker-1", + match: { channel: "chatapp", accountId: "*" }, + }, + ]; + const credentialsDir = path.join(stateDir, "credentials"); + await fs.mkdir(credentialsDir, { recursive: true }); + await fs.writeFile( + path.join(credentialsDir, "chatapp-allowFrom.json"), + '["default-user"]\n', + "utf8", + ); + await fs.writeFile( + path.join(credentialsDir, "chatapp-alpha-allowFrom.json"), + '["scoped-user"]\n', + "utf8", + ); + + const detected = await detectLegacyStateMigrations({ + cfg, + env, + homedir: () => root, + }); + const result = await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 }); + + expect(result.warnings).toEqual([]); + expect(readChannelPairingStateSnapshot("chatapp", env).allowFrom).toEqual({ + alpha: ["default-user", "scoped-user"], + }); + await expectMissingPath(path.join(credentialsDir, "chatapp-allowFrom.json")); + await expectMissingPath(path.join(credentialsDir, "chatapp-alpha-allowFrom.json")); + }); + it("keeps automatic migration read-only when the shared schema is current", async () => { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw"); diff --git a/src/media/parse.test.ts b/src/media/parse.test.ts index b39a509d5d56..53ad9b154d2d 100644 --- a/src/media/parse.test.ts +++ b/src/media/parse.test.ts @@ -143,7 +143,122 @@ describe("splitMediaFromOutput", () => { ]); }); + it("preserves paragraph breaks in ordered media text segments", () => { + const result = splitMediaFromOutput( + "First paragraph\n\nSecond paragraph\nMEDIA:https://example.com/a.png", + ); + + expect(result.segments).toEqual([ + { type: "text", text: "First paragraph\n\nSecond paragraph" }, + { type: "media", url: "https://example.com/a.png" }, + ]); + }); + + it.each([ + ["before", "First paragraph\n\nMEDIA:https://example.com/a.png\nSecond paragraph"], + ["after", "First paragraph\nMEDIA:https://example.com/a.png\n\nSecond paragraph"], + ["around", "First paragraph\n\nMEDIA:https://example.com/a.png\n\nSecond paragraph"], + ["with spaces", "First paragraph\n \nMEDIA:https://example.com/a.png\n \nSecond paragraph"], + ["with tabs", "First paragraph\n\t\nMEDIA:https://example.com/a.png\n\t\nSecond paragraph"], + ])("preserves a paragraph separator %s an attachment", (_placement, input) => { + const result = splitMediaFromOutput(input); + + expect(result.segments).toEqual([ + { type: "text", text: "First paragraph\n" }, + { type: "media", url: "https://example.com/a.png" }, + { type: "text", text: "Second paragraph" }, + ]); + }); + + it.each([" ", "\t"])("does not emit a whitespace-only media caption: %j", (whitespace) => { + const result = splitMediaFromOutput(`${whitespace}\nMEDIA:https://example.com/a.png`); + + expect(result.text).toBe(""); + expect(result.segments).toEqual([{ type: "media", url: "https://example.com/a.png" }]); + }); + + it("drops separator-only lines before the caption after extracting leading media", () => { + expectParsedMediaOutputCase("MEDIA:https://example.com/a.png\n\nCaption", { + text: "Caption", + mediaUrls: ["https://example.com/a.png"], + }); + }); + + it.each([ + { + name: "a marker carrying trailing text", + lines: ["```python", "value = 'a b'", "``` not a close", "other = 'c d'", "```"], + }, + { + name: "an unclosed fence", + lines: ["```python", "value = 'a b'", "other = 'c d'"], + }, + { + name: "an indented closing fence", + lines: ["```python", "value = 'a b'", " ```"], + }, + ])("preserves canonical code fences with $name", ({ lines }) => { + const code = lines.join("\n"); + + expectParsedMediaOutputCase(`MEDIA:https://example.com/a.png\n${code}`, { + text: code, + mediaUrls: ["https://example.com/a.png"], + }); + expectParsedMediaOutputCase(`[[audio_as_voice]]\nMEDIA:https://example.com/a.png\n${code}`, { + text: code, + mediaUrls: ["https://example.com/a.png"], + audioAsVoice: true, + }); + }); + const extractMarkdownImages = { extractMarkdownImages: true } as const; + const formattedMediaReply = [ + "Here is the code.", + "", + "```python", + "def summarize(rows):", + " totals = {}", + " for row in rows:", + " totals[row] = 1", + " return totals", + "```", + "", + "The attachment is ready.", + ].join("\n"); + + it.each([ + { + name: "a MEDIA directive", + input: `${formattedMediaReply}\n\nMEDIA:https://example.com/config.png`, + mediaUrl: "https://example.com/config.png", + options: undefined, + audioAsVoice: undefined, + }, + { + name: "an extracted Markdown image", + input: `${formattedMediaReply}\n\n![chart](https://example.com/chart.png)`, + mediaUrl: "https://example.com/chart.png", + options: extractMarkdownImages, + audioAsVoice: undefined, + }, + { + name: "an audio directive and media", + input: `[[audio_as_voice]]\n${formattedMediaReply}\n\nMEDIA:https://example.com/recording.ogg`, + mediaUrl: "https://example.com/recording.ogg", + options: undefined, + audioAsVoice: true, + }, + ])("preserves code indentation and paragraph breaks with $name", (testCase) => { + expectParsedMediaOutputCase( + testCase.input, + { + text: formattedMediaReply, + mediaUrls: [testCase.mediaUrl], + ...(testCase.audioAsVoice ? { audioAsVoice: true } : {}), + }, + testCase.options, + ); + }); it("keeps markdown image urls as text by default", () => { const input = "Caption\n\n![chart](https://example.com/chart.png)"; diff --git a/src/media/parse.ts b/src/media/parse.ts index 5b002ffadf50..f9c934dd3cb5 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -477,11 +477,6 @@ function collectMarkdownImageSegments(params: { line: string; media: string[] }) }; } -// Check if a character offset is inside any fenced code block -function isInsideFence(fenceSpans: Array<{ start: number; end: number }>, offset: number): boolean { - return fenceSpans.some((span) => offset >= span.start && offset < span.end); -} - /** Splits tool/stdout text into visible text, media attachments, voice tags, and ordered segments. */ export function splitMediaFromOutput( raw: string, @@ -510,17 +505,20 @@ export function splitMediaFromOutput( const media: string[] = []; let foundMediaToken = false; const segments: ParsedMediaOutputSegment[] = []; + let lastTextSegment: Extract | undefined; const pushTextSegment = (text: string) => { - if (!text) { - return; - } const last = segments[segments.length - 1]; if (last?.type === "text") { - last.text = `${last.text}\n${text}`; - return; + last.text = `${last.text}\n${text.trim() ? text : ""}`; + } else if (!text.trim()) { + if (last?.type === "media" && lastTextSegment && !lastTextSegment.text.endsWith("\n")) { + lastTextSegment.text += "\n"; + } + } else { + lastTextSegment = { type: "text", text }; + segments.push(lastTextSegment); } - segments.push({ type: "text", text }); }; // Parse fenced code blocks to avoid extracting MEDIA tokens from inside them @@ -534,7 +532,7 @@ export function splitMediaFromOutput( let lineOffset = 0; // Track character offset for fence checking for (const line of lines) { // Fenced examples must remain text; extracting their MEDIA tokens would mutate transcripts. - if (hasFenceMarkers && isInsideFence(fenceSpans, lineOffset)) { + if (fenceSpans.some((span) => lineOffset >= span.start && lineOffset < span.end)) { keptLines.push(line); pushTextSegment(line); lineOffset += line.length + 1; // +1 for newline @@ -687,19 +685,10 @@ export function splitMediaFromOutput( lineOffset += line.length + 1; // +1 for newline } - let cleanedText = keptLines - .join("\n") - .replace(/[ \t]+\n/g, "\n") - .replace(/[ \t]{2,}/g, " ") - .replace(/\n{2,}/g, "\n") - .trim(); - - // Detect and strip [[audio_as_voice]] tag - const audioTagResult = parseAudioTag(cleanedText); + const visibleText = keptLines.join("\n").replace(/^(?:[ \t]*\n)+/, ""); + const audioTagResult = parseAudioTag(visibleText); + const cleanedText = audioTagResult.text.trimEnd(); const hasAudioAsVoice = audioTagResult.audioAsVoice; - if (audioTagResult.hadTag) { - cleanedText = audioTagResult.text.replace(/\n{2,}/g, "\n").trim(); - } if (media.length === 0) { const parsedText = foundMediaToken || hasAudioAsVoice ? cleanedText : trimmedRaw; diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 0381150bab4e..881c41805b54 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -211,6 +211,16 @@ function createManifestRegistryFixture(): PluginManifestRegistry { origin: "global", activation: { onStartup: true }, }, + { + id: "source-external-startup", + enabledByDefault: true, + activation: { onStartup: true }, + channels: ["source-external-channel"], + providers: ["source-external-provider"], + packageManifest: { + build: { bundledDist: false }, + }, + }, { id: "demo-config-startup", enabledByDefault: true, @@ -314,6 +324,7 @@ function createInstalledPluginRecordFixture( origin: record.origin, enabled: true, ...(record.enabledByDefault === true ? { enabledByDefault: true } : {}), + ...(record.packageManifest?.build ? { packageBuild: record.packageManifest.build } : {}), startup: { sidecar: record.activation?.onStartup === true, memory, @@ -1429,6 +1440,72 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("does not ambient-start source-discovered external plugins from onStartup alone", () => { + expectStartupPluginIds({ + config: createStartupConfig({ + noConfiguredChannels: true, + memorySlot: "none", + }), + expected: ["browser"], + }); + }); + + it.each([ + [ + "plugins.entries", + createStartupConfig({ + enabledPluginIds: ["source-external-startup"], + noConfiguredChannels: true, + memorySlot: "none", + }), + ["browser", "source-external-startup"], + ], + [ + "plugins.allow", + createStartupConfig({ + allowPluginIds: ["source-external-startup"], + noConfiguredChannels: true, + memorySlot: "none", + }), + ["source-external-startup"], + ], + ])( + "starts source-discovered external plugins explicitly selected through %s", + (_name, config, expected) => { + expectStartupPluginIds({ + config, + expected, + }); + }, + ); + + it.each([ + [ + "configured channel", + { + channels: { + "source-external-channel": { enabled: true }, + }, + plugins: { + slots: { memory: "none" }, + }, + } as OpenClawConfig, + ], + [ + "selected provider", + createStartupConfig({ + modelId: "source-external-provider/demo-model", + noConfiguredChannels: true, + memorySlot: "none", + }), + ], + ])("preserves %s activation for source-discovered external plugins", (_name, config) => { + expectStartupPluginIds({ + config, + expected: ["browser", "source-external-startup"], + }); + }); + it("loads explicit trusted policy plugins at startup", () => { expectStartupPluginIds({ config: createStartupConfig({ diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index e1633bb43a68..7f733c929685 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -494,4 +494,20 @@ describe("copyBundledPluginMetadata", () => { type: "module", }); }); + + it("refuses to remove dist plugin trees through a symlinked dist root", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-meta-symlink-"); + const targetDir = path.join(repoRoot, "gateway-dist"); + const pluginFile = path.join(targetDir, "extensions", "acpx", "index.js"); + fs.mkdirSync(path.dirname(pluginFile), { recursive: true }); + fs.writeFileSync(pluginFile, "export {};\n"); + createPlugin(repoRoot, { id: "acpx", packageName: "@openclaw/acpx" }); + const distLink = path.join(repoRoot, "dist"); + fs.symlinkSync(targetDir, distLink, "dir"); + + expect(() => copyBundledPluginMetadataWithEnv({ repoRoot })).toThrow(/symbolic link/u); + + expect(fs.readlinkSync(distLink)).toBe(targetDir); + expect(fs.readFileSync(pluginFile, "utf8")).toBe("export {};\n"); + }); }); diff --git a/src/plugins/gateway-startup-plugin-plan.ts b/src/plugins/gateway-startup-plugin-plan.ts index 60fef360d28b..7bc0f462f7ff 100644 --- a/src/plugins/gateway-startup-plugin-plan.ts +++ b/src/plugins/gateway-startup-plugin-plan.ts @@ -418,9 +418,14 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { pluginIds.push(plugin.pluginId); continue; } + const isSourceExternalPlugin = + plugin.origin === "bundled" && plugin.packageBuild?.bundledDist === false; + // Source checkout discovery still uses the bundled root, but source-only + // packages are externally owned and must keep the external explicit-startup policy. + const startupPolicyOrigin = isSourceExternalPlugin ? "workspace" : plugin.origin; const activationState = resolveEffectivePluginActivationState({ id: plugin.pluginId, - origin: plugin.origin, + origin: startupPolicyOrigin, config: pluginsConfig, rootConfig: params.config, enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin, params.platform), @@ -430,7 +435,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { continue; } if ( - plugin.origin !== "bundled" + startupPolicyOrigin !== "bundled" ? activationState.explicitlyEnabled : activationState.source === "explicit" || activationState.source === "default" ) { diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 72c2c481ba13..c4a04dd73614 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -1972,6 +1972,54 @@ describe("official external plugin catalog", () => { }); }); + it("preserves DuckDuckGo's keyless web search setup contract", () => { + const duckduckgo = expectCatalogEntry("duckduckgo"); + const manifest = getOfficialExternalPluginCatalogManifest(duckduckgo); + + expect(resolveOfficialExternalPluginInstall(duckduckgo)).toEqual({ + clawhubSpec: "clawhub:@openclaw/duckduckgo-plugin", + npmSpec: "@openclaw/duckduckgo-plugin", + defaultChoice: "npm", + minHostVersion: ">=2026.7.2", + }); + expect(manifest?.contracts?.webSearchProviders).toEqual(["duckduckgo"]); + expect(manifest?.webSearchProviders).toEqual([ + { + id: "duckduckgo", + label: "DuckDuckGo Search (experimental)", + hint: "Free web search fallback with no API key required", + onboardingScopes: ["text-inference"], + requiresCredential: false, + envVars: [], + placeholder: "(no key needed)", + signupUrl: "https://duckduckgo.com/", + docsUrl: "https://docs.openclaw.ai/tools/duckduckgo-search", + credentialPath: "", + autoDetectOrder: 100, + }, + ]); + }); + + it("lists Voyage as an official external memory embedding provider", () => { + const voyage = expectCatalogEntry("voyage"); + const manifest = getOfficialExternalPluginCatalogManifest(voyage); + + expect(resolveOfficialExternalPluginId(voyage)).toBe("voyage"); + expect(resolveOfficialExternalPluginInstall(voyage)).toEqual({ + clawhubSpec: "clawhub:@openclaw/voyage-provider", + npmSpec: "@openclaw/voyage-provider", + defaultChoice: "npm", + minHostVersion: ">=2026.7.2", + }); + expect(manifest?.contracts?.memoryEmbeddingProviders).toEqual(["voyage"]); + expect(manifest?.providers).toEqual([ + expect.objectContaining({ + id: "voyage", + envVars: ["VOYAGE_API_KEY"], + }), + ]); + }); + it.each([ ["teams-meetings", "@openclaw/teams-meetings", "teams_meetings", "teams"], ["zoom-meetings", "@openclaw/zoom-meetings", "zoom_meetings", "zoom"], @@ -2039,6 +2087,12 @@ describe("official external plugin catalog", () => { providerIds: new Set(["groq", "moonshot", "zai"]), }), ).toEqual(["groq", "moonshot", "zai"]); + expect( + resolveOfficialExternalProviderContractPluginIds({ + contract: "memoryEmbeddingProviders", + providerIds: new Set(["voyage"]), + }), + ).toEqual(["voyage"]); }); it("maps env-only web-fetch credentials to external plugin owners", () => { @@ -2088,6 +2142,7 @@ describe("official external plugin catalog", () => { TOKENPLAN_API_KEY: "tokenplan-key", VENICE_API_KEY: "venice-key", AI_GATEWAY_API_KEY: "gateway-key", + VOYAGE_API_KEY: "voyage-key", ZAI_API_KEY: "zai-key", }), ).toEqual([ @@ -2110,6 +2165,7 @@ describe("official external plugin catalog", () => { "tencent", "venice", "vercel-ai-gateway", + "voyage", "zai", ]); expect(resolveOfficialExternalProviderPluginIdsForEnv({ GROQ_API_KEY: " " })).toEqual([]); diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index 7162464fd877..4e40b5e0ad7c 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -169,6 +169,72 @@ describe("startPluginServices", () => { expectServiceLifecycleState({ starts, stops, contexts, config }); }); + it("rolls back partially started services before starting their siblings", async () => { + const acquired = new Set(); + const received = vi.fn(); + const siblingStart = vi.fn(); + const rollback = vi.fn((ctx: OpenClawPluginServiceContext) => { + acquired.delete("failed-service"); + ctx.gatewayEvents?.emit("rolled-back", {}, { scope: "operator.read" }); + }); + const broadcastPluginEvent = vi.fn(); + + const handle = await startPluginServices({ + registry: createRegistry([ + { + id: "failed-service", + start: (ctx) => { + acquired.add("failed-service"); + ctx.gatewayEvents?.onSessionsChanged(received); + throw new Error("start failed after acquiring resources"); + }, + stop: rollback, + }, + { id: "sibling-service", start: siblingStart }, + ]), + config: createServiceConfig(), + broadcastPluginEvent, + }); + + expect(rollback).toHaveBeenCalledOnce(); + expect(acquired.size).toBe(0); + expect(siblingStart).toHaveBeenCalledOnce(); + expect(broadcastPluginEvent).toHaveBeenCalledWith( + "plugin.plugin:test.rolled-back", + {}, + "operator.read", + ); + + queuePluginSessionsChanged({ sessionKey: "agent:main:main" }); + await Promise.resolve(); + expect(received).not.toHaveBeenCalled(); + + await handle.stop(); + expect(rollback).toHaveBeenCalledOnce(); + }); + + it("runs concurrent and repeated shutdowns through one cleanup operation", async () => { + let releaseStop: (() => void) | undefined; + const stopping = new Promise((resolve) => { + releaseStop = resolve; + }); + const stop = vi.fn(() => stopping); + const handle = await startTrackingServices({ + services: [{ id: "service", start: () => {}, stop }], + }); + + const firstStop = handle.stop(); + const secondStop = handle.stop(); + releaseStop?.(); + await Promise.all([firstStop, secondStop]); + + expect(firstStop).toBe(secondStop); + expect(stop).toHaveBeenCalledOnce(); + + await handle.stop(); + expect(stop).toHaveBeenCalledOnce(); + }); + it("binds gateway events to the owning plugin namespace and scope", async () => { const broadcastPluginEvent = vi.fn(); await startPluginServices({ @@ -445,6 +511,35 @@ describe("startPluginServices", () => { expect(stopThrows).toHaveBeenCalledOnce(); }); + it("continues starting siblings when rollback also fails", async () => { + const rollback = vi.fn(() => { + throw new Error("rollback failed"); + }); + const siblingStart = vi.fn(); + + const handle = await startTrackingServices({ + services: [ + { + id: "failed-service", + start: () => { + throw new Error("start failed"); + }, + stop: rollback, + }, + { id: "sibling-service", start: siblingStart }, + ], + }); + + expect(rollback).toHaveBeenCalledOnce(); + expect(siblingStart).toHaveBeenCalledOnce(); + expect(mockedLogger.warn).toHaveBeenCalledWith( + "plugin service stop failed (failed-service): Error: rollback failed", + ); + + await handle.stop(); + expect(rollback).toHaveBeenCalledOnce(); + }); + it("emits per-service startup trace spans and summary", async () => { const measured: string[] = []; const details: Array<{ diff --git a/src/plugins/services.ts b/src/plugins/services.ts index fdbc65cf57a9..ead9ac142b04 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -174,6 +174,17 @@ export async function startPluginServices(params: { stop?: () => void | Promise; revokeGatewayEvents: () => void; }> = []; + const stopService = async (entry: (typeof running)[number]) => { + try { + if (entry.stop) { + await withPluginHttpRouteRegistry(params.registry, () => entry.stop?.()); + } + } catch (err) { + log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); + } finally { + entry.revokeGatewayEvents(); + } + }; let failedCount = 0; for (const entry of params.registry.services) { const service = entry.service; @@ -189,6 +200,11 @@ export async function startPluginServices(params: { service: entry, gatewayEvents: scopedGatewayEvents.gatewayEvents, }); + const runningService = { + id: service.id, + stop: service.stop ? () => service.stop?.(serviceContext) : undefined, + revokeGatewayEvents: scopedGatewayEvents.revoke, + }; try { const startService = () => withPluginHttpRouteRegistry(params.registry, () => service.start(serviceContext)); @@ -197,18 +213,15 @@ export async function startPluginServices(params: { } else { await startService(); } - running.push({ - id: service.id, - stop: service.stop ? () => service.stop?.(serviceContext) : undefined, - revokeGatewayEvents: scopedGatewayEvents.revoke, - }); + running.push(runningService); } catch (err) { - scopedGatewayEvents.revoke(); failedCount += 1; const error = err as Error; log.error( `plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}`, ); + // A failed start can already own resources; revoke events only after its cleanup runs. + await stopService(runningService); } } params.startupTrace?.detail?.("sidecars.plugin-services.summary", [ @@ -217,19 +230,14 @@ export async function startPluginServices(params: { ["failedCount", failedCount], ]); + let stopPromise: Promise | undefined; return { - stop: async () => { - for (const entry of running.toReversed()) { - try { - if (entry.stop) { - await withPluginHttpRouteRegistry(params.registry, () => entry.stop?.()); - } - } catch (err) { - log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); - } finally { - entry.revokeGatewayEvents(); + stop: () => + // Store the shared promise before plugin cleanup runs so shutdown cannot start twice. + (stopPromise ??= Promise.resolve().then(async () => { + for (const entry of running.toReversed()) { + await stopService(entry); } - } - }, + })), }; } diff --git a/src/plugins/status.compatibility.integration.test.ts b/src/plugins/status.compatibility.integration.test.ts index 4ec6d95ce08a..9eaaf42c4af5 100644 --- a/src/plugins/status.compatibility.integration.test.ts +++ b/src/plugins/status.compatibility.integration.test.ts @@ -5,6 +5,7 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; import { cleanupPluginLoaderFixturesForTest, + loadOpenClawPlugins, makeTempDir, resetPluginLoaderTestStateForTest, useNoBundledPlugins, @@ -66,4 +67,38 @@ describe("plugin compatibility snapshot notices", () => { expect(buildSnapshotCompatibilityNoticeCodes(plugin)).toStrictEqual([]); }); + + it("reports actual hook-only registrations without activating cold plugin modules", () => { + const pluginDir = makeTempDir(); + const runtimeMarker = path.join(pluginDir, "runtime-loaded"); + const plugin = writePlugin({ + id: "runtime-hook-only", + dir: pluginDir, + body: `module.exports = { id: "runtime-hook-only", register(api) { require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded"); api.on("message_received", () => {}); } };\n`, + }); + const stateDir = makeTempDir(); + const config = { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + }, + }; + + withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => { + useNoBundledPlugins(); + const params = { config, workspaceDir: plugin.dir, env: process.env }; + + expect(buildPluginCompatibilitySnapshotNotices(params)).toStrictEqual([]); + expect(fs.existsSync(runtimeMarker)).toBe(false); + + const registry = loadOpenClawPlugins({ ...params, cache: false }); + expect(fs.existsSync(runtimeMarker)).toBe(true); + expect(registry.typedHooks).toEqual([ + expect.objectContaining({ pluginId: plugin.id, hookName: "message_received" }), + ]); + expect(buildPluginCompatibilitySnapshotNotices(params)).toEqual([ + expect.objectContaining({ pluginId: plugin.id, code: "hook-only" }), + ]); + }); + }); }); diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 99f4b7753252..5a38a4c0aa9c 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -15,6 +15,7 @@ import { const loadConfigMock = vi.fn(); const loadOpenClawPluginsMock = vi.fn(); +const resolveCompatibleRuntimePluginRegistryMock = vi.fn(); const loadPluginMetadataRegistrySnapshotMock = vi.fn(); const loadPluginManifestRegistryForPluginRegistryMock = vi.fn(); const loadPluginRegistrySnapshotWithMetadataMock = vi.fn(); @@ -47,6 +48,7 @@ let buildPluginDiagnosticsReport: typeof import("./status.js").buildPluginDiagno let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport; let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports; let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices; +let buildPluginCompatibilitySnapshotNotices: typeof import("./status.js").buildPluginCompatibilitySnapshotNotices; let buildPluginCompatibilityWarnings: typeof import("./status.js").buildPluginCompatibilityWarnings; let formatPluginCompatibilityNotice: typeof import("./status.js").formatPluginCompatibilityNotice; let summarizePluginCompatibility: typeof import("./status.js").summarizePluginCompatibility; @@ -62,6 +64,8 @@ vi.mock("../config/plugin-auto-enable.js", () => ({ vi.mock("./loader.js", () => ({ loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), + resolveCompatibleRuntimePluginRegistry: (...args: unknown[]) => + resolveCompatibleRuntimePluginRegistryMock(...args), })); vi.mock("./runtime/metadata-registry-loader.js", () => ({ @@ -385,6 +389,7 @@ describe("plugin status reports", () => { ({ buildAllPluginInspectReports, buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices, buildPluginDiagnosticsReport, buildPluginCompatibilityWarnings, buildPluginInspectReport, @@ -397,6 +402,7 @@ describe("plugin status reports", () => { beforeEach(() => { loadConfigMock.mockReset(); loadOpenClawPluginsMock.mockReset(); + resolveCompatibleRuntimePluginRegistryMock.mockReset(); loadPluginMetadataRegistrySnapshotMock.mockReset(); loadPluginManifestRegistryForPluginRegistryMock.mockReset(); loadPluginRegistrySnapshotWithMetadataMock.mockReset(); @@ -791,6 +797,41 @@ describe("plugin status reports", () => { }); }); + it("reuses compatible runtime hook registrations without loading cold plugin modules", () => { + const metadataPlugin = createPluginRecord({ + id: "runtime-hook-only", + name: "Runtime Hook Only", + }); + const runtimePlugin = createPluginRecord({ + id: "runtime-hook-only", + name: "Runtime Hook Only", + hookCount: 1, + }); + setSinglePluginLoadResult(metadataPlugin); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue( + createPluginLoadResult({ + plugins: [runtimePlugin], + hooks: [createCustomHook({ pluginId: runtimePlugin.id, events: ["message"] })], + }), + ); + + expect(buildPluginCompatibilitySnapshotNotices({ config: {} })).toEqual([ + createCompatibilityNotice({ pluginId: runtimePlugin.id, code: "hook-only" }), + ]); + expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledOnce(); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("does not claim hook-only warnings from an unloaded metadata-only plugin", () => { + setSinglePluginLoadResult( + createPluginRecord({ id: "cold-plugin", name: "Cold Plugin", hookCount: 0 }), + ); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(undefined); + + expect(buildPluginCompatibilitySnapshotNotices({ config: {} })).toStrictEqual([]); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + it("warns external plugins off deprecated memory embedding provider registration", () => { setSinglePluginLoadResult( createPluginRecord({ diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 440fcd1244f3..69f9b9195f1d 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -20,7 +20,7 @@ import { type PluginCapabilityEntry, type PluginInspectShape, } from "./inspect-shape.js"; -import { loadOpenClawPlugins } from "./loader.js"; +import { loadOpenClawPlugins, resolveCompatibleRuntimePluginRegistry } from "./loader.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; @@ -548,9 +548,28 @@ export function buildPluginCompatibilitySnapshotNotices(params?: { env?: NodeJS.ProcessEnv; }): PluginCompatibilityNotice[] { const report = buildPluginSnapshotReport(params); + const context = resolvePluginRuntimeLoadContext(params); + const runtimeRegistry = resolveCompatibleRuntimePluginRegistry( + buildPluginRuntimeLoadOptions(context, { activate: false }), + ); + const registeredPlugins = new Map(runtimeRegistry?.plugins.map((plugin) => [plugin.id, plugin])); + // Hook shape is a runtime registration fact. Reuse compatible live registrations without + // importing cold plugins or guessing their capabilities from a manifest-only snapshot. + const registrationReport = runtimeRegistry + ? { + ...report, + ...runtimeRegistry, + workspaceDir: report.workspaceDir, + plugins: report.plugins.map((plugin) => ({ + ...plugin, + ...registeredPlugins.get(plugin.id), + imported: plugin.imported, + })), + } + : report; return buildPluginCompatibilityNotices({ ...params, - report, + report: registrationReport, }); } diff --git a/src/utils/directive-tags.test.ts b/src/utils/directive-tags.test.ts index 23f201f4867a..009757420412 100644 --- a/src/utils/directive-tags.test.ts +++ b/src/utils/directive-tags.test.ts @@ -205,6 +205,22 @@ describe("parseInlineDirectives", () => { expect(result.text).toBe(["~~~python", " x = 1", " y = 2", "~~~"].join("\n")); }); + test.each([ + [ + "a false closing marker", + ["```python", "value = 'a b'", "``` not a close", "x = 'c d'", "```"], + ], + ["an unclosed fence", ["```python", "value = 'a b'", "x = 'c d'"]], + ["an indented closing fence", ["```python", "value = 'a b'", " ```"]], + ])("preserves canonical code fences with %s after removing directives", (_name, lines) => { + const code = lines.join("\n"); + const result = parseInlineDirectives(`[[reply_to_current]]\n[[audio_as_voice]]\n${code}`); + + expect(result.hasReplyTag).toBe(true); + expect(result.audioAsVoice).toBe(true); + expect(result.text).toBe(code); + }); + test("normalizes plain text without directives using code-fence awareness", () => { const input = "plain text with extra spaces\n\n```\n code preserved\n```"; const result = parseInlineDirectives(input); diff --git a/src/utils/directive-tags.ts b/src/utils/directive-tags.ts index 95524953a922..e9f2ddd7b5a4 100644 --- a/src/utils/directive-tags.ts +++ b/src/utils/directive-tags.ts @@ -1,6 +1,7 @@ import { expectDefined } from "@openclaw/normalization-core"; // Directive tag helpers parse inline directive tags from user text. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { parseFenceSpans } from "../../packages/markdown-core/src/fences.js"; export type InlineDirectiveParseResult = { text: string; @@ -47,13 +48,19 @@ function normalizeDirectiveWhitespace(text: string): string { const blockSentinel = createBlockSentinel(text); const blockPlaceholderRe = new RegExp(`${blockSentinel}(\\d+)${blockSentinel}`, "g"); const blocks: string[] = []; - const masked = text.replace( - /(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1[^\n]*|(?:(?:^|\n)(?: |\t)[^\n]*)+/gm, - (block) => { - blocks.push(block); - return `${blockSentinel}${blocks.length - 1}${blockSentinel}`; - }, - ); + const fenceSpans = text.includes("```") || text.includes("~~~") ? parseFenceSpans(text) : []; + let masked = ""; + let cursor = 0; + // The canonical scanner keeps false closers, indented closers, and open fences intact. + for (const span of fenceSpans) { + blocks.push(text.slice(span.start, span.end)); + masked += `${text.slice(cursor, span.start)}${blockSentinel}${blocks.length - 1}${blockSentinel}`; + cursor = span.end; + } + masked = `${masked}${text.slice(cursor)}`.replace(/(?:(?:^|\n)(?: |\t)[^\n]*)+/gm, (block) => { + blocks.push(block); + return `${blockSentinel}${blocks.length - 1}${blockSentinel}`; + }); const normalized = masked .replace(/\r\n/g, "\n") diff --git a/test/external-script-modules.d.ts b/test/external-script-modules.d.ts index 9aefd53cc4da..eaf279d009d2 100644 --- a/test/external-script-modules.d.ts +++ b/test/external-script-modules.d.ts @@ -16,6 +16,12 @@ declare module "*scripts/ui.js" { windowsVerbatimArguments?: boolean; }; }; + export function assertUiBuildOutputRoot(params?: { + rootDir?: string; + fs?: { + lstatSync(path: string): { isSymbolicLink(): boolean }; + }; + }): void; export function shouldUseCmdExeForCommand(cmd: string, platform?: NodeJS.Platform): boolean; export function resolveSpawnCall( cmd: string, diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts index 0630f8ec31ea..29dba45f5462 100644 --- a/test/plugin-npm-runtime-build.test.ts +++ b/test/plugin-npm-runtime-build.test.ts @@ -1,5 +1,12 @@ // Plugin npm runtime build tests validate plugin runtime package builds. -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + readlinkSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -29,6 +36,43 @@ function expectPluginNpmRuntimeBuildPlan( } describe("plugin npm runtime build planning", () => { + it("rejects a symlinked package dist root before building", async () => { + const syntheticRepoRoot = tempDirs.make("openclaw-plugin-runtime-output-root-"); + const packageDir = path.join(syntheticRepoRoot, "extensions", "demo"); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + path.join(syntheticRepoRoot, "package.json"), + JSON.stringify({ version: "1.0.0" }), + ); + writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "@openclaw/demo", + version: "1.0.0", + openclaw: { + compat: { pluginApi: "1.0.0" }, + extensions: ["./index.ts"], + release: { publishToNpm: true }, + }, + }), + ); + writeFileSync(path.join(packageDir, "index.ts"), "export default {};\n"); + const targetDir = path.join(syntheticRepoRoot, "live-gateway-dist"); + mkdirSync(targetDir); + writeFileSync(path.join(targetDir, "sentinel.js"), "keep\n"); + symlinkSync(targetDir, path.join(packageDir, "dist"), "dir"); + + await expect( + buildPluginNpmRuntime({ + repoRoot: syntheticRepoRoot, + packageDir, + logLevel: "silent", + }), + ).rejects.toThrow(/symbolic link/u); + expect(readFileSync(path.join(targetDir, "sentinel.js"), "utf8")).toBe("keep\n"); + expect(readlinkSync(path.join(packageDir, "dist"))).toBe(targetDir); + }); + it("plans package-local runtime entries for every publishable plugin package", () => { const packageDirs = listPublishablePluginPackageDirs({ repoRoot }); expect(packageDirs.length).toBeGreaterThan(0); diff --git a/test/scripts/bundled-plugin-assets.test.ts b/test/scripts/bundled-plugin-assets.test.ts index b9b0a64a3f2b..3e8b6e1a322f 100644 --- a/test/scripts/bundled-plugin-assets.test.ts +++ b/test/scripts/bundled-plugin-assets.test.ts @@ -223,6 +223,21 @@ describe("bundled plugin assets", () => { }); }); + it("rejects a symlinked dist root before running copy hooks", async () => { + await withPluginAssetFixture(async (rootDir) => { + const targetDir = path.join(rootDir, "live-gateway-dist"); + fs.mkdirSync(targetDir); + fs.writeFileSync(path.join(targetDir, "sentinel.js"), "keep\n"); + fs.symlinkSync(targetDir, path.join(rootDir, "dist"), "dir"); + + await expect(runBundledPluginAssetHooks({ phase: "copy", rootDir })).rejects.toThrow( + /symbolic link/u, + ); + expect(fs.readFileSync(path.join(targetDir, "sentinel.js"), "utf8")).toBe("keep\n"); + expect(fs.readlinkSync(path.join(rootDir, "dist"))).toBe(targetDir); + }); + }); + it("parses phase and plugin filters", () => { expect(parseBundledPluginAssetArgs(["--phase", "build", "--plugin=canvas"])).toEqual({ check: false, diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index bb18e4c1531c..d8f337a6d754 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -367,6 +367,22 @@ describe("bundled plugin build entries", () => { expectNoPrefixMatches(artifacts, "dist/extensions/synthetic/"); }); + it("excludes the externalized DuckDuckGo plugin from bundled artifacts", () => { + const artifacts = listBundledPluginPackArtifacts(); + + expect(artifacts).not.toContain("dist/extensions/duckduckgo/index.js"); + expect(artifacts).not.toContain("dist/extensions/duckduckgo/openclaw.plugin.json"); + expect(artifacts).not.toContain("dist/extensions/duckduckgo/package.json"); + }); + + it("excludes the externalized Voyage provider from bundled artifacts", () => { + const artifacts = listBundledPluginPackArtifacts(); + + expect(artifacts).not.toContain("dist/extensions/voyage/index.js"); + expect(artifacts).not.toContain("dist/extensions/voyage/openclaw.plugin.json"); + expect(artifacts).not.toContain("dist/extensions/voyage/package.json"); + }); + it("keeps bundled channel secret contracts on packed top-level sidecars", () => { const artifacts = listBundledPluginPackArtifacts(); const excludedPackageDirs = collectRootPackageExcludedExtensionDirs(); diff --git a/test/scripts/copy-export-html-templates.test.ts b/test/scripts/copy-export-html-templates.test.ts new file mode 100644 index 000000000000..91b293a21ffe --- /dev/null +++ b/test/scripts/copy-export-html-templates.test.ts @@ -0,0 +1,25 @@ +// Export HTML template copy tests cover generated-output root safety. +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { copyExportHtmlTemplates } from "../../scripts/copy-export-html-templates.ts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("copyExportHtmlTemplates", () => { + it("rejects a symlinked dist root without changing its target", () => { + const projectRoot = tempDirs.make("openclaw-export-html-output-root-"); + const sourceDir = path.join(projectRoot, "src", "auto-reply", "reply", "export-html"); + const targetDir = path.join(projectRoot, "live-gateway-dist"); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(sourceDir, "template.html"), "\n"); + fs.mkdirSync(targetDir); + fs.writeFileSync(path.join(targetDir, "sentinel.js"), "keep\n"); + fs.symlinkSync(targetDir, path.join(projectRoot, "dist"), "dir"); + + expect(() => copyExportHtmlTemplates({ projectRoot })).toThrow(/symbolic link/u); + expect(fs.readFileSync(path.join(targetDir, "sentinel.js"), "utf8")).toBe("keep\n"); + expect(fs.readlinkSync(path.join(projectRoot, "dist"))).toBe(targetDir); + }); +}); diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index 4134c584a91c..753fcd06801c 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -729,7 +729,7 @@ esac const expectedReleaseEntries = matrixEntries.map((entry) => entry.expected_release_entries); expect(includeFilters).toEqual([ - "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:bundled-runtime-deps,scenario:agent-cold-warm-message", + "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:agent-cold-warm-message", "scenario:fresh-install,scenario:gateway-performance,scenario:agent-cold-warm-message", "scenario:agent-cold-warm-message", ]); @@ -742,7 +742,7 @@ esac expect(runKova.run).toContain('--include "$INCLUDE_FILTERS"'); expect(runKova.run).not.toContain("for filter in $INCLUDE_FILTERS"); expect(expectedReleaseEntries).toEqual([ - "fresh-install:fresh,fresh-install:onboarded-user,bundled-runtime-deps:missing-plugin-index,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins", + "fresh-install:fresh,fresh-install:onboarded-user,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins", "fresh-install:fresh,fresh-install:onboarded-user,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins", "agent-cold-warm-message:mock-openai-provider", ]); diff --git a/test/scripts/output-root-guard.test.ts b/test/scripts/output-root-guard.test.ts new file mode 100644 index 000000000000..0a9c4db43db2 --- /dev/null +++ b/test/scripts/output-root-guard.test.ts @@ -0,0 +1,76 @@ +// Output Root Guard tests cover the shared build output-root symlink guard. +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assertRealOutputRoot } from "../../scripts/lib/output-root-guard.mjs"; +import { createScriptTestHarness } from "./test-helpers.js"; + +const { createTempDir } = createScriptTestHarness(); + +describe("assertRealOutputRoot", () => { + it("accepts a missing output root", () => { + const rootDir = createTempDir("openclaw-output-root-guard-"); + + expect(() => assertRealOutputRoot(path.join(rootDir, "dist"))).not.toThrow(); + }); + + it("propagates failures other than a missing output root", () => { + const failure = Object.assign(new Error("permission denied"), { code: "EACCES" }); + const fsImpl = { + ...fs, + lstatSync: () => { + throw failure; + }, + }; + + expect(() => assertRealOutputRoot("/unreadable/dist", { fs: fsImpl })).toThrow(failure); + }); + + it("accepts a real directory output root", () => { + const rootDir = createTempDir("openclaw-output-root-guard-"); + fs.mkdirSync(path.join(rootDir, "dist")); + + expect(() => assertRealOutputRoot(path.join(rootDir, "dist"))).not.toThrow(); + }); + + it("accepts a plain file output root", () => { + const rootDir = createTempDir("openclaw-output-root-guard-"); + const distPath = path.join(rootDir, "dist"); + fs.writeFileSync(distPath, "stale\n"); + + expect(() => assertRealOutputRoot(distPath)).not.toThrow(); + }); + + it("rejects a symlinked output root and names the remediation", () => { + const rootDir = createTempDir("openclaw-output-root-guard-"); + const targetDir = path.join(rootDir, "gateway-dist"); + fs.mkdirSync(targetDir); + const distLink = path.join(rootDir, "dist"); + fs.symlinkSync(targetDir, distLink, "dir"); + + expect(() => assertRealOutputRoot(distLink)).toThrow( + /symbolic link.*Remove the symlink or replace it with a real directory/su, + ); + expect(fs.readlinkSync(distLink)).toBe(targetDir); + }); + + it("rejects a dangling symlinked output root", () => { + const rootDir = createTempDir("openclaw-output-root-guard-"); + const distLink = path.join(rootDir, "dist"); + fs.symlinkSync(path.join(rootDir, "missing-target"), distLink, "dir"); + + expect(() => assertRealOutputRoot(distLink)).toThrow(/symbolic link/u); + expect(fs.lstatSync(distLink).isSymbolicLink()).toBe(true); + }); + + it.runIf(process.platform === "win32")("rejects an NTFS junction output root", () => { + const rootDir = createTempDir("openclaw-output-root-guard-"); + const targetDir = path.join(rootDir, "gateway-dist"); + fs.mkdirSync(targetDir); + const distLink = path.join(rootDir, "dist"); + fs.symlinkSync(targetDir, distLink, "junction"); + + expect(() => assertRealOutputRoot(distLink)).toThrow(/symbolic link/u); + expect(fs.lstatSync(distLink).isSymbolicLink()).toBe(true); + }); +}); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 34dee02c5849..975fc8de821f 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -2667,15 +2667,26 @@ describe("package artifact reuse", () => { expected_sha: "${{ needs.resolve_target.outputs.revision }}", run_buzz: true, }); - expect(workflowJob(QA_LIVE_TRANSPORTS_WORKFLOW, "run_live_buzz").if).toBe("inputs.run_buzz"); - expect( - workflowStep( - workflowJob(QA_LIVE_TRANSPORTS_WORKFLOW, "run_live_buzz"), - "Upload Buzz QA artifacts", - ).with?.name, - ).toBe( + const buzzJob = workflowJob(QA_LIVE_TRANSPORTS_WORKFLOW, "run_live_buzz"); + expect(buzzJob.if).toBe("inputs.run_buzz"); + const resolveBuzz = workflowStep(buzzJob, "Resolve Buzz QA runner"); + expect(resolveBuzz.run).toContain('runner?.commandName === "buzz"'); + expect(resolveBuzz.run).toContain("selected ref does not declare the Buzz QA runner"); + expect(workflowStep(buzzJob, "Validate required Buzz QA credential env").if).toBe( + "steps.resolve_buzz.outputs.available == 'true'", + ); + expect(workflowStep(buzzJob, "Build private QA runtime").if).toBe( + "steps.resolve_buzz.outputs.available == 'true'", + ); + expect(workflowStep(buzzJob, "Run Buzz live lane").if).toBe( + "steps.resolve_buzz.outputs.available == 'true'", + ); + expect(workflowStep(buzzJob, "Upload Buzz QA artifacts").with?.name).toBe( "${{ inputs.expected_sha != '' && format('release-qa-live-buzz-{0}-{1}', inputs.expected_sha, github.run_attempt) || format('qa-live-buzz-{0}-{1}', github.run_id, github.run_attempt) }}", ); + expect(workflowStep(buzzJob, "Upload Buzz QA artifacts").with?.path).toBe( + "${{ steps.resolve_buzz.outputs.output_dir }}", + ); }); it("runs live transport lanes nightly while release checks stay gated", () => { @@ -4499,18 +4510,14 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$? }); it("keeps every tracked repository skill visible to Git-aware syncs", () => { - const gitignore = readFileSync(".gitignore", "utf8"); const skillFiles = execFileSync("git", ["ls-files", ".agents/skills/*/SKILL.md"], { encoding: "utf8", }) .trim() - .split("\n"); - const skillDirs = skillFiles.map((path) => path.split("/").slice(0, 3).join("/")); + .split("\n") + .filter(Boolean); - for (const skillDir of skillDirs) { - expect(gitignore).toContain(`!${skillDir}/`); - expect(gitignore).toContain(`!${skillDir}/**`); - } + expect(skillFiles.length).toBeGreaterThan(0); const ignored = spawnSync("git", ["check-ignore", "--no-index", "--stdin"], { encoding: "utf8", input: `${skillFiles.join("\n")}\n`, diff --git a/test/scripts/prune-docker-plugin-dist.test.ts b/test/scripts/prune-docker-plugin-dist.test.ts new file mode 100644 index 000000000000..dd584a2c24f9 --- /dev/null +++ b/test/scripts/prune-docker-plugin-dist.test.ts @@ -0,0 +1,25 @@ +// Prune Docker Plugin Dist tests cover prune docker plugin dist script behavior. +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { pruneDockerPluginDist } from "../../scripts/prune-docker-plugin-dist.mjs"; +import { createScriptTestHarness } from "./test-helpers.js"; + +const { createTempDir } = createScriptTestHarness(); + +describe("pruneDockerPluginDist", () => { + it("refuses to prune plugin trees through a symlinked dist root", () => { + const rootDir = createTempDir("openclaw-prune-docker-dist-symlink-"); + const targetDir = path.join(rootDir, "gateway-dist"); + const pluginFile = path.join(targetDir, "extensions", "telegram", "index.js"); + fs.mkdirSync(path.dirname(pluginFile), { recursive: true }); + fs.writeFileSync(pluginFile, "export {};\n"); + const distLink = path.join(rootDir, "dist"); + fs.symlinkSync(targetDir, distLink, "dir"); + + expect(() => pruneDockerPluginDist({ cwd: rootDir, env: {} })).toThrow(/symbolic link/u); + + expect(fs.readlinkSync(distLink)).toBe(targetDir); + expect(fs.readFileSync(pluginFile, "utf8")).toBe("export {};\n"); + }); +}); diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index b227209b9a13..17ab8d27b3ff 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -229,6 +229,28 @@ describe("runtime postbuild static assets", () => { ); }); + it("validates every postbuild root before running any phase", async () => { + const rootDir = createTempDir("openclaw-runtime-postbuild-roots-"); + const distFile = path.join(rootDir, "dist", "keep.js"); + const targetDir = path.join(rootDir, "gateway-runtime"); + await fs.mkdir(path.dirname(distFile), { recursive: true }); + await fs.mkdir(targetDir); + await fs.writeFile(distFile, "keep\n"); + await fs.symlink(targetDir, path.join(rootDir, "dist-runtime"), "dir"); + + expect(() => + runRuntimePostBuild({ + cwd: rootDir, + repoRoot: rootDir, + rootDir, + timings: false, + }), + ).toThrow(/symbolic link/u); + + await expect(fs.readdir(path.join(rootDir, "dist"))).resolves.toEqual(["keep.js"]); + await expect(fs.readFile(distFile, "utf8")).resolves.toBe("keep\n"); + }); + it("preserves restored dist static assets when plugin sources are absent", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const output = "assets/viewer-runtime.js"; @@ -415,6 +437,22 @@ describe("runtime postbuild static assets", () => { await expectPathMissing(path.join(distDir, "library.js")); }); + it("refuses to rewrite stable aliases through a symlinked dist root", async () => { + const rootDir = createTempDir("openclaw-runtime-postbuild-symlink-"); + const targetDir = path.join(rootDir, "gateway-dist"); + await fs.mkdir(targetDir, { recursive: true }); + const hashedFile = path.join(targetDir, "runtime-model-auth.runtime-XyZ987.js"); + await fs.writeFile(hashedFile, "export const auth = true;\n", "utf8"); + const distLink = path.join(rootDir, "dist"); + await fs.symlink(targetDir, distLink, "dir"); + + expect(() => writeStableRootRuntimeAliases({ rootDir })).toThrow(/symbolic link/u); + + expect(await fs.readlink(distLink)).toBe(targetDir); + expect(await fs.readFile(hashedFile, "utf8")).toBe("export const auth = true;\n"); + await expectPathMissing(path.join(targetDir, "runtime-model-auth.runtime.js")); + }); + it("forwards default exports through stable and legacy aliases", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const distDir = path.join(rootDir, "dist"); diff --git a/test/scripts/stage-bundled-plugin-runtime.test.ts b/test/scripts/stage-bundled-plugin-runtime.test.ts index 05e7dcb2108c..3da0b2482e3e 100644 --- a/test/scripts/stage-bundled-plugin-runtime.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime.test.ts @@ -55,4 +55,26 @@ describe("stageBundledPluginRuntime", () => { expect(symlinkSpy).toHaveBeenCalled(); }); }); + + it("refuses to stage through a symlinked dist root", async () => { + await withTempDir(async (repoRoot) => { + const targetDir = path.join(repoRoot, "gateway-dist"); + const pluginFile = path.join(targetDir, "extensions", "acpx", "index.js"); + await fs.promises.mkdir(path.dirname(pluginFile), { recursive: true }); + await fs.promises.writeFile(pluginFile, "export {};\n", "utf8"); + const distLink = path.join(repoRoot, "dist"); + await fs.promises.symlink(targetDir, distLink, "dir"); + + expect(() => stageBundledPluginRuntime({ repoRoot })).toThrow(/symbolic link/u); + + expect(await fs.promises.readlink(distLink)).toBe(targetDir); + expect(await fs.promises.readFile(pluginFile, "utf8")).toBe("export {};\n"); + await expect(fs.promises.stat(path.join(repoRoot, "dist-runtime"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect( + fs.promises.stat(path.join(targetDir, "extensions", "node_modules")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); }); diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index 12e3b0109fe6..586d5b9cf58f 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -18,6 +18,7 @@ import { parseTsdownBuildArgs, pruneSourceCheckoutBundledPluginNodeModules, pruneStaleRootChunkFiles, + pruneStaleRuntimeSymlinks, pruneUntrackedGeneratedSourceDeclarations, resolveTsdownBuildInvocation, resolveTsdownBuildInvocations, @@ -698,6 +699,132 @@ describe("resolveTsdownBuildInvocation", () => { await expectPathMissing(nestedStaleFile); }); + it("refuses a symlinked output root with preserved children and leaves the target unchanged", async () => { + const rootDir = createTempDir("openclaw-tsdown-clean-symlink-"); + const targetDir = path.join(rootDir, "gateway-dist"); + const targetFile = path.join(targetDir, "chunk-abc123.js"); + const metadataFile = path.join(targetDir, "cli-startup-metadata.json"); + await fsPromises.mkdir(targetDir, { recursive: true }); + await fsPromises.writeFile(targetFile, "generated\n"); + await fsPromises.writeFile(metadataFile, '{"generatedBy":"test"}\n'); + const distLink = path.join(rootDir, "dist"); + await fsPromises.symlink(targetDir, distLink, "dir"); + + expect(() => + cleanTsdownOutputRoots({ + cwd: rootDir, + roots: ["dist"], + env: { OPENCLAW_PRESERVE_CLI_STARTUP_METADATA: "1" }, + }), + ).toThrow(/symbolic link/u); + + expect(fs.readlinkSync(distLink)).toBe(targetDir); + await expect(fsPromises.readFile(targetFile, "utf8")).resolves.toBe("generated\n"); + await expect(fsPromises.readFile(metadataFile, "utf8")).resolves.toBe( + '{"generatedBy":"test"}\n', + ); + }); + + it("rejects a symlink before traversing protected output children", () => { + const readdirSync = vi.fn(fs.readdirSync); + const fsImpl = { + ...fs, + lstatSync: () => ({ isSymbolicLink: () => true }), + readdirSync, + } as unknown as typeof fs; + + expect(() => + cleanTsdownOutputRoots({ + cwd: "/workspace", + roots: ["dist"], + env: { OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1" }, + fs: fsImpl, + }), + ).toThrow(/symbolic link/u); + expect(readdirSync).not.toHaveBeenCalled(); + }); + + it("validates every clean root before mutating any output", async () => { + const rootDir = createTempDir("openclaw-tsdown-clean-roots-"); + const firstRootFile = path.join(rootDir, "dist", "keep.js"); + const targetDir = path.join(rootDir, "gateway-runtime"); + await fsPromises.mkdir(path.dirname(firstRootFile), { recursive: true }); + await fsPromises.mkdir(targetDir); + await fsPromises.writeFile(firstRootFile, "keep\n"); + await fsPromises.symlink(targetDir, path.join(rootDir, "dist-runtime"), "dir"); + + expect(() => + cleanTsdownOutputRoots({ + cwd: rootDir, + roots: ["dist", "dist-runtime"], + }), + ).toThrow(/symbolic link/u); + + await expect(fsPromises.readFile(firstRootFile, "utf8")).resolves.toBe("keep\n"); + }); + + it("refuses a symlinked output root even without protected children", async () => { + const rootDir = createTempDir("openclaw-tsdown-clean-symlink-plain-"); + const targetDir = path.join(rootDir, "gateway-dist"); + const targetFile = path.join(targetDir, "stale.js"); + await fsPromises.mkdir(targetDir, { recursive: true }); + await fsPromises.writeFile(targetFile, "stale\n"); + const distLink = path.join(rootDir, "dist"); + await fsPromises.symlink(targetDir, distLink, "dir"); + + expect(() => cleanTsdownOutputRoots({ cwd: rootDir, roots: ["dist"] })).toThrow( + /symbolic link/u, + ); + + expect(fs.readlinkSync(distLink)).toBe(targetDir); + await expect(fsPromises.readFile(targetFile, "utf8")).resolves.toBe("stale\n"); + }); + + it("refuses to prune stale root chunks through a symlinked output root", async () => { + const rootDir = createTempDir("openclaw-tsdown-prune-symlink-"); + const targetDir = path.join(rootDir, "gateway-dist"); + const hashedFile = path.join(targetDir, "delegate-BPjCe4gC.js"); + await fsPromises.mkdir(targetDir, { recursive: true }); + await fsPromises.writeFile(hashedFile, "old delegate\n"); + const distLink = path.join(rootDir, "dist"); + await fsPromises.symlink(targetDir, distLink, "dir"); + + expect(() => pruneStaleRootChunkFiles({ cwd: rootDir })).toThrow(/symbolic link/u); + + expect(fs.readlinkSync(distLink)).toBe(targetDir); + await expect(fsPromises.readFile(hashedFile, "utf8")).resolves.toBe("old delegate\n"); + }); + + it("validates every chunk root before pruning any output", async () => { + const rootDir = createTempDir("openclaw-tsdown-prune-roots-"); + const firstRootFile = path.join(rootDir, "dist", "delegate-OldHash.js"); + const targetDir = path.join(rootDir, "gateway-runtime"); + await fsPromises.mkdir(path.dirname(firstRootFile), { recursive: true }); + await fsPromises.mkdir(targetDir); + await fsPromises.writeFile(firstRootFile, "keep\n"); + await fsPromises.symlink(targetDir, path.join(rootDir, "dist-runtime"), "dir"); + + expect(() => pruneStaleRootChunkFiles({ cwd: rootDir })).toThrow(/symbolic link/u); + + await expect(fsPromises.readFile(firstRootFile, "utf8")).resolves.toBe("keep\n"); + }); + + it("refuses to prune runtime overlay symlinks through a symlinked output root", async () => { + const rootDir = createTempDir("openclaw-tsdown-runtime-symlink-"); + const targetDir = path.join(rootDir, "gateway-dist"); + const pluginNodeModules = path.join(targetDir, "extensions", "telegram", "node_modules"); + await fsPromises.mkdir(pluginNodeModules, { recursive: true }); + const markerFile = path.join(pluginNodeModules, "keep.js"); + await fsPromises.writeFile(markerFile, "keep\n"); + const distLink = path.join(rootDir, "dist"); + await fsPromises.symlink(targetDir, distLink, "dir"); + + expect(() => pruneStaleRuntimeSymlinks({ cwd: rootDir })).toThrow(/symbolic link/u); + + expect(fs.readlinkSync(distLink)).toBe(targetDir); + await expect(fsPromises.readFile(markerFile, "utf8")).resolves.toBe("keep\n"); + }); + it("preserves existing package declarations when tsdown DTS output is skipped", async () => { const rootDir = createTempDir("openclaw-tsdown-clean-skip-dts-"); const declarationFile = path.join( diff --git a/test/scripts/ui.test.ts b/test/scripts/ui.test.ts index 406e24dcc50e..f9b320e858d2 100644 --- a/test/scripts/ui.test.ts +++ b/test/scripts/ui.test.ts @@ -3,13 +3,17 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { + assertUiBuildOutputRoot, isDirectScriptExecution, resolvePnpmSpawnCall, resolveSpawnCall, shouldUseCmdExeForCommand, } from "../../scripts/ui.js"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); // writeFileSync creates the file before its content lands, so an existence // poll can observe an empty file on loaded runners; wait for bytes instead. @@ -55,6 +59,18 @@ async function waitForExit( } describe("scripts/ui windows spawn behavior", () => { + it("rejects a symlinked dist root before launching a UI build", () => { + const rootDir = tempDirs.make("openclaw-ui-output-root-"); + const targetDir = path.join(rootDir, "live-gateway-dist"); + fs.mkdirSync(targetDir); + fs.writeFileSync(path.join(targetDir, "sentinel.js"), "keep\n"); + fs.symlinkSync(targetDir, path.join(rootDir, "dist"), "dir"); + + expect(() => assertUiBuildOutputRoot({ rootDir })).toThrow(/symbolic link/u); + expect(fs.readFileSync(path.join(targetDir, "sentinel.js"), "utf8")).toBe("keep\n"); + expect(fs.readlinkSync(path.join(rootDir, "dist"))).toBe(targetDir); + }); + it("wraps Windows command launchers with cmd.exe without enabling shell mode", () => { expect( shouldUseCmdExeForCommand("C:\\Users\\dev\\AppData\\Local\\pnpm\\pnpm.CMD", "win32"), diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 6e64deaf133f..a958e9d65392 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -935,6 +935,7 @@ export type ModelCatalogEntry = { available?: boolean; contextWindow?: number; reasoning?: boolean; + supportsTools?: boolean; agentRuntime?: import("../../../packages/gateway-protocol/src/schema.js").GatewayAgentRuntime; input?: Array<"text" | "image" | "document">; apiKeySupported?: boolean; diff --git a/ui/src/components/cron-jobs-pagination.node.test.ts b/ui/src/components/cron-jobs-pagination.node.test.ts new file mode 100644 index 000000000000..0d4636419746 --- /dev/null +++ b/ui/src/components/cron-jobs-pagination.node.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment node +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("cron jobs pagination style ownership", () => { + it("loads shared pagination styles through the component on either route", async () => { + const [componentSource, sharedStyles, cronStyles, agentsSource, cronSource] = await Promise.all( + [ + readFile(new URL("./cron-jobs-pagination.ts", import.meta.url), "utf8"), + readFile(new URL("../styles/cron-jobs-pagination.css", import.meta.url), "utf8"), + readFile(new URL("../styles/cron.css", import.meta.url), "utf8"), + readFile(new URL("../pages/agents/panels-status-files.ts", import.meta.url), "utf8"), + readFile(new URL("../pages/cron/view.ts", import.meta.url), "utf8"), + ], + ); + + expect(componentSource).toContain('import "../styles/cron-jobs-pagination.css"'); + expect(agentsSource).toContain('from "../../components/cron-jobs-pagination.ts"'); + expect(cronSource).toContain('from "../../components/cron-jobs-pagination.ts"'); + + for (const selector of [".cron-table__footer", ".cron-load-more"]) { + expect(sharedStyles).toContain(`${selector} {`); + expect(cronStyles).not.toContain(`${selector} {`); + } + }); +}); diff --git a/ui/src/components/cron-jobs-pagination.ts b/ui/src/components/cron-jobs-pagination.ts new file mode 100644 index 000000000000..33ee364e20f4 --- /dev/null +++ b/ui/src/components/cron-jobs-pagination.ts @@ -0,0 +1,34 @@ +import { html, nothing } from "lit"; +import { t } from "../i18n/index.ts"; +import "../styles/cron-jobs-pagination.css"; + +export function renderCronJobsPagination(params: { + jobsShown: number; + jobsTotal: number; + hasMore: boolean; + loading: boolean; + loadingMore: boolean; + onLoadMore: () => void; +}) { + return html` + + `; +} diff --git a/ui/src/components/session-data-controller-events.ts b/ui/src/components/session-data-controller-events.ts new file mode 100644 index 000000000000..350a6d7d7895 --- /dev/null +++ b/ui/src/components/session-data-controller-events.ts @@ -0,0 +1,104 @@ +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; +import type { RouteId } from "../app-route-paths.ts"; +import type { ApplicationContext } from "../app/context.ts"; +import { readPresenceEntries, type PresencePayload } from "../app/user-profile.ts"; +import { createSessionEventRefreshCoordinator } from "../lib/sessions/event-refresh-coordinator.ts"; +import { readSessionChangedEvent } from "../lib/sessions/reconcile.ts"; +import { normalizeAgentId, parseAgentSessionKey } from "../lib/sessions/session-key.ts"; +import type { SidebarSessionStatusFilter } from "./app-sidebar-session-types.ts"; +import type { SessionDataControllerHost } from "./session-data-controller-catalog.ts"; + +type SessionGatewayEventOwner = { + presencePayload: PresencePayload | undefined; + readonly sessionScopeGeneration: number; + handleSessionCatalogHostEvent(payload: unknown): void; + handleSessionCatalogPresence(payload: unknown): void; + refreshSidebarSessions(agentId?: string): Promise; + requestSessionDataUpdate(): void; +}; + +type FilteredSessionRefreshScope = { + agentId: string; + archivedFilter: SidebarSessionStatusFilter; + client: ApplicationContext["gateway"]["snapshot"]["client"]; + generation: number; +}; + +export function subscribeSessionDataGatewayEvents( + gateway: ApplicationContext["gateway"], + owner: SessionGatewayEventOwner, + host: Pick< + SessionDataControllerHost, + "expandedAgentId" | "isConnected" | "sidebarSessionStatusFilter" + >, +): () => void { + let subscribed = true; + let refreshScope: FilteredSessionRefreshScope | null = null; + const scopeIsCurrent = () => + refreshScope !== null && + subscribed && + host.isConnected && + gateway.snapshot.phase === "connected" && + gateway.snapshot.client === refreshScope.client && + owner.sessionScopeGeneration === refreshScope.generation && + host.sidebarSessionStatusFilter() === refreshScope.archivedFilter && + normalizeAgentId(host.expandedAgentId()) === refreshScope.agentId; + const refreshCoordinator = createSessionEventRefreshCoordinator({ + canRefresh: scopeIsCurrent, + refresh: () => + refreshScope ? owner.refreshSidebarSessions(refreshScope.agentId) : Promise.resolve(), + }); + const unsubscribe = gateway.subscribeEvents((event) => { + if (event.event === "sessions.catalog.host") { + owner.handleSessionCatalogHostEvent(event.payload); + return; + } + if (event.event === "sessions.changed") { + const archivedFilter = host.sidebarSessionStatusFilter(); + if (archivedFilter === "active") { + return; + } + const agentId = normalizeAgentId(host.expandedAgentId()); + const sessionEvent = readSessionChangedEvent(event.payload); + const payloadAgentId = asNullableRecord(event.payload)?.agentId; + const eventAgentId = + sessionEvent?.agentId ?? + parseAgentSessionKey(sessionEvent?.key)?.agentId ?? + (typeof payloadAgentId === "string" ? payloadAgentId : undefined); + if (eventAgentId && normalizeAgentId(eventAgentId) !== agentId) { + return; + } + const nextScope: FilteredSessionRefreshScope = { + agentId, + archivedFilter, + client: gateway.snapshot.client, + generation: owner.sessionScopeGeneration, + }; + if ( + refreshScope && + (refreshScope.agentId !== nextScope.agentId || + refreshScope.archivedFilter !== nextScope.archivedFilter || + refreshScope.client !== nextScope.client || + refreshScope.generation !== nextScope.generation) + ) { + refreshCoordinator.reset(); + } + refreshScope = nextScope; + // Canonical debounce/max-wait and single-flight behavior belong to one + // coordinator; scope checks retire stale agents, filters, and clients. + refreshCoordinator.schedule(); + return; + } + if (event.event === "presence") { + const presence = readPresenceEntries(event.payload); + owner.presencePayload = presence ? { presence } : undefined; + owner.requestSessionDataUpdate(); + owner.handleSessionCatalogPresence(event.payload); + } + }); + return () => { + subscribed = false; + refreshCoordinator.dispose(); + unsubscribe(); + }; +} diff --git a/ui/src/components/session-data-controller-pagination.ts b/ui/src/components/session-data-controller-pagination.ts index 7c8cdbff86bf..e01f814b0fce 100644 --- a/ui/src/components/session-data-controller-pagination.ts +++ b/ui/src/components/session-data-controller-pagination.ts @@ -24,15 +24,27 @@ type SidebarSessionPaginationOwner = { export type SidebarSessionPaginationState = { listRequestToken: symbol | null; pageRequestToken: symbol | null; + loadedScope?: { + agentId: string; + archivedFilter: SidebarSessionStatusFilter; + generation: number; + }; }; function publishSidebarSessionResult( owner: SidebarSessionPaginationOwner, agentId: string, result: SessionsListResult | null, + archivedFilter: SidebarSessionStatusFilter, + generation: number, ) { owner.sessionsResult = result; owner.sessionsAgentId = agentId; + owner.sidebarSessionPaginationState.loadedScope = { + agentId: normalizeAgentId(agentId), + archivedFilter, + generation, + }; if (result) { owner.sessionRowsByAgent[normalizeAgentId(agentId)] = result.sessions; for (const row of result.sessions) { @@ -84,10 +96,19 @@ export async function refreshSidebarSessions( const state = owner.sidebarSessionPaginationState; state.pageRequestToken = null; const archivedFilter = statusFilter(); + const loadedScope = state.loadedScope; + const preservesVisibleScope = + archivedFilter !== "active" && + loadedScope?.generation === owner.sessionScopeGeneration && + loadedScope.archivedFilter === archivedFilter && + loadedScope.agentId === normalizeAgentId(agentId) && + normalizeAgentId(owner.sessionsAgentId ?? "") === loadedScope.agentId; const options = { agentId, archivedFilter, - limit: SIDEBAR_AGENT_SESSION_LIST_LIMIT, + limit: preservesVisibleScope + ? Math.max(SIDEBAR_AGENT_SESSION_LIST_LIMIT, owner.sessionsResult?.sessions.length ?? 0) + : SIDEBAR_AGENT_SESSION_LIST_LIMIT, includeGlobal: true, includeUnknown: true, configuredAgentsOnly: true, @@ -120,7 +141,7 @@ export async function refreshSidebarSessions( try { const result = await context.sessions.list(options); if (isCurrent()) { - publishSidebarSessionResult(owner, agentId, result); + publishSidebarSessionResult(owner, agentId, result, archivedFilter, generation); } } catch (error) { if (isCurrent()) { @@ -204,7 +225,13 @@ export async function loadMoreSidebarSessions( const page = await context.sessions.list(options); if (page && isCurrent()) { - publishSidebarSessionResult(owner, agentId, appendSidebarSessionResults(previous, page)); + publishSidebarSessionResult( + owner, + agentId, + appendSidebarSessionResults(previous, page), + archivedFilter, + generation, + ); } } catch (error) { if (isCurrent()) { diff --git a/ui/src/components/session-data-controller.event-refresh.test.ts b/ui/src/components/session-data-controller.event-refresh.test.ts new file mode 100644 index 000000000000..ddf97810e0d7 --- /dev/null +++ b/ui/src/components/session-data-controller.event-refresh.test.ts @@ -0,0 +1,308 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { ApplicationContext } from "../app/context.ts"; +import type { SessionCapability } from "../lib/sessions/index.ts"; +import type { SessionDataControllerHost } from "./session-data-controller-catalog.ts"; +import { SessionDataController } from "./session-data-controller.ts"; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function createFilteredSessionController(statusFilter: "archived" | "all", rowCount = 1) { + vi.stubGlobal("document", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + visibilityState: "visible", + }); + vi.stubGlobal("addEventListener", vi.fn()); + vi.stubGlobal("removeEventListener", vi.fn()); + + const rows = Array.from({ length: rowCount }, (_, index) => ({ + key: index === 0 ? "agent:main:remote-change" : `agent:main:session-${index}`, + kind: "direct" as const, + updatedAt: index + 1, + })); + const list = vi.fn(async (options?: Parameters[0]) => { + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 60; + const sessions = rows.slice(offset, offset + limit); + const nextOffset = offset + sessions.length; + const hasMore = nextOffset < rows.length; + return { + ts: 1, + path: "", + count: sessions.length, + totalCount: rows.length, + nextOffset: hasMore ? nextOffset : null, + hasMore, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions, + }; + }); + let eventListener: ((event: { event: string; payload: unknown }) => void) | undefined; + const sessions = { + state: { + result: null, + agentId: "main", + modelOverrides: {}, + loading: false, + error: null, + deletedSessions: [], + groups: [], + sectionOrder: [], + }, + canonicalListRevision: 1, + subscribe: () => () => undefined, + subscribeCreated: () => () => undefined, + groupsLoad: () => Promise.resolve(), + list, + } as unknown as SessionCapability; + const gateway = { + snapshot: { + phase: "connected", + client: {} as GatewayBrowserClient, + hello: null, + assistantAgentId: "main", + sessionKey: "agent:main:main", + }, + subscribe: () => () => undefined, + subscribeEvents(listener: (event: { event: string; payload: unknown }) => void) { + eventListener = listener; + return () => { + eventListener = undefined; + }; + }, + }; + const context = { gateway, sessions } as unknown as ApplicationContext; + let selectedAgentId = "main"; + let selectedStatusFilter = statusFilter; + const host = { + isConnected: true, + connected: true, + sessionDataContext: context, + addController: () => undefined, + removeController: () => undefined, + requestUpdate: () => undefined, + updateComplete: Promise.resolve(true), + dismissTransientMenus: () => false, + expandedAgentId: () => selectedAgentId, + promoteCreatedSession: () => undefined, + selectedAgentIdForSessions: () => selectedAgentId, + sidebarSessionStatusFilter: () => selectedStatusFilter, + querySelector: () => null, + } satisfies SessionDataControllerHost; + const controller = new SessionDataController(host); + + return { + controller, + list, + selectAgent: (agentId: string) => { + selectedAgentId = agentId; + }, + selectStatusFilter: (nextStatusFilter: "archived" | "all") => { + selectedStatusFilter = nextStatusFilter; + controller.resetForStatusFilter(nextStatusFilter); + }, + publishSessionChanged: (payload: Record = {}) => { + eventListener?.({ + event: "sessions.changed", + payload: { + sessionKey: "agent:main:remote-change", + agentId: "main", + reason: "archive", + ...payload, + }, + }); + }, + }; +} + +describe("filtered sidebar session event refresh", () => { + it.each(["archived", "all"] as const)( + "refreshes the %s list once for duplicate remote session events", + async (statusFilter) => { + vi.useFakeTimers(); + const { controller, list, publishSessionChanged } = + createFilteredSessionController(statusFilter); + controller.hostConnected(); + await Promise.resolve(); + await Promise.resolve(); + list.mockClear(); + + publishSessionChanged(); + publishSessionChanged(); + await vi.advanceTimersByTimeAsync(199); + expect(list).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(list).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "main", archivedFilter: statusFilter }), + ); + expect(controller.sessionsResult?.sessions[0]?.key).toBe("agent:main:remote-change"); + controller.hostDisconnected(); + }, + ); + + it.each(["archived", "all"] as const)( + "preserves every loaded %s page when a remote event replaces the list", + async (statusFilter) => { + vi.useFakeTimers(); + const { controller, list, publishSessionChanged } = createFilteredSessionController( + statusFilter, + 120, + ); + controller.hostConnected(); + await Promise.resolve(); + await Promise.resolve(); + expect(controller.sessionsResult?.sessions).toHaveLength(60); + + await controller.loadMoreSidebarSessions(); + expect(controller.sessionsResult?.sessions).toHaveLength(120); + list.mockClear(); + + publishSessionChanged(); + await vi.advanceTimersByTimeAsync(200); + + expect(list).toHaveBeenCalledOnce(); + expect(list).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "main", archivedFilter: statusFilter, limit: 120 }), + ); + expect(controller.sessionsResult?.sessions).toHaveLength(120); + controller.hostDisconnected(); + }, + ); + + it("ignores session changes belonging to another agent", async () => { + vi.useFakeTimers(); + const { controller, list, publishSessionChanged } = createFilteredSessionController("all"); + controller.hostConnected(); + await Promise.resolve(); + await Promise.resolve(); + list.mockClear(); + + publishSessionChanged({ sessionKey: "agent:research:remote-change", agentId: "research" }); + await vi.advanceTimersByTimeAsync(1_000); + + expect(list).not.toHaveBeenCalled(); + controller.hostDisconnected(); + }); + + it("retires queued refreshes when the selected agent changes", async () => { + vi.useFakeTimers(); + const { controller, list, publishSessionChanged, selectAgent } = + createFilteredSessionController("archived"); + controller.hostConnected(); + await Promise.resolve(); + await Promise.resolve(); + list.mockClear(); + + publishSessionChanged(); + selectAgent("research"); + await vi.advanceTimersByTimeAsync(200); + + expect(list).not.toHaveBeenCalled(); + controller.hostDisconnected(); + }); + + it("does not carry another filtered list's page depth across a filter change", async () => { + const { controller, list, selectStatusFilter } = createFilteredSessionController( + "archived", + 120, + ); + controller.hostConnected(); + await Promise.resolve(); + await Promise.resolve(); + await controller.loadMoreSidebarSessions(); + expect(controller.sessionsResult?.sessions).toHaveLength(120); + list.mockClear(); + + selectStatusFilter("all"); + await controller.refreshSidebarSessions(); + + expect(list).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "main", archivedFilter: "all", limit: 60 }), + ); + expect(controller.sessionsResult?.sessions).toHaveLength(60); + controller.hostDisconnected(); + }); + + it("bounds refresh latency while same-agent events continue arriving", async () => { + vi.useFakeTimers(); + const { controller, list, publishSessionChanged } = createFilteredSessionController("all"); + controller.hostConnected(); + await Promise.resolve(); + await Promise.resolve(); + list.mockClear(); + + publishSessionChanged(); + for (let index = 0; index < 5; index += 1) { + await vi.advanceTimersByTimeAsync(199); + publishSessionChanged(); + } + expect(list).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5); + + expect(list).toHaveBeenCalledOnce(); + controller.hostDisconnected(); + }); + + it("cancels a queued filtered refresh when its gateway subscription disconnects", async () => { + vi.useFakeTimers(); + const { controller, list, publishSessionChanged } = createFilteredSessionController("archived"); + controller.hostConnected(); + await Promise.resolve(); + await Promise.resolve(); + list.mockClear(); + + publishSessionChanged(); + controller.hostDisconnected(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(list).not.toHaveBeenCalled(); + }); + + it("serializes duplicate session events while a filtered refresh is in flight", async () => { + vi.useFakeTimers(); + const { controller, list, publishSessionChanged } = createFilteredSessionController("archived"); + controller.hostConnected(); + await Promise.resolve(); + await Promise.resolve(); + list.mockClear(); + let resolveFirstRefresh!: (value: Awaited>) => void; + const firstRefresh = new Promise>>((resolve) => { + resolveFirstRefresh = resolve; + }); + const refreshedPage = { + ts: 2, + path: "", + count: 1, + totalCount: 1, + nextOffset: null, + hasMore: false, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [{ key: "agent:main:remote-change", kind: "direct" as const, updatedAt: 2 }], + }; + list.mockImplementationOnce(async () => await firstRefresh).mockResolvedValue(refreshedPage); + + publishSessionChanged(); + await vi.advanceTimersByTimeAsync(200); + expect(list).toHaveBeenCalledOnce(); + + publishSessionChanged(); + await Promise.resolve(); + publishSessionChanged(); + await vi.advanceTimersByTimeAsync(200); + expect(list).toHaveBeenCalledOnce(); + + resolveFirstRefresh(refreshedPage); + await vi.advanceTimersByTimeAsync(0); + + expect(list).toHaveBeenCalledTimes(2); + expect(controller.sessionsResult?.sessions[0]?.updatedAt).toBe(2); + controller.hostDisconnected(); + }); +}); diff --git a/ui/src/components/session-data-controller.ts b/ui/src/components/session-data-controller.ts index 4f9097d6b0b7..4854acd77bdd 100644 --- a/ui/src/components/session-data-controller.ts +++ b/ui/src/components/session-data-controller.ts @@ -43,6 +43,7 @@ import { type SessionDataControllerHost, updateSessionCatalogData as updateSessionCatalogDataForHost, } from "./session-data-controller-catalog.ts"; +import { subscribeSessionDataGatewayEvents } from "./session-data-controller-events.ts"; import { loadMoreSidebarSessions as loadMoreSidebarSessionPage, refreshSidebarSessions as refreshSidebarSessionPage, @@ -133,19 +134,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog ) .effect( () => this.context?.gateway, - (gateway) => - gateway.subscribeEvents((event) => { - if (event.event === "sessions.catalog.host") { - this.handleSessionCatalogHostEvent(event.payload); - return; - } - if (event.event === "presence") { - const presence = readPresenceEntries(event.payload); - this.presencePayload = presence ? { presence } : undefined; - this.notify(); - this.handleSessionCatalogPresence(event.payload); - } - }), + (gateway) => subscribeSessionDataGatewayEvents(gateway, this, host), ) .watch( () => this.context?.agents, @@ -688,6 +677,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog resetForStatusFilter(statusFilter: SidebarSessionStatusFilter): void { this.sidebarSessionPaginationState.listRequestToken = null; this.sidebarSessionPaginationState.pageRequestToken = null; + this.sidebarSessionPaginationState.loadedScope = undefined; this.sessionsLoading = false; this.visibleSessionLimits.clear(); this.childSessionRowsByParent = {}; diff --git a/ui/src/components/sessions-hub-header.browser.test.ts b/ui/src/components/sessions-hub-header.browser.test.ts index 125449e517ef..7bffa070c03b 100644 --- a/ui/src/components/sessions-hub-header.browser.test.ts +++ b/ui/src/components/sessions-hub-header.browser.test.ts @@ -1,5 +1,5 @@ import { html, render } from "lit"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; import "../styles.css"; import { renderSessionsHubHeader } from "./sessions-hub-header.ts"; @@ -11,7 +11,11 @@ async function useViewport(width: number, height = 800) { await page.viewport(width, height); } -async function mount(active: "sessions" | "worktrees", withActions: boolean) { +async function mount( + active: "sessions" | "worktrees", + withActions: boolean, + onSelect: (tab: "sessions" | "worktrees") => void = () => undefined, +) { const container = document.createElement("div"); container.style.width = "calc(100vw - 32px)"; container.style.maxWidth = "1120px"; @@ -21,7 +25,7 @@ async function mount(active: "sessions" | "worktrees", withActions: boolean) { active, title: "Threads", actions: withActions ? html`
Agent selector
` : undefined, - onSelect: () => undefined, + onSelect, }), container, ); @@ -79,10 +83,35 @@ describe.skipIf(!hasBrowserLayout)("Sessions hub header browser layout", () => { }, ); - it("keeps the page header hidden on mobile", async () => { + it("keeps session navigation and operational headers available on mobile", async () => { await useViewport(414, 800); - const sessions = await mount("sessions", true); + const onSelect = vi.fn(); + const sessions = await mount("sessions", true, onSelect); const header = sessions.querySelector(".hub-page-header"); - expect(getComputedStyle(header!).display).toBe("none"); + const tabs = sessions.querySelector(".sessions-hub-tabs"); + const actions = sessions.querySelector(".hub-page-header__actions"); + expect(getComputedStyle(header!).display).toBe("grid"); + expect(tabs?.getBoundingClientRect().width).toBeGreaterThan(0); + expect(actions?.getBoundingClientRect().width).toBeGreaterThan(0); + + const worktreesTab = sessions.querySelector("#sessions-tab-worktrees"); + expect(worktreesTab?.getBoundingClientRect().width).toBeGreaterThan(0); + worktreesTab?.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 })); + expect(onSelect).toHaveBeenCalledWith("worktrees"); + + const operationalHeader = document.createElement("section"); + operationalHeader.className = "content-header"; + operationalHeader.innerHTML = ''; + document.body.append(operationalHeader); + expect(getComputedStyle(operationalHeader).display).toBe("flex"); + expect( + operationalHeader.querySelector("button")?.getBoundingClientRect().width, + ).toBeGreaterThan(0); + + const chatContent = document.createElement("main"); + chatContent.className = "content content--chat"; + chatContent.innerHTML = '
'; + document.body.append(chatContent); + expect(getComputedStyle(chatContent.querySelector(".content-header")!).display).toBe("none"); }); }); diff --git a/ui/src/e2e/chat-only-model.e2e.test.ts b/ui/src/e2e/chat-only-model.e2e.test.ts new file mode 100644 index 000000000000..8e45decdaff6 --- /dev/null +++ b/ui/src/e2e/chat-only-model.e2e.test.ts @@ -0,0 +1,168 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); +const sessionKey = "agent:main:main"; +const proofDir = + process.env.OPENCLAW_CAPTURE_UI_PROOF === "1" + ? path.join(process.cwd(), ".artifacts", "control-ui-e2e", "chat-only-model") + : null; + +const models = [ + { + id: "qwen3-8b", + name: "Qwen3 8B", + provider: "lmstudio", + contextWindow: 32_768, + supportsTools: false, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + contextWindow: 200_000, + supportsTools: true, + }, +]; + +function sessionsList(model: string, modelProvider: string) { + return { + count: 1, + defaults: { + contextTokens: 32_768, + model: "qwen3-8b", + modelProvider: "lmstudio", + thinkingDefault: "off", + thinkingLevels: [{ id: "off", label: "off" }], + }, + path: "", + sessions: [ + { + contextTokens: 32_768, + displayName: "Local chat", + hasActiveRun: false, + key: sessionKey, + kind: "direct", + label: "Local chat", + model, + modelProvider, + status: "done", + totalTokens: 0, + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }; +} + +suite.define(() => { + it("explains chat-only models and keeps model switching as the recovery path", async () => { + if (proofDir) { + await mkdir(proofDir, { recursive: true }); + } + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + agentModel: "lmstudio/qwen3-8b", + models, + sessionKey, + methodResponses: { + "sessions.list": sessionsList("qwen3-8b", "lmstudio"), + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + + const main = page.getByRole("main"); + const composer = main.locator(".agent-chat__composer-shell"); + const picker = composer.locator('[data-chat-model-select="true"]'); + const badge = picker.locator(".chat-controls__model-capability-badge"); + + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable"); + await expect.poll(async () => (await badge.textContent())?.trim()).toBe("Chat only"); + await expect.poll(() => picker.getAttribute("aria-label")).toContain("Chat only"); + + if (proofDir) { + await composer.screenshot({ + animations: "disabled", + path: path.join(proofDir, "01-desktop-chat-only-composer.png"), + }); + } + + await picker.click(); + const localOption = composer.locator('[data-chat-model-option="lmstudio/qwen3-8b"]'); + const openAiOption = composer.locator('[data-chat-model-option="openai/gpt-5.5"]'); + await expect + .poll(async () => (await localOption.textContent())?.replace(/\s+/g, " ").trim()) + .toContain("32.8k context · Chat only"); + await expect + .poll(async () => (await openAiOption.textContent())?.includes("Chat only")) + .toBe(false); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "02-desktop-model-picker.png"), + }); + } + + await composer.locator('[data-chat-model-provider="openai"]').click(); + await openAiOption.click(); + const patch = await gateway.waitForRequest("sessions.patch"); + expect(patch.params).toMatchObject({ key: sessionKey, model: "openai/gpt-5.5" }); + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("available"); + await expect.poll(() => badge.count()).toBe(0); + + const pickerDetails = composer.locator("details.chat-controls__model"); + if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) { + await picker.click(); + } + await composer.locator('[data-chat-model-provider="lmstudio"]').click(); + await localOption.click(); + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable"); + if (await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open)) { + await picker.click(); + } + await page.setViewportSize({ height: 844, width: 390 }); + await expect.poll(() => picker.isVisible()).toBe(true); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "03-mobile-chat-only-model.png"), + }); + } + + if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) { + await picker.click(); + } + const menu = composer.locator(".chat-controls__inline-select-menu--combined"); + await expect + .poll(async () => { + const box = await menu.boundingBox(); + return box !== null && box.x >= 0 && box.x + box.width <= 390; + }) + .toBe(true); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "04-mobile-model-picker.png"), + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/e2e/model-alias-display.e2e.test.ts b/ui/src/e2e/model-alias-display.e2e.test.ts index 95a176352487..01f106a4a082 100644 --- a/ui/src/e2e/model-alias-display.e2e.test.ts +++ b/ui/src/e2e/model-alias-display.e2e.test.ts @@ -143,8 +143,9 @@ suite.define(() => { expect(response?.status()).toBe(200); await gateway.waitForRequest("agents.list"); await gateway.waitForRequest("config.get"); - const modelRequest = await gateway.waitForRequest("models.list"); - expect(modelRequest.params).toEqual({ view: "configured" }); + const modelRequest = await gateway.waitForRequest("chat.metadata"); + expect(modelRequest.params).toEqual({ agentId: "main" }); + expect(await gateway.getRequests("models.list")).toHaveLength(0); const select = page.locator("select.settings-select").first(); await select.waitFor({ state: "visible", timeout: 10_000 }); diff --git a/ui/src/e2e/profile-page.e2e.test.ts b/ui/src/e2e/profile-page.e2e.test.ts index 91d4874114f5..6c1f2d609ed0 100644 --- a/ui/src/e2e/profile-page.e2e.test.ts +++ b/ui/src/e2e/profile-page.e2e.test.ts @@ -365,4 +365,59 @@ describeControlUiE2e("Control UI profile page mocked Gateway E2E", () => { await context.close(); } }); + + it("keeps identity refresh single-flight and retries after a failed request", async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["users.self"], + presenceUsers: testPresenceUsers, + methodResponses: { + "users.self": { profile: testProfile }, + }, + }); + + try { + const response = await page.goto(`${server.baseUrl}settings/profile`); + expect(response?.status()).toBe(200); + + const refresh = page.locator(".profile-refresh"); + await gateway.waitForRequest("users.self"); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(1); + await expect.poll(() => refresh.isDisabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refreshing…" [disabled]'); + + await refresh.evaluate((element) => { + const button = element as HTMLButtonElement; + button.click(); + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(1); + + await gateway.rejectDeferred("users.self", { message: "identity unavailable" }); + await page.getByText("identity unavailable", { exact: true }).waitFor({ timeout: 10_000 }); + await expect.poll(() => refresh.isEnabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refresh"'); + + await gateway.deferNext("users.self"); + await refresh.click(); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(2); + await expect.poll(() => refresh.isDisabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refreshing…" [disabled]'); + + await refresh.evaluate((element) => { + (element as HTMLButtonElement).dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(2); + + await gateway.resolveDeferred("users.self", { profile: testProfile }); + const displayName = page.locator('.identity-name-control input[type="text"]'); + await displayName.waitFor({ timeout: 10_000 }); + await expect(displayName.inputValue()).resolves.toBe(testProfile.displayName); + await expect.poll(() => refresh.isEnabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refresh"'); + } finally { + await context.close(); + } + }); }); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 544ff72a38ca..117a25cfc816 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4902,6 +4902,9 @@ export const en: TranslationMap = { fastHelp: "Fast responses finish sooner and can use more of your usage limits.", speedUnsupported: "Speed control is not supported for this model.", contextWindow: "{count} context", + chatOnly: "Chat only", + chatOnlyHelp: + "This model can chat, but it cannot use tools. Choose another model for files, commands, web, or media tasks.", providerModels: "{provider} models", resetReasoning: "Reset to default ({level})", useDefaultReasoning: "Use default reasoning ({level})", diff --git a/ui/src/lib/chat/message-normalizer.test.ts b/ui/src/lib/chat/message-normalizer.test.ts index 75668e23a92a..dc4acc5e030b 100644 --- a/ui/src/lib/chat/message-normalizer.test.ts +++ b/ui/src/lib/chat/message-normalizer.test.ts @@ -432,6 +432,86 @@ describe("message-normalizer", () => { ]); }); + it("preserves paragraph breaks and code indentation before an assistant attachment", () => { + const text = [ + "Here is the code.", + "", + "```python", + "def run():", + " if ready:", + " return True", + "```", + "", + "The attachment is ready.", + ].join("\n"); + + expect( + normalizeMessage({ + role: "assistant", + content: `${text}\nMEDIA:https://example.com/image.png`, + }).content, + ).toEqual([ + { type: "text", text }, + { + type: "attachment", + attachment: { + url: "https://example.com/image.png", + kind: "image", + label: "image.png", + mimeType: "image/png", + }, + }, + ]); + }); + + it.each(["", " ", "\t"])( + "preserves a %j paragraph separator around an assistant attachment", + (whitespace) => { + expect( + normalizeMessage({ + role: "assistant", + content: `First paragraph\n${whitespace}\nMEDIA:https://example.com/image.png\n${whitespace}\nSecond paragraph`, + }).content, + ).toEqual([ + { type: "text", text: "First paragraph\n" }, + { + type: "attachment", + attachment: { + url: "https://example.com/image.png", + kind: "image", + label: "image.png", + mimeType: "image/png", + }, + }, + { type: "text", text: "Second paragraph" }, + ]); + }, + ); + + it("preserves canonical code fences after removing reply and audio directives", () => { + const code = ["```python", "value = 'a b'", "``` not a close", "other = 'c d'", "```"].join( + "\n", + ); + + expect( + normalizeMessage({ + role: "assistant", + content: `[[reply_to_current]]\n[[audio_as_voice]]\n${code}\nMEDIA:https://example.com/image.png`, + }).content, + ).toEqual([ + { type: "text", text: code }, + { + type: "attachment", + attachment: { + url: "https://example.com/image.png", + kind: "image", + label: "image.png", + mimeType: "image/png", + }, + }, + ]); + }); + it("marks media-only audio attachments as voice notes when audio_as_voice is present", () => { const result = normalizeMessage({ role: "assistant", diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index 50fc5c9d4423..40d87efc2e66 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -412,7 +412,8 @@ function expandTextContent(text: string): { replyTarget = { kind: "current" }; } if (directives.text) { - parts.push({ type: "text", text: directives.text }); + const normalizedText = directives.text + (segment.text.endsWith("\n") ? "\n" : ""); + parts.push({ type: "text", text: normalizedText }); } } for (const preview of extracted.previews) { diff --git a/ui/src/lib/sessions/event-refresh-coordinator.ts b/ui/src/lib/sessions/event-refresh-coordinator.ts new file mode 100644 index 000000000000..ce6277c88196 --- /dev/null +++ b/ui/src/lib/sessions/event-refresh-coordinator.ts @@ -0,0 +1,93 @@ +const SESSION_EVENT_REFRESH_DEBOUNCE_MS = 200; +const SESSION_EVENT_REFRESH_MAX_WAIT_MS = 1_000; + +type SessionEventRefreshCoordinatorOptions = { + canRefresh: () => boolean; + refresh: () => Promise; +}; + +/** Canonical bounded event refresh policy shared by session-list owners. */ +export function createSessionEventRefreshCoordinator( + options: SessionEventRefreshCoordinatorOptions, +) { + let timer: ReturnType | null = null; + let deadline: number | null = null; + let inFlight: Promise | null = null; + let trailing = false; + let generation = 0; + let disposed = false; + + const clearTimer = () => { + if (timer !== null) { + globalThis.clearTimeout(timer); + timer = null; + } + deadline = null; + }; + + const start = () => { + if (disposed || !options.canRefresh()) { + return; + } + if (inFlight) { + trailing = true; + return; + } + const operationGeneration = generation; + const operation = options.refresh().catch(() => undefined); + const pending = operation.finally(() => { + if (generation !== operationGeneration || inFlight !== pending) { + return; + } + inFlight = null; + if (trailing) { + trailing = false; + start(); + } + }); + inFlight = pending; + }; + + return { + schedule() { + if (disposed || !options.canRefresh()) { + return; + } + const now = Date.now(); + deadline ??= now + SESSION_EVENT_REFRESH_MAX_WAIT_MS; + if (timer !== null) { + globalThis.clearTimeout(timer); + } + const delay = Math.min(SESSION_EVENT_REFRESH_DEBOUNCE_MS, Math.max(0, deadline - now)); + timer = globalThis.setTimeout(() => { + timer = null; + deadline = null; + start(); + }, delay); + }, + flush() { + if (timer === null) { + return; + } + clearTimer(); + start(); + }, + absorb() { + clearTimer(); + trailing = false; + }, + reset() { + clearTimer(); + trailing = false; + inFlight = null; + generation += 1; + }, + dispose() { + clearTimer(); + trailing = false; + inFlight = null; + generation += 1; + disposed = true; + }, + }; +} diff --git a/ui/src/lib/sessions/index.event-refresh.test.ts b/ui/src/lib/sessions/index.event-refresh.test.ts index 60eae3fc9be4..ebf45ab9c320 100644 --- a/ui/src/lib/sessions/index.event-refresh.test.ts +++ b/ui/src/lib/sessions/index.event-refresh.test.ts @@ -7,13 +7,16 @@ import { createSessionCapability } from "./index.ts"; const SESSION_EVENT_REFRESH_DEBOUNCE_MS = 200; const SESSION_EVENT_REFRESH_MAX_WAIT_MS = 1_000; -function sessionsResult(ts: number): SessionsListResult { +function sessionsResult( + ts: number, + sessions: SessionsListResult["sessions"] = [], +): SessionsListResult { return { ts, path: "", - count: 0, + count: sessions.length, defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], + sessions, }; } @@ -56,6 +59,49 @@ function createHarness(request: GatewayBrowserClient["request"]) { } describe("event-driven session list refresh", () => { + it("retains every loaded page when a session event replaces the canonical list", async () => { + vi.useFakeTimers(); + const rows = Array.from({ length: 120 }, (_, index) => ({ + key: `agent:main:session-${index}`, + kind: "direct" as const, + updatedAt: index + 1, + })); + const request = vi.fn(async (method: string, params?: { limit?: number; offset?: number }) => { + if (method !== "sessions.list") { + throw new Error(`Unexpected request: ${method}`); + } + const offset = params?.offset ?? 0; + const limit = params?.limit ?? 50; + const page = rows.slice(offset, offset + limit); + const hasMore = offset + page.length < rows.length; + return { + ...sessionsResult(offset + 1, page), + totalCount: rows.length, + nextOffset: hasMore ? offset + page.length : null, + hasMore, + }; + }); + const { sessions, emitEvent } = createHarness( + request as unknown as GatewayBrowserClient["request"], + ); + + try { + await sessions.refresh({ agentId: "main", limit: 60, force: true }); + await sessions.refresh({ agentId: "main", limit: 60, offset: 60, append: true, force: true }); + expect(sessions.state.result?.sessions).toHaveLength(120); + + emitEvent(sessionChangedEvent("agent:main:session-0")); + await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS); + + expect(request.mock.calls[2]?.[1]).toMatchObject({ agentId: "main", limit: 120 }); + expect(request.mock.calls[2]?.[1]).not.toHaveProperty("offset"); + expect(sessions.state.result?.sessions).toHaveLength(120); + } finally { + sessions.dispose(); + vi.useRealTimers(); + } + }); + it("clears a recreated session's prior deletion before the debounced refresh", async () => { vi.useFakeTimers(); const key = "agent:main:recreated-thread"; diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 8abc495bb911..063e890ecbee 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -40,6 +40,7 @@ import { type SessionCreateParams, } from "./create.ts"; import { readSessionCustomGroupNames, readSidebarSectionOrder } from "./custom-groups.ts"; +import { createSessionEventRefreshCoordinator } from "./event-refresh-coordinator.ts"; import { scopedAgentListParamsForSession, type SessionArchivedFilter } from "./navigation.ts"; import type { SessionPatch, SessionPatchOptions, SessionPatchRoute } from "./patch.ts"; import { @@ -106,9 +107,6 @@ export const DEFAULT_SESSION_LIST_QUERY = { limit: 50, } as const satisfies SessionListOptions; -const SESSION_EVENT_REFRESH_DEBOUNCE_MS = 200; -const SESSION_EVENT_REFRESH_MAX_WAIT_MS = 1_000; - type SessionRefreshOptions = SessionListOptions & { force?: boolean; // Sidebar startup hydration must not block session creation or drop the open session. @@ -729,8 +727,6 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil let inFlight: Promise | null = null; let queuedExplicitRefresh: SessionRefreshOptions | null = null; let eventRefreshQueued = false; - let eventRefreshTimer: ReturnType | null = null; - let eventRefreshDeadline: number | null = null; let canonicalListRevision = 0; let disposed = false; let connectionEpoch = 0; @@ -922,6 +918,17 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil result && append && requestOptions.offset && state.result ? appendSessionResults(state.result, result) : result; + if (append && nextResult && !backgroundHydrate) { + // Event replacements restart at page one, so retain the visible page + // depth instead of silently dropping every previously appended row. + lastListOptions = { + ...durableListOptions, + limit: Math.max( + durableListOptions.limit ?? DEFAULT_SESSION_LIST_QUERY.limit, + nextResult.sessions.length, + ), + }; + } if (backgroundHydrate && nextResult) { const currentKey = gateway.snapshot.sessionKey?.trim(); if (currentKey) { @@ -976,16 +983,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil } }; - const clearEventRefreshTimer = () => { - if (eventRefreshTimer !== null) { - globalThis.clearTimeout(eventRefreshTimer); - eventRefreshTimer = null; - } - eventRefreshDeadline = null; - }; - const absorbPendingEventRefresh = () => { - clearEventRefreshTimer(); + eventRefreshCoordinator.absorb(); eventRefreshQueued = false; }; @@ -1062,30 +1061,12 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil return startRefresh({ ...lastListOptions, force: true }); }; - const flushEventRefresh = () => { - if (eventRefreshTimer === null) { - return; - } - clearEventRefreshTimer(); - void refreshFromEvent(); - }; - - const scheduleEventRefresh = () => { - const now = Date.now(); - eventRefreshDeadline ??= now + SESSION_EVENT_REFRESH_MAX_WAIT_MS; - if (eventRefreshTimer !== null) { - globalThis.clearTimeout(eventRefreshTimer); - } - const delay = Math.min( - SESSION_EVENT_REFRESH_DEBOUNCE_MS, - Math.max(0, eventRefreshDeadline - now), - ); - eventRefreshTimer = globalThis.setTimeout(() => { - eventRefreshTimer = null; - eventRefreshDeadline = null; - void refreshFromEvent(); - }, delay); - }; + const eventRefreshCoordinator = createSessionEventRefreshCoordinator({ + canRefresh: () => + gateway.snapshot.phase === "connected" && gateway.snapshot.client !== null && !disposed, + refresh: refreshFromEvent, + }); + const flushEventRefresh = () => eventRefreshCoordinator.flush(); const handleVisibilityChange = () => { if (document.visibilityState === "hidden") { @@ -1848,7 +1829,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil connectionConnected = connected; if (connectionChanged) { const hadPullRequestSummaries = pullRequestSummaries.size > 0; - clearEventRefreshTimer(); + eventRefreshCoordinator.reset(); connectionEpoch += 1; if (previousClient) { resetGatewaySessionMessageSubscriptionCoordinator(previousClient); @@ -1976,7 +1957,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil // Gateway lists are filtered and windowed. Events cannot preserve server // membership or ordering, so the coalesced refresh remains canonical. Only // events debounce, with a max wait; explicit refreshes and page exit flush it. - scheduleEventRefresh(); + eventRefreshCoordinator.schedule(); } }); @@ -2038,6 +2019,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil // Page teardown must start the trailing refresh synchronously before the // disposed guard makes the capability inert. flushEventRefresh(); + eventRefreshCoordinator.dispose(); if (observesPageLifecycle) { document.removeEventListener("visibilitychange", handleVisibilityChange); globalThis.removeEventListener("pagehide", flushEventRefresh); diff --git a/ui/src/lib/skills/config-mutations.ts b/ui/src/lib/skills/config-mutations.ts new file mode 100644 index 000000000000..92207b6c8065 --- /dev/null +++ b/ui/src/lib/skills/config-mutations.ts @@ -0,0 +1,47 @@ +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { RuntimeConfigCapability } from "../config/index.ts"; + +export type SkillConfigMutationOwner = Pick; + +type SkillConfigPatch = { skillKey: string; enabled?: boolean; apiKey?: string }; + +export function normalizeSkillApiKeyReplacement(value: string | undefined): string | undefined { + const apiKey = value?.trim(); + // Blank skills.update API keys explicitly clear stored credentials; this UI only replaces them. + return apiKey || undefined; +} + +export async function runSkillConfigMutation( + owner: SkillConfigMutationOwner, + expectedClient: GatewayBrowserClient, + patch: SkillConfigPatch, +): Promise { + let requestError: Error | undefined; + // Settings autosave and skills.update persist the same config; one owner + // prevents a pending draft from restoring an older skill credential/toggle. + const mutation = await owner.runExternalMutation(async (client) => { + if (client !== expectedClient) { + throw new Error("Connection changed before the skill update started."); + } + try { + return await client.request("skills.update", patch); + } catch (error) { + requestError = error instanceof Error ? error : new Error(String(error)); + throw requestError; + } + }); + if (!mutation.ok) { + throw requestError ?? new Error(mutation.error); + } + return mutation.refresh.ok ? null : mutation.refresh.error; +} + +export function skillConfigMutationSuccess( + message: string, + refreshError: string | null, +): { kind: "success"; message: string } { + return { + kind: "success", + message: refreshError ? `${message}\n${refreshError}` : message, + }; +} diff --git a/ui/src/lib/skills/index.test.ts b/ui/src/lib/skills/index.test.ts index 3a766d4e4256..fc932f00b543 100644 --- a/ui/src/lib/skills/index.test.ts +++ b/ui/src/lib/skills/index.test.ts @@ -3,6 +3,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it, vi } from "vitest"; import { waitForFast } from "../../test-helpers/wait-for.ts"; +import { createRuntimeConfigCapability } from "../config/index.ts"; import { installFromClawHub, installSkill, @@ -37,6 +38,23 @@ function createState(): { state: SkillsState; request: ReturnType { + try { + return { + ok: true, + value: await task(expectDefined(state.client, "connected skill mutation client")), + refresh: { ok: true }, + }; + } catch (error) { + return { + ok: false, + reason: "error", + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }, skillsAgentId: null, skillsAgentRevision: 0, skillsLoading: false, @@ -706,7 +724,7 @@ describe("skill mutations", () => { { name: "saves API keys and reports success", run: async (state: SkillsState) => { - state.skillEdits.github = "sk-test"; + state.skillEdits.github = " sk-test "; await saveSkillApiKey(state, "github"); }, expectedRequest: ["skills.update", { skillKey: "github", apiKey: "sk-test" }], @@ -740,6 +758,88 @@ describe("skill mutations", () => { expect(state.skillsError).toBeNull(); }); + it.each([undefined, "", " ", "\t\n"])( + "does not clear an existing API key when its replacement is blank: %j", + async (editValue) => { + const { state, request } = createState(); + if (editValue !== undefined) { + state.skillEdits.github = editValue; + } + + await saveSkillApiKey(state, "github"); + + expect(request).not.toHaveBeenCalled(); + expect(state.skillMessages.github).toBeUndefined(); + expect(state.skillOperation).toBeNull(); + }, + ); + + it("serializes skill changes after pending settings drafts and refreshes both owners", async () => { + const { state, request } = createState(); + const methods: string[] = []; + let storedConfig: Record = { count: 1 }; + let hash = "hash-1"; + request.mockImplementation(async (method, params) => { + methods.push(method); + if (method === "config.get") { + return { + config: storedConfig, + raw: JSON.stringify(storedConfig), + hash, + valid: true, + issues: [], + }; + } + if (method === "config.set") { + storedConfig = JSON.parse((params as { raw: string }).raw) as Record; + hash = "hash-2"; + return { hash }; + } + if (method === "skills.update") { + storedConfig = { ...storedConfig, skillEnabled: true }; + hash = "hash-3"; + return {}; + } + return { workspaceDir: "/tmp/workspace", managedSkillsDir: "/tmp/skills", skills: [] }; + }); + const client = expectDefined(state.client, "connected skill mutation client"); + const runtimeConfig = createRuntimeConfigCapability({ + snapshot: { client, phase: "connected", sessionKey: "main" }, + subscribe: () => () => undefined, + }); + state.runtimeConfig = runtimeConfig; + await runtimeConfig.ensureLoaded(); + methods.length = 0; + + try { + runtimeConfig.patchForm(["count"], 2); + await updateSkillEnabled(state, "github", true); + + expect(methods).toEqual(["config.set", "skills.update", "config.get", "skills.status"]); + expect(runtimeConfig.state.configForm).toEqual({ count: 2, skillEnabled: true }); + expect(state.skillMessages.github).toEqual({ kind: "success", message: "Skill enabled" }); + } finally { + runtimeConfig.dispose(); + } + }); + + it("reports a committed skill update when its configuration refresh fails", async () => { + const { state, request } = createState(); + state.runtimeConfig.runExternalMutation = async (task) => ({ + ok: true, + value: await task(expectDefined(state.client, "connected skill mutation client")), + refresh: { ok: false, error: "Configuration refresh failed" }, + }); + mockSkillMutationRequests(request); + + await updateSkillEnabled(state, "github", true); + + expect(state.skillMessages.github).toEqual({ + kind: "success", + message: "Skill enabled\nConfiguration refresh failed", + }); + }); + it.each([ { name: "skill update blocks ClawHub install", diff --git a/ui/src/lib/skills/index.ts b/ui/src/lib/skills/index.ts index 5a7b02c17020..277d77116fd8 100644 --- a/ui/src/lib/skills/index.ts +++ b/ui/src/lib/skills/index.ts @@ -9,6 +9,12 @@ import type { SkillStatusEntry, SkillStatusReport, } from "../../api/types.ts"; +import { + normalizeSkillApiKeyReplacement, + runSkillConfigMutation, + skillConfigMutationSuccess, + type SkillConfigMutationOwner, +} from "./config-mutations.ts"; export type ClawHubSearchResult = { score: number; @@ -78,6 +84,7 @@ export type ClawHubSkillSecurityVerdict = { type SkillsState = { client: GatewayBrowserClient | null; connected: boolean; + runtimeConfig: SkillConfigMutationOwner; skillsAgentId: string | null; skillsAgentRevision: number; skillsLoading: boolean; @@ -561,22 +568,28 @@ async function runSkillMutation( export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) { await runSkillMutation(state, skillKey, async (client) => { - await client.request("skills.update", { skillKey, enabled }); - return { - kind: "success", - message: enabled ? "Skill enabled" : "Skill disabled", - }; + const refreshError = await runSkillConfigMutation(state.runtimeConfig, client, { + skillKey, + enabled, + }); + return skillConfigMutationSuccess(enabled ? "Skill enabled" : "Skill disabled", refreshError); }); } export async function saveSkillApiKey(state: SkillsState, skillKey: string) { + const apiKey = normalizeSkillApiKeyReplacement(state.skillEdits[skillKey]); + if (!apiKey) { + return; + } await runSkillMutation(state, skillKey, async (client) => { - const editValue = state.skillEdits[skillKey] ?? ""; - await client.request("skills.update", { skillKey, apiKey: editValue }); - return { - kind: "success", - message: `API key saved — stored in openclaw.json (skills.entries.${skillKey})`, - }; + const refreshError = await runSkillConfigMutation(state.runtimeConfig, client, { + skillKey, + apiKey, + }); + return skillConfigMutationSuccess( + `API key saved — stored in openclaw.json (skills.entries.${skillKey})`, + refreshError, + ); }); } diff --git a/ui/src/pages/agents/agents-page.test.ts b/ui/src/pages/agents/agents-page.test.ts index 9c9a4e167f02..fc2bb748024d 100644 --- a/ui/src/pages/agents/agents-page.test.ts +++ b/ui/src/pages/agents/agents-page.test.ts @@ -5,12 +5,13 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { AgentsFilesListResult, AgentsListResult, + CronJob, ModelCatalogEntry, ToolsEffectiveResult, } from "../../api/types.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; import type { AgentsPanel } from "../../lib/agents/panels.ts"; -import * as chatModels from "../chat/models.ts"; +import { loadCronJobsPage, type CronState } from "../../lib/cron/index.ts"; import type { AgentsRouteData } from "./route.ts"; import "./agents-page.ts"; @@ -30,7 +31,9 @@ type TestAgentsPage = HTMLElement & { toolsEffectiveLoading: boolean; toolsEffectiveResult: ToolsEffectiveResult | null; chatModelCatalog: ModelCatalogEntry[]; + chatModelCatalogError: string | null; chatModelCatalogRequest: unknown; + cron: CronState; requestGeneration: number; routeDataInitialized: boolean; subscriptions: { @@ -42,6 +45,9 @@ type TestAgentsPage = HTMLElement & { applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot, sourceChanged: boolean) => void; ensureAgentIdentities: () => void; loadActivePanelData: () => void; + refreshCron: () => Promise; + requestUpdate: () => void; + runCronTask: (task: (cronState: CronState) => Promise) => Promise; loadEffectiveToolsForAgent: (agentId: string) => void; loadAgentFiles: (agentId: string, force?: boolean) => Promise; }; @@ -82,6 +88,21 @@ function files(agentId: string, workspace: string): AgentsFilesListResult { return { agentId, workspace, files: [] }; } +function cronJob(id: string, agentId?: string): CronJob { + return { + id, + ...(agentId ? { agentId } : {}), + name: `Scheduled job ${id}`, + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + } as CronJob; +} + const agentsList: AgentsListResult = { defaultId: "main", mainKey: "main", @@ -139,7 +160,7 @@ function pageContext( } describe("AgentsPage gateway lifecycle", () => { - it("loads the configured model catalog once for the overview model picker", async () => { + it("loads the selected agent's configured model catalog once for the overview model picker", async () => { const models = [ { id: "claude-opus-4-8", @@ -160,7 +181,71 @@ describe("AgentsPage gateway lifecycle", () => { await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(models)); expect(request).toHaveBeenCalledOnce(); - expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }); + expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }); + }); + + it("caches separate configured model catalogs for the default and worker agents", async () => { + const defaultModels = [ + { id: "default-model", name: "Default account model", provider: "openai" }, + ]; + const workerModels = [ + { id: "worker-model", name: "Worker private model", provider: "anthropic" }, + ]; + const request = vi.fn(async (_method: string, params?: { agentId?: string }) => ({ + models: params?.agentId === "worker" ? workerModels : defaultModels, + })); + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "overview" } as AgentsRouteData; + page.client = { request } as unknown as GatewayBrowserClient; + page.connected = true; + page.agentsSelectedId = "main"; + + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(defaultModels)); + + page.agentsSelectedId = "worker"; + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(workerModels)); + + page.agentsSelectedId = "main"; + page.loadActivePanelData(); + expect(page.chatModelCatalog).toEqual(defaultModels); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(1, "chat.metadata", { agentId: "main" }); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "worker" }); + }); + + it("rejects a stale default-agent catalog after switching to a worker agent", async () => { + const defaultModels = [ + { id: "default-model", name: "Default account model", provider: "openai" }, + ]; + const workerModels = [ + { id: "worker-model", name: "Worker private model", provider: "anthropic" }, + ]; + const defaultResult = deferred<{ models: ModelCatalogEntry[] }>(); + const request = vi.fn((_method: string, params?: { agentId?: string }) => + params?.agentId === "worker" + ? Promise.resolve({ models: workerModels }) + : defaultResult.promise, + ); + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "overview" } as AgentsRouteData; + page.client = { request } as unknown as GatewayBrowserClient; + page.connected = true; + page.agentsSelectedId = "main"; + + page.loadActivePanelData(); + page.agentsSelectedId = "worker"; + page.loadActivePanelData(); + + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(workerModels)); + defaultResult.resolve({ models: defaultModels }); + await defaultResult.promise; + await Promise.resolve(); + + expect(page.chatModelCatalog).toEqual(workerModels); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "worker" }); }); it("rejects an old-client model catalog after the Gateway client changes", async () => { @@ -215,7 +300,7 @@ describe("AgentsPage gateway lifecycle", () => { expect(page.chatModelCatalog).toEqual(nextModels); expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenNthCalledWith(2, "models.list", { view: "configured" }); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "main" }); }); it("refreshes a settled model catalog after a same-client reconnect", async () => { @@ -242,34 +327,238 @@ describe("AgentsPage gateway lifecycle", () => { await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(nextModels)); expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenNthCalledWith(2, "models.list", { view: "configured" }); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "main" }); }); - it("handles a model catalog failure and retries without a stale request", async () => { + it("surfaces a rejected agent-scoped metadata RPC and retries without marking an empty catalog loaded", async () => { const models = [{ id: "new", name: "Opus 4.8", alias: "opus", provider: "anthropic" }]; - const loadModels = vi - .spyOn(chatModels, "loadModels") + const request = vi + .fn() .mockRejectedValueOnce(new Error("model catalog unavailable")) - .mockResolvedValueOnce(models); + .mockResolvedValueOnce({ models }); const page = document.createElement("openclaw-agents-page") as TestAgentsPage; page.routeData = { panel: "overview" } as AgentsRouteData; - page.client = { request: vi.fn() } as unknown as GatewayBrowserClient; + page.client = { request } as unknown as GatewayBrowserClient; page.connected = true; page.agentsSelectedId = "main"; - try { - page.loadActivePanelData(); - await vi.waitFor(() => expect(page.chatModelCatalogRequest).toBeNull()); - expect(page.chatModelCatalog).toEqual([]); + page.loadActivePanelData(); + await vi.waitFor(() => { + expect(page.chatModelCatalogError).toBe("model catalog unavailable"); + expect(page.chatModelCatalogRequest).toBeNull(); + }); + expect(page.chatModelCatalog).toEqual([]); - page.loadActivePanelData(); - await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(models)); + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(models)); - expect(loadModels).toHaveBeenCalledTimes(2); - expect(loadModels).toHaveBeenLastCalledWith(page.client, { refresh: true }); - } finally { - loadModels.mockRestore(); - } + expect(page.chatModelCatalogError).toBeNull(); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "main" }); + }); + + it("requests the selected agent's implicit default cron job before the first 50 unrelated jobs", async () => { + const unrelatedJobs = Array.from({ length: 50 }, (_, index) => + cronJob(`other-${index}`, "other"), + ); + const globalNextWakeAtMs = Date.now() + 60_000; + const scopedNextWakeAtMs = globalNextWakeAtMs + 3_600_000; + const implicitDefaultJob = { + ...cronJob("default-job"), + state: { nextRunAtMs: scopedNextWakeAtMs }, + }; + const request = vi.fn(async (method: string, params?: { agentId?: string; limit?: number }) => { + if (method === "cron.status") { + return { enabled: true, jobs: 51, nextWakeAtMs: globalNextWakeAtMs }; + } + if (method === "cron.list") { + const scoped = params?.agentId === "main"; + return { + jobs: scoped ? [implicitDefaultJob] : unrelatedJobs, + total: scoped ? 1 : 51, + offset: 0, + hasMore: !scoped, + }; + } + throw new Error(`Unexpected gateway method: ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + + await vi.waitFor(() => { + expect(page.cron.cronJobs).toEqual([implicitDefaultJob]); + expect(page.cron.cronScopedTotal).toBe(1); + expect(page.cron.cronScopedNextWakeAtMs).toBe(scopedNextWakeAtMs); + }); + expect(page.cron.cronStatus).toEqual({ + enabled: true, + jobs: 51, + nextWakeAtMs: globalNextWakeAtMs, + }); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "main", limit: 50, offset: 0 }), + ); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "main", limit: 1, enabled: "enabled" }), + ); + }); + + it("loads the selected agent's remaining cron jobs after preserving the first-page total", async () => { + const jobs = Array.from({ length: 50 }, (_, index) => cronJob(`main-${index}`, "main")); + const lastJob = cronJob("main-50", "main"); + const request = vi.fn( + async (method: string, params?: { agentId?: string; limit?: number; offset?: number }) => { + if (method === "cron.status") { + return { enabled: true, jobs: 80, nextWakeAtMs: null }; + } + if (params?.limit === 1) { + return { jobs: [jobs[0]], total: 51 }; + } + if (params?.offset === 50) { + return { jobs: [lastJob], total: 51, offset: 50, nextOffset: null, hasMore: false }; + } + return { jobs, total: 51, offset: 0, nextOffset: 50, hasMore: true }; + }, + ); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + + await vi.waitFor(() => { + expect(page.cron.cronJobs).toHaveLength(50); + expect(page.cron.cronJobsTotal).toBe(51); + expect(page.cron.cronScopedTotal).toBe(51); + }); + expect(page.cron.cronJobsHasMore).toBe(true); + + await page.runCronTask((cronState) => + loadCronJobsPage(cronState, { append: true, tableFilters: true }), + ); + + expect(page.cron.cronJobs).toHaveLength(51); + expect(page.cron.cronJobs.at(-1)).toEqual(lastJob); + expect(page.cron.cronJobsTotal).toBe(51); + expect(page.cron.cronScopedTotal).toBe(51); + expect(page.cron.cronJobsHasMore).toBe(false); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "main", limit: 50, offset: 50 }), + ); + }); + + it("reloads cron jobs when the selected agent changes", async () => { + const request = vi.fn(async (method: string, params?: { agentId?: string }) => { + if (method === "cron.status") { + return { enabled: true, jobs: 2, nextWakeAtMs: null }; + } + return { jobs: [cronJob(`${params?.agentId}-job`, params?.agentId)], total: 1 }; + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.cron.cronJobs[0]?.id).toBe("main-job")); + + page.agentsSelectedId = "other"; + page.loadActivePanelData(); + expect(page.cron.cronJobs).toEqual([]); + + await vi.waitFor(() => expect(page.cron.cronJobs[0]?.id).toBe("other-job")); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "other" }), + ); + }); + + it("keeps an in-flight scoped cron request attached to a same-client gateway snapshot", async () => { + const job = cronJob("same-client-job", "main"); + const pendingJobs = deferred<{ jobs: CronJob[]; total: number }>(); + const request = vi.fn((method: string, params?: { limit?: number }) => { + if (method === "cron.status") { + return Promise.resolve({ enabled: true, jobs: 1, nextWakeAtMs: null }); + } + if (params?.limit === 50) { + return pendingJobs.promise; + } + return Promise.resolve({ jobs: [job], total: 1 }); + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.cron.cronLoading).toBe(true)); + const inFlightState = page.cron; + + page.applyGatewaySnapshot(snapshot(client), false); + expect(page.cron).toBe(inFlightState); + + pendingJobs.resolve({ jobs: [job], total: 1 }); + await vi.waitFor(() => { + expect(page.cron.cronJobs).toEqual([job]); + expect(page.cron.cronLoading).toBe(false); + }); + }); + + it("immediately publishes cron loading and ignores a second refresh while the first is pending", async () => { + const job = cronJob("double-refresh-job", "main"); + const pendingJobs = deferred<{ jobs: CronJob[]; total: number }>(); + const request = vi.fn((method: string, params?: { limit?: number }) => { + if (method === "cron.status") { + return Promise.resolve({ enabled: true, jobs: 1, nextWakeAtMs: null }); + } + if (params?.limit === 50) { + return pendingJobs.promise; + } + return Promise.resolve({ jobs: [job], total: 1 }); + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.client = client; + page.connected = true; + page.cron = { ...page.cron, client, connected: true, cronAgentId: "main" }; + const requestUpdate = vi.spyOn(page, "requestUpdate"); + + const firstRefresh = page.refreshCron(); + expect(page.cron.cronLoading).toBe(true); + expect(requestUpdate).toHaveBeenCalled(); + + await page.refreshCron(); + expect( + request.mock.calls.filter( + ([method, params]) => method === "cron.list" && params?.limit === 50, + ), + ).toHaveLength(1); + + pendingJobs.resolve({ jobs: [job], total: 1 }); + await firstRefresh; + + expect(page.cron.cronLoading).toBe(false); + expect(page.cron.cronJobs).toEqual([job]); }); it("preserves matching initial route data, then resets it on provider replacement", () => { diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts index 8061ff625e87..706d7ed7b7bb 100644 --- a/ui/src/pages/agents/agents-page.ts +++ b/ui/src/pages/agents/agents-page.ts @@ -36,14 +36,15 @@ import { currentConfigObject, findAgentConfigEntryIndex } from "../../lib/config import { createInitialCronState, loadCronJobsPage, + loadCronScopeStats, loadCronStatus, runCronJob, + type CronState, } from "../../lib/cron/index.ts"; import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { normalizeStringEntries } from "../../lib/string-coerce.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; -import { loadModels } from "../chat/models.ts"; import { loadAgentFileContent, saveAgentFile } from "./files.ts"; import { resetIdentityDraft, @@ -91,6 +92,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { @state() toolsEffectiveError: string | null = null; @state() toolsEffectiveResult: ToolsEffectiveResult | null = null; @state() chatModelCatalog: ModelCatalogEntry[] = []; + @state() chatModelCatalogError: string | null = null; @state() agentFilesLoading = false; @state() agentFilesError: string | null = null; @state() agentFilesList: AgentsFilesListResult | null = null; @@ -121,10 +123,12 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private hasBoundSessions = false; private sessionsSource: ApplicationContext["sessions"] | null = null; private chatModelCatalogClient: GatewayBrowserClient | null = null; - private chatModelCatalogRefreshRequired = false; + private chatModelCatalogAgentId: string | null = null; + private readonly chatModelCatalogByAgentId = new Map(); private chatModelCatalogRequest: { client: GatewayBrowserClient; generation: number; + agentId: string; } | null = null; private normalizedLocation = ""; private readonly subscriptions = new SubscriptionsController(this) @@ -290,12 +294,13 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { this.syncGatewayState(snapshot); if (forceReset || (!initialBind && clientChanged)) { this.resetForClientChange(); - this.chatModelCatalogRefreshRequired = forceReset && !clientChanged; } else if (!initialBind && connectionChanged) { this.invalidateTransientRequests(); this.chatModelCatalog = []; this.chatModelCatalogClient = null; - this.chatModelCatalogRefreshRequired = true; + this.chatModelCatalogAgentId = null; + this.chatModelCatalogByAgentId.clear(); + this.chatModelCatalogError = null; } this.ensureInitialData(); } @@ -303,11 +308,11 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private syncGatewayState(snapshot: ApplicationGatewaySnapshot) { this.client = snapshot.client; this.connected = snapshot.phase === "connected"; - this.cron = { - ...this.cron, - client: snapshot.client, - connected: snapshot.phase === "connected", - }; + if (this.cron.client !== this.client || this.cron.connected !== this.connected) { + // In-flight cron loaders mutate their captured state; same-client + // snapshots must retain it or loading never clears in the visible state. + this.cron = { ...this.cron, client: this.client, connected: this.connected }; + } } private syncAgentState(agents = this.context.agents) { @@ -360,12 +365,10 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { this.agentsSelectedId = null; this.chatModelCatalog = []; this.chatModelCatalogClient = null; - this.chatModelCatalogRefreshRequired = false; + this.chatModelCatalogAgentId = null; + this.chatModelCatalogByAgentId.clear(); + this.chatModelCatalogError = null; this.resetSelectionState(); - this.cron = createInitialCronState({ - client: this.client, - connected: this.connected, - }); } private resetForAgentsSourceChange() { @@ -553,39 +556,68 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { void this.context.channels.refresh(false); return; } - if (this.agentsPanel === "cron" && !this.cron.cronLoading && !this.cron.cronStatus) { - void this.refreshCron(); + if (this.agentsPanel === "cron") { + if (this.cron.cronAgentId !== agentId) { + this.cron = createInitialCronState({ + client: this.client, + connected: this.connected, + }); + this.cron.cronAgentId = agentId; + } + if (!this.cron.cronLoading && !this.cron.cronStatus) { + void this.refreshCron(); + } } } private ensureModelCatalog() { const client = this.client; - if (!client || !this.connected || this.chatModelCatalogClient === client) { + const agentId = this.resolveSelectedAgentId(); + if (!client || !this.connected || !agentId) { return; } + if (this.chatModelCatalogClient === client) { + const cached = this.chatModelCatalogByAgentId.get(agentId); + if (cached) { + this.chatModelCatalog = cached; + this.chatModelCatalogAgentId = agentId; + this.chatModelCatalogError = null; + return; + } + } const generation = this.requestGeneration; const previousRequest = this.chatModelCatalogRequest; - if (previousRequest?.client === client && previousRequest.generation === generation) { + if ( + previousRequest?.client === client && + previousRequest.generation === generation && + previousRequest.agentId === agentId + ) { return; } - const request = { client, generation }; + if (this.chatModelCatalogAgentId !== agentId) { + this.chatModelCatalog = []; + } + const request = { client, generation, agentId }; this.chatModelCatalogRequest = request; - const refresh = this.chatModelCatalogRefreshRequired || previousRequest?.client === client; - this.chatModelCatalogRefreshRequired = false; - // A direct overview has no chat metadata. Refresh after reconnect so neither - // an in-flight request nor a settled cache can restore stale Gateway models. - void loadModels(client, refresh ? { refresh: true } : undefined) - .then((models) => { - if (this.isCurrentRequest(client, generation)) { + this.chatModelCatalogError = null; + // Only chat metadata projects the selected agent's private provider/auth + // scope; models.list always resolves against the default agent. + void client + .request<{ models?: ModelCatalogEntry[] }>("chat.metadata", { agentId }) + .then((result) => { + if (this.isCurrentRequest(client, generation, agentId)) { + const models = result.models ?? []; this.chatModelCatalog = models; this.chatModelCatalogClient = client; + this.chatModelCatalogAgentId = agentId; + this.chatModelCatalogByAgentId.set(agentId, models); + this.chatModelCatalogError = null; } }) - .catch(() => { - if (this.isCurrentRequest(client, generation)) { - this.chatModelCatalog = []; - this.chatModelCatalogClient = null; - this.chatModelCatalogRefreshRequired = true; + .catch((error: unknown) => { + if (this.isCurrentRequest(client, generation, agentId)) { + this.chatModelCatalogAgentId = null; + this.chatModelCatalogError = error instanceof Error ? error.message : String(error); } }) .finally(() => { @@ -644,15 +676,28 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private async refreshCron() { const cronState = this.cron; - if (!cronState.connected || !cronState.client) { + if (!cronState.connected || !cronState.client || cronState.cronLoading) { return; } await Promise.all([ - loadCronStatus(cronState), - loadCronJobsPage(cronState, { tableFilters: true }), + this.runCronTask((current) => loadCronStatus(current)), + this.runCronTask((current) => loadCronScopeStats(current)), + this.runCronTask((current) => loadCronJobsPage(current, { tableFilters: true })), ]); - if (this.cron === cronState) { - this.cron = { ...cronState, cronJobs: [...cronState.cronJobs] }; + } + + private async runCronTask(task: (cronState: CronState) => Promise): Promise { + const cronState = this.cron; + try { + const result = task(cronState); + if (this.cron === cronState) { + this.requestUpdate(); + } + return await result; + } finally { + if (this.cron === cronState) { + this.requestUpdate(); + } } } @@ -680,6 +725,9 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private resetSelectionState() { this.requestGeneration += 1; + this.chatModelCatalog = []; + this.chatModelCatalogAgentId = null; + this.chatModelCatalogError = null; this.agentFilesList = null; this.agentFilesError = null; this.agentFileActive = null; @@ -699,6 +747,10 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { this.toolsCatalogLoading = false; this.toolsCatalogLoadingAgentId = null; resetToolsEffectiveState(this); + this.cron = createInitialCronState({ + client: this.client, + connected: this.connected, + }); } private findAgentIndex(agentId: string) { @@ -794,9 +846,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { if (!this.cron.cronJobs.some((entry) => entry.id === jobId)) { return; } - void runCronJob(this.cron, jobId, "force").finally(() => { - this.cron = { ...this.cron, cronJobs: [...this.cron.cronJobs] }; - }); + void this.runCronTask((cronState) => runCronJob(cronState, jobId, "force")); } override render() { @@ -836,6 +886,11 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { cron: { status: this.cron.cronStatus, jobs: this.cron.cronJobs, + jobsTotal: this.cron.cronJobsTotal, + jobsHasMore: this.cron.cronJobsHasMore, + jobsLoadingMore: this.cron.cronJobsLoadingMore, + scopedTotal: this.cron.cronScopedTotal, + scopedNextWakeAtMs: this.cron.cronScopedNextWakeAtMs, loading: this.cron.cronLoading, error: this.cron.cronError, }, @@ -874,6 +929,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { runtimeSessionKey: this.sessionKey, runtimeSessionMatchesSelectedAgent: selectedAgentId === this.chatAgentId(), modelCatalog: this.chatModelCatalog, + modelCatalogError: this.chatModelCatalogError, pinnedAgentIds: this.context.navigation.snapshot.pinnedAgentIds, onTogglePinnedAgent: (agentId) => togglePinnedAgent(this.context.navigation, agentId), onRefresh: () => this.refreshAgents(), @@ -947,6 +1003,10 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { onOpenMemorySettings: () => this.context.navigate("memory"), onOpenAgentDefaults: () => this.context.navigate("ai-agents"), onCronRefresh: () => void this.refreshCron(), + onCronLoadMore: () => + void this.runCronTask((cronState) => + loadCronJobsPage(cronState, { append: true, tableFilters: true }), + ), onCronRunNow: (jobId) => this.runCronJobNow(jobId), onSkillsFilterChange: (next) => (this.skillsFilter = next), onSkillsRefresh: () => { @@ -994,6 +1054,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { stageAgentPrimaryModel(this.context.runtimeConfig, agentId, modelId); void refreshVisibleToolsEffectiveForCurrentSession(this); }, + onModelCatalogRetry: () => this.ensureModelCatalog(), onModelFallbacksChange: (agentId, fallbacks) => stageAgentModelFallbacks(this.context.runtimeConfig, agentId, fallbacks), onSetDefault: (agentId) => { diff --git a/ui/src/pages/agents/agents-view.test-helpers.ts b/ui/src/pages/agents/agents-view.test-helpers.ts new file mode 100644 index 000000000000..efaa1c020413 --- /dev/null +++ b/ui/src/pages/agents/agents-view.test-helpers.ts @@ -0,0 +1,114 @@ +import type { renderAgents } from "./view.ts"; + +type AgentsViewProps = Parameters[0]; + +export function createAgentViewTestProps( + overrides: Partial = {}, +): AgentsViewProps { + return { + basePath: "", + authToken: null, + loading: false, + error: null, + agentsList: { + defaultId: "alpha", + mainKey: "main", + scope: "workspace", + agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never], + }, + selectedAgentId: "beta", + activePanel: "overview", + config: { + form: null, + loading: false, + saving: false, + dirty: false, + }, + channels: { + snapshot: null, + loading: false, + error: null, + lastSuccess: null, + }, + cron: { + status: null, + jobs: [], + jobsTotal: 0, + jobsHasMore: false, + jobsLoadingMore: false, + scopedTotal: null, + scopedNextWakeAtMs: null, + loading: false, + error: null, + }, + agentFiles: { + list: null, + loading: false, + error: null, + active: null, + contents: {}, + drafts: {}, + saving: false, + }, + agentIdentityLoading: false, + agentIdentityError: null, + agentIdentityById: {}, + identityDraft: { name: null, emoji: null, avatar: null }, + identitySaving: false, + identityError: null, + agentSkills: { + report: null, + loading: false, + error: null, + agentId: null, + filter: "", + }, + toolsCatalog: { + loading: false, + error: null, + result: null, + }, + toolsEffective: { + loading: false, + error: null, + result: null, + }, + runtimeSessionKey: "main", + runtimeSessionMatchesSelectedAgent: false, + modelCatalog: [], + modelCatalogError: null, + pinnedAgentIds: [], + onRefresh: () => undefined, + onSelectAgent: () => undefined, + onCreateAgent: () => undefined, + onSelectPanel: () => undefined, + onLoadFiles: () => undefined, + onSelectFile: () => undefined, + onFileDraftChange: () => undefined, + onFileReset: () => undefined, + onFileSave: () => undefined, + onToolsProfileChange: () => undefined, + onToolsOverridesChange: () => undefined, + onConfigReload: () => undefined, + onConfigSave: () => undefined, + onModelChange: () => undefined, + onModelFallbacksChange: () => undefined, + onModelCatalogRetry: () => undefined, + onChannelsRefresh: () => undefined, + onCronRefresh: () => undefined, + onCronLoadMore: () => undefined, + onCronRunNow: () => undefined, + onSkillsFilterChange: () => undefined, + onSkillsRefresh: () => undefined, + onAgentSkillToggle: () => undefined, + onAgentSkillsClear: () => undefined, + onAgentSkillsDisableAll: () => undefined, + onSetDefault: () => undefined, + onIdentityFieldChange: () => undefined, + onIdentityAvatarSelect: () => undefined, + onIdentitySave: () => undefined, + onTogglePinnedAgent: () => undefined, + onOpenAgentDefaults: () => undefined, + ...overrides, + }; +} diff --git a/ui/src/pages/agents/panels-overview.ts b/ui/src/pages/agents/panels-overview.ts index 16dfb4cef6d4..04f7c7db1589 100644 --- a/ui/src/pages/agents/panels-overview.ts +++ b/ui/src/pages/agents/panels-overview.ts @@ -6,6 +6,7 @@ import type { AgentsListResult, ModelCatalogEntry, } from "../../api/types.ts"; +import { renderPanelRefreshStatus } from "../../components/panel-refresh-status.ts"; import { renderSettingsRow, renderSettingsSection } from "../../components/settings-ui.ts"; import "../../components/tooltip.ts"; import { t } from "../../i18n/index.ts"; @@ -46,6 +47,7 @@ export function renderAgentOverview(params: { configSaving: boolean; configDirty: boolean; modelCatalog: ModelCatalogEntry[]; + modelCatalogError: string | null; onConfigReload: () => void; onConfigSave: () => void; onIdentityFieldChange: (field: "name" | "emoji", value: string) => void; @@ -53,6 +55,7 @@ export function renderAgentOverview(params: { onIdentitySave: () => void; onModelChange: (agentId: string, modelId: string | null) => void; onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; + onModelCatalogRetry: () => void; onSelectPanel: (panel: AgentsPanel) => void; }) { const { @@ -275,6 +278,14 @@ export function renderAgentOverview(params: { `, }, html` + ${renderPanelRefreshStatus({ + status: { + error: params.modelCatalogError, + hasLoaded: params.modelCatalog.length > 0, + stale: Boolean(params.modelCatalogError && params.modelCatalog.length > 0), + }, + onRetry: params.onModelCatalogRetry, + })} ${renderSettingsRow({ title: isDefault ? t("agents.overview.primaryModelDefault") diff --git a/ui/src/pages/agents/panels-status-files.ts b/ui/src/pages/agents/panels-status-files.ts index c280a263bc4c..51d8aabc98ac 100644 --- a/ui/src/pages/agents/panels-status-files.ts +++ b/ui/src/pages/agents/panels-status-files.ts @@ -12,6 +12,7 @@ import type { CronJob, CronStatus, } from "../../api/types.ts"; +import { renderCronJobsPagination } from "../../components/cron-jobs-pagination.ts"; import { renderHubTabs } from "../../components/hub-tabs.ts"; import { icons } from "../../components/icons.ts"; import "../../components/modal-dialog.ts"; @@ -296,14 +297,19 @@ export function renderAgentCron(params: { context: AgentContext; agentId: string; jobs: CronJob[]; + jobsTotal: number; + jobsHasMore: boolean; + jobsLoadingMore: boolean; status: CronStatus | null; + scopedTotal: number | null; + scopedNextWakeAtMs: number | null; loading: boolean; error: string | null; onRefresh: () => void; + onLoadMore: () => void; onRunNow: (jobId: string) => void; onSelectPanel: (panel: AgentsPanel) => void; }) { - const jobs = params.jobs.filter((job) => job.agentId === params.agentId); return html` ${renderAgentContextSection( params.context, @@ -334,11 +340,13 @@ export function renderAgentCron(params: { })} ${renderSettingsRow({ title: t("agents.cronPanel.jobs"), - control: renderSettingsValue(params.status?.jobs ?? t("common.na")), + control: renderSettingsValue(params.scopedTotal ?? t("common.na")), })} ${renderSettingsRow({ title: t("agents.cronPanel.nextWake"), - control: renderSettingsValue(formatNextRun(params.status?.nextWakeAtMs ?? null)), + control: renderSettingsValue( + formatNextRun(params.status?.enabled === false ? null : params.scopedNextWakeAtMs), + ), })} `, )} @@ -347,34 +355,44 @@ export function renderAgentCron(params: { title: t("agents.cronPanel.agentJobsTitle"), description: t("agents.cronPanel.agentJobsSubtitle"), }, - jobs.length === 0 + params.jobs.length === 0 ? renderSettingsEmpty(t("agents.cronPanel.noJobs")) - : jobs.map((job) => { - const metaParts = [ - job.description, - formatCronSchedule(job), - job.sessionTarget, - formatCronState(job), - formatCronPayload(job), - ].filter(Boolean); - return renderSettingsRow({ - title: job.name, - description: metaParts.join(" · "), - control: html` - ${renderSettingsStatus({ - kind: job.enabled ? "ok" : "warn", - label: job.enabled ? t("common.enabled") : t("common.disabled"), - })} - - `, - }); - }), + : html` + ${params.jobs.map((job) => { + const metaParts = [ + job.description, + formatCronSchedule(job), + job.sessionTarget, + formatCronState(job), + formatCronPayload(job), + ].filter(Boolean); + return renderSettingsRow({ + title: job.name, + description: metaParts.join(" · "), + control: html` + ${renderSettingsStatus({ + kind: job.enabled ? "ok" : "warn", + label: job.enabled ? t("common.enabled") : t("common.disabled"), + })} + + `, + }); + })} + ${renderCronJobsPagination({ + jobsShown: params.jobs.length, + jobsTotal: params.jobsTotal, + hasMore: params.jobsHasMore, + loading: params.loading, + loadingMore: params.jobsLoadingMore, + onLoadMore: params.onLoadMore, + })} + `, )} `; } diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index 61c41c1e294b..3b077c10d303 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -1,14 +1,16 @@ // Control UI tests cover agents behavior. import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { ChannelAccountSnapshot } from "../../api/types.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ChannelAccountSnapshot, CronJob } from "../../api/types.ts"; import { i18n, t } from "../../i18n/index.ts"; +import { createInitialCronState, loadCronJobsPage } from "../../lib/cron/index.ts"; +import { formatNextRun } from "../../lib/presenter.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; +import { createAgentViewTestProps as createProps } from "./agents-view.test-helpers.ts"; import { renderAgentChannels, renderAgentFiles } from "./panels-status-files.ts"; import { renderAgents } from "./view.ts"; -type AgentsProps = Parameters[0]; - function createSkill() { return { name: "Repo Skill", @@ -40,6 +42,21 @@ function createSkill() { }; } +function createCronJob(id: string, overrides: Partial = {}): CronJob { + return { + id, + name: `Scheduled job ${id}`, + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + ...overrides, + } as CronJob; +} + function directText(element: Element | null | undefined): string | undefined { return Array.from(element?.childNodes ?? []) .filter((node) => node.nodeType === Node.TEXT_NODE) @@ -58,107 +75,6 @@ function expectAgentTab(container: Element, text: string): HTMLElement & { disab return button; } -function createProps(overrides: Partial = {}): AgentsProps { - return { - basePath: "", - authToken: null, - loading: false, - error: null, - agentsList: { - defaultId: "alpha", - mainKey: "main", - scope: "workspace", - agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never], - }, - selectedAgentId: "beta", - activePanel: "overview", - config: { - form: null, - loading: false, - saving: false, - dirty: false, - }, - channels: { - snapshot: null, - loading: false, - error: null, - lastSuccess: null, - }, - cron: { - status: null, - jobs: [], - loading: false, - error: null, - }, - agentFiles: { - list: null, - loading: false, - error: null, - active: null, - contents: {}, - drafts: {}, - saving: false, - }, - agentIdentityLoading: false, - agentIdentityError: null, - agentIdentityById: {}, - identityDraft: { name: null, emoji: null, avatar: null }, - identitySaving: false, - identityError: null, - agentSkills: { - report: null, - loading: false, - error: null, - agentId: null, - filter: "", - }, - toolsCatalog: { - loading: false, - error: null, - result: null, - }, - toolsEffective: { - loading: false, - error: null, - result: null, - }, - runtimeSessionKey: "main", - runtimeSessionMatchesSelectedAgent: false, - modelCatalog: [], - pinnedAgentIds: [], - onRefresh: () => undefined, - onSelectAgent: () => undefined, - onCreateAgent: () => undefined, - onSelectPanel: () => undefined, - onLoadFiles: () => undefined, - onSelectFile: () => undefined, - onFileDraftChange: () => undefined, - onFileReset: () => undefined, - onFileSave: () => undefined, - onToolsProfileChange: () => undefined, - onToolsOverridesChange: () => undefined, - onConfigReload: () => undefined, - onConfigSave: () => undefined, - onModelChange: () => undefined, - onModelFallbacksChange: () => undefined, - onChannelsRefresh: () => undefined, - onCronRefresh: () => undefined, - onCronRunNow: () => undefined, - onSkillsFilterChange: () => undefined, - onSkillsRefresh: () => undefined, - onAgentSkillToggle: () => undefined, - onAgentSkillsClear: () => undefined, - onAgentSkillsDisableAll: () => undefined, - onSetDefault: () => undefined, - onIdentityFieldChange: () => undefined, - onIdentityAvatarSelect: () => undefined, - onIdentitySave: () => undefined, - onTogglePinnedAgent: () => undefined, - onOpenAgentDefaults: () => undefined, - ...overrides, - }; -} - describe("renderAgents", () => { it("opens global Agent defaults before the per-agent tabs", () => { const container = document.createElement("div"); @@ -205,6 +121,152 @@ describe("renderAgents", () => { ).toBe("Fetched Beta"); }); + it("shows a model-catalog failure and lets the operator retry", () => { + const container = document.createElement("div"); + const onModelCatalogRetry = vi.fn(); + render( + renderAgents( + createProps({ modelCatalogError: "model catalog unavailable", onModelCatalogRetry }), + ), + container, + ); + + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("model catalog unavailable"); + const retry = Array.from(alert?.querySelectorAll("button") ?? []).find( + (button) => button.textContent?.trim() === t("common.retry"), + ); + retry?.click(); + + expect(onModelCatalogRetry).toHaveBeenCalledOnce(); + }); + + it("renders and counts a server-scoped default-agent cron job without an explicit agentId", () => { + const job = createCronJob("implicit-default-job", { + name: "Implicit default-agent reminder", + }); + const globalNextWakeAtMs = Date.now() + 60_000; + const scopedNextWakeAtMs = globalNextWakeAtMs + 3_600_000; + const container = document.createElement("div"); + render( + renderAgents( + createProps({ + activePanel: "cron", + selectedAgentId: "alpha", + cron: { + status: { enabled: true, jobs: 51, nextWakeAtMs: globalNextWakeAtMs }, + jobs: [job], + jobsTotal: 1, + jobsHasMore: false, + jobsLoadingMore: false, + scopedTotal: 1, + scopedNextWakeAtMs, + loading: false, + error: null, + }, + }), + ), + container, + ); + + expect(container.textContent).toContain("Implicit default-agent reminder"); + expect( + expectAgentTab(container, t("agents.tabs.cronJobs")).querySelector(".hub-tab__badge--count") + ?.textContent, + ).toContain("1"); + + const schedulerRows = [...container.querySelectorAll(".settings-row")]; + const jobsRow = schedulerRows.find( + (row) => + row.querySelector(".settings-row__title")?.textContent === t("agents.cronPanel.jobs"), + ); + const nextWakeRow = schedulerRows.find( + (row) => + row.querySelector(".settings-row__title")?.textContent === t("agents.cronPanel.nextWake"), + ); + expect(jobsRow?.querySelector(".settings-row__control")?.textContent?.trim()).toBe("1"); + expect(nextWakeRow?.querySelector(".settings-row__control")?.textContent?.trim()).toBe( + formatNextRun(scopedNextWakeAtMs), + ); + expect(nextWakeRow?.textContent).not.toContain(formatNextRun(globalNextWakeAtMs)); + }); + + it("loads and renders the selected agent's 51st cron job when Load more is clicked", async () => { + const jobs = Array.from({ length: 50 }, (_, index) => + createCronJob(`main-${index}`, { agentId: "alpha" }), + ); + const lastJob = createCronJob("main-50", { + agentId: "alpha", + name: "Fifty-first agent reminder", + }); + const request = vi.fn(async () => ({ + jobs: [lastJob], + total: 51, + offset: 50, + nextOffset: null, + hasMore: false, + })); + const client = { request } as unknown as GatewayBrowserClient; + const cronState = { + ...createInitialCronState({ client, connected: true }), + cronAgentId: "alpha", + cronJobs: jobs, + cronJobsTotal: 51, + cronJobsHasMore: true, + cronJobsNextOffset: 50, + }; + const container = document.createElement("div"); + const renderCurrentPage = (): void => { + render( + renderAgents( + createProps({ + activePanel: "cron", + selectedAgentId: "alpha", + cron: { + status: { enabled: true, jobs: 80, nextWakeAtMs: null }, + jobs: cronState.cronJobs, + jobsTotal: cronState.cronJobsTotal, + jobsHasMore: cronState.cronJobsHasMore, + jobsLoadingMore: cronState.cronJobsLoadingMore, + scopedTotal: 51, + scopedNextWakeAtMs: null, + loading: cronState.cronLoading, + error: cronState.cronError, + }, + onCronLoadMore: () => { + const nextPage = loadCronJobsPage(cronState, { + append: true, + tableFilters: true, + }); + renderCurrentPage(); + void nextPage.then(renderCurrentPage); + }, + }), + ), + container, + ); + }; + renderCurrentPage(); + + expect( + expectAgentTab(container, t("agents.tabs.cronJobs")).querySelector(".hub-tab__badge--count") + ?.textContent, + ).toContain("51"); + expect(container.textContent).not.toContain(lastJob.name); + + const loadMore = container.querySelector(".cron-load-more"); + expect(loadMore?.textContent?.trim()).toBe(t("cron.list.loadMore")); + loadMore?.click(); + expect(container.querySelector(".cron-load-more")?.disabled).toBe(true); + + await vi.waitFor(() => expect(container.textContent).toContain(lastJob.name)); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "alpha", limit: 50, offset: 50 }), + ); + expect(container.querySelector(".cron-load-more")).toBeNull(); + }); + it("renders Memory after Automations and scopes the panel to the selected agent", () => { const container = document.createElement("div"); render(renderAgents(createProps({ activePanel: "memory" })), container); diff --git a/ui/src/pages/agents/view.ts b/ui/src/pages/agents/view.ts index d8c170679eb1..e5874d440320 100644 --- a/ui/src/pages/agents/view.ts +++ b/ui/src/pages/agents/view.ts @@ -53,6 +53,11 @@ type ChannelsState = { type CronState = { status: CronStatus | null; jobs: CronJob[]; + jobsTotal: number; + jobsHasMore: boolean; + jobsLoadingMore: boolean; + scopedTotal: number | null; + scopedNextWakeAtMs: number | null; loading: boolean; error: string | null; }; @@ -111,6 +116,7 @@ type AgentsProps = { runtimeSessionKey: string; runtimeSessionMatchesSelectedAgent: boolean; modelCatalog: ModelCatalogEntry[]; + modelCatalogError: string | null; pinnedAgentIds: readonly string[]; onTogglePinnedAgent: (agentId: string) => void; onRefresh: () => void; @@ -131,11 +137,13 @@ type AgentsProps = { onIdentitySave: () => void; onModelChange: (agentId: string, modelId: string | null) => void; onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; + onModelCatalogRetry: () => void; onChannelsRefresh: () => void; onOpenMemoryImport?: () => void; onOpenMemorySettings?: () => void; onOpenAgentDefaults: () => void; onCronRefresh: () => void; + onCronLoadMore: () => void; onCronRunNow: (jobId: string) => void; onSkillsFilterChange: (next: string) => void; onSkillsRefresh: () => void; @@ -166,9 +174,7 @@ export function renderAgents(props: AgentsProps) { const channelEntryCount = props.channels.snapshot ? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length : null; - const cronJobCount = selectedId - ? props.cron.jobs.filter((j) => j.agentId === selectedId).length - : null; + const cronJobCount = selectedId ? props.cron.jobsTotal : null; const tabCounts: Record = { files: props.agentFiles.list?.files?.length ?? null, skills: selectedSkillCount, @@ -279,6 +285,7 @@ export function renderAgents(props: AgentsProps) { configSaving: props.config.saving, configDirty: props.config.dirty, modelCatalog: props.modelCatalog, + modelCatalogError: props.modelCatalogError, onConfigReload: props.onConfigReload, onConfigSave: props.onConfigSave, onIdentityFieldChange: props.onIdentityFieldChange, @@ -286,6 +293,7 @@ export function renderAgents(props: AgentsProps) { onIdentitySave: props.onIdentitySave, onModelChange: props.onModelChange, onModelFallbacksChange: props.onModelFallbacksChange, + onModelCatalogRetry: props.onModelCatalogRetry, onSelectPanel: props.onSelectPanel, }), ) @@ -378,10 +386,16 @@ export function renderAgents(props: AgentsProps) { ), agentId: selectedAgent.id, jobs: props.cron.jobs, + jobsTotal: props.cron.jobsTotal, + jobsHasMore: props.cron.jobsHasMore, + jobsLoadingMore: props.cron.jobsLoadingMore, status: props.cron.status, + scopedTotal: props.cron.scopedTotal, + scopedNextWakeAtMs: props.cron.scopedNextWakeAtMs, loading: props.cron.loading, error: props.cron.error, onRefresh: props.onCronRefresh, + onLoadMore: props.onCronLoadMore, onRunNow: props.onCronRunNow, onSelectPanel: props.onSelectPanel, }) diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 7e527fa19ab0..5e8c9fb8800f 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -853,6 +853,107 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStreamSegments).toEqual([]); }); + it.each([ + { + name: "provider timeout", + event: { + state: "error", + errorKind: "timeout", + errorMessage: "agent provider timeout", + }, + projectionStatus: "timeout", + sessionStatus: "timeout", + errorSummary: "Error: agent provider timeout", + }, + { + name: "provider failure", + event: { + state: "error", + errorMessage: "agent provider failure", + }, + projectionStatus: "error", + sessionStatus: "failed", + errorSummary: "Error: agent provider failure", + }, + { + name: "operator cancellation", + event: { state: "aborted" }, + projectionStatus: "aborted", + sessionStatus: "killed", + errorSummary: null, + }, + ] as const)( + "projects the canonical $name status onto the selected session", + ({ event, projectionStatus, sessionStatus, errorSummary }) => { + vi.useFakeTimers(); + try { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: "Partial assistant reply", + chatStreamStartedAt: 100, + }) as ChatState & { + chatRunStatus?: { phase: string; runId: string | null; sessionKey: string } | null; + lastLocalTerminalReconcile?: { sessionStatus: string } | null; + sessionsResult?: { + ts: number; + path: string; + count: number; + defaults: Record; + sessions: Array>; + }; + }; + state.sessionsResult = { + ts: 0, + path: "", + count: 1, + defaults: {}, + sessions: [ + { + key: "main", + kind: "direct", + updatedAt: 1, + hasActiveRun: true, + activeRunIds: ["run-1"], + status: "running", + startedAt: 100, + }, + ], + }; + + expect( + handleChatGatewayEvent(state, { + runId: "run-1", + sessionKey: "main", + ...event, + }), + ).toBe(event.state); + + expect( + getChatSessionProjection(state, state.chatMessages, { sessionKey: "main" }).runs["run-1"] + ?.status, + ).toBe(projectionStatus); + expect(state.sessionsResult.sessions[0]).toMatchObject({ + activeRunIds: [], + hasActiveRun: false, + status: sessionStatus, + }); + expect(state.lastLocalTerminalReconcile?.sessionStatus).toBe(sessionStatus); + expect(state.chatRunStatus).toMatchObject({ + phase: "interrupted", + runId: "run-1", + sessionKey: "main", + }); + expect(state.chatRunError?.summary ?? null).toBe(errorSummary); + expect(state.chatRunId).toBeNull(); + expect(state.chatStream).toBeNull(); + expect(state.chatStreamStartedAt).toBeNull(); + } finally { + vi.useRealTimers(); + } + }, + ); + it("reconciles cached run and indicator state on terminal events", () => { vi.useFakeTimers(); try { @@ -1950,7 +2051,7 @@ describe("handleChatGatewayEvent", () => { }, ); - it("does not let a completed run's late error interrupt a newer response", () => { + it("does not label a newer response with a completed run's late error", () => { const state = createState({ sessionKey: "main", chatRunId: "run-completed" }); expect( @@ -1982,7 +2083,7 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStream).toBe("Newer response"); expect(state.chatMessages).toHaveLength(1); expectTextChatMessage(state.chatMessages[0], "assistant", "Delivered once."); - expect(state.chatRunError).toEqual({ summary: "Error: late provider failure" }); + expect(state.chatRunError).toBeNull(); }); it("upgrades an empty final to one authoritative assistant reply", () => { diff --git a/ui/src/pages/chat/chat-gateway.ts b/ui/src/pages/chat/chat-gateway.ts index 0fa41cfc08ff..5589e7b8dfb6 100644 --- a/ui/src/pages/chat/chat-gateway.ts +++ b/ui/src/pages/chat/chat-gateway.ts @@ -273,11 +273,12 @@ function handleChatEvent( } if (payload.state === "error") { if ( + (!state.chatRunId || state.chatRunId === payload.runId) && payload.errorMessage?.trim() && projectedRun.currentRun?.errorMessage !== previousTerminalRun.errorMessage ) { - // A completed transcript is immutable; retain provider guidance without - // adopting its old run or interrupting a newer in-flight response. + // Completed-run diagnostics belong to an idle composer or that same run; + // publishing them over a newer response falsely marks the new run failed. setChatRunError(state, resolveGatewayErrorText(payload, null)); } return "error"; @@ -329,7 +330,7 @@ function handleChatEvent( }); const reconcileTerminalRun = ( outcome: "done" | "interrupted", - sessionStatus: "done" | "failed" | "killed", + sessionStatus: "done" | "failed" | "killed" | "timeout", ) => reconcileChatRunLifecycle(state as unknown as Parameters[0], { outcome, @@ -459,7 +460,12 @@ function handleChatEvent( state.chatMessages = materializeVisibleStream({ includeCurrent: true }); } } - reconcileTerminalRun("interrupted", "failed"); + // The shared Gateway projection owns timeout classification; preserve it + // when publishing selected-session and sidebar terminal status. + reconcileTerminalRun( + "interrupted", + projectedRun?.currentRun?.status === "timeout" ? "timeout" : "failed", + ); setChatRunError( state, resolveGatewayErrorText(payload, projectedErrorMessage ? visiblePayloadMessage : null), diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index 83a0b3e1a18a..d7d6a431652a 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -21,6 +21,7 @@ import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { renderBoardDockMenu, renderBoardFaceToggle } from "./board-session-surface.ts"; import { ChatPaneContext } from "./chat-pane-context.ts"; import { headerPlatformByClient } from "./chat-pane-shared.ts"; +import { patchChatSessionLabel } from "./chat-state-route.ts"; import { renderCatalogTerminalButton } from "./components/catalog-terminal-button.ts"; import { renderBackgroundTasksToggle, @@ -258,13 +259,13 @@ export abstract class ChatPaneHeader extends ChatPaneContext { const unchangedLabel = label === this.headerRenameInitialLabel; this.headerEditing = false; this.headerRenameSessionKey = ""; - if (!key || unchangedDerivedTitle || unchangedLabel) { + const state = this.state; + if (!key || !state || unchangedDerivedTitle || unchangedLabel) { return; } - const agentId = parseAgentSessionKey(key)?.agentId; - void this.context.sessions - .patch(key, { label }, agentId ? { agentId } : undefined) - .catch((error: unknown) => this.publishHeaderError(error)); + void patchChatSessionLabel(state, this.context.sessions, key, label).catch((error: unknown) => + this.publishHeaderError(error), + ); } protected async loadHeaderMenuData( diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 1aa17f798b2a..c796e105c2c4 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -140,6 +140,29 @@ describe("chat pane header state", () => { expect(patch).toHaveBeenLastCalledWith(session.key, { label: null }, { agentId: "main" }); }); + it("renames the selected agent's canonical global session", () => { + const patch = vi.fn(async () => ({})); + const sessions = { patch } as unknown as SessionCapability; + const { pane, state } = createTestChatPane({ client: {} as GatewayBrowserClient, sessions }); + state.sessionKey = "global"; + state.assistantAgentId = "research"; + const session = { + key: "global", + kind: "global", + updatedAt: 0, + } satisfies GatewaySessionRow; + + pane.beginHeaderRename(session); + pane.headerRenameValue = "Research thread"; + pane.commitHeaderRename(); + + expect(patch).toHaveBeenCalledWith( + "global", + { label: "Research thread" }, + { agentId: "research" }, + ); + }); + it("cancels and skips unchanged labels", () => { const patch = vi.fn(async () => ({})); const sessions = { patch } as unknown as SessionCapability; diff --git a/ui/src/pages/chat/chat-send-submit.test.ts b/ui/src/pages/chat/chat-send-submit.test.ts new file mode 100644 index 000000000000..da1c2dd6938a --- /dev/null +++ b/ui/src/pages/chat/chat-send-submit.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; +import { createSessionCapability } from "../../lib/sessions/index.ts"; +import { + getChatAttachmentDataUrl, + registerChatAttachmentPayload, + releaseChatAttachmentPayloads, +} from "./attachment-payload-store.ts"; +import type { ChatHost } from "./chat-send-contract.ts"; +import { handleSendChat } from "./chat-send-submit.ts"; + +const attachmentsToRelease: ChatAttachment[] = []; +const attachmentDataUrl = "data:application/pdf;base64,JVBERi0xLjQK"; + +afterEach(() => { + releaseChatAttachmentPayloads(attachmentsToRelease); + attachmentsToRelease.length = 0; +}); + +function createStagedAttachment(id: string): ChatAttachment { + const file = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" }); + const attachment = registerChatAttachmentPayload({ + attachment: { + id, + mimeType: "application/pdf", + fileName: "brief.pdf", + sizeBytes: file.size, + }, + dataUrl: attachmentDataUrl, + file, + }); + attachmentsToRelease.push(attachment); + return attachment; +} + +function createImmediateCommandHost( + command: string, + attachment: ChatAttachment, + overrides: Partial = {}, +): ChatHost { + const host = { + sessions: createSessionCapability({ + snapshot: { client: null, phase: "reconnecting", hello: null }, + subscribe: () => () => undefined, + subscribeEvents: () => () => undefined, + }), + client: null, + connected: true, + sessionKey: "agent:main", + chatLoading: false, + chatMessage: command, + chatMessages: [], + chatLocalInputHistoryBySession: {}, + chatInputHistorySessionKey: null, + chatInputHistoryItems: null, + chatInputHistoryIndex: -1, + chatDraftBeforeHistory: null, + chatAttachments: [attachment], + chatQueue: [], + chatRunId: null, + chatSending: false, + chatStream: null, + chatModelCatalog: [], + hello: null, + refreshSessionsAfterChat: new Map(), + ...overrides, + } satisfies Partial; + return host as ChatHost; +} + +describe("handleSendChat immediate local commands", () => { + it.each(["/export-session", "/export"])( + "preserves staged attachments while %s exports the chat", + async (command) => { + const attachment = createStagedAttachment("export-att"); + const exportCurrentChat = vi.fn(); + const host = createImmediateCommandHost(command, attachment, { exportCurrentChat }); + + await handleSendChat(host); + + expect(exportCurrentChat).toHaveBeenCalledOnce(); + expect(host.chatMessage).toBe(""); + expect(host.chatAttachments).toEqual([attachment]); + expect(getChatAttachmentDataUrl(attachment)).toBe(attachmentDataUrl); + expect(host.chatQueue).toStrictEqual([]); + }, + ); + + it("does not duplicate staged attachments into both old and new session composers", async () => { + const attachment = createStagedAttachment("new-session-att"); + const attachmentsBySession = new Map(); + const host = createImmediateCommandHost("/new", attachment); + host.createChatSession = vi.fn(async () => { + const previousSessionKey = host.sessionKey; + const nextSessionKey = "agent:main:new"; + // Session creation captures the next composer before route switching + // decides whether the old session's attachment needs a memory fallback. + const createdSessionAttachments = [...host.chatAttachments]; + attachmentsBySession.set(previousSessionKey, [...host.chatAttachments]); + host.sessionKey = nextSessionKey; + host.chatAttachments = createdSessionAttachments; + attachmentsBySession.set(nextSessionKey, [...host.chatAttachments]); + return true; + }); + + await handleSendChat(host); + + expect(host.createChatSession).toHaveBeenCalledOnce(); + expect(attachmentsBySession.get("agent:main")).toStrictEqual([]); + expect(attachmentsBySession.get("agent:main:new")).toStrictEqual([]); + expect(host.chatAttachments).toStrictEqual([]); + }); + + it("restores staged attachments when creating a new session is cancelled", async () => { + const attachment = createStagedAttachment("cancelled-new-session-att"); + const createChatSession = vi.fn(async () => false); + const host = createImmediateCommandHost("/new", attachment, { createChatSession }); + + await handleSendChat(host); + + expect(createChatSession).toHaveBeenCalledOnce(); + expect(host.chatMessage).toBe("/new"); + expect(host.chatAttachments).toHaveLength(1); + expect(host.chatAttachments[0]).toMatchObject(attachment); + expect(getChatAttachmentDataUrl(host.chatAttachments[0]!)).toBe(attachmentDataUrl); + }); +}); diff --git a/ui/src/pages/chat/chat-send-submit.ts b/ui/src/pages/chat/chat-send-submit.ts index d1de90a6f902..055d44fb8449 100644 --- a/ui/src/pages/chat/chat-send-submit.ts +++ b/ui/src/pages/chat/chat-send-submit.ts @@ -380,7 +380,11 @@ export async function handleSendChat( ).previousDraft; } else { host.chatMessage = ""; - host.chatAttachments = []; + // Export leaves the composer in its current session; /new must clear + // attachments before its handoff can capture them under both routes. + if (parsed.command.key !== "export-session") { + host.chatAttachments = []; + } resetChatInputHistoryNavigation(host); } } diff --git a/ui/src/pages/chat/chat-state-route.ts b/ui/src/pages/chat/chat-state-route.ts index 1b4f68d3018e..5da58e355bdd 100644 --- a/ui/src/pages/chat/chat-state-route.ts +++ b/ui/src/pages/chat/chat-state-route.ts @@ -2,7 +2,7 @@ import { loadLocalAssistantIdentity } from "../../app/assistant-identity.ts"; import { loadSettings, patchSettings } from "../../app/settings.ts"; import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; -import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; +import { scopedAgentParamsForSession, type SessionCapability } from "../../lib/sessions/index.ts"; import { DEFAULT_MAIN_KEY, areUiSessionKeysEquivalent, @@ -406,6 +406,15 @@ export function resolveChatAgentId(state: ChatPageHost) { ); } +export function patchChatSessionLabel( + state: ChatPageHost, + sessions: Pick, + sessionKey: string, + label: string | null, +) { + return sessions.patch(sessionKey, { label }, { agentId: resolveChatAgentId(state) }); +} + export function resolveChatAvatarUrl(state: ChatPageHost): string | null { const agentId = resolveChatAgentId(state); if (state.chatAvatarUrl) { diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 17fd51fc7b3b..4374dadd83ea 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -5352,6 +5352,45 @@ describe("chat model controls", () => { expect(modelOption?.closest("openclaw-tooltip")).toBeNull(); }); + it("marks chat-only models in the active control and picker", () => { + const { state } = createChatHeaderState({ + model: "qwen3-8b", + modelProvider: "lmstudio", + models: [ + { + id: "qwen3-8b", + name: "Qwen3 8B", + provider: "lmstudio", + contextWindow: 32_768, + supportsTools: false, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + supportsTools: true, + }, + ], + }); + const container = renderModelControls(state); + const trigger = getChatModelSelect(container); + + expect(trigger.dataset.chatModelTools).toBe("unavailable"); + expect( + trigger.querySelector(".chat-controls__model-capability-badge")?.textContent?.trim(), + ).toBe("Chat only"); + expect(trigger.getAttribute("aria-label")).toContain("Chat only"); + expect( + container + .querySelector('[data-chat-model-option="lmstudio/qwen3-8b"]') + ?.querySelector(".chat-controls__model-option-meta") + ?.textContent?.trim(), + ).toBe("32.8k context · Chat only"); + expect( + container.querySelector('[data-chat-model-option="openai/gpt-5.5"]')?.textContent, + ).not.toContain("Chat only"); + }); + it("shows canonical OpenAI model names instead of command aliases", () => { const { state } = createChatHeaderState({ model: "gpt-5.5", diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 1b4b5deb0c44..771c3fb8557f 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -622,6 +622,21 @@ afterEach(() => { }); describe("grouped chat rendering", () => { + it("preserves paragraph breaks around assistant attachments in rendered markdown", () => { + const container = document.createElement("div"); + + renderAssistantMessage(container, { + role: "assistant", + content: "First paragraph\n \nMEDIA:https://example.com/image.png\n\t\nSecond paragraph", + timestamp: 1000, + }); + + expect(markdownRenderMock).toHaveBeenCalledWith( + "First paragraph\n\nSecond paragraph", + expect.any(Object), + ); + }); + it("renders a compact count for collapsed duplicate messages", () => { const container = document.createElement("div"); renderAssistantMessageEntries(container, [ diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 406eda8e8160..838ca4164a76 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -60,6 +60,7 @@ type ChatModelProviderOption = ChatModelSelectOption & { contextWindow?: number; isDefault: boolean; provider: string; + supportsTools?: boolean; }; const CHAT_MODEL_PROVIDER_GROUP_ALIASES: Readonly> = { @@ -210,6 +211,9 @@ export function renderChatModelControls(props: ChatModelControlsProps) { return { commitValue: isDefault ? "" : option.value, ...(catalogEntry?.contextWindow ? { contextWindow: catalogEntry.contextWindow } : {}), + ...(typeof catalogEntry?.supportsTools === "boolean" + ? { supportsTools: catalogEntry.supportsTools } + : {}), isDefault, value: option.value, label: resolveChatModelPickerLabel(option.value, option.label, props.modelCatalog), @@ -403,8 +407,21 @@ function renderChatModelReasoningSelect(params: { } = params; const triggerModel = formatCombinedPickerModelLabel(triggerModelLabel); const triggerThinking = formatCombinedPickerThinkingLabel(triggerThinkingLabel); - const triggerTitle = `${triggerModel} · ${triggerThinking}`; - const triggerLabel = triggerTitle; + const defaultModelOption = modelOptions.find((option) => option.isDefault); + const activeModelOption = + selectedModelValue === "" + ? defaultModelOption + : modelOptions.find((option) => option.value === selectedModelValue); + const selectedModelOption = activeModelOption ?? modelOptions[0]; + const modelToolsUnavailable = activeModelOption?.supportsTools === false; + const triggerTitle = [ + triggerModel, + triggerThinking, + modelToolsUnavailable ? t("chat.modelControls.chatOnly") : "", + ] + .filter(Boolean) + .join(" · "); + const triggerLabel = `${triggerModel} · ${triggerThinking}`; const sliderStops = thinkingOptions.filter((option) => option.value !== ""); const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue); const hasThinkingOverride = selectedThinkingValue !== ""; @@ -530,7 +547,6 @@ function renderChatModelReasoningSelect(params: { providerGroups.set(option.provider, [option]); } } - const defaultModelOption = modelOptions.find((option) => option.isDefault); const orderedProviderGroups = [...providerGroups]; const defaultProviderIndex = orderedProviderGroups.findIndex( ([provider]) => provider === defaultModelOption?.provider, @@ -541,21 +557,22 @@ function renderChatModelReasoningSelect(params: { orderedProviderGroups.unshift(defaultProviderGroup); } } - const selectedModelOption = - (selectedModelValue === "" - ? defaultModelOption - : modelOptions.find((option) => option.value === selectedModelValue)) ?? modelOptions[0]; const selectedProvider = selectedModelOption?.provider ?? orderedProviderGroups[0]?.[0] ?? "other"; const renderModelOption = (entry: ChatModelProviderOption) => { const selected = entry.value === selectedModelValue || (entry.isDefault && selectedModelValue === ""); const modelLabel = formatCombinedPickerModelOptionLabel(entry); - const contextLabel = entry.contextWindow - ? t("chat.modelControls.contextWindow", { - count: formatCompactTokenCount(entry.contextWindow), - }) - : ""; + const modelMeta = [ + entry.contextWindow + ? t("chat.modelControls.contextWindow", { + count: formatCompactTokenCount(entry.contextWindow), + }) + : "", + entry.supportsTools === false ? t("chat.modelControls.chatOnly") : "", + ] + .filter(Boolean) + .join(" · "); return html`
- ` - : nothing} -
+ ${renderCronJobsPagination({ + jobsShown: props.jobs.length, + jobsTotal: props.jobsTotal, + hasMore: props.jobsHasMore, + loading: props.loading, + loadingMore: props.jobsLoadingMore, + onLoadMore: props.onLoadMoreJobs, + })} `; } diff --git a/ui/src/pages/profile/profile-page.test.ts b/ui/src/pages/profile/profile-page.test.ts index cbfe73032e84..7ad89fcc60b2 100644 --- a/ui/src/pages/profile/profile-page.test.ts +++ b/ui/src/pages/profile/profile-page.test.ts @@ -317,6 +317,125 @@ it("retries the identity bootstrap when users.self returns no profile", async () ); }); +it("keeps identity refresh single-flight and allows retry after settlement", async () => { + const profile: UserProfile = { + id: "profile-1", + displayName: "Ada", + avatarMime: null, + mergedInto: null, + createdAt: 1, + updatedAt: 2, + emails: ["ada@example.test"], + hasAvatar: false, + }; + let rejectIdentity: ((reason: Error) => void) | undefined; + const firstIdentity = new Promise((_resolve, reject) => { + rejectIdentity = reject; + }); + const request = vi.fn(async (method: string) => { + if (method !== "users.self") { + throw new Error(`unexpected method: ${method}`); + } + if (request.mock.calls.length === 1) { + return await firstIdentity; + } + return { profile }; + }); + const harness = createConnectedContext(request as GatewayBrowserClient["request"], { + id: profile.id, + email: profile.emails[0], + name: profile.displayName ?? undefined, + }); + const provider = createApplicationContextProvider(harness.context); + const page = document.createElement(PROFILE_PAGE_TEST_TAG) as ProfilePageElement; + provider.append(page); + document.body.append(provider); + + await waitForFast(() => + expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(1), + ); + await page.updateComplete; + const refresh = page.querySelector(".profile-refresh")!; + expect(refresh.disabled).toBe(true); + expect(refresh.textContent?.trim()).toBe(t("common.refreshing")); + + const pageWithIdentity = page as unknown as { loadIdentity: () => Promise }; + await Promise.all([pageWithIdentity.loadIdentity(), pageWithIdentity.loadIdentity()]); + expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(1); + + rejectIdentity?.(new Error("identity unavailable")); + await waitForFast(() => expect(refresh.disabled).toBe(false)); + expect(refresh.textContent?.trim()).toBe(t("common.refresh")); + expect(page.textContent).toContain("identity unavailable"); + + refresh.click(); + await waitForFast(() => + expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(2), + ); + await waitForFast(() => + expect(page.querySelector(".identity-name-control input")?.value).toBe("Ada"), + ); +}); + +it("replaces an in-flight identity request after a same-client reconnect", async () => { + const staleProfile: UserProfile = { + id: "profile-1", + displayName: "Stale identity", + avatarMime: null, + mergedInto: null, + createdAt: 1, + updatedAt: 2, + emails: ["ada@example.test"], + hasAvatar: false, + }; + const freshProfile = { ...staleProfile, displayName: "Fresh identity", updatedAt: 3 }; + let resolveStale: ((value: { profile: UserProfile }) => void) | undefined; + let resolveFresh: ((value: { profile: UserProfile }) => void) | undefined; + const staleRequest = new Promise<{ profile: UserProfile }>((resolve) => { + resolveStale = resolve; + }); + const freshRequest = new Promise<{ profile: UserProfile }>((resolve) => { + resolveFresh = resolve; + }); + const request = vi.fn(async (method: string) => { + if (method !== "users.self") { + throw new Error(`unexpected method: ${method}`); + } + return await (request.mock.calls.length === 1 ? staleRequest : freshRequest); + }); + const harness = createConnectedContext(request as GatewayBrowserClient["request"], { + id: staleProfile.id, + email: staleProfile.emails[0], + name: staleProfile.displayName ?? undefined, + }); + const provider = createApplicationContextProvider(harness.context); + const page = document.createElement(PROFILE_PAGE_TEST_TAG) as ProfilePageElement; + provider.append(page); + document.body.append(provider); + + await waitForFast(() => expect(request).toHaveBeenCalledTimes(1)); + harness.emitConnected(false); + await page.updateComplete; + harness.emitConnected(true); + await waitForFast(() => expect(request).toHaveBeenCalledTimes(2)); + + resolveFresh?.({ profile: freshProfile }); + await waitForFast(() => + expect(page.querySelector(".identity-name-control input")?.value).toBe( + "Fresh identity", + ), + ); + resolveStale?.({ profile: staleProfile }); + await staleRequest; + await Promise.resolve(); + await page.updateComplete; + + expect(page.querySelector(".identity-name-control input")?.value).toBe( + "Fresh identity", + ); + expect(request).toHaveBeenCalledTimes(2); +}); + it("bootstraps and refreshes the connected user's profile through users.self", async () => { let profile: UserProfile = { id: "profile-1", diff --git a/ui/src/pages/profile/profile-page.ts b/ui/src/pages/profile/profile-page.ts index 8694f834b163..6577ebb46c7f 100644 --- a/ui/src/pages/profile/profile-page.ts +++ b/ui/src/pages/profile/profile-page.ts @@ -85,19 +85,21 @@ export class ProfilePage extends OpenClawLightDomElement { private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) { const clientChanged = snapshot.client !== this.client; - const nextSelfUser = - snapshot.phase === "connected" - ? resolveCurrentSelfUser({ snapshotUser: snapshot.selfUser }) - : null; + const nextConnected = snapshot.phase === "connected"; + const connectionChanged = nextConnected !== this.connected; + const nextSelfUser = nextConnected + ? resolveCurrentSelfUser({ snapshotUser: snapshot.selfUser }) + : null; const selfProfileChanged = nextSelfUser?.id !== this.selfUser?.id; + const identitySourceChanged = clientChanged || connectionChanged || selfProfileChanged; this.client = snapshot.client; - this.connected = snapshot.phase === "connected"; + this.connected = nextConnected; this.selfUser = nextSelfUser; // connected/client are plain fields; an unidentified (token-auth) connect or // disconnect changes no @state, so the render branch must be invalidated // explicitly or the page sticks on the stale offline/connected view. this.requestUpdate(); - if (clientChanged || selfProfileChanged) { + if (identitySourceChanged) { this.identityRequestId += 1; this.ownProfile = null; this.displayName = ""; @@ -105,10 +107,10 @@ export class ProfilePage extends OpenClawLightDomElement { this.identityBusy = null; this.identityError = null; } - if (snapshot.phase !== "connected" || !snapshot.client) { + if (!nextConnected || !snapshot.client) { return; } - if (nextSelfUser && (clientChanged || selfProfileChanged)) { + if (nextSelfUser && identitySourceChanged) { void this.loadIdentity(); } void this.context.agents.ensureList().then((list) => { @@ -120,7 +122,9 @@ export class ProfilePage extends OpenClawLightDomElement { private async loadIdentity() { const client = this.client; - if (!client || !this.connected) { + // One active request owns the generation; reconnects clear loading before + // starting their replacement so stale responses cannot win out of order. + if (!client || !this.connected || this.identityLoading) { return; } const requestId = ++this.identityRequestId; @@ -300,7 +304,7 @@ export class ProfilePage extends OpenClawLightDomElement { } private refreshManually() { - if (this.selfUser && !this.identityBusy) { + if (this.selfUser && !this.identityBusy && !this.identityLoading) { void this.loadIdentity(); } } @@ -380,7 +384,11 @@ export class ProfilePage extends OpenClawLightDomElement { ${this.selfUser - ? html`` : nothing} diff --git a/ui/src/pages/skills/skills-page.ts b/ui/src/pages/skills/skills-page.ts index 6acdb399271c..e48225e91261 100644 --- a/ui/src/pages/skills/skills-page.ts +++ b/ui/src/pages/skills/skills-page.ts @@ -96,6 +96,10 @@ class SkillsPage extends OpenClawLightDomElement { @state() skillCardLoadingKey: string | null = null; @state() skillCardErrors: Record = {}; + get runtimeConfig(): ApplicationContext["runtimeConfig"] { + return this.context.runtimeConfig; + } + private clawhubSearchTimer: ReturnType | null = null; private routeDataInitialized = false; private routeDataEnabled = true; diff --git a/ui/src/pages/skills/view.test.ts b/ui/src/pages/skills/view.test.ts index 28a63667176e..8c683b9c5012 100644 --- a/ui/src/pages/skills/view.test.ts +++ b/ui/src/pages/skills/view.test.ts @@ -194,6 +194,50 @@ describe("renderSkills", () => { ); }); + it.each([ + { editValue: "", disabled: true }, + { editValue: " ", disabled: true }, + { editValue: " sk-test ", disabled: false }, + ])( + "only enables credential replacement for nonblank input: $editValue", + async ({ editValue, disabled }) => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + installDialogMethod("showModal", function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + const onSaveKey = vi.fn(); + + render( + renderSkills( + createProps({ + detailKey: "repo-skill", + edits: { "repo-skill": editValue }, + onSaveKey, + }), + ), + container, + ); + await Promise.resolve(); + + const input = container.querySelector('input[type="password"]'); + const save = Array.from(container.querySelectorAll("button")).find( + (button) => normalizeText(button) === "Save key", + ); + expect(input?.required).toBe(true); + expect(save?.disabled).toBe(disabled); + + save?.click(); + + if (disabled) { + expect(onSaveKey).not.toHaveBeenCalled(); + } else { + expect(onSaveKey).toHaveBeenCalledWith("repo-skill"); + } + }, + ); + it("renders skill groups as open collapsible sections with heading summaries", async () => { const container = document.createElement("div"); document.body.append(container); diff --git a/ui/src/pages/skills/view.ts b/ui/src/pages/skills/view.ts index 967fd770e6b7..674dbcfbf598 100644 --- a/ui/src/pages/skills/view.ts +++ b/ui/src/pages/skills/view.ts @@ -737,6 +737,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) { > @@ -756,7 +757,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) { })()} `; })} diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 33b61baa8274..3d9e532cb769 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -4287,6 +4287,27 @@ openclaw-chat-video-player { white-space: nowrap; } +.chat-controls__model-capability-badge { + display: inline-flex; + align-items: center; + gap: 4px; + height: 20px; + padding: 0 5px; + border: 1px solid color-mix(in srgb, var(--warn) 34%, var(--border)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--warn) 10%, transparent); + color: color-mix(in srgb, var(--warn) 82%, var(--text)); + flex: 0 0 auto; + font-size: 10px; + font-weight: 700; + line-height: 1; +} + +.chat-controls__model-capability-badge svg { + width: 12px; + height: 12px; +} + /* Keep the chevron pinned right when the mobile trigger stretches. */ .chat-controls__inline-select-icon { display: inline-flex; diff --git a/ui/src/styles/cron-jobs-pagination.css b/ui/src/styles/cron-jobs-pagination.css new file mode 100644 index 000000000000..859043c4307a --- /dev/null +++ b/ui/src/styles/cron-jobs-pagination.css @@ -0,0 +1,13 @@ +.cron-table__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + font-size: var(--control-ui-text-sm); +} + +.cron-load-more { + align-self: flex-start; +} diff --git a/ui/src/styles/cron.css b/ui/src/styles/cron.css index 300771733509..45ec15c153a5 100644 --- a/ui/src/styles/cron.css +++ b/ui/src/styles/cron.css @@ -425,16 +425,6 @@ height: 12px; } -.cron-table__footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); - font-size: var(--control-ui-text-sm); -} - .cron-empty-state { padding: var(--space-6) var(--space-4); display: grid; @@ -455,10 +445,6 @@ line-height: 1.45; } -.cron-load-more { - align-self: flex-start; -} - /* ── Detail view ── */ .cron-back-row { diff --git a/ui/src/styles/hub-tabs.css b/ui/src/styles/hub-tabs.css index ab1cdef194c5..9a5501c8042d 100644 --- a/ui/src/styles/hub-tabs.css +++ b/ui/src/styles/hub-tabs.css @@ -120,11 +120,7 @@ wa-tab.hub-tab:focus-visible::part(base) { } @media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { - .content-header.sessions-hub-header { - display: none; - } - - .content-header.hub-page-header:not(.sessions-hub-header) { + .content-header.hub-page-header { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: @@ -136,20 +132,20 @@ wa-tab.hub-tab:focus-visible::part(base) { max-height: none; } - .hub-page-header:not(.sessions-hub-header) .hub-page-header__title { + .hub-page-header .hub-page-header__title { grid-area: intro; justify-self: stretch; } - .hub-page-header:not(.sessions-hub-header) .page-title { + .hub-page-header .page-title { display: none; } - .hub-page-header:not(.sessions-hub-header) .hub-page-header__tabs { + .hub-page-header .hub-page-header__tabs { grid-area: tabs; } - .hub-page-header:not(.sessions-hub-header) .hub-page-header__actions { + .hub-page-header .hub-page-header__actions { grid-area: actions; justify-self: center; } diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 61b515ca92b7..23cf82a4c37f 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -338,11 +338,6 @@ html.openclaw-native-macos body .shell--mobile-nav .topnav-shell__actions { font-size: 12px; } - /* Content */ - .content-header { - display: none; - } - /* Hide the entire content-header on mobile chat — controls are in mobile gear menu */ .content--chat .content-header { display: none; diff --git a/ui/src/styles/usage.css b/ui/src/styles/usage.css index a36a74de7fa3..4fb478307f2d 100644 --- a/ui/src/styles/usage.css +++ b/ui/src/styles/usage.css @@ -1158,6 +1158,9 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-hour-cell { min-height: 46px; + padding: 0; + cursor: pointer; + appearance: none; transition: transform 0.18s var(--ease-out), border-color 0.18s var(--ease-out), @@ -1169,6 +1172,11 @@ details.usage-filter-select summary::-webkit-details-marker, box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent); } +.usage-hour-cell:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent); + outline-offset: 2px; +} + .usage-hour-cell.selected { border-color: color-mix(in srgb, var(--accent) 60%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); diff --git a/ui/src/test-helpers/app-sidebar-cases/session-pagination.ts b/ui/src/test-helpers/app-sidebar-cases/session-pagination.ts index 648c23a25f90..4af6cdda318a 100644 --- a/ui/src/test-helpers/app-sidebar-cases/session-pagination.ts +++ b/ui/src/test-helpers/app-sidebar-cases/session-pagination.ts @@ -13,6 +13,93 @@ import { waitForFast } from "../wait-for.ts"; import "../../components/app-sidebar.ts"; describe("AppSidebar gateway session pagination", () => { + it.each(["archived", "all"] as const)( + "refreshes the %s sidebar once when another client changes sessions", + async (statusFilter) => { + const harness = createSessionsHarness("main", ["agent:main:canonical-active"]); + const firstResult = createSessionState("main", ["agent:main:before-remote-change"]).result; + const changedResult = createSessionState("main", ["agent:main:after-remote-change"]).result; + if (!firstResult || !changedResult) { + throw new Error("expected filtered session results"); + } + harness.list.mockResolvedValue(firstResult); + const gateway = createGatewayHarness({} as GatewayBrowserClient); + const { sidebar } = await mountSidebar(gateway.gateway, harness.sessions); + (sidebar as unknown as { sessionsStatusFilter: "archived" | "all" }).sessionsStatusFilter = + statusFilter; + sidebar.sessionData.resetForStatusFilter(statusFilter); + await sidebar.sessionData.refreshSidebarSessions("main"); + harness.list.mockClear(); + harness.list.mockResolvedValue(changedResult); + + gateway.publishEvent("sessions.changed", { + sessionKey: "agent:main:after-remote-change", + reason: "archive", + }); + gateway.publishEvent("sessions.changed", { + sessionKey: "agent:main:after-remote-change", + reason: "archive", + }); + + await waitForFast(() => { + expect(harness.list).toHaveBeenCalledTimes(1); + expect(sidebar.sessionData.sessionsResult?.sessions.map((row) => row.key)).toEqual([ + "agent:main:after-remote-change", + ]); + }); + expect(harness.list).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "main", archivedFilter: statusFilter }), + ); + }, + ); + + it.each(["archived", "all"] as const)( + "keeps every loaded %s sidebar page after another client's session event", + async (statusFilter) => { + const keys = Array.from({ length: 120 }, (_, index) => `agent:main:session-${index}`); + const harness = createSessionsHarness("main", ["agent:main:canonical-active"]); + harness.list.mockImplementation(async (options) => { + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 60; + const sessions = createSessionState("main", keys.slice(offset, offset + limit)).result; + if (!sessions) { + throw new Error("expected a paginated filtered session result"); + } + const nextOffset = offset + sessions.sessions.length; + const hasMore = nextOffset < keys.length; + return { + ...sessions, + totalCount: keys.length, + nextOffset: hasMore ? nextOffset : null, + hasMore, + }; + }); + const gateway = createGatewayHarness({} as GatewayBrowserClient); + const { sidebar } = await mountSidebar(gateway.gateway, harness.sessions); + (sidebar as unknown as { sessionsStatusFilter: "archived" | "all" }).sessionsStatusFilter = + statusFilter; + sidebar.sessionData.resetForStatusFilter(statusFilter); + await sidebar.sessionData.refreshSidebarSessions("main"); + await sidebar.sessionData.loadMoreSidebarSessions(); + expect(sidebar.sessionData.sessionsResult?.sessions).toHaveLength(120); + harness.list.mockClear(); + + gateway.publishEvent("sessions.changed", { + sessionKey: keys[0], + agentId: "main", + reason: "archive", + }); + + await waitForFast(() => { + expect(harness.list).toHaveBeenCalledOnce(); + expect(sidebar.sessionData.sessionsResult?.sessions).toHaveLength(120); + }); + expect(harness.list).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "main", archivedFilter: statusFilter, limit: 120 }), + ); + }, + ); + it.each(["archived", "all"] as const)( "keeps a pending %s first page across a stable same-client Gateway notification", async (statusFilter) => { diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index a63f23702c84..91850e1a7ea7 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -207,6 +207,8 @@ export type ControlUiMockGatewayScenario = { name: string; provider: string; available?: boolean; + contextWindow?: number; + supportsTools?: boolean; }>; /** Operator scopes returned by the mocked connect handshake. */ operatorScopes?: string[];