Merge branch 'main' into meow/control-ui-mount-fallback

This commit is contained in:
Val Alexander
2026-05-11 07:58:59 -05:00
committed by GitHub
167 changed files with 5177 additions and 1171 deletions

View File

@@ -24,6 +24,7 @@ Inputs are provided as environment variables:
- `BASELINE_SHA`
- `CANDIDATE_REF`
- `CANDIDATE_SHA`
- `MANTIS_CANDIDATE_TRUST`
- `MANTIS_OUTPUT_DIR`
- `MANTIS_INSTRUCTIONS`
- `CRABBOX_PROVIDER`
@@ -34,9 +35,7 @@ Required workflow:
1. Read `.agents/skills/telegram-crabbox-e2e-proof/SKILL.md`.
2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and
`gh pr diff "$MANTIS_PR_NUMBER"` when `MANTIS_PR_NUMBER` is set. If the run
came from workflow dispatch without a PR number, inspect
`BASELINE_SHA..CANDIDATE_SHA`.
`gh pr diff "$MANTIS_PR_NUMBER"`.
3. Decide what Telegram message, mock model response, command, callback, button,
media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra
maintainer guidance, not as a replacement for reading the PR.
@@ -44,6 +43,12 @@ Required workflow:
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/baseline` and
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/candidate`, then
install and build each worktree with the repo's normal `pnpm` commands.
If `MANTIS_CANDIDATE_TRUST` is `fork-pr-head`, treat the
candidate worktree as untrusted fork code: do not pass GitHub, OpenAI,
Crabbox, Convex, or other workflow secrets into candidate install, build, or
runtime commands. The candidate SUT may receive only the proof runner's
short-lived Telegram bot token, generated local config/state paths, and mock
model key needed for this isolated proof.
5. In each worktree, run the real-user Telegram Crabbox proof flow from the
skill with `$OPENCLAW_TELEGRAM_USER_PROOF_CMD`; do not run
`pnpm qa:telegram-user:crabbox` directly. The proof command comes from the

View File

@@ -5,19 +5,9 @@ on:
types: [created]
workflow_dispatch:
inputs:
baseline_ref:
description: Ref, tag, or SHA to capture as the before GIF
required: true
default: main
type: string
candidate_ref:
description: Ref, tag, or SHA to capture as the after GIF
required: true
default: main
type: string
pr_number:
description: Optional PR number to receive the QA evidence comment
required: false
description: PR number to capture
required: true
type: string
instructions:
description: Optional freeform proof instructions for the agent
@@ -42,7 +32,7 @@ permissions:
pull-requests: write
concurrency:
group: mantis-telegram-desktop-proof-${{ github.event.issue.number || inputs.pr_number || inputs.candidate_ref || github.run_id }}-${{ github.run_attempt }}
group: mantis-telegram-desktop-proof-${{ github.event.issue.number || inputs.pr_number || github.run_id }}-${{ github.run_attempt }}
cancel-in-progress: false
env:
@@ -63,6 +53,7 @@ jobs:
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.issue.labels.*.name, 'mantis: telegram-visible-proof') &&
(
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
@@ -102,7 +93,6 @@ jobs:
lease_id: ${{ steps.resolve.outputs.lease_id }}
pr_number: ${{ steps.resolve.outputs.pr_number }}
request_source: ${{ steps.resolve.outputs.request_source }}
should_run: ${{ steps.resolve.outputs.should_run }}
steps:
- name: Resolve refs and target PR
id: resolve
@@ -116,47 +106,11 @@ jobs:
core.info(`${name}=${value ?? ""}`);
}
if (eventName === "workflow_dispatch") {
const inputs = context.payload.inputs ?? {};
setOutput("should_run", "true");
setOutput("baseline_ref", inputs.baseline_ref || "main");
setOutput("candidate_ref", inputs.candidate_ref || "main");
setOutput("pr_number", inputs.pr_number || "");
setOutput("instructions", inputs.instructions || "");
setOutput("crabbox_provider", inputs.crabbox_provider || "aws");
setOutput("lease_id", inputs.crabbox_lease_id || "");
setOutput("request_source", "workflow_dispatch");
return;
}
if (eventName !== "issue_comment") {
core.setFailed(`Unsupported event: ${eventName}`);
return;
}
const issue = context.payload.issue;
const body = context.payload.comment?.body ?? "";
if (!issue?.pull_request) {
core.setFailed("Mantis issue_comment trigger requires a pull request comment.");
return;
}
const normalized = body.toLowerCase();
const requested =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
normalized.includes("telegram") &&
(normalized.includes("desktop") || normalized.includes("native")) &&
normalized.includes("proof");
if (!requested) {
core.notice("Comment mentioned Mantis but did not request Telegram desktop proof.");
setOutput("should_run", "false");
setOutput("baseline_ref", "");
setOutput("candidate_ref", "");
setOutput("pr_number", "");
setOutput("instructions", "");
setOutput("crabbox_provider", "");
setOutput("lease_id", "");
setOutput("request_source", "unsupported_issue_comment");
const inputs = context.payload.inputs ?? {};
const prNumber =
eventName === "workflow_dispatch" ? inputs.pr_number : String(context.payload.issue?.number ?? "");
if (!prNumber) {
core.setFailed("Mantis Telegram desktop proof requires a pull request.");
return;
}
@@ -164,59 +118,40 @@ jobs:
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: issue.number,
pull_number: Number(prNumber),
});
let mergedBaseline = "";
let mergedCandidate = "";
if (pr.merged) {
const { data: commits } = await github.rest.pulls.listCommits({
owner,
repo,
pull_number: issue.number,
per_page: 100,
});
mergedCandidate = pr.merge_commit_sha || commits.at(-1)?.sha || "";
mergedBaseline = mergedCandidate && commits.length > 0 ? `${mergedCandidate}~${commits.length}` : "";
}
const baselineMatch = body.match(/(?:baseline|base)[\s:=]+([^\s`]+)/i);
const candidateMatch = body.match(/(?:candidate|head)[\s:=]+([^\s`]+)/i);
const providerMatch = body.match(/(?:provider|crabbox_provider)[\s:=]+([^\s`]+)/i);
const leaseMatch = body.match(/(?:lease|lease_id|crabbox_lease_id)[\s:=]+([^\s`]+)/i);
const provider = providerMatch?.[1] || "aws";
const body = eventName === "workflow_dispatch" ? inputs.instructions || "" : context.payload.comment?.body || "";
const provider = inputs.crabbox_provider || "aws";
if (!["aws", "hetzner"].includes(provider)) {
core.setFailed(`Unsupported Crabbox provider for Mantis Telegram desktop proof: ${provider}`);
return;
}
const rawCandidate = candidateMatch?.[1];
const candidate =
rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase())
? rawCandidate
: mergedCandidate || pr.head.sha;
setOutput("should_run", "true");
setOutput("baseline_ref", baselineMatch?.[1] || mergedBaseline || "main");
setOutput("candidate_ref", candidate);
setOutput("pr_number", String(issue.number));
setOutput("baseline_ref", pr.base.sha);
setOutput("candidate_ref", pr.head.sha);
setOutput("pr_number", String(pr.number));
setOutput("instructions", body);
setOutput("crabbox_provider", provider);
setOutput("lease_id", leaseMatch?.[1] || "");
setOutput("request_source", "issue_comment");
setOutput("lease_id", inputs.crabbox_lease_id || "");
setOutput("request_source", eventName);
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: context.payload.comment.id,
content: "eyes",
}).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`));
if (eventName === "issue_comment") {
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: context.payload.comment.id,
content: "eyes",
}).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`));
}
validate_refs:
name: Validate selected refs
needs: resolve_request
if: ${{ needs.resolve_request.outputs.should_run == 'true' }}
runs-on: ubuntu-24.04
outputs:
baseline_revision: ${{ steps.validate.outputs.baseline_revision }}
candidate_revision: ${{ steps.validate.outputs.candidate_revision }}
candidate_trust: ${{ steps.validate.outputs.candidate_trust }}
steps:
- name: Checkout harness ref
uses: actions/checkout@v6
@@ -240,55 +175,56 @@ jobs:
git fetch --no-tags origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" || true
fi
validate_ref() {
local label="$1"
resolve_commit() {
local input_ref="$2"
local revision=""
local reason=""
if ! revision="$(git rev-parse --verify "${input_ref}^{commit}" 2>/dev/null)"; then
echo "${label} ref '${input_ref}' is not available in the workflow checkout." >&2
exit 1
fi
if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then
reason="main-ancestor"
elif git tag --points-at "$revision" | grep -Eq '^v'; then
reason="release-tag"
else
local pr_head_count
pr_head_count="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length'
)"
if [[ "$pr_head_count" != "0" ]]; then
reason="open-pr-head"
fi
fi
if [[ -z "$reason" ]]; then
echo "${label} ref '${input_ref}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2
echo "$1 ref '${input_ref}' is not available in the workflow checkout." >&2
exit 1
fi
printf '%s\n' "$revision"
}
baseline_revision="$(validate_ref baseline "$BASELINE_REF")"
candidate_revision="$(validate_ref candidate "$CANDIDATE_REF")"
baseline_revision="$(resolve_commit baseline "$BASELINE_REF")"
candidate_revision="$(resolve_commit candidate "$CANDIDATE_REF")"
if ! git merge-base --is-ancestor "$baseline_revision" refs/remotes/origin/main; then
echo "baseline ref '${BASELINE_REF}' resolved to ${baseline_revision}, which is not on main." >&2
exit 1
fi
pr_head="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
--jq '{state, head_sha: .head.sha, head_repo: .head.repo.full_name}'
)"
pr_state="$(jq -r '.state' <<<"$pr_head")"
pr_head_sha="$(jq -r '.head_sha' <<<"$pr_head")"
pr_head_repo="$(jq -r '.head_repo' <<<"$pr_head")"
if [[ "$pr_state" != "open" || "$candidate_revision" != "$pr_head_sha" ]]; then
echo "candidate ref '${CANDIDATE_REF}' resolved to ${candidate_revision}, which is not the open PR head." >&2
exit 1
fi
candidate_trust="open-pr-head"
if [[ "$pr_head_repo" != "$GITHUB_REPOSITORY" ]]; then
candidate_trust="fork-pr-head"
fi
echo "baseline_revision=${baseline_revision}" >> "$GITHUB_OUTPUT"
echo "candidate_revision=${candidate_revision}" >> "$GITHUB_OUTPUT"
echo "candidate_trust=${candidate_trust}" >> "$GITHUB_OUTPUT"
{
echo "baseline: \`${BASELINE_REF}\`"
echo "baseline SHA: \`${baseline_revision}\`"
echo "baseline trust: \`main-ancestor\`"
echo "candidate: \`${CANDIDATE_REF}\`"
echo "candidate SHA: \`${candidate_revision}\`"
echo "candidate trust: \`${candidate_trust}\`"
} >> "$GITHUB_STEP_SUMMARY"
run_telegram_desktop_proof:
name: Run agentic native Telegram proof
needs: [resolve_request, validate_refs]
if: ${{ needs.resolve_request.outputs.should_run == 'true' }}
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 360
environment: qa-live-shared
@@ -375,7 +311,7 @@ jobs:
printf '%s\n' 'Defaults env_keep += "CODEX_HOME CODEX_INTERNAL_ORIGINATOR_OVERRIDE"'
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
} | sudo tee /etc/sudoers.d/mantis-codex-env >/dev/null
@@ -406,6 +342,7 @@ jobs:
CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }}
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
GH_TOKEN: ${{ github.token }}
MANTIS_CANDIDATE_TRUST: ${{ needs.validate_refs.outputs.candidate_trust }}
MANTIS_INSTRUCTIONS: ${{ needs.resolve_request.outputs.instructions }}
MANTIS_OUTPUT_DIR: ${{ env.MANTIS_OUTPUT_DIR }}
MANTIS_PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }}

View File

