mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 12:54:47 +00:00
Merge branch 'main' into meow/control-ui-mount-fallback
This commit is contained in:
@@ -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
|
||||
|
||||
177
.github/workflows/mantis-telegram-desktop-proof.yml
vendored
177
.github/workflows/mantis-telegram-desktop-proof.yml
vendored
@@ -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 }}
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
100
scripts/e2e/lib/openai-chat-tools/client.mjs
Normal file
100
scripts/e2e/lib/openai-chat-tools/client.mjs
Normal 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,
|
||||
}),
|
||||
);
|
||||
86
scripts/e2e/lib/openai-chat-tools/scenario.sh
Normal file
86
scripts/e2e/lib/openai-chat-tools/scenario.sh
Normal 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"
|
||||
90
scripts/e2e/lib/openai-chat-tools/write-config.mjs
Normal file
90
scripts/e2e/lib/openai-chat-tools/write-config.mjs
Normal 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 });
|
||||
43
scripts/e2e/openai-chat-tools-docker.sh
Normal file
43
scripts/e2e/openai-chat-tools-docker.sh
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
113
src/agents/bash-tools.exec.security-floor.test.ts
Normal file
113
src/agents/bash-tools.exec.security-floor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
95
src/channels/registry-lookup.ts
Normal file
95
src/channels/registry-lookup.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user