mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
merge main into voice-call pacer fix
* commit 'd8f229f2e6c39e2dc694730c2efc479e4fb3871e': (59 commits) fix(setup): isolate failed channel probes and report doctor diagnostics (#116824) fix(telegram): preserve edit previews, message cache, and group history (#116818) fix(whatsapp): preserve interactive replies and normalize media MIME (#116816) fix(file-transfer): keep fetched media attachable in sandboxed replies (#116400) fix(browser): preserve doctor JSON failure status (#116811) fix(ollama): stream native tool call lifecycle (#116809) fix(matrix): preserve message-tool room thread routing (#116802) fix(ui): restore mobile navigation and accessible usage filters (#116751) fix(ui): coalesce profile identity refresh (#116764) fix(ui): preserve chat attachments and run ownership (#116752) fix(plugins): clean up partial service startup once (#116804) fix(qa-channel): enforce message and thread lifecycle ownership (#116801) fix(qa-lab): support groups and isolate thread timelines (#116803) feat(plugins): externalize Voyage embeddings (#116785) fix(outbound): strip echoed inbound metadata before delivery (#50520) fix(anthropic-vertex): correct multi-region endpoints (#116757) fix(ui): scope agent cron jobs and surface model catalog failures (#116771) fix(ui): preserve scoped session state across updates (#116772) fix(build): fail closed on symlinked build output roots (#116705) fix(commands): validate native settings and preserve Telegram ingress outcomes (#116773) ...
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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-<id>` 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-<id>` 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-93
@@ -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]
|
||||
|
||||
+29
-28
@@ -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<Unit>()
|
||||
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<ChannelClient.Channel, CompletableDeferred<Unit>>()
|
||||
private val closeGates = ConcurrentHashMap<ChannelClient.Channel, CompletableDeferred<Unit>>()
|
||||
private val closeStarted = ConcurrentHashMap<ChannelClient.Channel, CompletableDeferred<Unit>>()
|
||||
private val closeCompleted = ConcurrentHashMap<ChannelClient.Channel, CompletableDeferred<Unit>>()
|
||||
private val closeCounts = ConcurrentHashMap<ChannelClient.Channel, Int>()
|
||||
private val writeGates = ConcurrentHashMap<ChannelClient.Channel, CountDownLatch>()
|
||||
private val writeStarted = ConcurrentHashMap<ChannelClient.Channel, CompletableDeferred<Unit>>()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
<Steps>
|
||||
<Step title="Install the plugin">
|
||||
```bash
|
||||
openclaw plugins install @openclaw/duckduckgo-plugin
|
||||
openclaw gateway restart
|
||||
```
|
||||
</Step>
|
||||
<Step title="Configure">
|
||||
```bash
|
||||
openclaw configure --section web
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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<typeof createDiscordClient>["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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 <https://docs.openclaw.ai/tools/duckduckgo-search> for setup and configuration.
|
||||
@@ -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());
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
`(?<!${IRC_NICK_CHARACTER})${buildIrcNickMentionPattern(mentionNick)}(?!${IRC_NICK_CHARACTER})[:,]?`,
|
||||
"i",
|
||||
)
|
||||
: null;
|
||||
const wasMentioned =
|
||||
core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||
|
||||
|
||||
@@ -12,6 +12,9 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
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" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<ResolvedMatrixAccount, MatrixProbe> =
|
||||
),
|
||||
},
|
||||
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<typeof resolveMatrixAccountConfig>
|
||||
>({
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,7 +9,10 @@ import { buildMistralRealtimeTranscriptionProvider } from "./realtime-transcript
|
||||
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
async function createRealtimeServer(onRequest: (url: URL) => void) {
|
||||
async function createRealtimeServer(
|
||||
onRequest: (url: URL) => void,
|
||||
transcriptionEvents: readonly Record<string, unknown>[] = [],
|
||||
) {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
||||
const clients = new Set<WebSocket>();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<OpenAIRealtimeVoiceLifecyclePhase, "idle">;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -306,6 +306,30 @@ const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/;
|
||||
const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5000;
|
||||
const resolvedKeychainSecretRefCache = new Map<string, string>();
|
||||
|
||||
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<string, unknown>) : 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)));
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -122,6 +122,16 @@ export function createQaBusState() {
|
||||
return created;
|
||||
};
|
||||
|
||||
const requireActiveMessageForAccount = (
|
||||
input: Pick<QaBusReadMessageInput, "accountId" | "messageId">,
|
||||
): 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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -399,6 +399,7 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter {
|
||||
};
|
||||
this.waitForOutboundSequence = async (input) =>
|
||||
await waitForQaTransportOutboundSequence({
|
||||
accountId: this.accountId,
|
||||
input,
|
||||
readEvents: () => this.#state.getOutboundEvents(),
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -184,6 +184,7 @@ class QaChannelTransport extends QaStateBackedTransportAdapter {
|
||||
}
|
||||
async waitForOutboundSequence(input: QaTransportOutboundSequenceMatch) {
|
||||
return await waitForQaTransportOutboundSequence({
|
||||
accountId: this.accountId,
|
||||
input,
|
||||
readEvents: () => this.state.getSnapshot().events,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T>(
|
||||
check: () => T | Promise<T | null | undefined> | 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;
|
||||
|
||||
@@ -20,8 +20,6 @@ function createDeps(overrides?: Partial<QaScenarioRuntimeDeps>): QaScenarioRunti
|
||||
randomUUID,
|
||||
runScenario: fn,
|
||||
waitForOutboundMessage: fn,
|
||||
waitForTransportOutboundMessage: fn,
|
||||
waitForChannelOutboundMessage: fn,
|
||||
waitForNoOutbound: fn,
|
||||
waitForNoTransportOutbound: fn,
|
||||
recentOutboundSummary: fn,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -118,6 +118,7 @@ function assertQaSuiteSummaryHasExecutedScenarios(
|
||||
summaryPath: string,
|
||||
errorCode: "summary_failure_count_missing" | "summary_blocking_count_missing",
|
||||
optionalScenarioNames?: ReadonlySet<string>,
|
||||
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<string> },
|
||||
options?: { optionalScenarioNames?: ReadonlySet<string>; requireExecutedScenario?: boolean },
|
||||
): Promise<number> {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLButtonElement>("[data-thread-select='owned-thread']")?.click();
|
||||
selectValue(root, "#conversation-kind", "group");
|
||||
const conversationInput = root.querySelector<HTMLInputElement>("#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<HTMLTextAreaElement>("#composer-text");
|
||||
if (!composer) {
|
||||
throw new Error("missing group message composer");
|
||||
}
|
||||
composer.value = "hello group";
|
||||
composer.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
root.querySelector<HTMLButtonElement>("[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<string, unknown>;
|
||||
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",
|
||||
|
||||
@@ -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<HTMLSelectElement>("#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<HTMLInputElement>("#conversation-id")?.addEventListener("input", (e) => {
|
||||
state.composer.conversationId = (e.currentTarget as HTMLInputElement).value;
|
||||
|
||||
@@ -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 {
|
||||
<select id="conversation-kind">
|
||||
<option value="direct"${state.composer.conversationKind === "direct" ? " selected" : ""}>DM</option>
|
||||
<option value="channel"${state.composer.conversationKind === "channel" ? " selected" : ""}>Channel</option>
|
||||
<option value="group"${state.composer.conversationKind === "group" ? " selected" : ""}>Group</option>
|
||||
</select>
|
||||
<span>as</span>
|
||||
<input id="sender-name" value="${esc(state.composer.senderName)}" placeholder="Name" />
|
||||
|
||||
@@ -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('<option value="group" selected>Group</option>');
|
||||
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<UiState["snapshot"]> = {
|
||||
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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof inspectSlackAccount>): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, { expiresAt: number }>();
|
||||
|
||||
type TelegramNativeCommandContext = Context & { match?: string };
|
||||
|
||||
function registerTelegramNativeCommandHandler(
|
||||
bot: Bot,
|
||||
command: string,
|
||||
handler: (ctx: TelegramNativeCommandContext) => Promise<void>,
|
||||
): 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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -56,11 +56,23 @@ export class TelegramSpooledReplayProcessingError extends Error {
|
||||
export async function runWithTelegramUpdateProcessingFrame<T>(
|
||||
fn: () => Promise<T>,
|
||||
): 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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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)}`);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<TelegramApiContext["api"]["editMessageText"]>[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<TelegramEditRichMessageTextParams, "reply_markup"> =
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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 ?? "<media>",
|
||||
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", () => {
|
||||
|
||||
@@ -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<TelegramMessageProcessingResult | void>;
|
||||
};
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user