@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- CI: add a non-blocking `plugin-inspector-advisory` artifact to Plugin Prerelease so release runs capture bundled plugin compatibility triage without changing the blocking gate.
- Runtime/Fly: detect Fly Machines as container environments from their runtime env vars, so gateway bind and Bonjour defaults match remote container launches. (#80209) Thanks @liorb-mountapps.
- Providers/fal: route GPT Image 2 and Nano Banana 2 reference-image edit requests to `/edit` with `image_urls` array, enforce NB2 edit geometry using `aspect_ratio` and `resolution` params, lift Fal edit mode input-image caps to 10 for GPT Image 2 and 14 for Nano Banana 2, and allow aspect-ratio hints in edit mode. (#77295) Thanks @leoge007.
- Control UI: show a plain HTML recovery panel when the app module never registers, giving blank dashboard pages a retry path and browser-extension troubleshooting link. Fixes #44107. Thanks @BunsDev.
@@ -39,6 +40,7 @@ Docs: https://docs.openclaw.ai
- Talk: add `talk.realtime.instructions` so operators can append realtime voice style instructions while preserving OpenClaw's built-in agent-consult guidance. (#79081) Thanks @VACInc.
- Discord/voice: default test and source installs to the pure-JS `opusscript` decoder by ignoring optional native `@discordjs/opus` builds, avoiding slow native addon compiles outside dedicated voice-performance lanes.
- Discord/voice: add an opt-in native `@discordjs/opus` install script and decoder preference for live voice-performance lanes without charging unrelated Docker/tests for native addon builds.
- Discord/voice: add `voice.allowedChannels` to restrict voice joins and bot voice-state moves to configured channels while preserving open voice behavior when unset.
- Gateway/skills: add an opt-in private skill archive upload install path gated by `skills.install.allowUploadedArchives`, so trusted Gateway clients can stage and install zip-backed skills only when operators explicitly enable the code-install surface. (#74430) Thanks @samzong.
- Codex app-server: enable Codex native code-mode-only for harness threads so deferred OpenClaw dynamic tools run through Codex's own searchable code execution surface instead of a PI-style wrapper.
- Dependencies: refresh workspace pins and patch targets, including ACPX `@agentclientprotocol/claude-agent-acp` `0.33.1`, Codex ACP `0.14.0`, Baileys `7.0.0-rc10`, Google GenAI `2.0.1`, OpenAI `6.37.0`, AWS SDK `3.1045.0`, Kysely `0.29.0`, Tlon skill `0.3.6`, Aimock `1.19.5`, and tsdown `0.22.0`.
@@ -55,7 +57,13 @@ Docs: https://docs.openclaw.ai
### Fixes
- Feishu: make manual App ID/App Secret setup the default channel-binding path while keeping QR scan-to-create as an optional best-effort flow, and document the manual fallback for domestic Feishu mobile clients that do not react to the QR code. Fixes #80591. Thanks @wei-wei-zhao.
- Telegram: show resolved thinking defaults in native `/status` and `/think` menus while preserving explicit session overrides. (#80341) Thanks @VACInc.
- Channels: cache selected channel registry lookups against the active fallback snapshot so pinned-empty registries refresh native command and alias routing after active registry swaps. (#80333) Thanks @samzong.
- Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong.
- Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong.
- Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc.
- Providers/self-hosted: read model-scoped llama.cpp runtime context from `/props.default_generation_settings.n_ctx` while keeping top-level `n_ctx` as a fallback, so session budgeting reflects the loaded context window. Fixes #73664. (#74057) Thanks @brokemac79.
- Memory: reject symlinked directory components in configured extra memory paths before reading Markdown files. (#80331) Thanks @samzong.
- Sessions/transcripts: replace whole-file `readFile` scans with shared streaming helpers (`streamSessionTranscriptLines` and `streamSessionTranscriptLinesReverse`) for idempotency lookup, latest/tail assistant text reads, delivery-mirror dedupe, and compaction fork loading, so long-running sessions no longer materialize the full transcript in memory. Forward scans use `readline` over a bounded `createReadStream`; reverse scans read bounded chunks from the file end and decode complete JSONL lines newest-first without a fixed tail cap. Synthetic 200 MiB transcript: peak RSS delta drops from +252 MiB to +27 MiB while preserving malformed-line tolerance and idempotency-key return semantics. Fixes #54296. Thanks @jack-stormentswe.
- WhatsApp: apply hot-reloaded `dmPolicy` and `allowFrom` settings to the active Web listener before processing new inbound DMs. Fixes #80538. Thanks @Ampaskopi129.
@@ -145,6 +153,7 @@ Docs: https://docs.openclaw.ai
- Control UI: show compact one-line live/idle/terminal run status badges in the Sessions table and rename the active-minute filter to its updated-within meaning. Fixes #78307. Thanks @BunsDev.
- Control UI: scope chat session-list refreshes by agent and skip disk-only agent store discovery for configured-only lists, preventing post-first-message session switching stalls on large Windows stores. Fixes #79675. Thanks @lovelefeng-glitch, @BunsDev.
- Control UI: allow Appearance tweakcn theme imports through the served CSP so browser-local custom theme links no longer fail with a `connect-src` violation. Fixes #78504. Thanks @BunsDev.
- Control UI/config: remove plugin allowlist entries that the form auto-added when a plugin enable toggle is reverted before saving, so reverting the visible toggle clears dirty state without persisting unintended allowlist changes. (#78329) Thanks @samzong.
- Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments.
- Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so `openclaw doctor` stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys.
- Doctor/OpenAI Codex: preserve Codex auth intent when auto-repairing legacy `openai-codex/*` model refs to canonical `openai/*` by adding provider/model-scoped Codex runtime policy, preventing repaired configs from falling through to direct OpenAI API-key auth. Fixes #78533 and #78570. Thanks @superck110 and @Azmodump.
@@ -411,6 +420,7 @@ Docs: https://docs.openclaw.ai
- Channels/iMessage: honor `channels.imessage.groups.<chat_id>.systemPrompt` (and the `groups["*"]` wildcard) by forwarding it as `GroupSystemPrompt` on inbound group turns, mirroring the byte-identical resolver semantic from WhatsApp where defining the key as an empty string on a specific group suppresses the wildcard fallback. Brings iMessage to parity with the per-group `systemPrompt` pattern already supported by Discord, Telegram, IRC, Slack, GoogleChat, and the retired BlueBubbles channel. Fixes #78285. (#79383) Thanks @omarshahine.
- iMessage: add opt-in inbound catchup that replays messages received while the gateway was offline (crash, restart, mac sleep) on next startup. Enable with `channels.imessage.catchup.enabled: true`; tunables for `maxAgeMinutes`, `perRunLimit`, `firstRunLookbackMinutes`, and `maxFailureRetries`. Persists a per-account cursor under the OpenClaw state dir (`<openclawStateDir>/imessage/catchup/`), replays each row through the live dispatch path so allowlists/group policy/dedupe behave identically on replayed and live messages, and force-advances past wedged guids after `maxFailureRetries` to prevent stuck cursors. Extends the persisted echo-cache retention window so the agent's own outbound rows from before a gap are not re-fed as inbound on replay. Includes a regenerated `src/config/bundled-channel-config-metadata.generated.ts` so the runtime AJV schema accepts the new `channels.imessage.catchup` block. Fixes #78649. (#79387) Thanks @omarshahine.
- Channels/Yuanbao: bump the bundled `openclaw-plugin-yuanbao` npm spec from `2.11.0` to `2.13.0` in the official external channel catalog and refresh the pinned integrity hash, so fresh installs and catalog-driven reinstalls pick up the newer Yuanbao channel plugin release. (#79620) Thanks @loongfay.
- Gateway/OpenAI-compatible Chat Completions: support function `tools`, `tool_choice`, `tool_calls`, and `role: "tool"` follow-up turns while keeping tool-call stream finalization aligned with the command result and reporting client-tool name conflicts as invalid requests. (#66278) Thanks @Lellansin.
- Providers/Mistral: add `mistral-medium-3-5` to the bundled catalog with reasoning support. Thanks @sliekens.
- Docs/Mistral: document Medium 3.5 setup, local infer smoke usage, adjustable reasoning, and the Mistral HTTP 400 caveat for `reasoning_effort="high"` with `temperature: 0`.
@@ -420,6 +430,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI: surface browser-blocked WebSocket security failures with wss:// and loopback dashboard guidance instead of leaving the connection on a dead security error. Thanks @BunsDev.
- Gateway/diagnostics: keep active-only transient event-loop max-delay samples as info-level stability telemetry instead of warning-level liveness diagnostics. Thanks @BunsDev.
- Google/Gemini: default new API-key onboarding to stable `google/gemini-2.5-flash` instead of the preview Pro route, reducing surprise daily quota exhaustion. Fixes #79670. Thanks @HugeBunny.
- Amazon Bedrock: expose Claude thinking profiles through the lightweight provider policy surface so `/think:adaptive` validates before the Bedrock runtime plugin is loaded. Fixes #79754. Thanks @phoenixyy and @hclsys.
- Codex/transcripts: mirror dynamic tool calls and outputs into Codex app-server transcripts so tool activity is visible alongside assistant text instead of being elided, with per-item output capped at 12,000 characters. (#79952) Thanks @scoootscooob.
@@ -584,6 +596,7 @@ Docs: https://docs.openclaw.ai
- Network/runtime: avoid importing Undici's package dispatcher during no-proxy timeout bootstrap so external channel plugin fetch requests with explicit Content-Length keep working. Fixes #78007. Thanks @shakkernerd.
- Status/doctor: treat a single healthy OpenClaw Gateway listener on loopback, LAN, or wildcard bind as the expected configured gateway instead of warning that the port is already in use. Fixes #77939. Thanks @GitHoubi and @brokemac79.
- Agents/TTS: send media-bearing block replies directly when block streaming is off, so agent `tts` tool audio attached to a final text reply is delivered instead of being consumed before final Telegram/media delivery. Thanks @Conan-Scott.
- Doctor: avoid crashing on partial Linux environments when the legacy crontab probe or terminal note wrapper receives missing or non-string output. Fixes #77773. Thanks @brokemac79 and @blackflame7983.
- Gateway/performance: reuse the current compatible plugin metadata snapshot across hot read-only status, channel, auth, skills, and embedded agent settings paths, avoiding repeated synchronous plugin metadata scans during Gateway activity. Fixes #77983. Thanks @shakkernerd.
- Tasks/maintenance: prune stale cron run session registry entries while preserving running cron jobs and non-cron sessions. Fixes #73867. Thanks @brokemac79.
- Plugins: dispatch cached descriptor-backed tools by the resolved runtime tool name for unnamed factories, fixing multi-tool plugins whose shared manifest contracts exposed sibling tools but failed at execution. Fixes #78671. Thanks @zanni098.
@@ -1290,6 +1303,7 @@ Docs: https://docs.openclaw.ai
- Plugins/npm: build package-local runtime dist files for publishable plugins and stop listing root-package-excluded plugin sidecars in the core package metadata, so npm plugin installs such as `@openclaw/diffs` and `@openclaw/discord` no longer publish source-only runtime payloads. Fixes #76426. Thanks @PrinceOfEgypt.
- Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. Fixes #76371. (#76449) Thanks @joshavant and @neeravmakwana.
- Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc.
- Exec/security: treat configured `tools.exec.security` as authoritative for normal tool calls so model-supplied `security` arguments cannot downgrade or tighten the operator policy, while preserving explicitly granted elevated-full overrides. (#65933) Thanks @bryanpearson.
- Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev.
- Agents/compaction: add an optional bundled compaction notifier hook and retry once from the compacted transcript when automatic compaction leaves a turn without a final visible reply. (#76651) Thanks @simplyclever914.
- Agents/incomplete-turn: detect and surface a warning when the agent's final text after a tool-call chain is silently dropped because the post-tool assistant response was never produced, instead of completing the turn with only the pre-tool analysis text. Fixes #76477. Thanks @amknight.

View File

@@ -1,4 +1,4 @@
ebb8fa25af8be3a6c42a8bbf505f119819ee49b3c28a317ae04a244f740be381 config-baseline.json
c963f607273fcce55080dc6d8068d8e124a4aa111a8ecf04807ebef98dfa5fd5 config-baseline.json
647f7a12deed46b4a962848a17ed5666d24fc526b777feab62cf331d84ce957d config-baseline.core.json
f90c9d96ccc4c0c703d6c489f86d89fde208cd7f697b396aeee96ff3ee087956 config-baseline.channel.json
222d0338d6ed290870cac70cdf5e390bc1bb60c4462e46f847003bafe25c5a6e config-baseline.channel.json
18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json

View File

@@ -1179,6 +1179,12 @@ Auto-join example:
channelId: "234567890123456789",
},
],
allowedChannels: [
{
guildId: "123456789012345678",
channelId: "234567890123456789",
},
],
daveEncryption: true,
decryptionFailureTolerance: 24,
connectTimeoutMs: 30000,
@@ -1212,6 +1218,7 @@ Notes:
- Discord voice is opt-in for text-only configs; set `channels.discord.voice.enabled=true` (or keep an existing `channels.discord.voice` block) to enable `/vc` commands, the voice runtime, and the `GuildVoiceStates` gateway intent.
- `channels.discord.intents.voiceStates` can explicitly override voice-state intent subscription. Leave it unset for the intent to follow effective voice enablement.
- If `voice.autoJoin` has multiple entries for the same guild, OpenClaw joins the last configured channel for that guild.
- `voice.allowedChannels` is an optional residency allowlist. Leave it unset to allow `/vc join` into any authorized Discord voice channel. When set, `/vc join`, startup auto-join, and bot voice-state moves are restricted to the listed `{ guildId, channelId }` entries. Set it to an empty array to deny all Discord voice joins. If Discord moves the bot outside the allowlist, OpenClaw leaves that channel and rejoins the configured auto-join target when one is available.
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
- OpenClaw defaults to the pure-JS `opusscript` decoder for Discord voice receive. The optional native `@discordjs/opus` package is ignored by the repo pnpm install policy so normal installs, Docker lanes, and unrelated tests do not compile a native addon. Dedicated voice-performance hosts can opt in with `OPENCLAW_DISCORD_OPUS_DECODER=native` after installing the native addon.

View File

@@ -23,7 +23,7 @@ Requires OpenClaw 2026.4.25 or above. Run `openclaw --version` to check. Upgrade
```bash
openclaw channels login --channel feishu
```
Scan the QR code with your Feishu/Lark mobile app to create a Feishu/Lark bot automatically.
Choose manual setup to paste an App ID and App Secret from Feishu Open Platform, or choose QR setup to create a bot automatically. If the domestic Feishu mobile app does not react to the QR code, rerun setup and choose manual setup.
</Step>
<Step title="After setup completes, restart the gateway to apply the changes">
@@ -211,6 +211,13 @@ Feishu/Lark does not support native slash-command menus, so send these as plain
5. Ensure the gateway is running: `openclaw gateway status`
6. Check logs: `openclaw logs --follow`
### QR setup does not react in the Feishu mobile app
1. Rerun setup: `openclaw channels login --channel feishu`
2. Choose manual setup
3. In Feishu Open Platform, create a self-built app and copy its App ID and App Secret
4. Paste those credentials into the setup wizard
### App Secret leaked
1. Reset the App Secret in Feishu Open Platform / Lark Developer

View File

@@ -191,6 +191,63 @@ Set `stream: true` to receive Server-Sent Events (SSE):
- Each event line is `data: <json>`
- Stream ends with `data: [DONE]`
## Chat tool contract
`/v1/chat/completions` supports a function-tool subset compatible with common OpenAI Chat clients.
### Supported request fields
- `tools`: array of `{ "type": "function", "function": { ... } }`
- `tool_choice`: `"auto"`, `"none"`
- `messages[*].role: "tool"` follow-up turns
- `messages[*].tool_call_id` for binding tool results back to a prior tool call
### Unsupported variants
The endpoint returns `400 invalid_request_error` for unsupported tool variants, including:
- non-array `tools`
- non-function tool entries
- missing `tool.function.name`
- `tool_choice` variants such as `allowed_tools` and `custom`
- `tool_choice: "required"` (not yet enforced at runtime; will be supported once hard enforcement is implemented)
- `tool_choice: { "type": "function", "function": { "name": "..." } }` (same rationale as `required`)
- `tool_choice.function.name` values that do not match provided `tools`
### Non-streaming tool response shape
When the agent decides to call tools, the response uses:
- `choices[0].finish_reason = "tool_calls"`
- `choices[0].message.tool_calls[]` entries with:
- `id`
- `type: "function"`
- `function.name`
- `function.arguments` (JSON string)
Assistant commentary before the tool call is returned in `choices[0].message.content` (possibly empty).
### Streaming tool response shape
When `stream: true`, tool calls are emitted as incremental SSE chunks:
- initial assistant role delta
- optional assistant commentary deltas
- one or more `delta.tool_calls` chunks carrying tool identity and argument fragments
- final chunk with `finish_reason: "tool_calls"`
- `data: [DONE]`
If `stream_options.include_usage=true`, a trailing usage chunk is emitted before `[DONE]`.
### Tool follow-up loop
After receiving `tool_calls`, the client should execute the requested function(s) and send a follow-up request that includes:
- prior assistant tool-call message
- one or more `role: "tool"` messages with matching `tool_call_id`
This allows the gateway agent run to continue the same reasoning loop and produce the final assistant answer.
## Open WebUI quick setup
For a basic Open WebUI connection:

View File

@@ -81,15 +81,15 @@ or Docker-facing stages need it.
The Docker release-path stage runs these chunks when `live_suite_filter` is
empty:
| Chunk | Coverage |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `core` | Core Docker release-path smoke lanes. |
| `package-update-openai` | OpenAI package install/update behavior, including Codex on-demand install. |
| `package-update-anthropic` | Anthropic package install and update behavior. |
| `package-update-core` | Provider-neutral package and update behavior. |
| `plugins-runtime-plugins` | Plugin runtime lanes that exercise plugin behavior. |
| `plugins-runtime-services` | Service-backed and live plugin runtime lanes; includes OpenWebUI when requested. |
| `plugins-runtime-install-a` through `plugins-runtime-install-h` | Plugin install/runtime batches split for parallel release validation. |
| Chunk | Coverage |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `core` | Core Docker release-path smoke lanes. |
| `package-update-openai` | OpenAI package install/update behavior, Codex on-demand install, and Chat Completions tool calls. |
| `package-update-anthropic` | Anthropic package install and update behavior. |
| `package-update-core` | Provider-neutral package and update behavior. |
| `plugins-runtime-plugins` | Plugin runtime lanes that exercise plugin behavior. |
| `plugins-runtime-services` | Service-backed and live plugin runtime lanes; includes OpenWebUI when requested. |
| `plugins-runtime-install-a` through `plugins-runtime-install-h` | Plugin install/runtime batches split for parallel release validation. |
Use targeted `docker_lanes=<lane[,lane]>` on the reusable live/E2E workflow when
only one Docker lane failed. The release artifacts include per-lane rerun

View File

@@ -46,7 +46,9 @@ Where to execute. `auto` resolves to `sandbox` when a sandbox runtime is active
</ParamField>
<ParamField path="security" type="'deny' | 'allowlist' | 'full'">
Enforcement mode for `gateway` / `node` execution.
Ignored for normal tool calls. `gateway` / `node` security is controlled by
`tools.exec.security` and `~/.openclaw/exec-approvals.json`; elevated mode can
force `security=full` only when the operator explicitly grants elevated access.
</ParamField>
<ParamField path="ask" type="'off' | 'on-miss' | 'always'">

View File

@@ -250,6 +250,22 @@ describe("gateway bonjour advertiser", () => {
await expect(started.stop()).resolves.toBeUndefined();
});
it("auto-disables Bonjour on Fly Machines without Docker sentinel files", async () => {
enableAdvertiserUnitMode();
process.env.FLY_MACHINE_ID = "3d8d5459a03038";
process.env.FLY_APP_NAME = "openclaw-clawcks-test";
vi.spyOn(fs, "existsSync").mockReturnValue(false);
vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n");
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
expect(createService).not.toHaveBeenCalled();
await expect(started.stop()).resolves.toBeUndefined();
});
it("honors explicit Bonjour opt-in inside detected containers", async () => {
enableAdvertiserUnitMode();
process.env.OPENCLAW_DISABLE_BONJOUR = "0";

View File

@@ -135,6 +135,10 @@ function readBonjourDisableOverride(): boolean | null {
}
function isContainerEnvironment() {
if (process.env.FLY_MACHINE_ID?.trim() && process.env.FLY_APP_NAME?.trim()) {
return true;
}
for (const sentinelPath of ["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"]) {
try {
if (fs.existsSync(sentinelPath)) {

View File

@@ -226,6 +226,25 @@ describe("discord config schema", () => {
expect(cfg.voice?.captureSilenceGraceMs).toBe(3_500);
});
it("accepts Discord voice allowed channels", () => {
const cfg = expectValidDiscordConfig({
voice: {
allowedChannels: [{ guildId: "123", channelId: "456" }],
},
});
expect(cfg.voice?.allowedChannels).toEqual([{ guildId: "123", channelId: "456" }]);
});
it("rejects invalid Discord voice allowed channels", () => {
for (const voice of [
{ allowedChannels: [{ guildId: "", channelId: "456" }] },
{ allowedChannels: [{ guildId: "123", channelId: "" }] },
]) {
expectInvalidDiscordConfig({ voice });
}
});
it("rejects invalid Discord voice timing overrides", () => {
for (const voice of [
{ connectTimeoutMs: 0 },

View File

@@ -230,6 +230,10 @@ export const discordChannelConfigUiHints = {
label: "Discord Voice Auto-Join",
help: "Voice channels to auto-join on startup (list of guildId/channelId entries).",
},
"voice.allowedChannels": {
label: "Discord Voice Allowed Channels",
help: "Optional voice channel residency allowlist. When set, /vc join, auto-join, and bot voice-state moves are restricted to these guildId/channelId entries. Leave unset to allow any voice channel.",
},
"voice.daveEncryption": {
label: "Discord Voice DAVE Encryption",
help: "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).",

View File

@@ -2,6 +2,7 @@ import {
GatewayDispatchEvents,
type APIMessage,
type APIReaction,
type APIVoiceState,
type GatewayPresenceUpdateDispatchData,
type GatewayThreadUpdateDispatchData,
} from "discord-api-types/v10";
@@ -76,6 +77,11 @@ export abstract class PresenceUpdateListener extends BaseListener {
): Promise<void> | void;
}
export abstract class VoiceStateUpdateListener extends BaseListener {
readonly type = GatewayDispatchEvents.VoiceStateUpdate;
abstract override handle(data: APIVoiceState, client: Client): Promise<void> | void;
}
export abstract class ThreadUpdateListener extends BaseListener {
readonly type = GatewayDispatchEvents.ThreadUpdate;
abstract override handle(

View File

@@ -131,6 +131,7 @@ vi.mock("../voice/manager.runtime.js", () => {
DiscordVoiceManager: function DiscordVoiceManager() {},
DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {},
DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {},
DiscordVoiceStateUpdateListener: function DiscordVoiceStateUpdateListener() {},
};
});
describe("monitorDiscordProvider", () => {
@@ -263,6 +264,7 @@ describe("monitorDiscordProvider", () => {
DiscordVoiceManager: function DiscordVoiceManager() {},
DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {},
DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {},
DiscordVoiceStateUpdateListener: function DiscordVoiceStateUpdateListener() {},
} as never;
});
providerTesting.setLoadDiscordProviderSessionRuntime(

View File

@@ -497,8 +497,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
let voiceManager: DiscordVoiceManager | null = null;
if (voiceEnabled) {
const { DiscordVoiceManager, DiscordVoiceReadyListener, DiscordVoiceResumedListener } =
await loadDiscordVoiceRuntime();
const {
DiscordVoiceManager,
DiscordVoiceReadyListener,
DiscordVoiceResumedListener,
DiscordVoiceStateUpdateListener,
} = await loadDiscordVoiceRuntime();
voiceManager = new DiscordVoiceManager({
client,
cfg,
@@ -510,6 +514,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
voiceManagerRef.current = voiceManager;
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
registerDiscordListener(client.listeners, new DiscordVoiceResumedListener(voiceManager));
registerDiscordListener(client.listeners, new DiscordVoiceStateUpdateListener(voiceManager));
}
const messageHandler = discordProviderSessionRuntime.createDiscordMessageHandler({

View File

@@ -560,6 +560,76 @@ describe("DiscordVoiceManager", () => {
expectConnectedStatus(manager, "1002");
});
it("rejects joins outside configured allowed voice channels", async () => {
const manager = createManager({
voice: {
enabled: true,
mode: "stt-tts",
allowedChannels: [{ guildId: "g1", channelId: "1001" }],
},
});
const result = await manager.join({ guildId: "g1", channelId: "1002" });
expect(result.ok).toBe(false);
expect(result.message).toBe(
"<#1002> is not allowed by channels.discord.voice.allowedChannels.",
);
expect(joinVoiceChannelMock).not.toHaveBeenCalled();
});
it("allows joins inside configured allowed voice channels", async () => {
const manager = createManager({
voice: {
enabled: true,
mode: "stt-tts",
allowedChannels: [{ guildId: "g1", channelId: "1001" }],
},
});
const result = await manager.join({ guildId: "g1", channelId: "1001" });
expect(result.ok).toBe(true);
expectConnectedStatus(manager, "1001");
});
it("treats an empty allowed voice channel list as deny-all", async () => {
const manager = createManager({
voice: {
enabled: true,
mode: "stt-tts",
allowedChannels: [],
},
});
const result = await manager.join({ guildId: "g1", channelId: "1001" });
expect(result.ok).toBe(false);
expect(joinVoiceChannelMock).not.toHaveBeenCalled();
});
it("leaves and rejoins the configured target when Discord moves the bot outside allowed voice channels", async () => {
const manager = createManager({
voice: {
enabled: true,
mode: "stt-tts",
autoJoin: [{ guildId: "g1", channelId: "1001" }],
allowedChannels: [{ guildId: "g1", channelId: "1001" }],
},
});
manager.setBotUserId("bot-user");
await manager.join({ guildId: "g1", channelId: "1001" });
await manager.handleVoiceStateUpdate({
guild_id: "g1",
user_id: "bot-user",
channel_id: "1002",
} as never);
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2);
expectConnectedStatus(manager, "1001");
});
it("skips destroying stale tracked voice connections that are already destroyed", async () => {
const staleConnection = createConnectionMock();
staleConnection.state.status = "destroyed";

View File

@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { GatewayDispatchEvents } from "../internal/discord.js";
import { DiscordVoiceReadyListener, DiscordVoiceResumedListener } from "./manager.js";
import {
DiscordVoiceReadyListener,
DiscordVoiceResumedListener,
DiscordVoiceStateUpdateListener,
} from "./manager.js";
describe("DiscordVoiceReadyListener", () => {
it("starts auto-join without blocking the ready listener", async () => {
@@ -34,4 +38,17 @@ describe("DiscordVoiceReadyListener", () => {
expect(listener.type).toBe(GatewayDispatchEvents.Resumed);
expect(autoJoin).toHaveBeenCalledTimes(1);
});
it("forwards bot voice state updates to the voice manager", async () => {
const handleVoiceStateUpdate = vi.fn(async () => {});
const listener = new DiscordVoiceStateUpdateListener({
handleVoiceStateUpdate,
} as unknown as ConstructorParameters<typeof DiscordVoiceStateUpdateListener>[0]);
const payload = { guild_id: "g1", user_id: "bot", channel_id: "1001" };
await expect(listener.handle(payload as never, {} as never)).resolves.toBeUndefined();
expect(listener.type).toBe(GatewayDispatchEvents.VoiceStateUpdate);
expect(handleVoiceStateUpdate).toHaveBeenCalledWith(payload);
});
});

View File

@@ -2,6 +2,7 @@ import {
DiscordVoiceManager as DiscordVoiceManagerImpl,
DiscordVoiceReadyListener as DiscordVoiceReadyListenerImpl,
DiscordVoiceResumedListener as DiscordVoiceResumedListenerImpl,
DiscordVoiceStateUpdateListener as DiscordVoiceStateUpdateListenerImpl,
} from "./manager.js";
export class DiscordVoiceManager extends DiscordVoiceManagerImpl {}
@@ -9,3 +10,5 @@ export class DiscordVoiceManager extends DiscordVoiceManagerImpl {}
export class DiscordVoiceReadyListener extends DiscordVoiceReadyListenerImpl {}
export class DiscordVoiceResumedListener extends DiscordVoiceResumedListenerImpl {}
export class DiscordVoiceStateUpdateListener extends DiscordVoiceStateUpdateListenerImpl {}

View File

@@ -5,7 +5,13 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveDiscordAccountAllowFrom } from "../accounts.js";
import { type Client, ReadyListener, ResumedListener } from "../internal/discord.js";
import {
type APIVoiceState,
type Client,
ReadyListener,
ResumedListener,
VoiceStateUpdateListener,
} from "../internal/discord.js";
import type { VoicePlugin } from "../internal/voice.js";
import { formatMention } from "../mentions.js";
import { parseDiscordTarget } from "../target-parsing.js";
@@ -61,6 +67,10 @@ const VOICE_LOG_PREVIEW_CHARS = 500;
type DiscordVoiceSdk = ReturnType<typeof loadDiscordVoiceSdk>;
type DiscordVoiceConnection = ReturnType<DiscordVoiceSdk["joinVoiceChannel"]>;
type VoiceChannelResidency = {
guildId: string;
channelId: string;
};
function formatVoiceLogPreview(text: string): string {
const oneLine = text.replace(/\s+/g, " ").trim();
@@ -98,6 +108,33 @@ function destroyVoiceConnectionSafely(params: {
}
}
function normalizeVoiceChannelResidencies(
entries: Array<{ guildId?: string; channelId?: string }> | undefined,
): VoiceChannelResidency[] {
const normalized: VoiceChannelResidency[] = [];
for (const entry of entries ?? []) {
const guildId = entry.guildId?.trim();
const channelId = entry.channelId?.trim();
if (guildId && channelId) {
normalized.push({ guildId, channelId });
}
}
return normalized;
}
function isVoiceChannelAllowed(params: {
allowedChannels: VoiceChannelResidency[] | null;
guildId: string;
channelId: string;
}): boolean {
return (
params.allowedChannels === null ||
params.allowedChannels.some(
(entry) => entry.guildId === params.guildId && entry.channelId === params.channelId,
)
);
}
function startAutoJoin(manager: Pick<DiscordVoiceManager, "autoJoin">) {
void manager
.autoJoin()
@@ -160,6 +197,7 @@ export class DiscordVoiceManager {
private autoJoinTask: Promise<void> | null = null;
private readonly ownerAllowFrom?: string[];
private readonly speakerContext: DiscordVoiceSpeakerContextResolver;
private readonly allowedChannels: VoiceChannelResidency[] | null;
constructor(
private params: {
@@ -178,6 +216,10 @@ export class DiscordVoiceManager {
params.discordConfig.allowFrom ??
params.discordConfig.dm?.allowFrom ??
[];
this.allowedChannels =
params.discordConfig.voice?.allowedChannels === undefined
? null
: normalizeVoiceChannelResidencies(params.discordConfig.voice.allowedChannels);
this.speakerContext = new DiscordVoiceSpeakerContextResolver({
client: params.client,
ownerAllowFrom: this.ownerAllowFrom,
@@ -229,10 +271,15 @@ export class DiscordVoiceManager {
for (const entry of entriesByGuild.values()) {
logVoiceVerbose(`autoJoin: joining guild ${entry.guildId} channel ${entry.channelId}`);
await this.join({
const result = await this.join({
guildId: entry.guildId,
channelId: entry.channelId,
});
if (!result.ok) {
logger.warn(
`discord voice: autoJoin skipped guild=${entry.guildId} channel=${entry.channelId}: ${result.message}`,
);
}
}
})().finally(() => {
this.autoJoinTask = null;
@@ -249,6 +296,14 @@ export class DiscordVoiceManager {
}));
}
isAllowedVoiceChannel(params: { guildId: string; channelId: string }): boolean {
return isVoiceChannelAllowed({
allowedChannels: this.allowedChannels,
guildId: params.guildId.trim(),
channelId: params.channelId.trim(),
});
}
async join(params: { guildId: string; channelId: string }): Promise<VoiceOperationResult> {
if (!this.voiceEnabled) {
return {
@@ -261,6 +316,17 @@ export class DiscordVoiceManager {
if (!guildId || !channelId) {
return { ok: false, message: "Missing guildId or channelId." };
}
if (!this.isAllowedVoiceChannel({ guildId, channelId })) {
logger.warn(
`discord voice: join rejected for non-allowed channel guild=${guildId} channel=${channelId}`,
);
return {
ok: false,
message: `${formatMention({ channelId })} is not allowed by channels.discord.voice.allowedChannels.`,
guildId,
channelId,
};
}
logVoiceVerbose(`join requested: guild ${guildId} channel ${channelId}`);
const existing = this.sessions.get(guildId);
@@ -590,6 +656,53 @@ export class DiscordVoiceManager {
};
}
async handleVoiceStateUpdate(data: APIVoiceState): Promise<void> {
if (!this.botUserId || data.user_id !== this.botUserId) {
return;
}
const guildId = data.guild_id?.trim();
const channelId = data.channel_id?.trim();
if (!guildId || !channelId) {
return;
}
const existing = this.sessions.get(guildId);
if (this.isAllowedVoiceChannel({ guildId, channelId })) {
if (existing && existing.channelId !== channelId) {
logger.warn(
`discord voice: bot moved to allowed channel guild=${guildId} from=${existing.channelId} to=${channelId}; rebuilding voice session`,
);
await this.join({ guildId, channelId });
}
return;
}
logger.warn(
`discord voice: bot moved to non-allowed channel guild=${guildId} channel=${channelId}; leaving`,
);
if (existing) {
await this.leave({ guildId });
} else {
const voiceSdk = loadDiscordVoiceSdk();
const connection = voiceSdk.getVoiceConnection(guildId);
if (connection) {
destroyVoiceConnectionSafely({
connection,
voiceSdk,
reason: `non-allowed voice state guild ${guildId} channel ${channelId}`,
});
}
}
const target = this.resolveVoiceResidencyTarget(guildId);
if (target) {
logger.warn(
`discord voice: rejoining allowed voice channel guild=${guildId} channel=${target.channelId}`,
);
await this.join(target);
}
}
async destroy(): Promise<void> {
for (const entry of this.sessions.values()) {
entry.stop();
@@ -597,6 +710,22 @@ export class DiscordVoiceManager {
this.sessions.clear();
}
private resolveVoiceResidencyTarget(guildId: string): VoiceChannelResidency | null {
const autoJoinTarget = normalizeVoiceChannelResidencies(
this.params.discordConfig.voice?.autoJoin,
)
.toReversed()
.find((entry) => entry.guildId === guildId);
if (autoJoinTarget && this.isAllowedVoiceChannel(autoJoinTarget)) {
return autoJoinTarget;
}
if (this.allowedChannels === null) {
return null;
}
const guildAllowed = this.allowedChannels.filter((entry) => entry.guildId === guildId);
return guildAllowed.length === 1 ? guildAllowed[0] : null;
}
private enqueueProcessing(entry: VoiceSessionEntry, task: () => Promise<void>) {
entry.processingQueue = entry.processingQueue
.then(task)
@@ -972,3 +1101,13 @@ export class DiscordVoiceResumedListener extends ResumedListener {
startAutoJoin(this.manager);
}
}
export class DiscordVoiceStateUpdateListener extends VoiceStateUpdateListener {
constructor(private manager: DiscordVoiceManager) {
super();
}
async handle(data: APIVoiceState, _client: Client): Promise<void> {
await this.manager.handleVoiceStateUpdate(data);
}
}

View File

@@ -167,7 +167,7 @@ export async function pollAppRegistration(params: {
expireIn: number;
initialDomain?: FeishuDomain;
abortSignal?: AbortSignal;
/** Registration type parameter: "ob_user" for user mode, "ob_app" for bot mode. */
/** Registration type parameter. The CLI bot QR flow uses "ob_cli_app". */
tp?: string;
}): Promise<PollOutcome> {
const { deviceCode, expireIn, initialDomain = "feishu", abortSignal, tp } = params;

View File

@@ -8,7 +8,19 @@ import {
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { FeishuProbeResult } from "./types.js";
const { probeFeishuMock } = vi.hoisted(() => ({
const {
beginAppRegistrationMock,
getAppOwnerOpenIdMock,
initAppRegistrationMock,
pollAppRegistrationMock,
printQrCodeMock,
probeFeishuMock,
} = vi.hoisted(() => ({
beginAppRegistrationMock: vi.fn(),
getAppOwnerOpenIdMock: vi.fn(),
initAppRegistrationMock: vi.fn(),
pollAppRegistrationMock: vi.fn(),
printQrCodeMock: vi.fn(),
probeFeishuMock: vi.fn<() => Promise<FeishuProbeResult>>(async () => ({
ok: false,
error: "mocked",
@@ -20,13 +32,11 @@ vi.mock("./probe.js", () => ({
}));
vi.mock("./app-registration.js", () => ({
initAppRegistration: vi.fn(async () => {
throw new Error("mocked: scan-to-create not available");
}),
beginAppRegistration: vi.fn(),
pollAppRegistration: vi.fn(),
printQrCode: vi.fn(async () => {}),
getAppOwnerOpenId: vi.fn(async () => undefined),
initAppRegistration: initAppRegistrationMock,
beginAppRegistration: beginAppRegistrationMock,
pollAppRegistration: pollAppRegistrationMock,
printQrCode: printQrCodeMock,
getAppOwnerOpenId: getAppOwnerOpenIdMock,
}));
import { feishuPlugin } from "./channel.js";
@@ -86,6 +96,116 @@ describe("feishu setup wizard", () => {
beforeEach(() => {
probeFeishuMock.mockReset();
probeFeishuMock.mockResolvedValue({ ok: false, error: "mocked" });
initAppRegistrationMock.mockReset();
initAppRegistrationMock.mockRejectedValue(new Error("mocked: scan-to-create not available"));
beginAppRegistrationMock.mockReset();
pollAppRegistrationMock.mockReset();
printQrCodeMock.mockReset();
printQrCodeMock.mockResolvedValue(undefined);
getAppOwnerOpenIdMock.mockReset();
getAppOwnerOpenIdMock.mockResolvedValue(undefined);
});
it("uses manual credentials by default instead of starting scan-to-create", async () => {
const text = vi.fn().mockResolvedValueOnce("cli_manual").mockResolvedValueOnce("secret_manual");
const prompter = createTestWizardPrompter({ text });
const result = await runSetupWizardConfigure({
configure: feishuConfigure,
cfg: {} as never,
prompter,
runtime: createNonExitingRuntimeEnv(),
});
expect(initAppRegistrationMock).not.toHaveBeenCalled();
expect(beginAppRegistrationMock).not.toHaveBeenCalled();
expect(result.cfg.channels?.feishu).toMatchObject({
appId: "cli_manual",
appSecret: "secret_manual",
connectionMode: "websocket",
domain: "feishu",
});
});
it("passes selected domain through scan-to-create and poll", async () => {
initAppRegistrationMock.mockResolvedValueOnce(undefined);
beginAppRegistrationMock.mockResolvedValueOnce({
deviceCode: "device-code",
qrUrl: "https://accounts.larksuite.com/qr",
userCode: "user-code",
interval: 1,
expireIn: 10,
});
pollAppRegistrationMock.mockResolvedValueOnce({
status: "success",
result: {
appId: "cli_lark",
appSecret: "secret_lark",
domain: "lark",
openId: "ou_owner",
},
});
const prompter = createTestWizardPrompter({
select: vi
.fn()
.mockResolvedValueOnce("scan")
.mockResolvedValueOnce("lark")
.mockResolvedValueOnce("open") as never,
});
const result = await runSetupWizardConfigure({
configure: feishuConfigure,
cfg: {} as never,
prompter,
runtime: createNonExitingRuntimeEnv(),
});
expect(initAppRegistrationMock).toHaveBeenCalledWith("lark");
expect(beginAppRegistrationMock).toHaveBeenCalledWith("lark");
expect(pollAppRegistrationMock).toHaveBeenCalledWith(
expect.objectContaining({
deviceCode: "device-code",
initialDomain: "lark",
tp: "ob_cli_app",
}),
);
expect(result.cfg.channels?.feishu).toMatchObject({
appId: "cli_lark",
appSecret: "secret_lark",
domain: "lark",
groupPolicy: "open",
requireMention: true,
});
});
it("falls back to manual credentials when selected scan-to-create is unavailable", async () => {
const text = vi
.fn()
.mockResolvedValueOnce("cli_from_fallback")
.mockResolvedValueOnce("secret_from_fallback");
const prompter = createTestWizardPrompter({
text,
select: vi
.fn()
.mockResolvedValueOnce("scan")
.mockResolvedValueOnce("feishu")
.mockResolvedValueOnce("allowlist") as never,
});
const result = await runSetupWizardConfigure({
configure: feishuConfigure,
cfg: {} as never,
prompter,
runtime: createNonExitingRuntimeEnv(),
});
expect(initAppRegistrationMock).toHaveBeenCalledWith("feishu");
expect(beginAppRegistrationMock).not.toHaveBeenCalled();
expect(result.cfg.channels?.feishu).toMatchObject({
appId: "cli_from_fallback",
appSecret: "secret_from_fallback",
domain: "feishu",
});
});
it("prompts over SecretRef appId/appSecret config objects", async () => {

View File

@@ -17,6 +17,7 @@ import type { AppRegistrationResult } from "./app-registration.js";
import type { FeishuConfig, FeishuDomain } from "./types.js";
const channel = "feishu" as const;
const SCAN_TO_CREATE_TP = "ob_cli_app";
// ---------------------------------------------------------------------------
// Helpers
@@ -213,6 +214,7 @@ const feishuDmPolicy: ChannelSetupDmPolicy = {
};
type WizardPrompter = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
type FeishuSetupMethod = "manual" | "scan";
// ---------------------------------------------------------------------------
// Security policy helpers
@@ -245,11 +247,39 @@ function applyNewAppSecurityPolicy(
// Scan-to-create flow
// ---------------------------------------------------------------------------
async function runScanToCreate(prompter: WizardPrompter): Promise<AppRegistrationResult | null> {
async function promptFeishuDomain(params: {
prompter: WizardPrompter;
initialValue?: FeishuDomain;
}): Promise<FeishuDomain> {
return (await params.prompter.select({
message: "Which Feishu domain?",
options: [
{ value: "feishu", label: "Feishu (feishu.cn) - China" },
{ value: "lark", label: "Lark (larksuite.com) - International" },
],
initialValue: params.initialValue ?? "feishu",
})) as FeishuDomain;
}
async function promptFeishuSetupMethod(prompter: WizardPrompter): Promise<FeishuSetupMethod> {
return (await prompter.select({
message: "How do you want to connect Feishu?",
options: [
{ value: "manual", label: "Enter App ID and App Secret manually" },
{ value: "scan", label: "Scan a QR code to create a bot automatically" },
],
initialValue: "manual",
})) as FeishuSetupMethod;
}
async function runScanToCreate(
prompter: WizardPrompter,
domain: FeishuDomain,
): Promise<AppRegistrationResult | null> {
const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } =
await import("./app-registration.js");
try {
await initAppRegistration("feishu");
await initAppRegistration(domain);
} catch {
await prompter.note(
"Scan-to-create is not available in this environment. Falling back to manual input.",
@@ -258,9 +288,12 @@ async function runScanToCreate(prompter: WizardPrompter): Promise<AppRegistratio
return null;
}
const begin = await beginAppRegistration("feishu");
const begin = await beginAppRegistration(domain);
await prompter.note("Scan the QR with Lark/Feishu on your phone.", "Feishu scan-to-create");
await prompter.note(
"Scan the QR with Lark/Feishu on your phone. If the mobile app does not react, rerun setup and choose manual input.",
"Feishu scan-to-create",
);
await printQrCode(begin.qrUrl);
const progress = prompter.progress("Fetching configuration results...");
@@ -269,8 +302,8 @@ async function runScanToCreate(prompter: WizardPrompter): Promise<AppRegistratio
deviceCode: begin.deviceCode,
interval: begin.interval,
expireIn: begin.expireIn,
initialDomain: "feishu",
tp: "ob_app",
initialDomain: domain,
tp: SCAN_TO_CREATE_TP,
});
switch (outcome.status) {
@@ -314,8 +347,17 @@ async function runNewAppFlow(params: {
let appSecretProbeValue: string | null = null;
let scanDomain: FeishuDomain | undefined;
let scanOpenId: string | undefined;
const feishuCfg = next.channels?.feishu as FeishuConfig | undefined;
const currentDomain = feishuCfg?.domain ?? "feishu";
const setupMethod = await promptFeishuSetupMethod(prompter);
const selectedDomain = await promptFeishuDomain({
prompter,
initialValue: currentDomain,
});
scanDomain = selectedDomain;
const scanResult = await runScanToCreate(prompter);
const scanResult =
setupMethod === "scan" ? await runScanToCreate(prompter, selectedDomain) : null;
if (scanResult) {
appId = scanResult.appId;
appSecret = scanResult.appSecret;
@@ -324,21 +366,8 @@ async function runNewAppFlow(params: {
scanOpenId = scanResult.openId;
} else {
// Fallback to manual input: collect domain, appId, appSecret.
const feishuCfg = next.channels?.feishu as FeishuConfig | undefined;
await noteFeishuCredentialHelp(prompter);
// Domain selection first (needed for API calls).
const currentDomain = feishuCfg?.domain ?? "feishu";
const domain = (await prompter.select({
message: "Which Feishu domain?",
options: [
{ value: "feishu", label: "Feishu (feishu.cn) - China" },
{ value: "lark", label: "Lark (larksuite.com) - International" },
],
initialValue: currentDomain,
})) as FeishuDomain;
scanDomain = domain;
appId = await promptFeishuAppId({
prompter,
initialValue: normalizeString(process.env.FEISHU_APP_ID),
@@ -369,7 +398,7 @@ async function runNewAppFlow(params: {
scanOpenId = await getAppOwnerOpenId({
appId,
appSecret: appSecretProbeValue,
domain: scanDomain,
domain: selectedDomain,
});
}
}

View File

@@ -121,7 +121,7 @@ describe("openrouter image generation provider", () => {
});
expect(resolveApiKeyForProviderMock).toHaveBeenCalledOnce();
expect(resolveApiKeyForProviderMock.mock.calls[0]?.[0]).toEqual({
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({
provider: "openrouter",
cfg: {
models: {

View File

@@ -28,6 +28,7 @@ type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
>;
type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies;
type DeliverRepliesParams = Parameters<DeliverRepliesFn>[0];
type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog;
type MatchPluginCommandFn = typeof import("./bot-native-commands.runtime.js").matchPluginCommand;
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
@@ -53,6 +54,16 @@ const sessionMocks = vi.hoisted(() => ({
const commandAuthMocks = vi.hoisted(() => ({
resolveCommandArgMenu: vi.fn(),
}));
const agentRuntimeMocks = vi.hoisted(() => ({
loadModelCatalog: vi.fn<LoadModelCatalogFn>(async () => [
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
reasoning: true,
},
]),
}));
const pluginRuntimeMocks = vi.hoisted(() => ({
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
matchPluginCommand: vi.fn<MatchPluginCommandFn>(() => null),
@@ -169,6 +180,15 @@ vi.mock("openclaw/plugin-sdk/command-auth-native", async () => {
resolveCommandArgMenu: commandAuthMocks.resolveCommandArgMenu,
};
});
vi.mock("openclaw/plugin-sdk/agent-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/agent-runtime")>(
"openclaw/plugin-sdk/agent-runtime",
);
return {
...actual,
loadModelCatalog: agentRuntimeMocks.loadModelCatalog,
};
});
vi.mock("./bot-native-commands.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./bot-native-commands.runtime.js")>(
"./bot-native-commands.runtime.js",
@@ -524,6 +544,14 @@ describe("registerTelegramNativeCommands — session metadata", () => {
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear();
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
commandAuthMocks.resolveCommandArgMenu.mockClear();
agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
reasoning: true,
},
]);
sessionMocks.loadSessionStore.mockClear().mockReturnValue({});
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
sessionMocks.resolveAndPersistSessionFile.mockClear().mockImplementation(async (params) => {
@@ -701,6 +729,36 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("hydrates runtime catalog metadata for thinking menu defaults", async () => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
},
},
} as OpenClawConfig;
sessionMocks.loadSessionStore.mockReturnValue({});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith({
config: cfg,
});
expectSendMessageCall({
sendMessage,
chatId: 100,
textIncludes: "Current thinking level: medium.\nChoose level for /think.",
requireReplyMarkup: true,
label: "runtime catalog thinking menu",
});
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("uses target model thinking defaults before global thinking defaults", async () => {
const cfg = {
agents: {

View File

@@ -2,10 +2,10 @@ import { randomUUID } from "node:crypto";
import path from "node:path";
import type { Bot, Context } from "grammy";
import {
buildConfiguredModelCatalog,
loadModelCatalog,
resolveAgentConfig,
resolveDefaultModelForAgent,
resolveThinkingDefault,
resolveThinkingDefaultWithRuntimeCatalog,
} from "openclaw/plugin-sdk/agent-runtime";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
@@ -256,13 +256,26 @@ function resolveTelegramCommandMenuModelContext(params: {
}
}
function resolveTelegramThinkMenuCurrentLevel(params: {
async function resolveTelegramDefaultThinkingLevel(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
}): Promise<string> {
return resolveThinkingDefaultWithRuntimeCatalog({
cfg: params.cfg,
provider: params.provider,
model: params.model,
loadModelCatalog: () => loadModelCatalog({ config: params.cfg }),
});
}
async function resolveTelegramThinkMenuCurrentLevel(params: {
cfg: OpenClawConfig;
agentId: string;
provider?: string;
model?: string;
thinkingLevel?: string;
}): string {
}): Promise<string> {
const explicit = normalizeOptionalString(params.thinkingLevel);
if (explicit) {
return explicit;
@@ -277,11 +290,10 @@ function resolveTelegramThinkMenuCurrentLevel(params: {
cfg: params.cfg,
agentId: params.agentId,
});
return resolveThinkingDefault({
return await resolveTelegramDefaultThinkingLevel({
cfg: params.cfg,
provider: params.provider ?? defaultModel.provider,
model: params.model ?? defaultModel.model,
catalog: buildConfiguredModelCatalog({ cfg: params.cfg }),
});
}
@@ -1050,7 +1062,7 @@ export const registerTelegramNativeCommands = ({
menu,
currentThinkingLevel:
commandDefinition.key === "think"
? resolveTelegramThinkMenuCurrentLevel({
? await resolveTelegramThinkMenuCurrentLevel({
cfg: runtimeCfg,
agentId: route.agentId,
...menuModelContext,

View File

@@ -1601,6 +1601,7 @@
"test:docker:npm-onboard-slack-channel-agent": "OPENCLAW_NPM_ONBOARD_CHANNEL=slack bash scripts/e2e/npm-onboard-channel-agent-docker.sh",
"test:docker:npm-telegram-live": "bash scripts/e2e/npm-telegram-live-docker.sh",
"test:docker:onboard": "bash scripts/e2e/onboard-docker.sh",
"test:docker:openai-chat-tools": "bash scripts/e2e/openai-chat-tools-docker.sh",
"test:docker:openai-image-auth": "bash scripts/e2e/openai-image-auth-docker.sh",
"test:docker:openai-web-search-minimal": "bash scripts/e2e/openai-web-search-minimal-docker.sh",
"test:docker:openwebui": "bash scripts/e2e/openwebui-docker.sh",

View File

@@ -14,7 +14,12 @@ const packageJson = JSON.parse(readText("package.json"));
const packageScripts = new Set(Object.keys(packageJson.scripts ?? {}));
// These lanes prove package-installed surfaces against live auth, so they
// intentionally need both live credentials and a package-backed image.
const livePackageBackedLanes = new Set(["live-codex-npm-plugin", "live-plugin-tool", "openwebui"]);
const livePackageBackedLanes = new Set([
"live-codex-npm-plugin",
"live-plugin-tool",
"openai-chat-tools",
"openwebui",
]);
function readText(relativePath) {
return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8");

View File

@@ -0,0 +1,100 @@
const port = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
const backendModel = process.env.MODEL_REF || "openai/gpt-5.4-mini";
const timeoutSeconds = Number.parseInt(
process.env.OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS ?? "180",
10,
);
if (!port || !token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
const started = Date.now();
const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
"x-openclaw-model": backendModel,
},
body: JSON.stringify({
model: "openclaw",
stream: false,
messages: [
{
role: "user",
content:
"Use the get_weather tool exactly once for Paris, France. Return the tool call only.",
},
],
tool_choice: "auto",
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Return weather for a city.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
properties: {
city: { type: "string", description: "City and country." },
},
required: ["city"],
},
},
},
],
}),
signal: controller.signal,
});
clearTimeout(timeout);
const text = await response.text();
let body;
try {
body = text ? JSON.parse(text) : {};
} catch {
throw new Error(`non-JSON response ${response.status}: ${text}`);
}
if (!response.ok) {
throw new Error(`chat completions request failed ${response.status}: ${JSON.stringify(body)}`);
}
const choice = body.choices?.[0];
const toolCalls = choice?.message?.tool_calls;
if (choice?.finish_reason !== "tool_calls") {
throw new Error(`expected finish_reason tool_calls: ${JSON.stringify(body)}`);
}
if (!Array.isArray(toolCalls) || toolCalls.length !== 1) {
throw new Error(`expected exactly one tool call: ${JSON.stringify(body)}`);
}
const [toolCall] = toolCalls;
if (toolCall?.type !== "function" || toolCall?.function?.name !== "get_weather") {
throw new Error(`unexpected tool call: ${JSON.stringify(toolCall)}`);
}
let args = {};
try {
args = JSON.parse(toolCall.function.arguments || "{}");
} catch {
throw new Error(`tool arguments were not valid JSON: ${toolCall.function.arguments}`);
}
if (typeof args.city !== "string" || !/paris/i.test(args.city)) {
throw new Error(`expected Paris city argument: ${JSON.stringify(args)}`);
}
console.log(
JSON.stringify({
ok: true,
elapsedMs: Date.now() - started,
finishReason: choice.finish_reason,
toolName: toolCall.function.name,
args,
}),
);

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export OPENCLAW_SKIP_CHANNELS=1
export OPENCLAW_SKIP_GMAIL_WATCHER=1
export OPENCLAW_SKIP_CRON=1
export OPENCLAW_SKIP_CANVAS_HOST=1
export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1
export OPENCLAW_SKIP_ACPX_RUNTIME=1
export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1
export OPENCLAW_AGENT_HARNESS_FALLBACK=none
for profile_path in "$HOME/.profile" /home/appuser/.profile; do
if [ -f "$profile_path" ] && [ -r "$profile_path" ]; then
set +e +u
# shellcheck disable=SC1090
source "$profile_path"
set -euo pipefail
break
fi
done
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "ERROR: OPENAI_API_KEY was not available after sourcing ~/.profile." >&2
exit 1
fi
export OPENAI_API_KEY
if [ -n "${OPENAI_BASE_URL:-}" ]; then
export OPENAI_BASE_URL
fi
PORT="${PORT:?missing PORT}"
TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}"
MODEL_REF="${OPENCLAW_OPENAI_CHAT_TOOLS_MODEL:?missing OPENCLAW_OPENAI_CHAT_TOOLS_MODEL}"
GATEWAY_LOG="/tmp/openclaw-openai-chat-tools-gateway.log"
CLIENT_LOG="/tmp/openclaw-openai-chat-tools-client.log"
gateway_pid=""
cleanup() {
openclaw_e2e_stop_process "$gateway_pid"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "OpenAI Chat Completions tools Docker E2E failed with exit code $status" >&2
openclaw_e2e_dump_logs "$GATEWAY_LOG" "$CLIENT_LOG"
if [ -f "$OPENCLAW_CONFIG_PATH" ]; then
echo "--- $OPENCLAW_CONFIG_PATH keys ---" >&2
node -e "const fs=require('fs'); const cfg=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); console.error(JSON.stringify({model:cfg.agents?.defaults?.model, tools:cfg.tools, provider:cfg.models?.providers?.openai && {api:cfg.models.providers.openai.api, baseUrl:cfg.models.providers.openai.baseUrl, agentRuntime:cfg.models.providers.openai.agentRuntime}}, null, 2));" "$OPENCLAW_CONFIG_PATH" || true
fi
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
entry="$(openclaw_e2e_resolve_entrypoint)"
mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR"
node scripts/e2e/lib/openai-chat-tools/write-config.mjs
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
for _ in $(seq 1 360); do
if ! kill -0 "$gateway_pid" 2>/dev/null; then
echo "gateway exited before listening" >&2
exit 1
fi
if node "$entry" gateway health \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 120000 \
--json >/dev/null 2>&1; then
break
fi
sleep 0.25
done
node "$entry" gateway health \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 120000 \
--json >/dev/null
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" MODEL_REF="$MODEL_REF" \
node scripts/e2e/lib/openai-chat-tools/client.mjs >"$CLIENT_LOG" 2>&1
cat "$CLIENT_LOG"
echo "OpenAI Chat Completions tools Docker E2E passed"

View File

@@ -0,0 +1,90 @@
import fs from "node:fs";
import path from "node:path";
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`missing ${name}`);
}
return value;
}
const configPath = requireEnv("OPENCLAW_CONFIG_PATH");
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
const workspaceDir = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
const modelRef = requireEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MODEL");
const token = requireEnv("OPENCLAW_GATEWAY_TOKEN");
const timeoutSeconds = Number.parseInt(
process.env.OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS ?? "180",
10,
);
const [providerId, modelId] = modelRef.split("/");
if (providerId !== "openai" || !modelId) {
throw new Error(`OPENCLAW_OPENAI_CHAT_TOOLS_MODEL must be openai/*, got ${modelRef}`);
}
const config = {
gateway: {
port: Number.parseInt(process.env.PORT ?? "18789", 10),
bind: "loopback",
auth: { mode: "token", token },
controlUi: { enabled: false },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
models: {
mode: "merge",
providers: {
openai: {
api: "openai-responses",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
agentRuntime: { id: "pi" },
timeoutSeconds,
models: [
{
id: modelId,
name: modelId,
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
contextTokens: 64000,
maxTokens: 512,
},
],
},
},
},
agents: {
defaults: {
model: { primary: modelRef, fallbacks: [] },
models: {
[modelRef]: {
agentRuntime: { id: "pi" },
params: { transport: "sse", openaiWsWarmup: false },
},
},
workspace: workspaceDir,
skipBootstrap: true,
timeoutSeconds,
contextTokens: 64000,
},
},
plugins: {
enabled: true,
allow: ["openai"],
entries: { openai: { enabled: true } },
},
skills: { allowBundled: [] },
tools: { allow: ["get_weather"] },
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
fs.mkdirSync(path.join(stateDir, "logs"), { recursive: true });

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-openai-chat-tools-e2e" OPENCLAW_OPENAI_CHAT_TOOLS_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_OPENAI_CHAT_TOOLS_E2E_SKIP_BUILD:-0}"
PORT="${OPENCLAW_OPENAI_CHAT_TOOLS_PORT:-18789}"
TOKEN="openai-chat-tools-e2e-$$"
PROFILE_FILE="${OPENCLAW_OPENAI_CHAT_TOOLS_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFILE_FILE:-$HOME/.openclaw-testbox-live.profile}}"
if [ ! -f "$PROFILE_FILE" ] && [ -f "$HOME/.profile" ]; then
PROFILE_FILE="$HOME/.profile"
fi
docker_e2e_build_or_reuse "$IMAGE_NAME" openai-chat-tools "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 openai-chat-tools empty)"
PROFILE_MOUNT=()
PROFILE_STATUS="none"
if [ -f "$PROFILE_FILE" ] && [ -r "$PROFILE_FILE" ]; then
set -a
# shellcheck disable=SC1090
source "$PROFILE_FILE"
set +a
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/appuser/.profile:ro)
PROFILE_STATUS="$PROFILE_FILE"
fi
echo "Running OpenAI Chat Completions tools Docker E2E..."
echo "Profile file: $PROFILE_STATUS"
docker_e2e_run_logged_with_harness openai-chat-tools \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENAI_API_KEY \
-e OPENAI_BASE_URL \
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
-e "OPENCLAW_OPENAI_CHAT_TOOLS_MODEL=${OPENCLAW_OPENAI_CHAT_TOOLS_MODEL:-openai/gpt-5.4-mini}" \
-e "OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS=${OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS:-180}" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
-e "PORT=$PORT" \
"${PROFILE_MOUNT[@]}" \
"$IMAGE_NAME" \
bash scripts/e2e/lib/openai-chat-tools/scenario.sh

View File

@@ -417,9 +417,40 @@ function optionalString(source: JsonObject, key: string) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function childProcessBaseEnv() {
const keys = [
"CI",
"COREPACK_HOME",
"FORCE_COLOR",
"HOME",
"LANG",
"LC_ALL",
"NODE_OPTIONS",
"OPENCLAW_BUILD_PRIVATE_QA",
"OPENCLAW_ENABLE_PRIVATE_QA_CLI",
"PATH",
"PNPM_HOME",
"SHELL",
"TEMP",
"TMP",
"TMPDIR",
"USER",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
];
const env: NodeJS.ProcessEnv = {};
for (const key of keys) {
const value = process.env[key];
if (value) {
env[key] = value;
}
}
return env;
}
function mockServerEnv(params: { mockPort: number; mockResponseText: string; requestLog: string }) {
return {
...process.env,
...childProcessBaseEnv(),
MOCK_PORT: String(params.mockPort),
MOCK_REQUEST_LOG: params.requestLog,
SUCCESS_MARKER: params.mockResponseText,
@@ -428,7 +459,7 @@ function mockServerEnv(params: { mockPort: number; mockResponseText: string; req
function gatewayEnv(params: { configPath: string; stateDir: string; sutToken: string }) {
return {
...process.env,
...childProcessBaseEnv(),
OPENAI_API_KEY: "sk-openclaw-e2e-mock",
OPENCLAW_CONFIG_PATH: params.configPath,
OPENCLAW_STATE_DIR: params.stateDir,

View File

@@ -333,6 +333,7 @@ function laneCredentialRequirements(poolLane) {
}
if (
poolLane.name === "openwebui" ||
poolLane.name === "openai-chat-tools" ||
poolLane.name === "openai-web-search-minimal" ||
poolLane.name === "live-codex-npm-plugin" ||
poolLane.name === "live-plugin-tool"

View File

@@ -141,6 +141,22 @@ function livePluginToolLane() {
);
}
function liveOpenAiChatToolsLane() {
return liveLane(
"openai-chat-tools",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-chat-tools",
{
e2eImageKind: "functional",
needsLiveImage: false,
provider: "openai",
resources: ["service"],
stateScenario: "empty",
timeoutMs: 10 * 60 * 1000,
weight: 2,
},
);
}
export const mainLanes = [
liveLane("live-models", liveDockerScriptCommand("test-live-models-docker.sh"), {
providers: ["claude-cli", "codex-cli", "google-gemini-cli"],
@@ -539,6 +555,7 @@ const releasePathPackageInstallOpenAiLanes = [
weight: 3,
},
),
liveOpenAiChatToolsLane(),
npmLane("codex-on-demand", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:codex-on-demand", {
resources: ["service"],
stateScenario: "empty",

View File

@@ -1244,7 +1244,7 @@ async function main() {
if (buildEnabled) {
const buildEntries = [];
if (scheduledLanes.some((poolLane) => poolLane.live)) {
if (scheduledLanes.some((poolLane) => poolLane.needsLiveImage)) {
buildEntries.push({
command: liveDockerHarnessScriptCommand("test-live-build-docker.sh"),
label: "shared live-test image once",

View File

@@ -0,0 +1,113 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
import { createExecTool } from "./bash-tools.exec.js";
describe("exec security floor", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
let tempRoot: string | undefined;
beforeEach(() => {
envSnapshot = captureEnv([
"HOME",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"SHELL",
]);
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-security-floor-"));
process.env.HOME = tempRoot;
process.env.USERPROFILE = tempRoot;
process.env.OPENCLAW_HOME = tempRoot;
process.env.OPENCLAW_STATE_DIR = path.join(tempRoot, "state");
if (process.platform === "win32") {
const parsed = path.parse(tempRoot);
process.env.HOMEDRIVE = parsed.root.slice(0, 2);
process.env.HOMEPATH = tempRoot.slice(2) || "\\";
} else {
delete process.env.HOMEDRIVE;
delete process.env.HOMEPATH;
}
resetProcessRegistryForTests();
});
afterEach(() => {
const dir = tempRoot;
tempRoot = undefined;
envSnapshot.restore();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("ignores model-supplied allowlist security when configured security is full", async () => {
const tool = createExecTool({
security: "full",
ask: "off",
});
const result = await tool.execute("call-1", {
command: "echo hello",
security: "allowlist",
ask: "off",
});
expect(result.content[0]).toMatchObject({ type: "text" });
const text = (result.content[0] as { text?: string }).text ?? "";
expect(text).not.toMatch(/exec denied/i);
expect(text).not.toMatch(/allowlist miss/i);
expect(text.trim()).toContain("hello");
});
it("enforces configured allowlist security when model also passes allowlist", async () => {
const tool = createExecTool({
security: "allowlist",
ask: "off",
safeBins: [],
});
await expect(
tool.execute("call-2", {
command: "echo hello",
security: "allowlist",
ask: "off",
}),
).rejects.toThrow(/exec denied: allowlist miss/i);
});
it("ignores model-supplied deny security when configured security is allowlist", async () => {
const tool = createExecTool({
security: "allowlist",
ask: "off",
safeBins: [],
});
await expect(
tool.execute("call-3", {
command: "echo hello",
security: "deny",
ask: "off",
}),
).rejects.toThrow(/exec denied: allowlist miss/i);
});
it("ignores model-supplied full security when configured security is deny", async () => {
const tool = createExecTool({
security: "deny",
ask: "off",
});
await expect(
tool.execute("call-4", {
command: "echo hello",
security: "full",
ask: "off",
}),
).rejects.toThrow(/exec denied/i);
});
});

View File

@@ -8,7 +8,6 @@ import {
type ExecSecurity,
loadExecApprovals,
maxAsk,
minSecurity,
requireValidExecTarget,
} from "../infra/exec-approvals.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
@@ -40,7 +39,6 @@ import {
applyPathPrepend,
applyShellPath,
normalizeExecAsk,
normalizeExecSecurity,
normalizePathPrepend,
resolveExecTarget,
resolveApprovalRunningNoticeMs,
@@ -1346,8 +1344,7 @@ export function createExecTool(
const approvalDefaults = loadExecApprovals().defaults;
const configuredSecurity =
defaults?.security ?? approvalDefaults?.security ?? (host === "sandbox" ? "deny" : "full");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
let security = configuredSecurity;
if (elevatedRequested && elevatedMode === "full") {
security = "full";
}

View File

@@ -34,7 +34,8 @@ export const execSchema = Type.Object({
}),
security: Type.Optional(
Type.String({
description: "Exec security mode (deny|allowlist|full).",
description:
"Ignored for normal calls; exec security is set by tools.exec.security and host approvals.",
}),
),
ask: Type.Optional(

View File

@@ -17,7 +17,10 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import { findModelInCatalog } from "./model-catalog-lookup.js";
import type { ModelCatalogEntry } from "./model-catalog.types.js";
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
export { resolveThinkingDefault } from "./model-thinking-default.js";
export {
resolveThinkingDefault,
resolveThinkingDefaultWithRuntimeCatalog,
} from "./model-thinking-default.js";
import {
type ModelRef,
findNormalizedProviderKey,

View File

@@ -7,6 +7,7 @@ import {
import type { ModelCatalogEntry } from "./model-catalog.types.js";
import { legacyModelKey, modelKey, normalizeProviderId } from "./model-selection-normalize.js";
import { normalizeModelSelection } from "./model-selection-resolve.js";
import { buildConfiguredModelCatalog } from "./model-selection-shared.js";
type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max";
@@ -77,3 +78,33 @@ export function resolveThinkingDefault(params: {
catalog: params.catalog,
});
}
export async function resolveThinkingDefaultWithRuntimeCatalog(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
loadModelCatalog: () => Promise<ModelCatalogEntry[]>;
}): Promise<ThinkLevel> {
const configuredCatalog = buildConfiguredModelCatalog({ cfg: params.cfg });
const configuredSelectedEntry = configuredCatalog.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);
const needsRuntimeCatalog =
configuredCatalog.length === 0 ||
!configuredSelectedEntry ||
configuredSelectedEntry.reasoning === undefined;
const runtimeCatalog = needsRuntimeCatalog ? await params.loadModelCatalog() : undefined;
const runtimeSelectedEntry = runtimeCatalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);
const catalog =
runtimeSelectedEntry || configuredCatalog.length === 0
? (runtimeCatalog ?? configuredCatalog)
: configuredCatalog;
return resolveThinkingDefault({
cfg: params.cfg,
provider: params.provider,
model: params.model,
catalog,
});
}

View File

@@ -124,9 +124,10 @@ function makeClientTool(name: string): ClientToolDefinition {
};
}
async function executeClientTool(
params: unknown,
): Promise<{ calledWith: Record<string, unknown> | undefined }> {
async function executeClientTool(params: unknown): Promise<{
calledWith: Record<string, unknown> | undefined;
result: Awaited<ReturnType<ToolExecute>>;
}> {
let captured: Record<string, unknown> | undefined;
const [def] = toClientToolDefinitions([makeClientTool("search")], (_name, p) => {
captured = p;
@@ -134,14 +135,40 @@ async function executeClientTool(
if (!def) {
throw new Error("missing client tool definition");
}
await def.execute("call-c1", params, undefined, undefined, extensionContext);
return { calledWith: captured };
const result = await def.execute("call-c1", params, undefined, undefined, extensionContext);
return { calledWith: captured, result };
}
describe("toClientToolDefinitions param coercion", () => {
it("returns terminal pending results for each client tool in a batch", async () => {
const completed: Array<{ id: string; name: string; params: Record<string, unknown> }> = [];
const defs = toClientToolDefinitions([makeClientTool("search"), makeClientTool("lookup")], {
complete: (id, name, params) => {
completed.push({ id, name, params });
},
});
const [search, lookup] = defs;
if (!search || !lookup) {
throw new Error("missing client tool definition");
}
const [searchResult, lookupResult] = await Promise.all([
search.execute("call-search", { query: "first" }, undefined, undefined, extensionContext),
lookup.execute("call-lookup", { query: "second" }, undefined, undefined, extensionContext),
]);
expect(searchResult.terminate).toBe(true);
expect(lookupResult.terminate).toBe(true);
expect(completed).toEqual([
{ id: "call-search", name: "search", params: { query: "first" } },
{ id: "call-lookup", name: "lookup", params: { query: "second" } },
]);
});
it("passes plain object params through unchanged", async () => {
const { calledWith } = await executeClientTool({ query: "hello" });
const { calledWith, result } = await executeClientTool({ query: "hello" });
expect(calledWith).toEqual({ query: "hello" });
expect(result.terminate).toBe(true);
});
it("parses a JSON string into an object (streaming delta accumulation)", async () => {

View File

@@ -377,12 +377,15 @@ export function toClientToolDefinitions(
}
throw err;
}
// Return a pending result - the client will execute this tool
return jsonResult({
status: "pending",
tool: func.name,
message: "Tool execution delegated to client",
});
// Return a terminal pending result; the client will execute the tool.
return {
...jsonResult({
status: "pending",
tool: func.name,
message: "Tool execution delegated to client",
}),
terminate: true,
};
},
} satisfies ToolDefinition;
});

View File

@@ -67,6 +67,13 @@ function expectFields(value: unknown, expected: Record<string, unknown>, label =
}
}
function expectSubagentSessionKey(value: unknown, label: string): string {
expect(value, label).toBeTypeOf("string");
const sessionKey = value as string;
expect(sessionKey.startsWith("agent:main:subagent:")).toBe(true);
return sessionKey;
}
function setConfig(next: Record<string, unknown>) {
hoisted.configOverride = createSubagentSpawnTestConfig(undefined, next);
}
@@ -234,9 +241,16 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
expectFields(result, { status: "accepted", runId: "run-1" }, "spawn result");
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledWith(
const [spawningEvent, spawningContext] = (hookRunnerMocks.runSubagentSpawning.mock.calls[0] ??
[]) as unknown as [Record<string, unknown>, Record<string, unknown>];
const spawningChildSessionKey = expectSubagentSessionKey(
spawningEvent?.childSessionKey,
"spawning event child session key",
);
expectFields(
spawningEvent,
{
childSessionKey: expect.stringMatching(/^agent:main:subagent:/),
childSessionKey: spawningChildSessionKey,
agentId: "main",
label: "research",
mode: "session",
@@ -248,10 +262,15 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
},
threadRequested: true,
},
"spawning event",
);
expectFields(
spawningContext,
{
childSessionKey: expect.stringMatching(/^agent:main:subagent:/),
childSessionKey: spawningChildSessionKey,
requesterSessionKey: "main",
},
"spawning context",
);
expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1);
@@ -280,7 +299,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
},
"spawned requester",
);
expect(event.childSessionKey).toEqual(expect.stringMatching(/^agent:main:subagent:/));
expectSubagentSessionKey(event.childSessionKey, "spawned event child session key");
expectFields(
ctx,
{
@@ -423,7 +442,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
const [event] = (hookRunnerMocks.runSubagentEnded.mock.calls[0] ?? []) as unknown as [
Record<string, unknown>,
];
expect(event.targetSessionKey).toEqual(expect.stringMatching(/^agent:main:subagent:/));
expectSubagentSessionKey(event.targetSessionKey, "ended event target session key");
expectFields(
event,
{

View File

@@ -28,12 +28,11 @@ import { buildTaskStatusSnapshotForRelatedSessionKeyForOwner } from "../../tasks
import { formatTaskStatusDetail, formatTaskStatusTitle } from "../../tasks/task-status.js";
import { loadModelCatalog } from "../model-catalog.js";
import {
buildConfiguredModelCatalog,
buildModelAliasIndex,
modelKey,
resolveDefaultModelForAgent,
resolveModelRefFromString,
resolveThinkingDefault,
resolveThinkingDefaultWithRuntimeCatalog,
} from "../model-selection.js";
import { createModelVisibilityPolicy } from "../model-visibility-policy.js";
import {
@@ -713,32 +712,13 @@ export function createSessionStatusTool(opts?: {
resolvedVerboseLevel: (statusSessionEntry.verboseLevel ?? "off") as VerboseLevel,
resolvedReasoningLevel: (statusSessionEntry.reasoningLevel ?? "off") as ReasoningLevel,
resolvedElevatedLevel: statusSessionEntry.elevatedLevel as ElevatedLevel | undefined,
resolveDefaultThinkingLevel: async () => {
const configuredCatalog = buildConfiguredModelCatalog({ cfg });
const configuredSelectedEntry = configuredCatalog.find(
(entry) => entry.provider === providerForCard && entry.id === defaultModelForCard,
);
const shouldHydrateRuntimeCatalog =
configuredCatalog.length === 0 ||
!configuredSelectedEntry ||
configuredSelectedEntry.reasoning === undefined;
const runtimeCatalog = shouldHydrateRuntimeCatalog
? await loadModelCatalog({ config: cfg })
: undefined;
const runtimeSelectedEntry = runtimeCatalog?.find(
(entry) => entry.provider === providerForCard && entry.id === defaultModelForCard,
);
const catalog =
runtimeSelectedEntry || configuredCatalog.length === 0
? (runtimeCatalog ?? configuredCatalog)
: configuredCatalog;
return resolveThinkingDefault({
resolveDefaultThinkingLevel: () =>
resolveThinkingDefaultWithRuntimeCatalog({
cfg,
provider: providerForCard,
model: defaultModelForCard,
catalog,
});
},
loadModelCatalog: () => loadModelCatalog({ config: cfg }),
}),
isGroup,
defaultGroupActivation: () => "mention",
taskLineOverride: taskLine,

View File

@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import {
pinActivePluginChannelRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import {
buildCommandText,
@@ -82,12 +86,27 @@ function installOllamaThinkingProvider() {
setActivePluginRegistry(registry);
}
function createNativeCommandsRegistry(id: "discord" | "slack") {
return createTestRegistry([
{
pluginId: id,
plugin: createChannelTestPluginBase({
id,
capabilities: { nativeCommands: true, chatTypes: ["direct"] },
}),
source: "test",
},
]);
}
beforeEach(() => {
vi.doUnmock("../channels/plugins/index.js");
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createTestRegistry([]));
});
afterEach(() => {
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createTestRegistry([]));
});
@@ -455,6 +474,65 @@ describe("commands registry", () => {
).toBe(true);
});
it("refreshes dock commands when pinned-empty fallback active registry changes", () => {
const pinnedEmptyRegistry = createTestRegistry([]);
setActivePluginRegistry(pinnedEmptyRegistry);
pinActivePluginChannelRegistry(pinnedEmptyRegistry);
setActivePluginRegistry(createNativeCommandsRegistry("discord"));
expect([...commandKeySet(listChatCommands())]).toEqual(
expect.arrayContaining(["dock:discord"]),
);
expect([...commandKeySet(listChatCommands())]).not.toEqual(
expect.arrayContaining(["dock:slack"]),
);
setActivePluginRegistry(createNativeCommandsRegistry("slack"));
expect([...commandKeySet(listChatCommands())]).not.toEqual(
expect.arrayContaining(["dock:discord"]),
);
expect([...commandKeySet(listChatCommands())]).toEqual(expect.arrayContaining(["dock:slack"]));
});
it("refreshes text-command gating when pinned-empty fallback active registry changes", () => {
const cfg = { commands: { text: false } };
const pinnedEmptyRegistry = createTestRegistry([]);
setActivePluginRegistry(pinnedEmptyRegistry);
pinActivePluginChannelRegistry(pinnedEmptyRegistry);
setActivePluginRegistry(createNativeCommandsRegistry("discord"));
expect(
shouldHandleTextCommands({
cfg,
surface: "discord",
commandSource: "text",
}),
).toBe(false);
expect(
shouldHandleTextCommands({
cfg,
surface: "slack",
commandSource: "text",
}),
).toBe(true);
setActivePluginRegistry(createNativeCommandsRegistry("slack"));
expect(
shouldHandleTextCommands({
cfg,
surface: "discord",
commandSource: "text",
}),
).toBe(true);
expect(
shouldHandleTextCommands({
cfg,
surface: "slack",
commandSource: "text",
}),
).toBe(false);
});
it("normalizes telegram-style command mentions for the current bot", () => {
expect(normalizeCommandBody("/help@openclaw", { botUsername: "openclaw" })).toBe("/help");
expect(

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const completeSimple = vi.hoisted(() => vi.fn());
const getRuntimeAuthForModel = vi.hoisted(() => vi.fn());
@@ -63,6 +63,10 @@ describe("generateConversationLabel", () => {
});
});
afterEach(() => {
vi.useRealTimers();
});
it("uses routed agentDir for model and auth resolution", async () => {
await generateConversationLabel({
userMessage: "Need help with invoices",
@@ -94,31 +98,35 @@ describe("generateConversationLabel", () => {
});
it("passes the label prompt as systemPrompt and the user text as message content", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_710_000_000_000);
await generateConversationLabel({
userMessage: "Need help with invoices",
prompt: "Generate a label",
cfg: {},
});
expect(completeSimple).toHaveBeenCalledWith(
{ provider: "openai" },
{
systemPrompt: "Generate a label",
messages: [
{
role: "user",
content: "Need help with invoices",
timestamp: expect.any(Number),
},
],
},
expect.objectContaining({
apiKey: "resolved-key",
maxTokens: 100,
temperature: 0.3,
signal: expect.any(AbortSignal),
}),
);
expect(completeSimple).toHaveBeenCalledOnce();
const call = completeSimple.mock.calls[0];
if (!call) {
throw new Error("expected simple completion call");
}
expect(call[0]).toStrictEqual({ provider: "openai" });
expect(call[1]).toStrictEqual({
systemPrompt: "Generate a label",
messages: [
{
role: "user",
content: "Need help with invoices",
timestamp: 1_710_000_000_000,
},
],
});
expect(call[2].apiKey).toBe("resolved-key");
expect(call[2].maxTokens).toBe(100);
expect(call[2].temperature).toBe(0.3);
expect(call[2].signal).toBeInstanceOf(AbortSignal);
});
it("omits temperature for Codex Responses simple completions", async () => {
@@ -135,9 +143,12 @@ describe("generateConversationLabel", () => {
cfg: {},
});
expect(completeSimple.mock.calls[0]?.[2]).toEqual(
expect.not.objectContaining({ temperature: expect.anything() }),
);
expect(completeSimple).toHaveBeenCalledOnce();
const options = completeSimple.mock.calls[0]?.[2];
if (!options) {
throw new Error("expected simple completion options");
}
expect(Object.hasOwn(options, "temperature")).toBe(false);
});
it("logs completion errors instead of treating them as empty labels", async () => {

View File

@@ -1,10 +1,15 @@
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import {
resolveThinkingDefaultWithRuntimeCatalog,
type ModelAliasIndex,
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import type { GetReplyOptions } from "../get-reply-options.types.js";
import type { ReplyPayload } from "../reply-payload.js";
import type { MsgContext } from "../templating.js";
import { normalizeThinkLevel, type ThinkLevel } from "../thinking.js";
import { buildCommandContext } from "./commands-context.js";
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
import { resolveReplyDirectives } from "./get-reply-directives.js";
@@ -42,6 +47,19 @@ function shouldRunNativeSlashCommandFastPath(ctx: MsgContext): boolean {
return Boolean(commandName && commandName !== "new" && commandName !== "reset");
}
async function resolveNativeSlashDefaultThinkingLevel(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
}): Promise<ThinkLevel> {
return resolveThinkingDefaultWithRuntimeCatalog({
cfg: params.cfg,
provider: params.provider,
model: params.model,
loadModelCatalog: () => loadModelCatalog({ config: params.cfg }),
});
}
export async function maybeResolveNativeSlashCommandFastReply(params: {
ctx: MsgContext;
cfg: OpenClawConfig;
@@ -84,6 +102,16 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
if (command.commandBodyNormalized === "/status") {
const targetSessionEntry =
sessionState.sessionStore[sessionState.sessionKey] ?? sessionState.sessionEntry;
let resolvedDefaultThinkingLevel: ThinkLevel | undefined;
const resolveDefaultThinkingLevel = async () => {
resolvedDefaultThinkingLevel ??= await resolveNativeSlashDefaultThinkingLevel({
cfg: params.cfg,
provider: params.provider,
model: params.model,
});
return resolvedDefaultThinkingLevel;
};
const resolvedThinkLevel = normalizeThinkLevel(targetSessionEntry?.thinkingLevel);
const { buildStatusReply } = await loadStatusCommandRuntime();
return {
handled: true,
@@ -98,11 +126,11 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
provider: params.provider,
model: params.model,
workspaceDir: params.workspaceDir,
resolvedThinkLevel: undefined,
resolvedThinkLevel,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
resolveDefaultThinkingLevel,
isGroup: sessionState.isGroup,
defaultGroupActivation: () => "always",
mediaDecisions: params.ctx.MediaUnderstandingDecisions,

View File

@@ -18,12 +18,37 @@ import {
import { loadGetReplyModuleForTest } from "./get-reply.test-loader.js";
import "./get-reply.test-runtime-mocks.js";
type LoadModelCatalogFn = typeof import("../../agents/model-catalog.js").loadModelCatalog;
type ModelAliasIndex = import("../../agents/model-selection.js").ModelAliasIndex;
function emptyAliasIndex(): ModelAliasIndex {
return { byAlias: new Map(), byKey: new Map() };
}
const mocks = vi.hoisted(() => ({
ensureAgentWorkspace: vi.fn(),
initSessionState: vi.fn(),
loadModelCatalog: vi.fn<LoadModelCatalogFn>(async () => [
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
reasoning: true,
},
]),
resolveReplyDirectives: vi.fn(),
}));
vi.mock("../../agents/model-catalog.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/model-catalog.js")>(
"../../agents/model-catalog.js",
);
return {
...actual,
loadModelCatalog: mocks.loadModelCatalog,
};
});
vi.mock("../../agents/workspace.js", () => ({
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace",
ensureAgentWorkspace: (...args: unknown[]) => mocks.ensureAgentWorkspace(...args),
@@ -31,11 +56,14 @@ vi.mock("../../agents/workspace.js", () => ({
registerGetReplyRuntimeOverrides(mocks);
let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig;
let resolveDefaultModelMock: typeof import("./directive-handling.defaults.js").resolveDefaultModel;
let loadConfigMock: typeof import("../../config/config.js").getRuntimeConfig;
let runPreparedReplyMock: typeof import("./get-reply-run.js").runPreparedReply;
async function loadGetReplyRuntimeForTest() {
({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url }));
({ resolveDefaultModel: resolveDefaultModelMock } =
await import("./directive-handling.defaults.js"));
({ getRuntimeConfig: loadConfigMock } = await import("../../config/config.js"));
({ runPreparedReply: runPreparedReplyMock } = await import("./get-reply-run.js"));
}
@@ -49,7 +77,22 @@ describe("getReplyFromConfig fast test bootstrap", () => {
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
mocks.ensureAgentWorkspace.mockReset();
mocks.initSessionState.mockReset();
mocks.loadModelCatalog.mockReset();
mocks.loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
reasoning: true,
},
]);
mocks.resolveReplyDirectives.mockReset();
vi.mocked(resolveDefaultModelMock).mockReset();
vi.mocked(resolveDefaultModelMock).mockReturnValue({
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex: emptyAliasIndex(),
});
vi.mocked(loadConfigMock).mockReset();
vi.mocked(runPreparedReplyMock).mockReset();
vi.mocked(loadConfigMock).mockReturnValue({});
@@ -89,11 +132,12 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(mocks.initSessionState).not.toHaveBeenCalled();
expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled();
expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledWith(
expect.objectContaining({
cfg,
}),
);
expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledOnce();
const preparedReplyParams = vi.mocked(runPreparedReplyMock).mock.calls[0]?.[0];
if (!preparedReplyParams) {
throw new Error("expected prepared reply params");
}
expect(preparedReplyParams.cfg).toBe(cfg);
});
it("still merges partial config overrides against getRuntimeConfig()", async () => {
@@ -219,6 +263,11 @@ describe("getReplyFromConfig fast test bootstrap", () => {
},
session: { store: path.join(home, "sessions.json") },
} as OpenClawConfig);
vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: emptyAliasIndex(),
});
const reply = await getReplyFromConfig(
buildGetReplyCtx({
@@ -235,7 +284,117 @@ describe("getReplyFromConfig fast test bootstrap", () => {
cfg,
);
expect(reply).toEqual(expect.objectContaining({ text: expect.stringContaining("OpenClaw") }));
if (!reply || Array.isArray(reply) || typeof reply.text !== "string") {
throw new Error("expected status reply text");
}
expect(reply.text.includes("OpenClaw")).toBe(true);
expect(reply.text.includes("Think: medium")).toBe(true);
expect(mocks.loadModelCatalog).toHaveBeenCalledWith({ config: cfg });
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(mocks.initSessionState).not.toHaveBeenCalled();
expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled();
expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled();
});
it("uses configured agent thinking defaults for native /status", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-agent-think-"));
const targetSessionKey = "agent:main:telegram:123";
const cfg = markCompleteReplyConfig({
agents: {
defaults: {
model: "openai/gpt-5.5",
workspace: path.join(home, "workspace"),
thinkingDefault: "low",
},
list: [
{
id: "main",
thinkingDefault: "high",
},
],
},
session: { store: path.join(home, "sessions.json") },
} as OpenClawConfig);
vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: emptyAliasIndex(),
});
const reply = await getReplyFromConfig(
buildGetReplyCtx({
Body: "/status",
BodyForAgent: "/status",
RawBody: "/status",
CommandBody: "/status",
CommandSource: "native",
CommandAuthorized: true,
SessionKey: "telegram:slash:123",
CommandTargetSessionKey: targetSessionKey,
}),
undefined,
cfg,
);
expect(reply).toEqual(
expect.objectContaining({ text: expect.stringContaining("Think: high") }),
);
expect(mocks.loadModelCatalog).not.toHaveBeenCalled();
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(mocks.initSessionState).not.toHaveBeenCalled();
expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled();
expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled();
});
it("uses the target session thinking override for native /status", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-think-"));
const storePath = path.join(home, "sessions.json");
const targetSessionKey = "agent:main:telegram:123";
await fs.writeFile(
storePath,
JSON.stringify({
[targetSessionKey]: {
sessionId: "existing-telegram-session",
thinkingLevel: "xhigh",
updatedAt: 1,
},
}),
"utf8",
);
const cfg = markCompleteReplyConfig({
agents: {
defaults: {
model: "openai/gpt-5.5",
workspace: path.join(home, "workspace"),
},
},
session: { store: storePath },
} as OpenClawConfig);
vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: emptyAliasIndex(),
});
const reply = await getReplyFromConfig(
buildGetReplyCtx({
Body: "/status",
BodyForAgent: "/status",
RawBody: "/status",
CommandBody: "/status",
CommandSource: "native",
CommandAuthorized: true,
SessionKey: "telegram:slash:123",
CommandTargetSessionKey: targetSessionKey,
}),
undefined,
cfg,
);
expect(reply).toEqual(
expect.objectContaining({ text: expect.stringContaining("Think: xhigh") }),
);
expect(mocks.loadModelCatalog).not.toHaveBeenCalled();
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(mocks.initSessionState).not.toHaveBeenCalled();
expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled();
@@ -279,12 +438,15 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(mocks.initSessionState).not.toHaveBeenCalled();
expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled();
expect(mocks.resolveReplyDirectives).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: targetSessionKey,
workspaceDir: expect.any(String),
}),
);
expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce();
const directiveParams = mocks.resolveReplyDirectives.mock.calls[0]?.[0] as
| { sessionKey?: string; workspaceDir?: string }
| undefined;
if (!directiveParams) {
throw new Error("expected directive params");
}
expect(directiveParams.sessionKey).toBe(targetSessionKey);
expect(directiveParams.workspaceDir).toBe("/tmp/workspace");
});
it("uses native command target session keys during fast bootstrap", () => {

View File

@@ -0,0 +1,95 @@
import type {
ActivePluginChannelRegistration,
ActivePluginChannelRegistry,
} from "../plugins/channel-registry-state.types.js";
import { getActivePluginChannelRegistrySnapshotFromState } from "../plugins/runtime-channel-state.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
export type RegisteredChannelPluginEntry = ActivePluginChannelRegistration & {
plugin: ActivePluginChannelRegistration["plugin"] & {
id?: string | null;
meta?: {
aliases?: readonly string[];
markdownCapable?: boolean;
} | null;
};
};
type RegisteredChannelPluginLookup = {
registry: ActivePluginChannelRegistry | null;
channels: ActivePluginChannelRegistration[] | undefined;
channelCount: number;
version: number;
entries: RegisteredChannelPluginEntry[];
byKey: Map<string, RegisteredChannelPluginEntry>;
byId: Map<string, RegisteredChannelPluginEntry>;
};
let registeredChannelPluginLookup: RegisteredChannelPluginLookup | undefined;
function setLookupEntry(
map: Map<string, RegisteredChannelPluginEntry>,
key: string | undefined,
entry: RegisteredChannelPluginEntry,
): void {
if (key && !map.has(key)) {
map.set(key, entry);
}
}
function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup {
const { registry, version } = getActivePluginChannelRegistrySnapshotFromState();
const channels = Array.isArray(registry?.channels) ? registry.channels : undefined;
const channelCount = channels?.length ?? 0;
const cached = registeredChannelPluginLookup;
if (
cached &&
cached.registry === registry &&
cached.channels === channels &&
cached.channelCount === channelCount &&
cached.version === version
) {
return cached;
}
const entries = channelCount > 0 ? (channels as RegisteredChannelPluginEntry[]) : [];
const byKey = new Map<string, RegisteredChannelPluginEntry>();
const byId = new Map<string, RegisteredChannelPluginEntry>();
for (const entry of entries) {
const id = normalizeOptionalLowercaseString(entry.plugin.id ?? "");
setLookupEntry(byKey, id, entry);
setLookupEntry(byId, id, entry);
for (const alias of entry.plugin.meta?.aliases ?? []) {
setLookupEntry(byKey, normalizeOptionalLowercaseString(alias), entry);
}
}
registeredChannelPluginLookup = {
registry,
channels,
channelCount,
version,
entries,
byKey,
byId,
};
return registeredChannelPluginLookup;
}
export function listRegisteredChannelPluginEntries(): RegisteredChannelPluginEntry[] {
return buildRegisteredChannelPluginLookup().entries;
}
export function findRegisteredChannelPluginEntry(
normalizedKey: string,
): RegisteredChannelPluginEntry | undefined {
return buildRegisteredChannelPluginLookup().byKey.get(normalizedKey);
}
export function findRegisteredChannelPluginEntryById(
id: string,
): RegisteredChannelPluginEntry | undefined {
const normalizedId = normalizeOptionalLowercaseString(id);
if (!normalizedId) {
return undefined;
}
return buildRegisteredChannelPluginLookup().byId.get(normalizedId);
}

View File

@@ -1,30 +1,11 @@
import type { ActivePluginChannelRegistration } from "../plugins/channel-registry-state.types.js";
import { getActivePluginChannelRegistryFromState } from "../plugins/runtime-channel-state.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import type { ChannelId } from "./plugins/channel-id.types.js";
function listRegisteredChannelPluginEntries(): ActivePluginChannelRegistration[] {
const channelRegistry = getActivePluginChannelRegistryFromState();
if (channelRegistry?.channels && channelRegistry.channels.length > 0) {
return channelRegistry.channels;
}
return [];
}
import { findRegisteredChannelPluginEntry } from "./registry-lookup.js";
export function normalizeAnyChannelId(raw?: string | null): ChannelId | null {
const key = normalizeOptionalLowercaseString(raw);
if (!key) {
return null;
}
return (
listRegisteredChannelPluginEntries().find((entry) => {
const id = normalizeOptionalLowercaseString(entry.plugin.id ?? "") ?? "";
if (id && id === key) {
return true;
}
return (entry.plugin.meta?.aliases ?? []).some(
(alias) => normalizeOptionalLowercaseString(alias) === key,
);
})?.plugin.id ?? null
);
return findRegisteredChannelPluginEntry(key)?.plugin.id ?? null;
}

View File

@@ -2,12 +2,16 @@ import { afterEach, describe, expect, it } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import {
pinActivePluginChannelRegistry,
getActivePluginChannelRegistryVersion,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { listChatChannels } from "./chat-meta.js";
import { normalizeAnyChannelId as normalizeAnyChannelIdLight } from "./registry-normalize.js";
import {
formatChannelSelectionLine,
getRegisteredChannelPluginMeta,
listRegisteredChannelPluginIds,
normalizeAnyChannelId,
} from "./registry.js";
@@ -32,6 +36,16 @@ describe("channel registry helpers", () => {
return label ?? path ?? "";
}
function createRegistryWithRegisteredChannel(id: string, aliases: string[] = []) {
return createTestRegistry([
{
pluginId: id,
plugin: { id, meta: { aliases } },
source: "test",
},
]);
}
it("keeps Feishu first in the current default order", () => {
const channels = listChatChannels();
expect(channels[0]?.id).toBe("feishu");
@@ -53,29 +67,16 @@ describe("channel registry helpers", () => {
});
it("prefers the pinned channel registry when resolving registered plugin channels", () => {
const startupRegistry = createEmptyPluginRegistry();
startupRegistry.channels = [
{
pluginId: "openclaw-weixin",
plugin: { id: "openclaw-weixin", meta: { aliases: ["weixin"] } },
source: "test",
},
] as never;
const startupRegistry = createRegistryWithRegisteredChannel("openclaw-weixin", ["weixin"]);
setActivePluginRegistry(startupRegistry);
pinActivePluginChannelRegistry(startupRegistry);
const replacementRegistry = createEmptyPluginRegistry();
replacementRegistry.channels = [
{
pluginId: "qqbot",
plugin: { id: "qqbot", meta: { aliases: ["qq"] } },
source: "test",
},
] as never;
const replacementRegistry = createRegistryWithRegisteredChannel("qqbot", ["qq"]);
setActivePluginRegistry(replacementRegistry);
expect(listRegisteredChannelPluginIds()).toEqual(["openclaw-weixin"]);
expect(normalizeAnyChannelId("weixin")).toBe("openclaw-weixin");
expect(getRegisteredChannelPluginMeta("OPENCLAW-WEIXIN")?.aliases).toEqual(["weixin"]);
});
it("falls back to the active registry when the pinned channel registry has no channels", () => {
@@ -83,17 +84,45 @@ describe("channel registry helpers", () => {
setActivePluginRegistry(startupRegistry);
pinActivePluginChannelRegistry(startupRegistry);
const replacementRegistry = createEmptyPluginRegistry();
replacementRegistry.channels = [
{
pluginId: "qqbot",
plugin: { id: "qqbot", meta: { aliases: ["qq"] } },
source: "test",
},
] as never;
const replacementRegistry = createRegistryWithRegisteredChannel("qqbot", ["qq"]);
setActivePluginRegistry(replacementRegistry);
expect(listRegisteredChannelPluginIds()).toEqual(["qqbot"]);
expect(normalizeAnyChannelId("qq")).toBe("qqbot");
});
it("rebuilds registered channel lookups when pinned-empty fallback active registry changes", () => {
const startupRegistry = createEmptyPluginRegistry();
setActivePluginRegistry(startupRegistry);
pinActivePluginChannelRegistry(startupRegistry);
const alphaRegistry = createRegistryWithRegisteredChannel("alpha", ["a"]);
setActivePluginRegistry(alphaRegistry);
const channelVersion = getActivePluginChannelRegistryVersion();
expect(normalizeAnyChannelId("a")).toBe("alpha");
expect(normalizeAnyChannelIdLight("a")).toBe("alpha");
const betaRegistry = createRegistryWithRegisteredChannel("beta", ["b"]);
setActivePluginRegistry(betaRegistry);
expect(getActivePluginChannelRegistryVersion()).not.toBe(channelVersion);
expect(normalizeAnyChannelId("a")).toBeNull();
expect(normalizeAnyChannelId("b")).toBe("beta");
expect(normalizeAnyChannelIdLight("a")).toBeNull();
expect(normalizeAnyChannelIdLight("b")).toBe("beta");
});
it("refreshes registered channel lookups when selected registry channels grow in place", () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
expect(normalizeAnyChannelId("a")).toBeNull();
expect(normalizeAnyChannelIdLight("a")).toBeNull();
registry.channels.push(createRegistryWithRegisteredChannel("alpha", ["a"]).channels[0]);
expect(normalizeAnyChannelId("a")).toBe("alpha");
expect(normalizeAnyChannelIdLight("a")).toBe("alpha");
});
});

View File

@@ -1,4 +1,3 @@
import { getActivePluginChannelRegistryFromState } from "../plugins/runtime-channel-state.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -6,50 +5,14 @@ import {
import { normalizeChatChannelId, type ChatChannelId } from "./ids.js";
import type { ChannelId } from "./plugins/channel-id.types.js";
import type { ChannelMeta } from "./plugins/types.core.js";
import {
findRegisteredChannelPluginEntry,
findRegisteredChannelPluginEntryById,
listRegisteredChannelPluginEntries,
} from "./registry-lookup.js";
export { getChatChannelMeta } from "./chat-meta.js";
export { CHAT_CHANNEL_ORDER } from "./ids.js";
export type { ChatChannelId } from "./ids.js";
type RegisteredChannelPluginEntry = {
plugin: {
id?: string | null;
meta?: Pick<ChannelMeta, "aliases" | "markdownCapable"> | null;
};
};
function listRegisteredChannelPluginEntries(): RegisteredChannelPluginEntry[] {
const channelRegistry = getActivePluginChannelRegistryFromState();
if (channelRegistry && channelRegistry.channels && channelRegistry.channels.length > 0) {
return channelRegistry.channels;
}
return [];
}
function findRegisteredChannelPluginEntry(
normalizedKey: string,
): RegisteredChannelPluginEntry | undefined {
return listRegisteredChannelPluginEntries().find((entry) => {
const id = normalizeOptionalLowercaseString(entry.plugin.id ?? "") ?? "";
if (id && id === normalizedKey) {
return true;
}
return (entry.plugin.meta?.aliases ?? []).some(
(alias) => normalizeOptionalLowercaseString(alias) === normalizedKey,
);
});
}
function findRegisteredChannelPluginEntryById(
id: string,
): RegisteredChannelPluginEntry | undefined {
const normalizedId = normalizeOptionalLowercaseString(id);
if (!normalizedId) {
return undefined;
}
return listRegisteredChannelPluginEntries().find(
(entry) => normalizeOptionalLowercaseString(entry.plugin.id) === normalizedId,
);
}
export { normalizeChatChannelId };
// Channel docking: prefer this helper in shared code. Importing from

View File

@@ -1,4 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { VERSION } from "../../version.js";
import {
defaultRuntime,
resetLifecycleRuntimeLogs,
@@ -9,6 +10,15 @@ import {
const readConfigFileSnapshotMock = vi.fn();
const loadConfig = vi.fn(() => ({}));
const newerConfigHints = [
"Run the newer openclaw binary on PATH, or reinstall the intended gateway service from the newer install.",
"Set OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 only for an intentional downgrade or recovery action.",
];
const newerConfigHintItems = newerConfigHints.map((text) => ({ kind: "generic", text }));
function expectLatestRuntimeJson(payload: unknown) {
expect(defaultRuntime.writeJson.mock.calls.at(-1)?.[0]).toEqual(payload);
}
vi.mock("../../config/config.js", () => ({
getRuntimeConfig: () => loadConfig(),
@@ -90,11 +100,14 @@ describe("runServiceRestart config pre-flight (#35862)", () => {
await expect(runServiceRestart(createServiceRunArgs())).rejects.toThrow("__exit__:1");
expect(service.restart).not.toHaveBeenCalled();
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining("Refusing to restart the gateway service"),
}),
);
expectLatestRuntimeJson({
action: "restart",
ok: false,
error: `Gateway restart blocked: Refusing to restart the gateway service because this OpenClaw binary (${VERSION}) is older than the config last written by OpenClaw 9999.1.1.`,
hints: newerConfigHints,
hintItems: newerConfigHintItems,
warnings: undefined,
});
});
it("proceeds with restart when config is valid", async () => {
@@ -208,10 +221,13 @@ describe("runServiceStop future-config guard", () => {
).rejects.toThrow("__exit__:1");
expect(service.stop).not.toHaveBeenCalled();
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining("Refusing to stop the gateway service"),
}),
);
expectLatestRuntimeJson({
action: "stop",
ok: false,
error: `Gateway stop blocked: Refusing to stop the gateway service because this OpenClaw binary (${VERSION}) is older than the config last written by OpenClaw 9999.1.1.`,
hints: newerConfigHints,
hintItems: newerConfigHintItems,
warnings: undefined,
});
});
});

View File

@@ -283,6 +283,30 @@ async function createSignaledLoopHarness(exitCallOrder?: string[]) {
return { close, start, runtime, exited, loopPromise };
}
function expectRestartHandoffCall(expected: {
restartKind: "full-process" | "update-process";
reason: string | undefined;
supervisorMode: "external" | "launchd";
}) {
expect(writeGatewayRestartHandoffSync).toHaveBeenCalledTimes(1);
const [handoff] = writeGatewayRestartHandoffSync.mock.calls[0] ?? [];
if (!handoff || typeof handoff !== "object" || Array.isArray(handoff)) {
throw new Error("expected restart handoff options object");
}
const processInstanceId = (handoff as { processInstanceId?: unknown }).processInstanceId;
expect(typeof processInstanceId).toBe("string");
if (typeof processInstanceId !== "string") {
throw new Error("expected restart handoff processInstanceId string");
}
expect(processInstanceId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(handoff).toEqual({
...expected,
processInstanceId,
});
}
describe("runGatewayLoop", () => {
it("exits 0 on SIGTERM after graceful close", async () => {
vi.clearAllMocks();
@@ -405,9 +429,7 @@ describe("runGatewayLoop", () => {
expect(waitForActiveEmbeddedRuns).not.toHaveBeenCalled();
expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" });
expect(gatewayLog.warn).toHaveBeenCalledWith(
expect.stringContaining(
"restart blocked by active background task run(s): taskId=task-force",
),
"restart blocked by active background task run(s): taskId=task-force runId=run-force status=running runtime=cron label=forced",
);
expect(gatewayLog.warn).toHaveBeenCalledWith(
"forced restart requested; skipping active work drain",
@@ -662,10 +684,9 @@ describe("runGatewayLoop", () => {
await expect(exited).resolves.toBe(0);
expect(runtime.exit).toHaveBeenCalledWith(0);
expect(writeGatewayRestartHandoffSync).toHaveBeenCalledWith({
expectRestartHandoffCall({
restartKind: "full-process",
reason: undefined,
processInstanceId: expect.any(String),
supervisorMode: "launchd",
});
});
@@ -741,7 +762,7 @@ describe("runGatewayLoop", () => {
expect(acquireGatewayLock).toHaveBeenCalledTimes(2);
expect(start).toHaveBeenCalledTimes(1);
expect(gatewayLog.error).toHaveBeenCalledWith(
expect.stringContaining("failed to reacquire gateway lock for in-process restart"),
"failed to reacquire gateway lock for in-process restart: Error: lock timeout",
);
});
});
@@ -791,10 +812,9 @@ describe("runGatewayLoop", () => {
await expect(exited).resolves.toBe(0);
expect(runtime.exit).toHaveBeenCalledWith(0);
expect(writeGatewayRestartHandoffSync).toHaveBeenCalledWith({
expectRestartHandoffCall({
restartKind: "update-process",
reason: "update.run",
processInstanceId: expect.any(String),
supervisorMode: "external",
});
});

View File

@@ -1,9 +1,11 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
import { formatCliCommand } from "../cli/command-format.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
@@ -33,6 +35,10 @@ import { agentsAddCommand } from "./agents.js";
const runtime = createTestRuntime();
function oauthProfileSecretId(authStorePath: string, profileId: string): string {
return createHash("sha256").update(`${authStorePath}\0${profileId}`).digest("hex").slice(0, 32);
}
describe("agents add command", () => {
beforeEach(() => {
readConfigFileSnapshotMock.mockClear();
@@ -49,7 +55,10 @@ describe("agents add command", () => {
await agentsAddCommand({ name: "Work" }, runtime, { hasFlags: true });
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--workspace"));
expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith(
`Non-interactive agent creation requires --workspace. Re-run ${formatCliCommand("openclaw agents add <id> --workspace <path>")} or omit flags to use the wizard.`,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
@@ -61,7 +70,10 @@ describe("agents add command", () => {
hasFlags: false,
});
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--workspace"));
expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith(
`Non-interactive agent creation requires --workspace. Re-run ${formatCliCommand("openclaw agents add <id> --workspace <path>")} or omit flags to use the wizard.`,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
@@ -146,6 +158,7 @@ describe("agents add command", () => {
const sourceAgentDir = path.join(root, "main", "agent");
const destAgentDir = path.join(root, "work", "agent");
const destAuthPath = path.join(destAgentDir, "auth-profiles.json");
const expires = Date.now() + 60_000;
await fs.mkdir(sourceAgentDir, { recursive: true });
saveAuthProfileStore(
{
@@ -156,7 +169,7 @@ describe("agents add command", () => {
provider: "openai-codex",
access: "codex-copy-access-token",
refresh: "codex-copy-refresh-token",
expires: Date.now() + 60_000,
expires,
copyToAgents: true,
},
},
@@ -177,19 +190,17 @@ describe("agents add command", () => {
profiles: Record<string, Record<string, unknown>>;
};
const credential = copied.profiles["openai-codex:default"];
expect(credential).toMatchObject({
expect(credential).toStrictEqual({
type: "oauth",
provider: "openai-codex",
expires,
copyToAgents: true,
oauthRef: {
source: "openclaw-credentials",
provider: "openai-codex",
id: expect.any(String),
id: oauthProfileSecretId(destAuthPath, "openai-codex:default"),
},
});
expect(credential).not.toHaveProperty("access");
expect(credential).not.toHaveProperty("refresh");
expect(credential).not.toHaveProperty("idToken");
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;

View File

@@ -422,4 +422,33 @@ describe("noteLegacyWhatsAppCrontabHealthCheck", () => {
expect(noteMock).not.toHaveBeenCalled();
});
it("ignores malformed crontab output instead of crashing", async () => {
await expect(
noteLegacyWhatsAppCrontabHealthCheck({
platform: "linux",
readCrontab: async () => ({
stdout: undefined,
}),
}),
).resolves.toBeUndefined();
await expect(
noteLegacyWhatsAppCrontabHealthCheck({
platform: "linux",
readCrontab: async () => ({
stdout: 12345,
}),
}),
).resolves.toBeUndefined();
await expect(
noteLegacyWhatsAppCrontabHealthCheck({
platform: "linux",
readCrontab: async () => ({
stdout: { lines: ["*/5 * * * * ~/.openclaw/bin/ensure-whatsapp.sh"] },
}),
}),
).resolves.toBeUndefined();
expect(noteMock).not.toHaveBeenCalled();
});
});

View File

@@ -22,7 +22,7 @@ type CronDoctorOutcome = {
warnings: string[];
};
type CrontabReader = () => Promise<{ stdout: string; stderr?: string }>;
type CrontabReader = () => Promise<{ stdout?: unknown; stderr?: unknown }>;
const execFileAsync = promisify(execFile);
const LEGACY_WHATSAPP_HEALTH_SCRIPT_RE =
@@ -153,8 +153,21 @@ async function readUserCrontab(): Promise<{ stdout: string; stderr?: string }> {
};
}
function findLegacyWhatsAppHealthCrontabLines(crontab: string): string[] {
return crontab
function coerceCrontabText(crontab: unknown): string {
if (typeof crontab === "string") {
return crontab;
}
if (crontab == null) {
return "";
}
if (typeof crontab === "number" || typeof crontab === "boolean" || typeof crontab === "bigint") {
return String(crontab);
}
return "";
}
function findLegacyWhatsAppHealthCrontabLines(crontab: unknown): string[] {
return coerceCrontabText(crontab)
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"))
@@ -171,7 +184,7 @@ export async function noteLegacyWhatsAppCrontabHealthCheck(
return;
}
let crontab: string;
let crontab: unknown;
try {
crontab = (await (params.readCrontab ?? readUserCrontab)()).stdout;
} catch {

View File

@@ -1,10 +1,14 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { formatCliCommand } from "../cli/command-format.js";
import type { ExtraGatewayService } from "../daemon/inspect.js";
import * as launchd from "../daemon/launchd.js";
import type { GatewayRestartHandoff } from "../infra/restart-handoff.js";
import { withEnvAsync } from "../test-utils/env.js";
import { createDoctorPrompter } from "./doctor-prompter.js";
import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js";
import {
EXTERNAL_SERVICE_REPAIR_NOTE,
SERVICE_REPAIR_POLICY_ENV,
} from "./doctor-service-repair-policy.js";
const service = vi.hoisted(() => ({
isLoaded: vi.fn(),
@@ -164,6 +168,7 @@ describe("maybeRepairGatewayDaemon", () => {
});
afterEach(() => {
vi.useRealTimers();
if (originalPlatformDescriptor) {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
@@ -266,6 +271,8 @@ describe("maybeRepairGatewayDaemon", () => {
});
it("reports recent restart handoffs during deep doctor", async () => {
vi.useFakeTimers();
vi.setSystemTime(40_000);
setPlatform("linux");
service.readCommand.mockResolvedValueOnce({
programArguments: ["/bin/node", "cli", "gateway"],
@@ -306,11 +313,7 @@ describe("maybeRepairGatewayDaemon", () => {
expect(handoffEnv?.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-service");
expect(handoffEnv?.OPENCLAW_CONFIG_PATH).toBe("/tmp/openclaw-service/openclaw.json");
expect(note).toHaveBeenCalledWith(
expect.stringContaining("Recent restart handoff: full-process via systemd"),
"Gateway",
);
expect(note).toHaveBeenCalledWith(
expect.stringContaining("reason=plugin source changed"),
"Recent restart handoff: full-process via systemd; source=plugin-change; reason=plugin source changed; pid=12345; age=30s; expiresIn=30s",
"Gateway",
);
});
@@ -382,7 +385,7 @@ describe("maybeRepairGatewayDaemon", () => {
expect(service.install).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(
expect.stringContaining("openclaw gateway install"),
`Run ${formatCliCommand("openclaw gateway install")} when you want to install the gateway service.`,
"Gateway",
);
});
@@ -428,7 +431,13 @@ describe("maybeRepairGatewayDaemon", () => {
expect(service.install).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(
expect.stringContaining("System-level OpenClaw gateway service detected"),
[
"System-level OpenClaw gateway service detected while the user gateway service is not installed.",
"- openclaw-gateway.service (unit: /etc/systemd/system/openclaw-gateway.service)",
"OpenClaw will not install a second user-level gateway service automatically.",
"Run `openclaw gateway status --deep` or `openclaw doctor --deep` to inspect duplicate services.",
`Set ${SERVICE_REPAIR_POLICY_ENV}=external if a system supervisor owns the gateway lifecycle.`,
].join("\n"),
"Gateway",
);
});

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { formatCliCommand } from "../cli/command-format.js";
import type { RuntimeEnv } from "../runtime.js";
import { onboardCommand, setupWizardCommand } from "./onboard.js";
@@ -66,10 +67,10 @@ describe("setupWizardCommand", () => {
runtime,
);
expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining('Invalid --secret-input-mode. Use "plaintext" or "ref", or run '),
`Invalid --secret-input-mode. Use "plaintext" or "ref", or run ${formatCliCommand("openclaw onboard")} for the interactive setup.`,
);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("onboard"));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
@@ -161,12 +162,10 @@ describe("setupWizardCommand", () => {
runtime,
);
expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining(
'Invalid --reset-scope. Use "config", "config+creds+sessions", or "full".',
),
`Invalid --reset-scope. Use "config", "config+creds+sessions", or "full". Run ${formatCliCommand("openclaw onboard --reset --reset-scope config")} for a config-only reset.`,
);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("config-only reset"));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();

View File

@@ -129,6 +129,13 @@ export type DiscordVoiceAutoJoinConfig = {
channelId: string;
};
export type DiscordVoiceAllowedChannelConfig = {
/** Guild ID that owns the voice channel. */
guildId: string;
/** Voice channel ID allowed for realtime voice sessions. */
channelId: string;
};
export type DiscordVoiceMode = "stt-tts" | "agent-proxy" | "bidi";
export type DiscordVoiceRealtimeConsultPolicy = "auto" | "always";
@@ -178,6 +185,8 @@ export type DiscordVoiceConfig = {
realtime?: DiscordVoiceRealtimeConfig;
/** Voice channels to auto-join on startup. */
autoJoin?: DiscordVoiceAutoJoinConfig[];
/** Voice channels the bot is allowed to join or remain in. Unset means any voice channel is allowed. */
allowedChannels?: DiscordVoiceAllowedChannelConfig[];
/** Enable/disable DAVE end-to-end encryption (default: true; Discord may require this). */
daveEncryption?: boolean;
/** Consecutive decrypt failures before DAVE session reinitialization (default: 24). */

View File

@@ -540,6 +540,13 @@ const DiscordVoiceAutoJoinSchema = z
})
.strict();
const DiscordVoiceAllowedChannelSchema = z
.object({
guildId: z.string().min(1),
channelId: z.string().min(1),
})
.strict();
const DiscordVoiceRealtimeToolPolicySchema = z.enum(["safe-read-only", "owner", "none"]);
const DiscordVoiceRealtimeConsultPolicySchema = z.enum(["auto", "always"]);
const DiscordVoiceRealtimeSchema = z
@@ -581,6 +588,7 @@ const DiscordVoiceSchema = z
model: z.string().min(1).optional(),
realtime: DiscordVoiceRealtimeSchema.optional(),
autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(),
allowedChannels: z.array(DiscordVoiceAllowedChannelSchema).optional(),
daveEncryption: z.boolean().optional(),
decryptionFailureTolerance: z.number().int().min(0).optional(),
connectTimeoutMs: z.number().int().positive().max(120_000).optional(),

View File

@@ -21,7 +21,7 @@ describe("cron run diagnostics", () => {
expect(diagnostics?.entries).toHaveLength(10);
expect(diagnostics?.entries[0]?.message).toBe("entry 2");
expect(diagnostics?.entries.at(-1)?.message).toMatch(/…$/);
expect(diagnostics?.entries.at(-1)?.message.endsWith("…")).toBe(true);
expect(diagnostics?.entries.at(-1)?.message).not.toContain("sk-1234567890abcdef");
expect(diagnostics?.entries.at(-1)?.truncated).toBe(true);
expect(diagnostics?.summary).toHaveLength(2_000);
@@ -47,7 +47,8 @@ describe("cron run diagnostics", () => {
expect(diagnostics?.entries).toHaveLength(10);
expect(diagnostics?.entries.map((entry) => entry.message)).not.toContain("tool warning 0");
expect(diagnostics?.entries.at(-1)).toMatchObject({
expect(diagnostics?.entries.at(-1)).toEqual({
ts: 11,
source: "delivery",
severity: "error",
message: "delivery failed",
@@ -118,8 +119,11 @@ describe("cron run diagnostics", () => {
"retry limit exceeded",
"SYSTEM_RUN_DENIED",
]);
expect(diagnostics?.entries[1]).toMatchObject({
expect(diagnostics?.entries[1]).toEqual({
ts: 123,
source: "exec",
severity: "warn",
message: "stdout\nstderr failure",
toolName: "exec",
exitCode: 2,
});
@@ -146,26 +150,30 @@ describe("cron run diagnostics", () => {
});
it("captures silent failed exec details with a fallback message", () => {
const diagnostics = createCronRunDiagnosticsFromAgentResult({
payloads: [
{
toolName: "exec",
details: {
status: "completed",
exitCode: 2,
const diagnostics = createCronRunDiagnosticsFromAgentResult(
{
payloads: [
{
toolName: "exec",
details: {
status: "completed",
exitCode: 2,
},
},
},
],
});
],
},
{ nowMs: () => 500 },
);
expect(diagnostics?.entries).toEqual([
expect.objectContaining({
{
ts: 500,
source: "exec",
severity: "warn",
message: "exec failed with exit code 2",
toolName: "exec",
exitCode: 2,
}),
},
]);
});
});

View File

@@ -46,6 +46,14 @@ describe("CronService - armTimer tight loop prevention", () => {
.filter((d: unknown): d is number => typeof d === "number");
}
function latestTimeoutHandle(timeoutSpy: ReturnType<typeof vi.spyOn>) {
const result = timeoutSpy.mock.results.at(-1);
if (!result || result.type !== "return") {
throw new Error("Expected setTimeout to return a timer handle");
}
return result.value;
}
function createTimerState(params: {
storePath: string;
now: number;
@@ -90,7 +98,7 @@ describe("CronService - armTimer tight loop prevention", () => {
armTimer(state);
expect(state.timer).toEqual(expect.anything());
expect(state.timer).toBe(latestTimeoutHandle(timeoutSpy));
const delays = extractTimeoutDelays(timeoutSpy);
// Before the fix, delay would be 0 (tight loop).
@@ -171,7 +179,7 @@ describe("CronService - armTimer tight loop prevention", () => {
armTimer(state);
expect(state.timer).toEqual(expect.anything());
expect(state.timer).toBe(latestTimeoutHandle(timeoutSpy));
const delays = extractTimeoutDelays(timeoutSpy);
expect(delays).toContain(60_000);
@@ -208,7 +216,7 @@ describe("CronService - armTimer tight loop prevention", () => {
await onTimer(state);
expect(state.running).toBe(false);
expect(state.timer).toEqual(expect.anything());
expect(state.timer).toBe(latestTimeoutHandle(timeoutSpy));
// The re-armed timer must NOT use delay=0. It should use at least
// MIN_REFIRE_GAP_MS to prevent the hot-loop.

View File

@@ -111,12 +111,13 @@ describe("cron schedule error isolation", () => {
recomputeNextRuns(state);
expect(state.deps.log.warn).toHaveBeenCalledWith(
expect.objectContaining({
{
jobId: "bad-job",
name: "Bad Job",
errorCount: 1,
}),
expect.stringContaining("failed to compute next run"),
err: "TypeError: CronPattern: invalid configuration format ('not valid'), exactly five, six, or seven space separated parts are required.",
},
"cron: failed to compute next run for job (skipping)",
);
});
@@ -135,12 +136,13 @@ describe("cron schedule error isolation", () => {
expect(badJob.enabled).toBe(false);
expect(badJob.state.scheduleErrorCount).toBe(3);
expect(state.deps.log.error).toHaveBeenCalledWith(
expect.objectContaining({
{
jobId: "bad-job",
name: "Bad Job",
errorCount: 3,
}),
expect.stringContaining("auto-disabled job"),
err: "TypeError: CronPattern: invalid configuration format ('garbage'), exactly five, six, or seven space separated parts are required.",
},
"cron: auto-disabled job after repeated schedule errors",
);
});

View File

@@ -116,17 +116,23 @@ describe("sweepCronRunSessions", () => {
expect(result.pruned).toBe(2);
const updated = JSON.parse(fs.readFileSync(storePath, "utf-8"));
expect(updated["agent:main:cron:job1"]).toMatchObject({ sessionId: "base-session" });
expect(updated["agent:main:cron:job1:run:old-run"]).toBeUndefined();
expect(updated["agent:main:cron:job1:run:old-run:subagent:worker"]).toBeUndefined();
expect(updated["agent:main:cron:job1:run:recent-run"]).toMatchObject({
sessionId: "recent-run",
});
expect(updated["agent:main:cron:job1:run:recent-run:thread:reply"]).toMatchObject({
sessionId: "recent-run-thread",
});
expect(updated["agent:main:telegram:dm:123"]).toMatchObject({
sessionId: "regular-session",
expect(updated).toEqual({
"agent:main:cron:job1": {
sessionId: "base-session",
updatedAt: now,
},
"agent:main:cron:job1:run:recent-run": {
sessionId: "recent-run",
updatedAt: now - 1 * 3_600_000,
},
"agent:main:cron:job1:run:recent-run:thread:reply": {
sessionId: "recent-run-thread",
updatedAt: now - 1 * 3_600_000,
},
"agent:main:telegram:dm:123": {
sessionId: "regular-session",
updatedAt: now - 100 * 3_600_000,
},
});
});

View File

@@ -103,7 +103,7 @@ describe("installScheduledTask", () => {
expect(script).not.toContain("set OC_INJECT=");
const parsed = await readScheduledTaskCommand(env);
expect(parsed).toMatchObject({
expect(parsed).toStrictEqual({
programArguments: [
"node",
"gateway.js",
@@ -115,15 +115,22 @@ describe("installScheduledTask", () => {
"!token!",
],
workingDirectory: "C:\\temp\\poc&calc",
environment: {
OC_INJECT: "safe & whoami | calc",
OC_CARET: "a^b",
OC_PERCENT: "%TEMP%",
OC_BANG: "!token!",
OC_QUOTE: 'he said "hi"',
},
environmentValueSources: {
OC_INJECT: "inline",
OC_CARET: "inline",
OC_PERCENT: "inline",
OC_BANG: "inline",
OC_QUOTE: "inline",
},
sourcePath: scriptPath,
});
expect(parsed?.environment).toMatchObject({
OC_INJECT: "safe & whoami | calc",
OC_CARET: "a^b",
OC_PERCENT: "%TEMP%",
OC_BANG: "!token!",
OC_QUOTE: 'he said "hi"',
});
expect(parsed?.environment).not.toHaveProperty("OC_EMPTY");
expect(schtasksCalls[0]).toEqual(["/Query"]);
expect(schtasksCalls[1]).toEqual(["/Query", "/TN", "OpenClaw Gateway"]);
@@ -258,11 +265,18 @@ describe("installScheduledTask", () => {
});
const command = await readScheduledTaskCommand(env);
expect(command?.environmentValueSources).toMatchObject({
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline",
TAVILY_API_KEY: "inline",
expect(command).toStrictEqual({
programArguments: ["node", "gateway.js"],
environment: {
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "TAVILY_API_KEY",
TAVILY_API_KEY: "old-inline-value",
},
environmentValueSources: {
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline",
TAVILY_API_KEY: "inline",
},
sourcePath: scriptPath,
});
expect(command?.sourcePath).toBe(scriptPath);
const audit = await auditGatewayServiceConfig({
env,

View File

@@ -283,7 +283,8 @@ describe("auditGatewayServiceConfig", () => {
const issue = audit.issues.find(
(entry) => entry.code === SERVICE_AUDIT_CODES.gatewayPortMismatch,
);
expect(issue).toMatchObject({
expect(issue).toStrictEqual({
code: SERVICE_AUDIT_CODES.gatewayPortMismatch,
message: "Gateway service port does not match current gateway config.",
detail: "18789 -> 18888",
level: "recommended",
@@ -497,9 +498,12 @@ describe("checkTokenDrift", () => {
it("detects drift when config has token but service has different token", () => {
const result = checkTokenDrift({ serviceToken: "old-token", configToken: "new-token" });
expect(result).toMatchObject({
expect(result).toStrictEqual({
code: SERVICE_AUDIT_CODES.gatewayTokenDrift,
message: expect.stringContaining("differs from service token"),
message:
"Config token differs from service token. The daemon will use the old token after restart.",
detail: "Run `openclaw gateway install --force` to sync the token.",
level: "recommended",
});
});

View File

@@ -16,11 +16,10 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
expect(resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" })).toMatchObject({
agentId: "main",
name: "Main assistant",
avatar: "M",
});
const identity = resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" });
expect(identity.agentId).toBe("main");
expect(identity.name).toBe("Main assistant");
expect(identity.avatar).toBe("M");
});
it("prefers non-default agent identity over global ui.assistant identity", () => {
@@ -36,13 +35,10 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
expect(resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" })).toMatchObject(
{
agentId: "fs-daying",
name: "大颖",
avatar: "D",
},
);
const identity = resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" });
expect(identity.agentId).toBe("fs-daying");
expect(identity.name).toBe("大颖");
expect(identity.avatar).toBe("D");
});
it("falls back to ui.assistant identity for non-default agents without their own identity", () => {
@@ -58,11 +54,10 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
expect(resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" })).toMatchObject({
agentId: "worker",
name: "Main assistant",
avatar: "M",
});
const identity = resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" });
expect(identity.agentId).toBe("worker");
expect(identity.name).toBe("Main assistant");
expect(identity.avatar).toBe("M");
});
it("drops sentence-like avatar placeholders", () => {

View File

@@ -136,12 +136,8 @@ describe("runBootOnce", () => {
expect(agentCommand).toHaveBeenCalledTimes(1);
const call = agentCommand.mock.calls[0]?.[0];
expect(call).toEqual(
expect.objectContaining({
deliver: false,
sessionKey: resolveMainSessionKey({}),
}),
);
expect(call?.deliver).toBe(false);
expect(call?.sessionKey).toBe(resolveMainSessionKey({}));
expect(call?.message).toContain("BOOT.md:");
expect(call?.message).toContain(content);
expect(call?.message).toContain("NO_REPLY");

View File

@@ -894,12 +894,16 @@ describe("callGateway error details", () => {
expect(err?.message).toContain("Source: local loopback");
expect(err?.message).toContain("Bind: loopback");
expect(isGatewayTransportError(err)).toBe(true);
expect(err).toMatchObject({
name: "GatewayTransportError",
kind: "closed",
code: 1006,
reason: "no close reason",
});
const transportError = err as {
name?: string;
kind?: string;
code?: number;
reason?: string;
};
expect(transportError.name).toBe("GatewayTransportError");
expect(transportError.kind).toBe("closed");
expect(transportError.code).toBe(1006);
expect(transportError.reason).toBe("no close reason");
});
it("keeps the request alive through internally retried startup-unavailable handshakes", async () => {
@@ -944,11 +948,10 @@ describe("callGateway error details", () => {
await promise;
expect(isGatewayTransportError(err)).toBe(true);
expect(err).toMatchObject({
name: "GatewayTransportError",
kind: "timeout",
timeoutMs: 5,
});
const transportError = err as { name?: string; kind?: string; timeoutMs?: number };
expect(transportError.name).toBe("GatewayTransportError");
expect(transportError.kind).toBe("timeout");
expect(transportError.timeoutMs).toBe(5);
});
it("charges event-loop readiness against the wrapper timeout", async () => {
@@ -985,11 +988,15 @@ describe("callGateway error details", () => {
aborted: false,
};
await expect(callGateway({ method: "health", timeoutMs: 5 })).rejects.toMatchObject({
name: "GatewayTransportError",
kind: "timeout",
timeoutMs: 5,
let err: unknown;
await callGateway({ method: "health", timeoutMs: 5 }).catch((caught) => {
err = caught;
});
expect(isGatewayTransportError(err)).toBe(true);
const transportError = err as { name?: string; kind?: string; timeoutMs?: number };
expect(transportError.name).toBe("GatewayTransportError");
expect(transportError.kind).toBe("timeout");
expect(transportError.timeoutMs).toBe(5);
expect(eventLoopReadyState.calls).toHaveLength(1);
expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(5);
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
abortChatRunById,
isChatStopCommandText,
@@ -6,6 +6,23 @@ import {
type ChatAbortControllerEntry,
} from "./chat-abort.js";
type ChatAbortPayload = {
runId: string;
sessionKey: string;
seq: number;
state: "aborted";
stopReason?: string;
message?: {
role: "assistant";
content: Array<{ type: "text"; text: string }>;
timestamp: number;
};
};
afterEach(() => {
vi.useRealTimers();
});
function createActiveEntry(sessionKey: string): ChatAbortControllerEntry {
const now = Date.now();
return {
@@ -64,6 +81,9 @@ describe("isChatStopCommandText", () => {
describe("abortChatRunById", () => {
it("broadcasts aborted payload with partial message when buffered text exists", () => {
const now = new Date("2026-01-02T03:04:05.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
const runId = "run-1";
const sessionKey = "main";
const entry = createActiveEntry(sessionKey);
@@ -85,23 +105,19 @@ describe("abortChatRunById", () => {
expect(ops.agentRunSeq.has("client-run-1")).toBe(false);
expect(ops.broadcast).toHaveBeenCalledTimes(1);
const payload = ops.broadcast.mock.calls[0]?.[1] as Record<string, unknown>;
expect(payload).toEqual(
expect.objectContaining({
runId,
sessionKey,
seq: 3,
state: "aborted",
stopReason: "user",
}),
);
expect(payload.message).toEqual(
expect.objectContaining({
const payload = ops.broadcast.mock.calls[0]?.[1] as ChatAbortPayload;
expect(payload).toEqual({
runId,
sessionKey,
seq: 3,
state: "aborted",
stopReason: "user",
message: {
role: "assistant",
content: [{ type: "text", text: " Partial reply " }],
}),
);
expect((payload.message as { timestamp?: unknown }).timestamp).toBeGreaterThan(0);
timestamp: now.getTime(),
},
});
expect(ops.nodeSendToSession).toHaveBeenCalledWith(sessionKey, "chat", payload);
});
@@ -119,6 +135,9 @@ describe("abortChatRunById", () => {
});
it("preserves partial message even when abort listeners clear buffers synchronously", () => {
const now = new Date("2026-01-02T03:04:05.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
const runId = "run-1";
const sessionKey = "main";
const entry = createActiveEntry(sessionKey);
@@ -132,12 +151,18 @@ describe("abortChatRunById", () => {
const result = abortChatRunById(ops, { runId, sessionKey });
expect(result).toEqual({ aborted: true });
const payload = ops.broadcast.mock.calls[0]?.[1] as Record<string, unknown>;
expect(payload.message).toEqual(
expect.objectContaining({
const payload = ops.broadcast.mock.calls[0]?.[1] as ChatAbortPayload;
expect(payload).toEqual({
runId,
sessionKey,
seq: 1,
state: "aborted",
stopReason: undefined,
message: {
role: "assistant",
content: [{ type: "text", text: "streamed text" }],
}),
);
timestamp: now.getTime(),
},
});
});
});

View File

@@ -295,16 +295,19 @@ describe("parseMessageWithAttachments", () => {
describe("parseMessageWithAttachments validation errors", () => {
it("throws UnsupportedAttachmentError on empty payload", async () => {
await expect(
parseMessageWithAttachments(
let caught: unknown;
try {
await parseMessageWithAttachments(
"x",
[{ type: "file", mimeType: "application/pdf", fileName: "empty.pdf", content: "" }],
{ log: { warn: () => {} } },
),
).rejects.toMatchObject({
name: "UnsupportedAttachmentError",
reason: "empty-payload",
});
);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(UnsupportedAttachmentError);
expect((caught as UnsupportedAttachmentError).name).toBe("UnsupportedAttachmentError");
expect((caught as UnsupportedAttachmentError).reason).toBe("empty-payload");
expect(saveMediaBufferMock).not.toHaveBeenCalled();
});
@@ -396,10 +399,8 @@ describe("parseMessageWithAttachments validation errors", () => {
expect(parsed.images).toHaveLength(0);
expect(parsed.imageOrder).toStrictEqual([]);
expect(parsed.offloadedRefs).toHaveLength(1);
expect(parsed.offloadedRefs[0]).toMatchObject({
mimeType: "application/pdf",
label: "brief.pdf",
});
expect(parsed.offloadedRefs[0]?.mimeType).toBe("application/pdf");
expect(parsed.offloadedRefs[0]?.label).toBe("brief.pdf");
expect(parsed.message).toBe("read this");
} finally {
await cleanupOffloadedRefs(parsed.offloadedRefs);
@@ -477,7 +478,9 @@ describe("parseMessageWithAttachments validation errors", () => {
expect(parsed.message).toContain(
"[image attachment omitted: text-only attachment limit reached]",
);
expect(logs).toContainEqual(expect.stringMatching(/offload limit 10/i));
expect(logs).toEqual([
"attachment dot-10.png: dropping image because text-only offload limit 10 was reached",
]);
} finally {
await cleanupOffloadedRefs(parsed.offloadedRefs);
}

View File

@@ -16,7 +16,9 @@ describe("startGatewayClientWhenEventLoopReady", () => {
await vi.advanceTimersByTimeAsync(1);
expect(client.start).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await expect(promise).resolves.toMatchObject({ ready: true });
const readiness = await promise;
expect(readiness.ready).toBe(true);
expect(readiness.aborted).toBe(false);
expect(client.start).toHaveBeenCalledTimes(1);
});
@@ -32,7 +34,9 @@ describe("startGatewayClientWhenEventLoopReady", () => {
});
controller.abort();
await expect(promise).resolves.toMatchObject({ ready: false, aborted: true });
const readiness = await promise;
expect(readiness.ready).toBe(false);
expect(readiness.aborted).toBe(true);
expect(client.start).not.toHaveBeenCalled();
});
});

View File

@@ -513,10 +513,10 @@ describe("GatewayClient request errors", () => {
expect(onConnectError).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(ws.lastClose).toEqual({ code: 1013, reason: "gateway starting" });
expect(logDebugMock).toHaveBeenCalledWith(expect.stringContaining("gateway connect failed:"));
expect(logErrorMock).not.toHaveBeenCalledWith(
expect.stringContaining("gateway connect failed:"),
);
expect(logDebugMock.mock.calls).toEqual([
["gateway connect failed: GatewayClientRequestError: gateway starting; retry shortly"],
]);
expect(logErrorMock.mock.calls).toEqual([]);
expect(wsInstances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(249);
@@ -568,7 +568,7 @@ describe("GatewayClient close handling", () => {
expect(getLatestWs().emitClose(1008, "unauthorized: device token mismatch")).toBeUndefined();
expect(logDebugMock).toHaveBeenCalledWith(
expect.stringContaining("failed clearing stale device-auth token"),
"failed clearing stale device-auth token for device dev-2: Error: disk unavailable",
);
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
client.stop();

View File

@@ -214,7 +214,7 @@ describe("handleControlUiHttpRequest", () => {
}) {
expect(params.handled).toBe(true);
expect(params.res.statusCode).toBe(403);
expect(JSON.parse(String(params.end.mock.calls[0]?.[0] ?? ""))).toMatchObject({
expect(JSON.parse(String(params.end.mock.calls[0]?.[0] ?? ""))).toEqual({
ok: false,
error: {
type: "forbidden",
@@ -377,7 +377,7 @@ describe("handleControlUiHttpRequest", () => {
mediaTicket?: string;
mediaTicketExpiresAt?: string;
};
expect(payload).toMatchObject({ available: true });
expect(payload.available).toBe(true);
expect(payload.mediaTicket).toMatch(/^v1\./);
expect(Date.parse(payload.mediaTicketExpiresAt ?? "")).not.toBeNaN();
} finally {
@@ -419,7 +419,7 @@ describe("handleControlUiHttpRequest", () => {
mediaTicket?: string;
mediaTicketExpiresAt?: string;
};
expect(payload).toMatchObject({ available: true });
expect(payload.available).toBe(true);
expect(payload.mediaTicket).toMatch(/^v1\./);
expect(Date.parse(payload.mediaTicketExpiresAt ?? "")).not.toBeNaN();
},

View File

@@ -136,9 +136,7 @@ describe("gateway cli backend connect", () => {
const health = await client.request("health", undefined, {
timeoutMs: 1_000,
});
expect(health).toMatchObject({
ok: true,
});
expect(health.ok).toBe(true);
expect(server.requests).toEqual(["connect", "health"]);
} finally {
await client?.stopAndWait({ timeoutMs: 1_000 }).catch(() => {});

View File

@@ -90,15 +90,13 @@ describe("gateway cli backend live helpers", () => {
expect(client.start).toBeTypeOf("function");
expect(client.stopAndWait).toBeTypeOf("function");
expect(gatewayClientState.lastOptions).toMatchObject({
url: "ws://127.0.0.1:18789",
token: "gateway-token",
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "vitest-live",
clientVersion: "dev",
mode: GATEWAY_CLIENT_MODES.TEST,
connectChallengeTimeoutMs: 45_000,
});
expect(gatewayClientState.lastOptions?.url).toBe("ws://127.0.0.1:18789");
expect(gatewayClientState.lastOptions?.token).toBe("gateway-token");
expect(gatewayClientState.lastOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.TEST);
expect(gatewayClientState.lastOptions?.clientDisplayName).toBe("vitest-live");
expect(gatewayClientState.lastOptions?.clientVersion).toBe("dev");
expect(gatewayClientState.lastOptions?.mode).toBe(GATEWAY_CLIENT_MODES.TEST);
expect(gatewayClientState.lastOptions?.connectChallengeTimeoutMs).toBe(45_000);
expect(gatewayClientState.lastOptions).not.toHaveProperty("requestTimeoutMs");
});

View File

@@ -461,11 +461,13 @@ describeLive("gateway live (cli backend)", () => {
} else {
expect(text).toContain(`CLI-BACKEND-${nonce}`);
}
expect(
const injectedFileNames =
resultWithMeta.meta?.systemPromptReport?.injectedWorkspaceFiles?.map(
(entry) => entry.name,
) ?? [],
).toEqual(expect.arrayContaining(bootstrapWorkspace?.expectedInjectedFiles ?? []));
) ?? [];
for (const expectedFile of bootstrapWorkspace?.expectedInjectedFiles ?? []) {
expect(injectedFileNames).toContain(expectedFile);
}
}
if (modelSwitchTarget) {

View File

@@ -19,6 +19,7 @@ import {
DEFAULT_DANGEROUS_NODE_COMMANDS,
resolveNodeCommandAllowlist,
} from "./node-command-policy.js";
import type { SerializedEventPayload } from "./node-registry.js";
import type { RequestFrame } from "./protocol/index.js";
import { createGatewayBroadcaster } from "./server-broadcast.js";
import { createChatRunRegistry } from "./server-chat.js";
@@ -26,6 +27,7 @@ import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import { handleNodeInvokeResult } from "./server-methods/nodes.handlers.invoke-result.js";
import type { GatewayClient as GatewayMethodClient } from "./server-methods/types.js";
import type { GatewayRequestContext, RespondFn } from "./server-methods/types.js";
import { createGatewayNodeSessionRuntime } from "./server-node-session-runtime.js";
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js";
import type { GatewayWsClient } from "./server/ws-types.js";
@@ -110,9 +112,10 @@ describe("GatewayClient", () => {
const client = new GatewayClient({ url: "ws://127.0.0.1:1" });
client.start();
const last = wsMockState.last as { url: unknown; opts: unknown } | null;
const opts = last?.opts as { maxPayload?: number } | undefined;
expect(last?.url).toBe("ws://127.0.0.1:1");
expect(last?.opts).toEqual(expect.objectContaining({ maxPayload: 25 * 1024 * 1024 }));
expect(opts?.maxPayload).toBe(25 * 1024 * 1024);
});
test("does not pass an explicit direct agent for loopback control-plane WebSocket connections", () => {
@@ -571,6 +574,47 @@ describe("gateway broadcaster", () => {
]);
});
it("reuses the same payload shape while assigning per-client seq values", () => {
const firstSocket = makeRecordingSocket();
const secondSocket = makeRecordingSocket();
const thirdSocket = makeRecordingSocket();
const clients = new Set<GatewayWsClient>([
makeGatewayWsClient("c-1", firstSocket, {
role: "operator",
scopes: ["operator.read"],
} as GatewayWsClient["connect"]),
makeGatewayWsClient("c-2", secondSocket, {
role: "operator",
scopes: ["operator.write"],
} as GatewayWsClient["connect"]),
makeGatewayWsClient("c-3", thirdSocket, {
role: "operator",
scopes: ["operator.admin"],
} as GatewayWsClient["connect"]),
]);
const payloadKeys: string[] = [];
const payload = {
toJSON(key: string) {
payloadKeys.push(key);
return { foo: key };
},
};
const { broadcast } = createGatewayBroadcaster({ clients });
broadcast("talk.mode", { enabled: true });
broadcast("chat", payload);
expect(payloadKeys).toEqual(["payload"]);
expect(firstSocket.sent.at(-1)?.payload).toEqual({ foo: "payload" });
expect(secondSocket.sent.at(-1)?.payload).toEqual({ foo: "payload" });
expect(thirdSocket.sent.at(-1)?.payload).toEqual({ foo: "payload" });
expect([
firstSocket.sent.at(-1)?.seq,
secondSocket.sent.at(-1)?.seq,
thirdSocket.sent.at(-1)?.seq,
]).toEqual([1, 2, 2]);
});
it("preserves seq gaps when dropIfSlow skips an eligible broadcast", () => {
const slowReadSocket = makeRecordingSocket();
slowReadSocket.bufferedAmount = Number.MAX_SAFE_INTEGER;
@@ -621,16 +665,13 @@ describe("gateway broadcaster", () => {
broadcast("chat", { sessionKey: "agent:main:main", message: "secret" }, { dropIfSlow: true });
broadcast("heartbeat", { ts: 1 });
expect(events).toContainEqual(
expect.objectContaining({
type: "payload.large",
surface: "gateway.ws.outbound_buffer",
action: "rejected",
bytes: MAX_BUFFERED_BYTES + 1,
limitBytes: MAX_BUFFERED_BYTES,
reason: "ws_send_buffer_drop",
}),
);
const payloadEvent = events.find((event) => event.type === "payload.large");
expect(payloadEvent?.type).toBe("payload.large");
expect(payloadEvent?.surface).toBe("gateway.ws.outbound_buffer");
expect(payloadEvent?.action).toBe("rejected");
expect(payloadEvent?.bytes).toBe(MAX_BUFFERED_BYTES + 1);
expect(payloadEvent?.limitBytes).toBe(MAX_BUFFERED_BYTES);
expect(payloadEvent?.reason).toBe("ws_send_buffer_drop");
expect(
events.reduce((count, event) => count + (event.type === "payload.large" ? 1 : 0), 0),
).toBe(1);
@@ -710,10 +751,13 @@ describe("node subscription manager", () => {
const sent: Array<{
nodeId: string;
event: string;
payloadJSON?: string | null;
payloadJSON?: SerializedEventPayload | null;
}> = [];
const sendEvent = (evt: { nodeId: string; event: string; payloadJSON?: string | null }) =>
sent.push(evt);
const sendEvent = (evt: {
nodeId: string;
event: string;
payloadJSON?: SerializedEventPayload | null;
}) => sent.push(evt);
manager.subscribe("node-a", "main");
manager.subscribe("node-b", "main");
@@ -724,6 +768,45 @@ describe("node subscription manager", () => {
expect(sent[0].event).toBe("chat");
});
test("runtime forwards subscribed node payload json without parsing it again", () => {
const frames: string[] = [];
const socket: TestSocket = {
bufferedAmount: 0,
send: vi.fn((payload: string) => frames.push(payload)),
close: vi.fn(),
};
const parseSpy = vi.spyOn(JSON, "parse");
try {
const runtime = createGatewayNodeSessionRuntime({ broadcast: vi.fn() });
runtime.nodeRegistry.register(
makeGatewayWsClient("conn-node-a", socket, {
role: "node",
scopes: [],
client: {
id: "node-client",
version: "1.0.0",
platform: "darwin",
mode: "node",
},
device: { id: "node-a" },
} as unknown as GatewayWsClient["connect"]),
{},
);
runtime.nodeSubscribe("node-a", "main");
runtime.nodeSendToSession("main", "chat", { ok: true });
expect(parseSpy).not.toHaveBeenCalled();
} finally {
parseSpy.mockRestore();
}
expect(JSON.parse(frames[0] ?? "{}")).toEqual({
type: "event",
event: "chat",
payload: { ok: true },
});
});
test("unsubscribeAll clears session mappings", () => {
const manager = createNodeSubscriptionManager();
const sent: string[] = [];

View File

@@ -1358,10 +1358,8 @@ describe("sanitizeAuthProfileStoreForLiveGateway", () => {
try {
const sanitized = sanitizeAuthProfileStoreForLiveGateway(store);
expect(sanitized.profiles.openaiProfile).toBeUndefined();
expect(sanitized.profiles.codexProfile).toMatchObject({
type: "oauth",
provider: "openai-codex",
});
expect(sanitized.profiles.codexProfile?.type).toBe("oauth");
expect(sanitized.profiles.codexProfile?.provider).toBe("openai-codex");
expect(sanitized.order).toEqual({ "openai-codex": ["codexProfile"] });
expect(sanitized.lastGood).toEqual({ "openai-codex": "codexProfile" });
expect(sanitized.usageStats).toEqual({ codexProfile: { lastUsed: 2 } });

View File

@@ -152,10 +152,8 @@ async function approveTrajectoryExport(client: GatewayClient): Promise<string> {
const approval = approvals.find((entry) =>
entry.request?.command?.includes("sessions export-trajectory"),
);
expect(approval).toMatchObject({
id: expect.any(String),
request: { command: expect.stringContaining("sessions export-trajectory") },
});
expect(typeof approval?.id).toBe("string");
expect(approval?.request?.command).toContain("sessions export-trajectory");
if (!approval?.id) {
throw new Error("expected trajectory export approval id");
}
@@ -285,17 +283,18 @@ describeLive("gateway live trajectory export", () => {
if (finalText) {
expect(finalText).toContain("Approve once");
}
expect(await listDirectoryNames(bundleDir)).toEqual(
expect.arrayContaining([
"artifacts.json",
"events.jsonl",
"manifest.json",
"metadata.json",
"prompts.json",
"session.jsonl",
"tools.json",
]),
);
const bundleNames = await listDirectoryNames(bundleDir);
for (const expectedName of [
"artifacts.json",
"events.jsonl",
"manifest.json",
"metadata.json",
"prompts.json",
"session.jsonl",
"tools.json",
]) {
expect(bundleNames).toContain(expectedName);
}
expect(beforeExport.has("bundle")).toBe(false);
const manifest = JSON.parse(

View File

@@ -454,13 +454,10 @@ describe("gateway hooks helpers", () => {
},
} as OpenClawConfig);
expect(resolved.mappings).toMatchObject([
{
action: "wake",
matchPath: "wake",
sessionKey: "hook:wake:{{payload.id}}",
},
]);
expect(resolved.mappings).toHaveLength(1);
expect(resolved.mappings[0]?.action).toBe("wake");
expect(resolved.mappings[0]?.matchPath).toBe("wake");
expect(resolved.mappings[0]?.sessionKey).toBe("hook:wake:{{payload.id}}");
expect(resolved.sessionPolicy.allowedSessionKeyPrefixes).toBeUndefined();
});

View File

@@ -159,13 +159,11 @@ describe("handleGatewayPostJsonEndpoint", () => {
},
);
expect(resolveOperatorScopes).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
authMethod: "token",
trustDeclaredOperatorScopes: false,
}),
);
const [, requestAuth] = (resolveOperatorScopes.mock.calls[0] as unknown as
| [IncomingMessage, { authMethod?: string; trustDeclaredOperatorScopes: boolean }]
| undefined) ?? [undefined, undefined];
expect(requestAuth?.authMethod).toBe("token");
expect(requestAuth?.trustDeclaredOperatorScopes).toBe(false);
expect(result).toEqual({
body: { ok: true },
requestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false },

View File

@@ -65,15 +65,12 @@ describe("live-agent-probes", () => {
exactReply: spec.name,
}),
).toContain("previous OpenClaw cron MCP tool call was cancelled");
expect(JSON.parse(spec.argsJson)).toEqual(
expect.objectContaining({
job: expect.objectContaining({
sessionTarget: "session:agent:codex:acp:test",
agentId: "codex",
sessionKey: "agent:codex:acp:test",
}),
}),
);
const args = JSON.parse(spec.argsJson) as {
job?: { sessionTarget?: string; agentId?: string; sessionKey?: string };
};
expect(args.job?.sessionTarget).toBe("session:agent:codex:acp:test");
expect(args.job?.agentId).toBe("codex");
expect(args.job?.sessionKey).toBe("agent:codex:acp:test");
});
it("validates cron cli job shape for the shared live probe", () => {

View File

@@ -1,5 +1,5 @@
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js";
import {
__resetContainerCacheForTest,
@@ -18,6 +18,40 @@ import {
resolveHostName,
} from "./net.js";
const flyMachineEnvKeys = ["FLY_MACHINE_ID", "FLY_APP_NAME"] as const;
function clearFlyMachineEnvForTest(): () => void {
const previousEnv = new Map<(typeof flyMachineEnvKeys)[number], string | undefined>();
for (const key of flyMachineEnvKeys) {
previousEnv.set(key, process.env[key]);
delete process.env[key];
}
return () => {
for (const key of flyMachineEnvKeys) {
const value = previousEnv.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
}
function useClearedFlyMachineEnv() {
let restoreFlyMachineEnv: (() => void) | undefined;
beforeEach(() => {
restoreFlyMachineEnv = clearFlyMachineEnvForTest();
});
afterEach(() => {
restoreFlyMachineEnv?.();
restoreFlyMachineEnv = undefined;
});
}
describe("resolveHostName", () => {
it.each([
{ input: "localhost:18789", expected: "localhost" },
@@ -482,6 +516,8 @@ describe("isPrivateOrLoopbackHost", () => {
});
describe("isContainerEnvironment", () => {
useClearedFlyMachineEnv();
afterEach(() => {
__resetContainerCacheForTest();
vi.restoreAllMocks();
@@ -515,6 +551,18 @@ describe("isContainerEnvironment", () => {
expect(isContainerEnvironment()).toBe(true);
});
it("returns true on Fly Machines without Docker sentinel files", () => {
const fs = require("node:fs");
vi.spyOn(fs, "accessSync").mockImplementation(() => {
throw new Error("ENOENT");
});
vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n");
process.env.FLY_MACHINE_ID = "3d8d5459a03038";
process.env.FLY_APP_NAME = "openclaw-test";
expect(isContainerEnvironment()).toBe(true);
});
it("returns true when /proc/1/cgroup contains docker marker", () => {
const fs = require("node:fs");
vi.spyOn(fs, "accessSync").mockImplementation(() => {
@@ -586,6 +634,8 @@ describe("isContainerEnvironment", () => {
});
describe("resolveGatewayBindHost", () => {
useClearedFlyMachineEnv();
afterEach(() => {
__resetContainerCacheForTest();
vi.restoreAllMocks();
@@ -625,6 +675,8 @@ describe("resolveGatewayBindHost", () => {
});
describe("defaultGatewayBindMode", () => {
useClearedFlyMachineEnv();
afterEach(() => {
__resetContainerCacheForTest();
vi.restoreAllMocks();

View File

@@ -319,15 +319,20 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
const forwarded = result.params as Record<string, unknown>;
expect(forwarded.command).toEqual(["/usr/bin/echo", "SAFE"]);
expect(forwarded.rawCommand).toBe("/usr/bin/echo SAFE");
expect(forwarded.systemRunPlan).toEqual(
expect.objectContaining({
argv: ["/usr/bin/echo", "SAFE"],
cwd: "/real/cwd",
commandText: "/usr/bin/echo SAFE",
agentId: "main",
sessionKey: "agent:main:main",
}),
);
const systemRunPlan = forwarded.systemRunPlan as
| {
argv?: string[];
cwd?: string;
commandText?: string;
agentId?: string;
sessionKey?: string;
}
| undefined;
expect(systemRunPlan?.argv).toEqual(["/usr/bin/echo", "SAFE"]);
expect(systemRunPlan?.cwd).toBe("/real/cwd");
expect(systemRunPlan?.commandText).toBe("/usr/bin/echo SAFE");
expect(systemRunPlan?.agentId).toBe("main");
expect(systemRunPlan?.sessionKey).toBe("agent:main:main");
expect(forwarded.cwd).toBe("/real/cwd");
expect(forwarded.agentId).toBe("main");
expect(forwarded.sessionKey).toBe("agent:main:main");

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { NodeRegistry } from "./node-registry.js";
import { NodeRegistry, serializeEventPayload } from "./node-registry.js";
import type { GatewayWsClient } from "./server/ws-types.js";
function makeClient(connId: string, nodeId: string, sent: string[] = []): GatewayWsClient {
@@ -54,4 +54,34 @@ describe("gateway/node-registry", () => {
expect(registry.get("node-1")).toBe(newSession);
await expect(oldDisconnected).resolves.toBeInstanceOf(Error);
});
it("sends raw event payload JSON without changing the envelope shape", () => {
const registry = new NodeRegistry();
const frames: string[] = [];
registry.register(makeClient("conn-1", "node-1", frames), {});
const payload = serializeEventPayload({ foo: "bar" });
expect(registry.sendEventRaw("node-1", "chat", payload)).toBe(true);
expect(registry.sendEventRaw("missing-node", "chat", payload)).toBe(false);
expect(registry.sendEventRaw("node-1", "heartbeat", null)).toBe(true);
expect(
registry.sendEventRaw(
"node-1",
"chat",
"not-json" as unknown as Parameters<NodeRegistry["sendEventRaw"]>[2],
),
).toBe(false);
expect(
registry.sendEventRaw(
"node-1",
"chat",
'{"x":1},"seq":999' as unknown as Parameters<NodeRegistry["sendEventRaw"]>[2],
),
).toBe(false);
expect(frames).toEqual([
'{"type":"event","event":"chat","payload":{"foo":"bar"}}',
'{"type":"event","event":"heartbeat"}',
]);
});
});

View File

@@ -38,6 +38,30 @@ type NodeInvokeResult = {
error?: { code?: string; message?: string } | null;
};
const SERIALIZED_EVENT_PAYLOAD = Symbol("openclaw.serializedEventPayload");
export type SerializedEventPayload = {
readonly json: string;
readonly [SERIALIZED_EVENT_PAYLOAD]: true;
};
export function serializeEventPayload(payload: unknown): SerializedEventPayload | null {
if (!payload) {
return null;
}
const json = JSON.stringify(payload);
return typeof json === "string" ? { json, [SERIALIZED_EVENT_PAYLOAD]: true } : null;
}
function isSerializedEventPayload(value: unknown): value is SerializedEventPayload {
return (
typeof value === "object" &&
value !== null &&
(value as { [SERIALIZED_EVENT_PAYLOAD]?: unknown })[SERIALIZED_EVENT_PAYLOAD] === true &&
typeof (value as { json?: unknown }).json === "string"
);
}
export class NodeRegistry {
private nodesById = new Map<string, NodeSession>();
private nodesByConn = new Map<string, string>();
@@ -198,6 +222,18 @@ export class NodeRegistry {
return this.sendEventToSession(node, event, payload);
}
sendEventRaw(
nodeId: string,
event: string,
payloadJSON?: SerializedEventPayload | null,
): boolean {
const node = this.nodesById.get(nodeId);
if (!node) {
return false;
}
return this.sendEventRawInternal(node, event, payloadJSON);
}
private sendEventInternal(node: NodeSession, event: string, payload: unknown): boolean {
try {
node.client.socket.send(
@@ -213,6 +249,29 @@ export class NodeRegistry {
}
}
private sendEventRawInternal(
node: NodeSession,
event: string,
payloadJSON?: SerializedEventPayload | null,
): boolean {
if (
payloadJSON !== null &&
payloadJSON !== undefined &&
!isSerializedEventPayload(payloadJSON)
) {
return false;
}
try {
const payloadFragment = payloadJSON ? `,"payload":${payloadJSON.json}` : "";
node.client.socket.send(
`{"type":"event","event":${JSON.stringify(event)}${payloadFragment}}`,
);
return true;
} catch {
return false;
}
}
private sendEventToSession(node: NodeSession, event: string, payload: unknown): boolean {
return this.sendEventInternal(node, event, payload);
}

View File

@@ -7,6 +7,7 @@ import {
emitAssistantTextDelta,
} from "../agents/pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "../agents/pi-embedded-subscribe.js";
import { createClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js";
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
import { emitAgentEvent } from "../infra/agent-events.js";
@@ -136,6 +137,15 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
message?: string;
extraSystemPrompt?: string;
images?: Array<{ type: string; data: string; mimeType: string }>;
clientTools?: Array<{
type?: string;
function?: {
name?: string;
description?: string;
parameters?: Record<string, unknown>;
strict?: boolean;
};
}>;
senderIsOwner?: boolean;
}
| undefined;
@@ -649,6 +659,397 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
await res.text();
}
{
mockAgentOnce([{ text: "tool choice none" }]);
const res = await postChatCompletions(port, {
model: "openclaw",
tool_choice: "none",
tools: [
{
type: "function",
function: {
name: "get_time",
description: "Get current time",
parameters: { type: "object", properties: {} },
},
},
],
messages: [{ role: "user", content: "time?" }],
});
expect(res.status).toBe(200);
const firstCall = getFirstAgentCall();
expect(firstCall?.clientTools).toBeUndefined();
await res.text();
}
{
mockAgentOnce([{ text: "tool choice auto" }]);
const res = await postChatCompletions(port, {
model: "openclaw",
tool_choice: "auto",
tools: [
{
type: "function",
function: {
name: "get_time",
description: "Get current time",
parameters: { type: "object", properties: {} },
strict: true,
},
},
],
messages: [{ role: "user", content: "time?" }],
});
expect(res.status).toBe(200);
const firstCall = getFirstAgentCall();
const clientTools = firstCall?.clientTools ?? [];
expect(clientTools).toHaveLength(1);
expect(clientTools[0]?.type).toBe("function");
expect(clientTools[0]?.function?.name).toBe("get_time");
expect(clientTools[0]?.function?.strict).toBe(true);
expect(firstCall).not.toHaveProperty("toolsAllow");
await res.text();
}
{
agentCommand.mockClear();
const res = await postChatCompletions(port, {
model: "openclaw",
tool_choice: { type: "function", function: { name: "get_weather" } },
tools: [
{
type: "function",
function: {
name: "get_time",
description: "Get current time",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_weather",
description: "Get current weather",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
},
],
messages: [{ role: "user", content: "weather?" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message ?? "").toContain("not supported");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
agentCommand.mockClear();
const res = await postChatCompletions(port, {
model: "openclaw",
tool_choice: "required",
messages: [{ role: "user", content: "weather?" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message ?? "").toContain("tool_choice=required");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
agentCommand.mockClear();
const res = await postChatCompletions(port, {
model: "openclaw",
tool_choice: { type: "function", function: { name: "missing_tool" } },
tools: [
{
type: "function",
function: {
name: "get_time",
description: "Get current time",
parameters: { type: "object", properties: {} },
},
},
],
messages: [{ role: "user", content: "weather?" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message ?? "").toContain("not supported");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
agentCommand.mockClear();
const res = await postChatCompletions(port, {
model: "openclaw",
tool_choice: {
type: "allowed_tools",
tools: [{ type: "function", function: { name: "x" } }],
},
tools: [
{
type: "function",
function: {
name: "x",
description: "x",
parameters: { type: "object", properties: {} },
},
},
],
messages: [{ role: "user", content: "x?" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message ?? "").toContain("allowed_tools");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
agentCommand.mockClear();
const res = await postChatCompletions(port, {
model: "openclaw",
tools: [
{
type: "function",
name: "invalid_flat_shape",
parameters: { type: "object", properties: {} },
},
],
messages: [{ role: "user", content: "x?" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message ?? "").toContain("tool.function is required");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
mockAgentOnce([{ text: "ok" }]);
const res = await postChatCompletions(port, {
model: "openclaw",
messages: [
{ role: "user", content: "What's the weather?" },
{ role: "assistant", content: "Checking the weather." },
{
role: "tool",
tool_call_id: "call_1",
content: [{ type: "text", text: "Sunny, 70F." }],
},
],
});
expect(res.status).toBe(200);
const message = getFirstAgentMessage();
expectMessageContext(message, {
history: ["User: What's the weather?", "Assistant: Checking the weather."],
current: ["Tool:call_1: Sunny, 70F."],
});
await res.text();
}
{
mockAgentOnce([{ text: "ok" }]);
const res = await postChatCompletions(port, {
model: "openclaw",
messages: [
{ role: "user", content: "What's the weather?" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: {
name: "get_weather",
arguments: '{"city":"Taipei"}',
},
},
],
},
{
role: "tool",
tool_call_id: "call_1",
content: [{ type: "text", text: "Sunny, 70F." }],
},
],
});
expect(res.status).toBe(200);
const message = getFirstAgentMessage();
expectMessageContext(message, {
history: [
"User: What's the weather?",
'Assistant: tool_call id=call_1 name=get_weather arguments={"city":"Taipei"}',
],
current: ["Tool:call_1: Sunny, 70F."],
});
await res.text();
}
{
agentCommand.mockClear();
agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"]));
const res = await postChatCompletions(port, {
stream: false,
model: "openclaw",
tools: [
{
type: "function",
function: {
name: "exec",
description: "conflicts with a built-in tool",
parameters: { type: "object", properties: {} },
},
},
],
messages: [{ role: "user", content: "run command" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message).toBe("invalid tool configuration");
}
{
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "Let me check that." }],
meta: {
stopReason: "tool_calls",
pendingToolCalls: [
{
id: "call_1",
name: "get_weather",
arguments: '{"city":"Taipei"}',
},
{
id: "call_2",
name: "get_time",
arguments: "{}",
},
],
agentMeta: {
usage: {
input: 10,
output: 5,
total: 15,
},
},
},
} as never);
const res = await postChatCompletions(port, {
stream: false,
model: "openclaw",
tool_choice: "auto",
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get weather",
parameters: { type: "object", properties: { city: { type: "string" } } },
},
},
{
type: "function",
function: {
name: "get_time",
description: "Get time",
parameters: { type: "object", properties: {} },
},
},
],
messages: [{ role: "user", content: "weather?" }],
});
expect(res.status).toBe(200);
const json = (await res.json()) as {
choices?: Array<{
finish_reason?: string | null;
message?: {
role?: string;
content?: string;
tool_calls?: Array<{
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
}>;
};
}>;
};
const choice = json.choices?.[0];
expect(choice?.finish_reason).toBe("tool_calls");
expect(choice?.message?.role).toBe("assistant");
expect(choice?.message?.content).toBe("Let me check that.");
expect(choice?.message?.tool_calls).toEqual([
{
id: "call_1",
type: "function",
function: { name: "get_weather", arguments: '{"city":"Taipei"}' },
},
{
id: "call_2",
type: "function",
function: { name: "get_time", arguments: "{}" },
},
]);
expect(choice?.message?.tool_calls?.some((call) => Object.hasOwn(call, "index"))).toBe(
false,
);
}
{
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [],
meta: {
stopReason: "tool_calls",
pendingToolCalls: [
{
id: "call_1",
name: "get_weather",
arguments: '{"city":"Taipei"}',
},
],
},
} as never);
const res = await postChatCompletions(port, {
stream: false,
model: "openclaw",
tool_choice: "auto",
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get weather",
parameters: { type: "object", properties: { city: { type: "string" } } },
},
},
],
messages: [{ role: "user", content: "weather?" }],
});
expect(res.status).toBe(200);
const json = (await res.json()) as {
choices?: Array<{
finish_reason?: string | null;
message?: { content?: string; tool_calls?: unknown[] };
}>;
};
const choice = json.choices?.[0];
expect(choice?.finish_reason).toBe("tool_calls");
expect(choice?.message?.content).toBe("");
expect(choice?.message?.tool_calls).toHaveLength(1);
}
{
mockAgentOnce([{ text: "hello" }]);
const json = await postSyncUserMessage("hi");
@@ -999,6 +1400,221 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(fallbackText).toContain("hello");
}
{
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "Let me check that." }],
meta: {
stopReason: "tool_calls",
pendingToolCalls: [
{
id: "call_1",
name: "get_weather",
arguments: '{"city":"Taipei"}',
},
],
},
} as never);
const toolCallRes = await postChatCompletions(port, {
stream: true,
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
});
expect(toolCallRes.status).toBe(200);
const toolCallText = await toolCallRes.text();
const toolCallData = parseSseDataLines(toolCallText);
const toolCallChunks = toolCallData
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const toolDeltaChunks = toolCallChunks.filter((chunk) => {
const choice = ((chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])[0];
const delta = (choice?.delta as Record<string, unknown> | undefined) ?? {};
return Array.isArray(delta.tool_calls);
});
expect(toolDeltaChunks.length).toBeGreaterThan(0);
const toolCallDeltaRecords = toolDeltaChunks.flatMap((chunk) => {
const choice = ((chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])[0];
const delta = (choice?.delta as Record<string, unknown> | undefined) ?? {};
return (delta.tool_calls as Array<Record<string, unknown>> | undefined) ?? [];
});
const withIdentity = toolCallDeltaRecords.find(
(record) =>
record.id === "call_1" &&
record.type === "function" &&
((record.function as Record<string, unknown> | undefined)?.name as
| string
| undefined) === "get_weather",
);
expect(withIdentity).toBeTruthy();
const argsJoined = toolCallDeltaRecords
.filter((record) => record.index === 0)
.map(
(record) =>
((record.function as Record<string, unknown> | undefined)?.arguments as
| string
| undefined) ?? "",
)
.join("");
expect(argsJoined).toBe('{"city":"Taipei"}');
const finishChunk = toolCallChunks
.flatMap((chunk) => (chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])
.find((choice) => choice.finish_reason === "tool_calls");
expect(finishChunk).toBeTruthy();
}
{
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "Let me check that." }],
meta: {
stopReason: "tool_calls",
pendingToolCalls: [
{
id: "call_1",
name: "get_weather",
arguments: '{"city":"Taipei"}',
},
],
agentMeta: {
usage: {
input: 12,
output: 3,
total: 15,
},
},
},
} as never);
const toolCallUsageRes = await postChatCompletions(port, {
stream: true,
stream_options: { include_usage: true },
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
});
expect(toolCallUsageRes.status).toBe(200);
const toolCallUsageText = await toolCallUsageRes.text();
const toolCallUsageData = parseSseDataLines(toolCallUsageText);
const jsonChunks = toolCallUsageData
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const usageChunk = jsonChunks.find((chunk) => "usage" in chunk);
expect(usageChunk).toBeTruthy();
expect(usageChunk?.choices).toEqual([]);
expect(usageChunk?.usage).toEqual({
prompt_tokens: 12,
completion_tokens: 3,
total_tokens: 15,
});
expect(toolCallUsageData[toolCallUsageData.length - 1]).toBe("[DONE]");
}
{
agentCommand.mockClear();
let resolveLateToolCall:
| ((result: {
payloads: Array<{ text: string }>;
meta: {
stopReason: string;
pendingToolCalls: Array<{ id: string; name: string; arguments: string }>;
};
}) => void)
| undefined;
agentCommand.mockImplementationOnce(
((opts: unknown) =>
new Promise((resolve) => {
resolveLateToolCall = resolve;
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "Let me check that." } });
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
})) as never,
);
const lateToolCallRes = await postChatCompletions(port, {
stream: true,
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
});
expect(lateToolCallRes.status).toBe(200);
const lateToolCallTextPromise = lateToolCallRes.text();
const earlyCompletion = await Promise.race([
lateToolCallTextPromise.then(() => "completed" as const),
new Promise<"pending">((resolve) => {
setTimeout(() => resolve("pending"), 1200);
}),
]);
expect(earlyCompletion).toBe("pending");
resolveLateToolCall?.({
payloads: [{ text: "Let me check that." }],
meta: {
stopReason: "tool_calls",
pendingToolCalls: [
{
id: "call_1",
name: "get_weather",
arguments: '{"city":"Taipei"}',
},
],
},
});
const lateToolCallText = await lateToolCallTextPromise;
const lateToolCallData = parseSseDataLines(lateToolCallText);
const lateToolCallChunks = lateToolCallData
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const finishChunk = lateToolCallChunks
.flatMap((chunk) => (chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])
.find((choice) => choice.finish_reason === "tool_calls");
expect(finishChunk).toBeTruthy();
const anyToolCalls = lateToolCallChunks.some((chunk) => {
const choice = ((chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])[0];
const delta = (choice?.delta as Record<string, unknown> | undefined) ?? {};
return Array.isArray(delta.tool_calls);
});
expect(anyToolCalls).toBe(true);
}
{
agentCommand.mockClear();
agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"]));
const toolConflictRes = await postChatCompletions(port, {
stream: true,
model: "openclaw",
tools: [
{
type: "function",
function: {
name: "exec",
description: "conflicts with a built-in tool",
parameters: { type: "object", properties: {} },
},
},
],
messages: [{ role: "user", content: "run command" }],
});
expect(toolConflictRes.status).toBe(200);
const toolConflictText = await toolConflictRes.text();
const toolConflictData = parseSseDataLines(toolConflictText);
expect(toolConflictData[toolConflictData.length - 1]).toBe("[DONE]");
const toolConflictChunks = toolConflictData
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const protocolError = toolConflictChunks.find(
(chunk) =>
typeof chunk.error === "object" &&
((chunk.error as { type?: unknown }).type ?? "") === "invalid_request_error" &&
((chunk.error as { message?: unknown }).message ?? "") === "invalid tool configuration",
);
expect(protocolError).toBeTruthy();
const stopChoice = toolConflictChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.find((choice) => choice.finish_reason === "stop");
expect(stopChoice).toBeUndefined();
}
{
agentCommand.mockClear();
agentCommand.mockRejectedValueOnce(new Error("boom"));
@@ -1297,15 +1913,18 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
},
);
it("does not block stream finalization on usage when include_usage is not requested", async () => {
it("does not require usage to finalize when include_usage is not requested", async () => {
const port = enabledPort;
agentCommand.mockClear();
agentCommand.mockImplementationOnce(
((opts: unknown) =>
new Promise(() => {
new Promise((resolve) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hello" } });
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
setTimeout(() => {
resolve({ payloads: [{ text: "hello" }] });
}, 100);
})) as never,
);

View File

@@ -1,6 +1,8 @@
import { randomUUID } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClientToolDefinition } from "../agents/command/shared-types.js";
import type { ImageContent } from "../agents/command/types.js";
import { isClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js";
import {
hasNonzeroUsage,
normalizeUsage,
@@ -58,6 +60,8 @@ type OpenAiChatMessage = {
role?: unknown;
content?: unknown;
name?: unknown;
tool_call_id?: unknown;
tool_calls?: unknown;
};
type OpenAiChatCompletionRequest = {
@@ -65,6 +69,8 @@ type OpenAiChatCompletionRequest = {
stream?: unknown;
// Naming/style reference: src/agents/openai-transport-stream.ts:1262-1273
stream_options?: unknown;
tools?: unknown;
tool_choice?: unknown;
messages?: unknown;
user?: unknown;
};
@@ -119,6 +125,7 @@ function writeSse(res: ServerResponse, data: unknown) {
function buildAgentCommandInput(params: {
prompt: { message: string; extraSystemPrompt?: string; images?: ImageContent[] };
clientTools?: ClientToolDefinition[];
modelOverride?: string;
sessionKey: string;
runId: string;
@@ -130,6 +137,7 @@ function buildAgentCommandInput(params: {
message: params.prompt.message,
extraSystemPrompt: params.prompt.extraSystemPrompt,
images: params.prompt.images,
clientTools: params.clientTools,
model: params.modelOverride,
sessionKey: params.sessionKey,
runId: params.runId,
@@ -142,6 +150,72 @@ function buildAgentCommandInput(params: {
};
}
function extractClientToolsFromChatRequest(tools: unknown): ClientToolDefinition[] {
if (tools == null) {
return [];
}
if (!Array.isArray(tools)) {
throw new Error("tools must be an array");
}
const clientTools: ClientToolDefinition[] = [];
for (const tool of tools) {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
throw new Error("each tool must be an object");
}
if ((tool as { type?: unknown }).type !== "function") {
throw new Error("only function tools are supported");
}
const functionValue = (tool as { function?: unknown }).function;
if (!functionValue || typeof functionValue !== "object" || Array.isArray(functionValue)) {
throw new Error("tool.function is required");
}
const rawName = (functionValue as { name?: unknown }).name;
const name = typeof rawName === "string" ? rawName.trim() : "";
if (!name) {
throw new Error("tool.function.name is required");
}
const description = (functionValue as { description?: unknown }).description;
const parameters = (functionValue as { parameters?: unknown }).parameters;
const strict = (functionValue as { strict?: unknown }).strict;
clientTools.push({
type: "function",
function: {
name,
...(typeof description === "string" ? { description } : {}),
...(parameters && typeof parameters === "object" && !Array.isArray(parameters)
? { parameters: parameters as Record<string, unknown> }
: {}),
...(typeof strict === "boolean" ? { strict } : {}),
},
});
}
return clientTools;
}
function applyChatToolChoice(params: { tools: ClientToolDefinition[]; toolChoice: unknown }): {
tools: ClientToolDefinition[];
extraSystemPrompt?: string;
} {
const { tools, toolChoice } = params;
if (toolChoice == null || toolChoice === "auto") {
return { tools };
}
if (toolChoice === "none") {
return { tools: [] };
}
if (toolChoice === "required") {
throw new Error("tool_choice=required is not supported");
}
if (typeof toolChoice !== "object" || Array.isArray(toolChoice)) {
throw new Error("tool_choice must be a string or object");
}
const choiceType = (toolChoice as { type?: unknown }).type;
if (typeof choiceType !== "string") {
throw new Error("unsupported tool_choice type");
}
throw new Error(`tool_choice ${choiceType} is not supported`);
}
function writeAssistantRoleChunk(res: ServerResponse, params: { runId: string; model: string }) {
writeSse(res, {
id: params.runId,
@@ -171,7 +245,10 @@ function writeAssistantContentChunk(
});
}
function writeAssistantStopChunk(res: ServerResponse, params: { runId: string; model: string }) {
function writeAssistantFinishChunk(
res: ServerResponse,
params: { runId: string; model: string; finishReason: "stop" | "tool_calls" },
) {
writeSse(res, {
id: params.runId,
object: "chat.completion.chunk",
@@ -181,12 +258,81 @@ function writeAssistantStopChunk(res: ServerResponse, params: { runId: string; m
{
index: 0,
delta: {},
finish_reason: "stop",
finish_reason: params.finishReason,
},
],
});
}
function splitArgumentsForStreaming(argumentsValue: string): string[] {
if (!argumentsValue) {
return [""];
}
const chunkSize = 256;
const chunks: string[] = [];
for (let i = 0; i < argumentsValue.length; i += chunkSize) {
chunks.push(argumentsValue.slice(i, i + chunkSize));
}
return chunks.length > 0 ? chunks : [""];
}
function writeAssistantToolCallsIncrementalChunks(
res: ServerResponse,
params: {
runId: string;
model: string;
toolCalls: Array<{ id: string; name: string; arguments: string }>;
},
) {
for (const [index, call] of params.toolCalls.entries()) {
writeSse(res, {
id: params.runId,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: params.model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index,
id: call.id,
type: "function",
function: { name: call.name, arguments: "" },
},
],
},
finish_reason: null,
},
],
});
for (const argsDelta of splitArgumentsForStreaming(call.arguments)) {
writeSse(res, {
id: params.runId,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: params.model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index,
function: { arguments: argsDelta },
},
],
},
finish_reason: null,
},
],
});
}
}
}
function writeUsageChunk(
res: ServerResponse,
params: {
@@ -239,6 +385,59 @@ function extractTextContent(content: unknown): string {
return "";
}
type AssistantToolCall = {
id: string;
name: string;
arguments: string;
};
function stringifyToolCallArguments(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value == null) {
return "";
}
try {
const serialized = JSON.stringify(value);
return typeof serialized === "string" ? serialized : "";
} catch {
return "";
}
}
function extractAssistantToolCalls(value: unknown): AssistantToolCall[] {
if (!Array.isArray(value)) {
return [];
}
const calls: AssistantToolCall[] = [];
for (const rawCall of value) {
if (!rawCall || typeof rawCall !== "object" || Array.isArray(rawCall)) {
continue;
}
const id = normalizeOptionalString((rawCall as { id?: unknown }).id) ?? "";
const functionValue = (rawCall as { function?: unknown }).function;
if (!functionValue || typeof functionValue !== "object" || Array.isArray(functionValue)) {
continue;
}
const name = normalizeOptionalString((functionValue as { name?: unknown }).name) ?? "";
if (!id || !name) {
continue;
}
const argumentsValue = stringifyToolCallArguments(
(functionValue as { arguments?: unknown }).arguments,
);
calls.push({ id, name, arguments: argumentsValue });
}
return calls;
}
function renderAssistantToolCalls(calls: AssistantToolCall[]): string {
return calls
.map((call) => `tool_call id=${call.id} name=${call.name} arguments=${call.arguments}`)
.join("\n");
}
function resolveImageUrlPart(part: unknown): string | undefined {
if (!part || typeof part !== "object") {
return undefined;
@@ -410,26 +609,36 @@ function buildAgentPrompt(
if (normalizedRole !== "user" && normalizedRole !== "assistant" && normalizedRole !== "tool") {
continue;
}
const assistantToolCalls =
normalizedRole === "assistant" ? extractAssistantToolCalls(msg.tool_calls) : [];
const assistantToolCallsSummary =
assistantToolCalls.length > 0 ? renderAssistantToolCalls(assistantToolCalls) : "";
// Keep the image-only placeholder scoped to the active user turn so we don't
// mention historical image-only turns whose bytes are intentionally not replayed.
const messageContent =
const baseMessageContent =
normalizedRole === "user" && !content && hasImage && i === activeUserMessageIndex
? IMAGE_ONLY_USER_MESSAGE
: content;
const messageContent = [baseMessageContent, assistantToolCallsSummary]
.filter((part): part is string => Boolean(part))
.join("\n");
if (!messageContent) {
continue;
}
const name = normalizeOptionalString(msg.name) ?? "";
const toolCallId = normalizeOptionalString(msg.tool_call_id) ?? "";
const sender =
normalizedRole === "assistant"
? "Assistant"
: normalizedRole === "user"
? "User"
: name
? `Tool:${name}`
: "Tool";
: toolCallId
? `Tool:${toolCallId}`
: name
? `Tool:${name}`
: "Tool";
conversationEntries.push({
role: normalizedRole,
@@ -464,6 +673,17 @@ function resolveAgentResponseText(result: unknown): string {
return content || "No response from OpenClaw.";
}
function resolveAgentResponseCommentary(result: unknown): string {
const payloads = (result as { payloads?: Array<{ text?: string }> } | null)?.payloads;
if (!Array.isArray(payloads) || payloads.length === 0) {
return "";
}
return payloads
.map((p) => (typeof p.text === "string" ? p.text : ""))
.filter(Boolean)
.join("\n\n");
}
type AgentUsageMeta = {
input?: number;
output?: number;
@@ -472,6 +692,12 @@ type AgentUsageMeta = {
total?: number;
};
type PendingToolCall = {
id?: unknown;
name?: unknown;
arguments?: unknown;
};
function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined {
const agentMeta = (
result as {
@@ -494,6 +720,38 @@ function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined {
return primary ?? fallback;
}
function resolveStopReasonAndPendingToolCalls(meta: unknown): {
stopReason: string | undefined;
pendingToolCalls: Array<{ id: string; name: string; arguments: string }> | undefined;
} {
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
return { stopReason: undefined, pendingToolCalls: undefined };
}
const stopReasonRaw = (meta as { stopReason?: unknown }).stopReason;
const stopReason = typeof stopReasonRaw === "string" ? stopReasonRaw : undefined;
const pendingRaw = (meta as { pendingToolCalls?: unknown }).pendingToolCalls;
if (!Array.isArray(pendingRaw)) {
return { stopReason, pendingToolCalls: undefined };
}
const pendingToolCalls: Array<{ id: string; name: string; arguments: string }> = [];
for (const call of pendingRaw as PendingToolCall[]) {
const id = typeof call?.id === "string" ? call.id.trim() : "";
const name = typeof call?.name === "string" ? call.name.trim() : "";
const argsValue = call?.arguments;
const argumentsValue =
typeof argsValue === "string"
? argsValue
: argsValue == null
? ""
: JSON.stringify(argsValue);
if (!id || !name) {
continue;
}
pendingToolCalls.push({ id, name, arguments: argumentsValue });
}
return { stopReason, pendingToolCalls };
}
function resolveChatCompletionUsage(result: unknown): {
prompt_tokens: number;
completion_tokens: number;
@@ -512,6 +770,16 @@ function resolveIncludeUsageForStreaming(payload: OpenAiChatCompletionRequest):
return (streamOptions as { include_usage?: unknown }).include_usage === true;
}
function resolveErrorMessage(err: unknown): string {
if (err instanceof Error) {
const message = err.message.trim();
if (message) {
return message;
}
}
return String(err);
}
export async function handleOpenAiHttpRequest(
req: IncomingMessage,
res: ServerResponse,
@@ -567,6 +835,25 @@ export async function handleOpenAiHttpRequest(
}
const activeTurnContext = resolveActiveTurnContext(payload.messages);
const prompt = buildAgentPrompt(payload.messages, activeTurnContext.activeUserMessageIndex);
let resolvedClientTools: ClientToolDefinition[] = [];
let toolChoicePrompt: string | undefined;
try {
const parsedClientTools = extractClientToolsFromChatRequest(payload.tools);
const toolChoiceResult = applyChatToolChoice({
tools: parsedClientTools,
toolChoice: payload.tool_choice,
});
resolvedClientTools = toolChoiceResult.tools;
toolChoicePrompt = toolChoiceResult.extraSystemPrompt;
} catch (err) {
sendJson(res, 400, {
error: {
message: `Invalid tools/tool_choice: ${resolveErrorMessage(err)}`,
type: "invalid_request_error",
},
});
return true;
}
let images: ImageContent[] = [];
try {
images = await resolveImagesForRequest(activeTurnContext, limits);
@@ -594,12 +881,16 @@ export async function handleOpenAiHttpRequest(
const runId = `chatcmpl_${randomUUID()}`;
const deps = createDefaultDeps();
const abortController = new AbortController();
const mergedExtraSystemPrompt = [prompt.extraSystemPrompt, toolChoicePrompt]
.filter((part): part is string => Boolean(part))
.join("\n\n");
const commandInput = buildAgentCommandInput({
prompt: {
message: prompt.message,
extraSystemPrompt: prompt.extraSystemPrompt,
extraSystemPrompt: mergedExtraSystemPrompt || undefined,
images: images.length > 0 ? images : undefined,
},
clientTools: resolvedClientTools.length > 0 ? resolvedClientTools : undefined,
modelOverride,
sessionKey,
runId,
@@ -617,8 +908,37 @@ export async function handleOpenAiHttpRequest(
return true;
}
const content = resolveAgentResponseText(result);
const usage = resolveChatCompletionUsage(result);
const meta = (result as { meta?: unknown } | null)?.meta;
const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta);
if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) {
const commentary = resolveAgentResponseCommentary(result);
sendJson(res, 200, {
id: runId,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: {
role: "assistant",
content: commentary,
tool_calls: pendingToolCalls.map((call) => ({
id: call.id,
type: "function",
function: { name: call.name, arguments: call.arguments },
})),
},
finish_reason: "tool_calls",
},
],
usage,
});
return true;
}
const content = resolveAgentResponseText(result);
sendJson(res, 200, {
id: runId,
@@ -639,6 +959,12 @@ export async function handleOpenAiHttpRequest(
return true;
}
logWarn(`openai-compat: chat completion failed: ${String(err)}`);
if (isClientToolNameConflictError(err)) {
sendJson(res, 400, {
error: { message: "invalid tool configuration", type: "invalid_request_error" },
});
return true;
}
sendJson(res, 500, {
error: { message: "internal error", type: "api_error" },
});
@@ -661,6 +987,8 @@ export async function handleOpenAiHttpRequest(
}
| undefined;
let finalizeRequested = false;
let finalizeFinishReason: "stop" | "tool_calls" = "stop";
let resultResolved = false;
let closed = false;
let stopWatchingDisconnect = () => {};
@@ -668,6 +996,9 @@ export async function handleOpenAiHttpRequest(
if (closed || !finalizeRequested) {
return;
}
if (!resultResolved) {
return;
}
if (streamIncludeUsage && !finalUsage) {
return;
}
@@ -675,7 +1006,7 @@ export async function handleOpenAiHttpRequest(
stopWatchingDisconnect();
unsubscribe();
if (!wroteStopChunk) {
writeAssistantStopChunk(res, { runId, model });
writeAssistantFinishChunk(res, { runId, model, finishReason: finalizeFinishReason });
wroteStopChunk = true;
}
if (streamIncludeUsage && finalUsage) {
@@ -685,7 +1016,8 @@ export async function handleOpenAiHttpRequest(
res.end();
};
const requestFinalize = () => {
const requestFinalize = (finishReason: "stop" | "tool_calls" = "stop") => {
finalizeFinishReason = finishReason;
finalizeRequested = true;
maybeFinalize();
};
@@ -738,12 +1070,41 @@ export async function handleOpenAiHttpRequest(
void (async () => {
try {
const result = await agentCommandFromIngress(commandInput, defaultRuntime, deps);
resultResolved = true;
if (closed) {
return;
}
finalUsage = resolveChatCompletionUsage(result);
const meta = (result as { meta?: unknown } | null)?.meta;
const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta);
if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) {
if (!wroteRole) {
wroteRole = true;
writeAssistantRoleChunk(res, { runId, model });
}
if (!sawAssistantDelta) {
const commentary = resolveAgentResponseCommentary(result);
if (commentary) {
sawAssistantDelta = true;
writeAssistantContentChunk(res, {
runId,
model,
content: commentary,
finishReason: null,
});
}
}
writeAssistantToolCallsIncrementalChunks(res, {
runId,
model,
toolCalls: pendingToolCalls,
});
requestFinalize("tool_calls");
return;
}
if (!sawAssistantDelta) {
if (!wroteRole) {
@@ -763,14 +1124,27 @@ export async function handleOpenAiHttpRequest(
}
requestFinalize();
} catch (err) {
resultResolved = true;
if (closed || abortController.signal.aborted) {
return;
}
logWarn(`openai-compat: streaming chat completion failed: ${String(err)}`);
if (isClientToolNameConflictError(err)) {
closed = true;
stopWatchingDisconnect();
unsubscribe();
writeSse(res, {
error: { message: "invalid tool configuration", type: "invalid_request_error" },
});
writeDone(res);
res.end();
return;
}
const content = "Error: internal error";
writeAssistantContentChunk(res, {
runId,
model,
content: "Error: internal error",
content,
finishReason: "stop",
});
wroteStopChunk = true;

View File

@@ -51,6 +51,13 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
// (e.g. reconfiguring wake-word triggers).
const NODE_ALLOWED_EVENTS = new Set<string>(["voicewake.changed", "voicewake.routing.changed"]);
function serializeFrameField(name: "payload" | "stateVersion", value: unknown): string {
const fieldJSON = JSON.stringify({ [name]: value });
const keyJSON = JSON.stringify(name);
const prefix = `{${keyJSON}:`;
return fieldJSON.startsWith(prefix) ? `,${keyJSON}:${fieldJSON.slice(prefix.length, -1)}` : "";
}
function hasEventScope(client: GatewayWsClient, event: string): boolean {
const required = EVENT_SCOPE_GUARDS[event];
// Plugin-defined gateway broadcast events (plugin.* namespace) are allowed
@@ -113,6 +120,26 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
}
logWs("out", "event", logMeta);
}
let frameBase:
| {
eventJSON: string;
payloadFragment: string;
stateVersionFragment: string;
}
| undefined;
const getFrameBase = () => {
if (!frameBase) {
frameBase = {
eventJSON: JSON.stringify(event),
payloadFragment: serializeFrameField("payload", payload),
stateVersionFragment:
opts?.stateVersion === undefined
? ""
: serializeFrameField("stateVersion", opts.stateVersion),
};
}
return frameBase;
};
for (const c of params.clients) {
if (targetConnIds && !targetConnIds.has(c.connId)) {
continue;
@@ -152,13 +179,9 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
if (!isTargeted) {
clientSeq.set(c, nextSeq);
}
const frame = JSON.stringify({
type: "event",
event,
payload,
seq: eventSeq,
stateVersion: opts?.stateVersion,
});
const base = getFrameBase();
const seqFragment = eventSeq === undefined ? "" : `,"seq":${eventSeq}`;
const frame = `{"type":"event","event":${base.eventJSON}${base.payloadFragment}${seqFragment}${base.stateVersionFragment}}`;
c.socket.send(frame);
} catch {
/* ignore */

View File

@@ -1,11 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginGatewayDiscoveryServiceRegistration } from "../plugins/registry-types.js";
type WriteWideAreaGatewayZone = typeof import("../infra/widearea-dns.js").writeWideAreaGatewayZone;
const mocks = vi.hoisted(() => ({
pickPrimaryTailnetIPv4: vi.fn(() => "100.64.0.10"),
pickPrimaryTailnetIPv6: vi.fn(() => undefined as string | undefined),
resolveWideAreaDiscoveryDomain: vi.fn(() => "openclaw.internal."),
writeWideAreaGatewayZone: vi.fn(async () => ({
writeWideAreaGatewayZone: vi.fn<WriteWideAreaGatewayZone>(async () => ({
changed: true,
zonePath: "/tmp/openclaw.internal.db",
})),
@@ -218,15 +220,15 @@ describe("startGatewayDiscovery", () => {
expect(service.service.advertise).not.toHaveBeenCalled();
expect(mocks.resolveTailnetDnsHint).toHaveBeenCalledWith({ enabled: true });
expect(mocks.writeWideAreaGatewayZone).toHaveBeenCalledWith(
expect.objectContaining({
domain: "openclaw.internal.",
gatewayPort: 18789,
displayName: "Lab Mac (OpenClaw)",
tailnetIPv4: "100.64.0.10",
tailnetDns: "gateway.tailnet.example.ts.net",
}),
);
const [zoneParams] = mocks.writeWideAreaGatewayZone.mock.calls.at(-1) ?? [];
if (zoneParams === undefined) {
throw new Error("Expected wide-area gateway zone to be written");
}
expect(zoneParams.domain).toBe("openclaw.internal.");
expect(zoneParams.gatewayPort).toBe(18789);
expect(zoneParams.displayName).toBe("Lab Mac (OpenClaw)");
expect(zoneParams.tailnetIPv4).toBe("100.64.0.10");
expect(zoneParams.tailnetDns).toBe("gateway.tailnet.example.ts.net");
expect(logs.info).toHaveBeenCalledWith(expect.stringContaining("wide-area DNS-SD updated"));
expect(result.bonjourStop).toBeNull();
});

View File

@@ -95,14 +95,11 @@ describe("gateway control-plane write rate limit", () => {
const blocked = await runRequest({ method: "config.patch", context, client, handler });
expect(handlerCalls).toHaveBeenCalledTimes(3);
expect(blocked).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "UNAVAILABLE",
retryable: true,
}),
);
const error = blocked.mock.calls[0]?.[2] as { code?: string; retryable?: boolean } | undefined;
expect(blocked.mock.calls[0]?.[0]).toBe(false);
expect(blocked.mock.calls[0]?.[1]).toBeUndefined();
expect(error?.code).toBe("UNAVAILABLE");
expect(error?.retryable).toBe(true);
expect(logWarn).toHaveBeenCalledTimes(1);
});
@@ -120,11 +117,9 @@ describe("gateway control-plane write rate limit", () => {
await runRequest({ method: "update.run", context, client, handler });
const blocked = await runRequest({ method: "update.run", context, client, handler });
expect(blocked).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "UNAVAILABLE" }),
);
expect(blocked.mock.calls[0]?.[0]).toBe(false);
expect(blocked.mock.calls[0]?.[1]).toBeUndefined();
expect(blocked.mock.calls[0]?.[2]?.code).toBe("UNAVAILABLE");
vi.advanceTimersByTime(60_001);
@@ -150,17 +145,13 @@ describe("gateway control-plane write rate limit", () => {
const blocked = await runRequest({ method, context, client, handler });
expect(handlerCalls).not.toHaveBeenCalled();
expect(blocked).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "UNAVAILABLE",
retryable: true,
retryAfterMs: 500,
details: { reason: "startup-sidecars", method },
}),
);
const error = blocked.mock.calls[0]?.[2];
expect(blocked.mock.calls[0]?.[0]).toBe(false);
expect(blocked.mock.calls[0]?.[1]).toBeUndefined();
expect(error?.code).toBe("UNAVAILABLE");
expect(error?.retryable).toBe(true);
expect(error?.retryAfterMs).toBe(500);
expect(error?.details).toEqual({ reason: "startup-sidecars", method });
expect(isRetryableGatewayStartupUnavailableError(error)).toBe(true);
},
);

View File

@@ -386,16 +386,15 @@ describe("agent wait dedupe helper", () => {
payload: { runId, status: "ok" },
});
await expect(first).resolves.toEqual(
expect.objectContaining({
status: "ok",
}),
);
await expect(second).resolves.toEqual(
expect.objectContaining({
status: "ok",
}),
);
const firstResult = await first;
const secondResult = await second;
if (!firstResult || !secondResult) {
throw new Error("expected waiters to resolve");
}
expect(firstResult.status).toBe("ok");
expect(firstResult.error).toBeUndefined();
expect(secondResult.status).toBe("ok");
expect(secondResult.error).toBeUndefined();
expect(__testing.getWaiterCount(runId)).toBe(0);
});

View File

@@ -89,26 +89,29 @@ describe("agent handler session create events", () => {
req: { id: "req-agent-create-event" } as never,
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
status: "accepted",
runId: "idem-agent-create-event",
}),
undefined,
{ runId: "idem-agent-create-event" },
);
const responseCall = respond.mock.calls[0] as
| [boolean, { status?: string; runId?: string }, unknown, { runId?: string }]
| undefined;
expect(responseCall?.[0]).toBe(true);
expect(responseCall?.[1]?.status).toBe("accepted");
expect(responseCall?.[1]?.runId).toBe("idem-agent-create-event");
expect(responseCall?.[2]).toBeUndefined();
expect(responseCall?.[3]?.runId).toBe("idem-agent-create-event");
await vi.waitFor(
() => {
expect(broadcastToConnIds).toHaveBeenCalledWith(
"sessions.changed",
expect.objectContaining({
sessionKey: "agent:main:subagent:create-test",
reason: "create",
}),
new Set(["conn-1"]),
{ dropIfSlow: true },
);
const call = broadcastToConnIds.mock.calls[0] as
| [
string,
{ sessionKey?: string; reason?: string },
Set<string>,
{ dropIfSlow?: boolean },
]
| undefined;
expect(call?.[0]).toBe("sessions.changed");
expect(call?.[1]?.sessionKey).toBe("agent:main:subagent:create-test");
expect(call?.[1]?.reason).toBe("create");
expect(call?.[2]).toEqual(new Set(["conn-1"]));
expect(call?.[3]).toEqual({ dropIfSlow: true });
},
{ timeout: 2_000, interval: 5 },
);

View File

@@ -600,8 +600,15 @@ describe("agents.create", () => {
rootDir: "/resolved/tmp/ws",
relativePath: "IDENTITY.md",
});
expect(write.data).toEqual(
expect.stringMatching(/- Name: Fancy Agent[\s\S]*- Emoji: 🤖[\s\S]*- Avatar:/),
expect(write.data).toBe(
[
"# IDENTITY.md - Agent Identity",
"",
"- Name: Fancy Agent",
"- Emoji: 🤖",
"- Avatar: https://example.com/avatar.png",
"",
].join("\n"),
);
});
@@ -772,10 +779,16 @@ describe("agents.update", () => {
rootDir: "/workspace/test-agent",
relativePath: "IDENTITY.md",
});
expect(write.data).toEqual(
expect.stringMatching(
/- Name: Current Agent[\s\S]*- Theme: steady[\s\S]*- Emoji: 🐢[\s\S]*- Avatar: https:\/\/example\.com\/avatar\.png/,
),
expect(write.data).toBe(
[
"# IDENTITY.md - Agent Identity",
"",
"- Name: Current Agent",
"- Theme: steady",
"- Emoji: 🐢",
"- Avatar: https://example.com/avatar.png",
"",
].join("\n"),
);
});
@@ -793,8 +806,15 @@ describe("agents.update", () => {
rootDir: "/workspace/test-agent",
relativePath: "IDENTITY.md",
});
expect(write.data).toEqual(
expect.stringMatching(/- Name: Current Agent[\s\S]*- Theme: steady[\s\S]*- Emoji: 🦀/),
expect(write.data).toBe(
[
"# IDENTITY.md - Agent Identity",
"",
"- Name: Current Agent",
"- Theme: steady",
"- Emoji: 🦀",
"",
].join("\n"),
);
});
@@ -820,10 +840,16 @@ describe("agents.update", () => {
rootDir: "/workspace/test-agent",
relativePath: "IDENTITY.md",
});
expect(write.data).toEqual(
expect.stringMatching(
/- Name: New Name[\s\S]*- Theme: steady[\s\S]*- Emoji: 🤖[\s\S]*- Avatar: https:\/\/example\.com\/new\.png/,
),
expect(write.data).toBe(
[
"# IDENTITY.md - Agent Identity",
"",
"- Name: New Name",
"- Theme: steady",
"- Emoji: 🤖",
"- Avatar: https://example.com/new.png",
"",
].join("\n"),
);
});

View File

@@ -60,7 +60,12 @@ describe("normalizeWebchatReplyMediaPathsForDisplay", () => {
}
async function expectPathMissing(targetPath: string): Promise<void> {
await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
try {
await fs.stat(targetPath);
throw new Error(`expected ${targetPath} to be missing`);
} catch (error) {
expect((error as { code?: string }).code).toBe("ENOENT");
}
}
it("stages Codex-home image paths before Gateway managed-image display", async () => {

View File

@@ -332,12 +332,8 @@ describe("config shared auth disconnects", () => {
await configHandlers["config.patch"](options);
expect(restartSentinelMocks.writeRestartSentinel).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
}),
);
const payload = restartSentinelMocks.writeRestartSentinel.mock.calls.at(-1)?.[0];
expect(payload?.sessionKey).toBe("agent:main:main");
expect(payload?.continuation).toBeUndefined();
});
});

View File

@@ -21,9 +21,13 @@ describe("diagnostics gateway methods", () => {
stopDiagnosticStabilityRecorder();
resetDiagnosticStabilityRecorderForTest();
resetDiagnosticEventsForTest();
vi.useRealTimers();
});
it("returns a filtered stability snapshot", async () => {
const now = new Date("2026-01-02T03:04:05.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
emitDiagnosticEvent({ type: "webhook.received", channel: "telegram" });
emitDiagnosticEvent({
type: "payload.large",
@@ -43,20 +47,53 @@ describe("diagnostics gateway methods", () => {
respond,
});
expect(respond).toHaveBeenCalledWith(
expect(respond).toHaveBeenCalledTimes(1);
expect(respond.mock.calls[0]).toEqual([
true,
expect.objectContaining({
{
generatedAt: now.toISOString(),
capacity: 1000,
count: 1,
dropped: 0,
firstSeq: 2,
lastSeq: 2,
events: [
expect.objectContaining({
{
seq: 2,
ts: now.getTime(),
type: "payload.large",
surface: "gateway.http.json",
action: "rejected",
}),
bytes: 1024,
limitBytes: 512,
count: undefined,
channel: undefined,
pluginId: undefined,
},
],
}),
summary: {
byType: { "payload.large": 1 },
payloadLarge: {
count: 1,
rejected: 1,
truncated: 0,
chunked: 0,
bySurface: { "gateway.http.json": 1 },
},
},
},
undefined,
);
]);
expect(Object.keys(respond.mock.calls[0]?.[1] as Record<string, unknown>).toSorted()).toEqual([
"capacity",
"count",
"dropped",
"events",
"firstSeq",
"generatedAt",
"lastSeq",
"summary",
]);
});
it("rejects invalid stability params", async () => {
@@ -70,13 +107,15 @@ describe("diagnostics gateway methods", () => {
respond,
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: "limit must be between 1 and 1000",
}),
);
expect(respond.mock.calls).toEqual([
[
false,
undefined,
{
code: "INVALID_REQUEST",
message: "limit must be between 1 and 1000",
},
],
]);
});
});

Some files were not shown because too many files have changed in this diff Show More