mirror of https://github.com/openclaw/openclaw.git
Merge branch 'main' into fix/followup-audio-transcription
This commit is contained in:
commit
2c946bf2cf
|
|
@ -159,6 +159,9 @@ jobs:
|
|||
- runtime: node
|
||||
task: extensions
|
||||
command: pnpm test:extensions
|
||||
- runtime: node
|
||||
task: channels
|
||||
command: pnpm test:channels
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ jobs:
|
|||
environment: docker-release
|
||||
steps:
|
||||
- name: Approve Docker backfill
|
||||
run: echo "Approved Docker backfill for ${{ inputs.tag }}"
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: echo "Approved Docker backfill for $RELEASE_TAG"
|
||||
|
||||
# KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS.
|
||||
# DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish (for example v2026.3.14 or v2026.3.14-beta.1)
|
||||
description: Release tag to publish (for example v2026.3.14, v2026.3.14-beta.1, or fallback v2026.3.14-1)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
|
@ -47,9 +47,18 @@ jobs:
|
|||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
TAG_KIND="fallback correction"
|
||||
else
|
||||
TAG_KIND="standard"
|
||||
fi
|
||||
echo "Release plan for ${RELEASE_TAG}:"
|
||||
echo "Resolved release SHA: ${RELEASE_SHA}"
|
||||
echo "Resolved package version: ${PACKAGE_VERSION}"
|
||||
echo "Resolved tag kind: ${TAG_KIND}"
|
||||
if [[ "${TAG_KIND}" == "fallback correction" ]]; then
|
||||
echo "Correction tag note: npm version remains ${PACKAGE_VERSION}"
|
||||
fi
|
||||
echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main"
|
||||
echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check"
|
||||
echo "Would run: npm view openclaw@${PACKAGE_VERSION} version"
|
||||
|
|
@ -71,16 +80,31 @@ jobs:
|
|||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
IS_CORRECTION_TAG=0
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
IS_CORRECTION_TAG=1
|
||||
fi
|
||||
|
||||
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error."
|
||||
exit 0
|
||||
fi
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Previewing openclaw@${PACKAGE_VERSION}"
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}"
|
||||
else
|
||||
echo "Previewing openclaw@${PACKAGE_VERSION}"
|
||||
fi
|
||||
|
||||
- name: Check
|
||||
run: |
|
||||
|
|
@ -114,7 +138,7 @@ jobs:
|
|||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -13,6 +13,14 @@ Docs: https://docs.openclaw.ai
|
|||
|
||||
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
|
|
@ -86,6 +94,8 @@ Docs: https://docs.openclaw.ai
|
|||
- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
|
||||
- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic.
|
||||
- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix
|
||||
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931)
|
||||
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
|
|
@ -318,6 +328,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode.
|
||||
- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows.
|
||||
- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint.
|
||||
- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ Welcome to the lobster tank! 🦞
|
|||
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
|
||||
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
|
||||
|
||||
- **Andrew (Bubbles) Demczuk** - Agents/Gateway/TTS/VTT
|
||||
- GitHub: [@ademczuk](https://github.com/ademczuk) · X: [@ademczuk](https://x.com/ademczuk)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
|
|
|
|||
|
|
@ -782,6 +782,11 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
|
|||
- `--poll-public`
|
||||
- `--thread-id` for forum topics (or use a `:topic:` target)
|
||||
|
||||
Telegram send also supports:
|
||||
|
||||
- `--buttons` for inline keyboards when `channels.telegram.capabilities.inlineButtons` allows it
|
||||
- `--force-document` to send outbound images and GIFs as documents instead of compressed photo or animated-media uploads
|
||||
|
||||
Action gating:
|
||||
|
||||
- `channels.telegram.actions.sendMessage=false` disables outbound Telegram messages, including polls
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ openclaw gateway health --url ws://127.0.0.1:18789
|
|||
```bash
|
||||
openclaw gateway status
|
||||
openclaw gateway status --json
|
||||
openclaw gateway status --require-rpc
|
||||
```
|
||||
|
||||
Options:
|
||||
|
|
@ -105,11 +106,13 @@ Options:
|
|||
- `--timeout <ms>`: probe timeout (default `10000`).
|
||||
- `--no-probe`: skip the RPC probe (service-only view).
|
||||
- `--deep`: scan system-level services too.
|
||||
- `--require-rpc`: exit non-zero when the RPC probe fails. Cannot be combined with `--no-probe`.
|
||||
|
||||
Notes:
|
||||
|
||||
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
|
||||
- If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first.
|
||||
- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need the Gateway RPC itself to be healthy.
|
||||
- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files).
|
||||
|
||||
### `gateway probe`
|
||||
|
|
|
|||
|
|
@ -780,7 +780,7 @@ Subcommands:
|
|||
Notes:
|
||||
|
||||
- `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url/--token/--password`).
|
||||
- `gateway status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
||||
- `gateway status` supports `--no-probe`, `--deep`, `--require-rpc`, and `--json` for scripting.
|
||||
- `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra".
|
||||
- `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL.
|
||||
- On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ Name lookup:
|
|||
- Required: `--target`, plus `--message` or `--media`
|
||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
||||
- Telegram only: `--force-document` (send images and GIFs as documents to avoid Telegram compression)
|
||||
- Telegram only: `--thread-id` (forum topic id)
|
||||
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
||||
- WhatsApp only: `--gif-playback`
|
||||
|
|
@ -258,3 +259,10 @@ Send Telegram inline buttons:
|
|||
openclaw message send --channel telegram --target @mychat --message "Choose:" \
|
||||
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
|
||||
```
|
||||
|
||||
Send a Telegram image as a document to avoid compression:
|
||||
|
||||
```bash
|
||||
openclaw message send --channel telegram --target @mychat \
|
||||
--media ./diagram.png --force-document
|
||||
```
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ The default workspace layout uses two memory layers:
|
|||
- Read today + yesterday at session start.
|
||||
- `MEMORY.md` (optional)
|
||||
- Curated long-term memory.
|
||||
- If both `MEMORY.md` and `memory.md` exist at the workspace root, OpenClaw only loads `MEMORY.md`.
|
||||
- Lowercase `memory.md` is only used as a fallback when `MEMORY.md` is absent.
|
||||
- **Only load in the main, private session** (never in group contexts).
|
||||
|
||||
These files live under the workspace (`agents.defaults.workspace`, default
|
||||
|
|
|
|||
|
|
@ -1009,7 +1009,8 @@
|
|||
"tools/loop-detection",
|
||||
"tools/reactions",
|
||||
"tools/thinking",
|
||||
"tools/web"
|
||||
"tools/web",
|
||||
"tools/btw"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1358,7 +1358,8 @@ Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and confi
|
|||
These files live in the **agent workspace**, not `~/.openclaw`.
|
||||
|
||||
- **Workspace (per agent)**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`,
|
||||
`MEMORY.md` (or `memory.md`), `memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`.
|
||||
`MEMORY.md` (or legacy fallback `memory.md` when `MEMORY.md` is absent),
|
||||
`memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`.
|
||||
- **State dir (`~/.openclaw`)**: config, credentials, auth profiles, sessions, logs,
|
||||
and shared skills (`~/.openclaw/skills`).
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md
|
|||
|
||||
## Session start (required)
|
||||
|
||||
- Read `SOUL.md`, `USER.md`, `memory.md`, and today+yesterday in `memory/`.
|
||||
- Read `SOUL.md`, `USER.md`, and today+yesterday in `memory/`.
|
||||
- Read `MEMORY.md` when present; only fall back to lowercase `memory.md` when `MEMORY.md` is absent.
|
||||
- Do it before responding.
|
||||
|
||||
## Soul (required)
|
||||
|
|
@ -65,8 +66,9 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md
|
|||
## Memory system (recommended)
|
||||
|
||||
- Daily log: `memory/YYYY-MM-DD.md` (create `memory/` if needed).
|
||||
- Long-term memory: `memory.md` for durable facts, preferences, and decisions.
|
||||
- On session start, read today + yesterday + `memory.md` if present.
|
||||
- Long-term memory: `MEMORY.md` for durable facts, preferences, and decisions.
|
||||
- Lowercase `memory.md` is legacy fallback only; do not keep both root files on purpose.
|
||||
- On session start, read today + yesterday + `MEMORY.md` when present, otherwise `memory.md`.
|
||||
- Capture: decisions, preferences, constraints, open loops.
|
||||
- Avoid secrets unless explicitly requested.
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ Current OpenClaw releases use date-based versioning.
|
|||
- Beta prerelease version: `YYYY.M.D-beta.N`
|
||||
- Git tag: `vYYYY.M.D-beta.N`
|
||||
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
|
||||
- Fallback correction tag: `vYYYY.M.D-N`
|
||||
- Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it.
|
||||
- The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release.
|
||||
- Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready.
|
||||
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
|
||||
- `package.json`: `2026.3.8`
|
||||
- Git tag: `v2026.3.8`
|
||||
|
|
@ -38,12 +42,12 @@ Current OpenClaw releases use date-based versioning.
|
|||
- `latest` = stable
|
||||
- `beta` = prerelease/testing
|
||||
- Dev is the moving head of `main`, not a normal git-tagged release.
|
||||
- The tag-triggered preview run enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
|
||||
Historical note:
|
||||
|
||||
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
|
||||
- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
|
||||
- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
|
||||
|
||||
1. **Version & metadata**
|
||||
|
||||
|
|
@ -99,7 +103,9 @@ Historical note:
|
|||
- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval.
|
||||
- Stable tags publish to npm `latest`.
|
||||
- Beta tags publish to npm `beta`.
|
||||
- Both the preview run and the manual publish run reject tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`.
|
||||
- Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version.
|
||||
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`).
|
||||
|
||||
### Troubleshooting (notes from 2.0.0-beta2 release)
|
||||
|
|
@ -109,8 +115,9 @@ Historical note:
|
|||
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
|
||||
- **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache:
|
||||
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
|
||||
- **Tag needs repointing after a late fix**: force-update and push the tag, then ensure the GitHub release assets still match:
|
||||
- `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`
|
||||
- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`.
|
||||
- Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only.
|
||||
- Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release.
|
||||
|
||||
7. **GitHub release + appcast**
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
---
|
||||
summary: "Ephemeral side questions with /btw"
|
||||
read_when:
|
||||
- You want to ask a quick side question about the current session
|
||||
- You are implementing or debugging BTW behavior across clients
|
||||
title: "BTW Side Questions"
|
||||
---
|
||||
|
||||
# BTW Side Questions
|
||||
|
||||
`/btw` lets you ask a quick side question about the **current session** without
|
||||
turning that question into normal conversation history.
|
||||
|
||||
It is modeled after Claude Code's `/btw` behavior, but adapted to OpenClaw's
|
||||
Gateway and multi-channel architecture.
|
||||
|
||||
## What it does
|
||||
|
||||
When you send:
|
||||
|
||||
```text
|
||||
/btw what changed?
|
||||
```
|
||||
|
||||
OpenClaw:
|
||||
|
||||
1. snapshots the current session context,
|
||||
2. runs a separate **tool-less** model call,
|
||||
3. answers only the side question,
|
||||
4. leaves the main run alone,
|
||||
5. does **not** write the BTW question or answer to session history,
|
||||
6. emits the answer as a **live side result** rather than a normal assistant message.
|
||||
|
||||
The important mental model is:
|
||||
|
||||
- same session context
|
||||
- separate one-shot side query
|
||||
- no tool calls
|
||||
- no future context pollution
|
||||
- no transcript persistence
|
||||
|
||||
## What it does not do
|
||||
|
||||
`/btw` does **not**:
|
||||
|
||||
- create a new durable session,
|
||||
- continue the unfinished main task,
|
||||
- run tools or agent tool loops,
|
||||
- write BTW question/answer data to transcript history,
|
||||
- appear in `chat.history`,
|
||||
- survive a reload.
|
||||
|
||||
It is intentionally **ephemeral**.
|
||||
|
||||
## How context works
|
||||
|
||||
BTW uses the current session as **background context only**.
|
||||
|
||||
If the main run is currently active, OpenClaw snapshots the current message
|
||||
state and includes the in-flight main prompt as background context, while
|
||||
explicitly telling the model:
|
||||
|
||||
- answer only the side question,
|
||||
- do not resume or complete the unfinished main task,
|
||||
- do not emit tool calls or pseudo-tool calls.
|
||||
|
||||
That keeps BTW isolated from the main run while still making it aware of what
|
||||
the session is about.
|
||||
|
||||
## Delivery model
|
||||
|
||||
BTW is **not** delivered as a normal assistant transcript message.
|
||||
|
||||
At the Gateway protocol level:
|
||||
|
||||
- normal assistant chat uses the `chat` event
|
||||
- BTW uses the `chat.side_result` event
|
||||
|
||||
This separation is intentional. If BTW reused the normal `chat` event path,
|
||||
clients would treat it like regular conversation history.
|
||||
|
||||
Because BTW uses a separate live event and is not replayed from
|
||||
`chat.history`, it disappears after reload.
|
||||
|
||||
## Surface behavior
|
||||
|
||||
### TUI
|
||||
|
||||
In TUI, BTW is rendered inline in the current session view, but it remains
|
||||
ephemeral:
|
||||
|
||||
- visibly distinct from a normal assistant reply
|
||||
- dismissible with `Enter` or `Esc`
|
||||
- not replayed on reload
|
||||
|
||||
### External channels
|
||||
|
||||
On channels like Telegram, WhatsApp, and Discord, BTW is delivered as a
|
||||
clearly labeled one-off reply because those surfaces do not have a local
|
||||
ephemeral overlay concept.
|
||||
|
||||
The answer is still treated as a side result, not normal session history.
|
||||
|
||||
### Control UI / web
|
||||
|
||||
The Gateway emits BTW correctly as `chat.side_result`, and BTW is not included
|
||||
in `chat.history`, so the persistence contract is already correct for web.
|
||||
|
||||
The current Control UI still needs a dedicated `chat.side_result` consumer to
|
||||
render BTW live in the browser. Until that client-side support lands, BTW is a
|
||||
Gateway-level feature with full TUI and external-channel behavior, but not yet
|
||||
a complete browser UX.
|
||||
|
||||
## When to use BTW
|
||||
|
||||
Use `/btw` when you want:
|
||||
|
||||
- a quick clarification about the current work,
|
||||
- a factual side answer while a long run is still in progress,
|
||||
- a temporary answer that should not become part of future session context.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
/btw what file are we editing?
|
||||
/btw what does this error mean?
|
||||
/btw summarize the current task in one sentence
|
||||
/btw what is 17 * 19?
|
||||
```
|
||||
|
||||
## When not to use BTW
|
||||
|
||||
Do not use `/btw` when you want the answer to become part of the session's
|
||||
future working context.
|
||||
|
||||
In that case, ask normally in the main session instead of using BTW.
|
||||
|
||||
## Related
|
||||
|
||||
- [Slash commands](/tools/slash-commands)
|
||||
- [Thinking Levels](/tools/thinking)
|
||||
- [Session](/concepts/session)
|
||||
|
|
@ -76,7 +76,7 @@ Text + native (when enabled):
|
|||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/btw <question>` (ask a quick side question about the current session without changing future session context)
|
||||
- `/btw <question>` (ask an ephemeral side question about the current session without changing future session context; see [/tools/btw](/tools/btw))
|
||||
- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/session idle <duration|off>` (manage inactivity auto-unfocus for focused thread bindings)
|
||||
|
|
@ -224,3 +224,27 @@ Notes:
|
|||
- **`/stop`** targets the active chat session so it can abort the current run.
|
||||
- **Slack:** `channels.slack.slashCommand` is still supported for a single `/openclaw`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons.
|
||||
- Slack native exception: register `/agentstatus` (not `/status`) because Slack reserves `/status`. Text `/status` still works in Slack messages.
|
||||
|
||||
## BTW side questions
|
||||
|
||||
`/btw` is a quick **side question** about the current session.
|
||||
|
||||
Unlike normal chat:
|
||||
|
||||
- it uses the current session as background context,
|
||||
- it runs as a separate **tool-less** one-shot call,
|
||||
- it does not change future session context,
|
||||
- it is not written to transcript history,
|
||||
- it is delivered as a live side result instead of a normal assistant message.
|
||||
|
||||
That makes `/btw` useful when you want a temporary clarification while the main
|
||||
task keeps going.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
/btw what are we doing right now?
|
||||
```
|
||||
|
||||
See [BTW Side Questions](/tools/btw) for the full behavior and client UX
|
||||
details.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
type ChannelPlugin,
|
||||
type ResolvedDiscordAccount,
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
|
||||
type DiscordSendFn = ReturnType<
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
|
||||
import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
|
||||
import { normalizeDiscordOutboundTarget } from "./normalize.js";
|
||||
import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js";
|
||||
|
|
|
|||
|
|
@ -510,4 +510,50 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers streaming after start() throws (HTTP 400)", async () => {
|
||||
const errorMock = vi.fn();
|
||||
let shouldFailStart = true;
|
||||
|
||||
// Intercept streaming instance creation to make first start() reject
|
||||
const origPush = streamingInstances.push;
|
||||
streamingInstances.push = function (this: any[], ...args: any[]) {
|
||||
if (shouldFailStart) {
|
||||
args[0].start = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Create card request failed with HTTP 400"));
|
||||
shouldFailStart = false;
|
||||
}
|
||||
return origPush.apply(this, args);
|
||||
} as any;
|
||||
|
||||
try {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: errorMock } as never,
|
||||
chatId: "oc_chat",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
|
||||
// First deliver with markdown triggers startStreaming - which will fail
|
||||
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "block" });
|
||||
|
||||
// Wait for the async error to propagate
|
||||
await vi.waitFor(() => {
|
||||
expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed"));
|
||||
});
|
||||
|
||||
// Second deliver should create a NEW streaming session (not stuck)
|
||||
await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" });
|
||||
|
||||
// Two instances created: first failed, second succeeded and closed
|
||||
expect(streamingInstances).toHaveLength(2);
|
||||
expect(streamingInstances[1].start).toHaveBeenCalled();
|
||||
expect(streamingInstances[1].close).toHaveBeenCalled();
|
||||
} finally {
|
||||
streamingInstances.push = origPush;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|||
} catch (error) {
|
||||
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
|
||||
streaming = null;
|
||||
streamingStartPromise = null; // allow retry on next deliver
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
type ChannelPlugin,
|
||||
type ResolvedIMessageAccount,
|
||||
} from "openclaw/plugin-sdk/imessage";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
|
||||
import { getIMessageRuntime } from "./runtime.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/msteams";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { createMSTeamsPollStoreFs } from "./polls.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
type ChannelPlugin,
|
||||
type ResolvedSignalAccount,
|
||||
} from "openclaw/plugin-sdk/signal";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { getSignalRuntime } from "./runtime.js";
|
||||
|
||||
const signalMessageActions: ChannelMessageActionAdapter = {
|
||||
|
|
|
|||
|
|
@ -74,7 +74,10 @@ function createAutoAbortController() {
|
|||
}
|
||||
|
||||
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider(opts);
|
||||
return monitorSignalProvider({
|
||||
config: config as OpenClawConfig,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
async function receiveSignalPayloads(params: {
|
||||
|
|
@ -304,7 +307,9 @@ describe("monitorSignalProvider tool results", () => {
|
|||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
|
|
@ -460,8 +465,9 @@ describe("monitorSignalProvider tool results", () => {
|
|||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(updateLastRouteMock).toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resend pairing code when a request is already pending", async () => {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import {
|
|||
type ChannelPlugin,
|
||||
type ResolvedSlackAccount,
|
||||
} from "openclaw/plugin-sdk/slack";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
|
||||
import { getSlackRuntime } from "./runtime.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ type SlackProviderMonitor = (params: {
|
|||
botToken: string;
|
||||
appToken: string;
|
||||
abortSignal: AbortSignal;
|
||||
config?: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
type SlackTestState = {
|
||||
|
|
@ -49,14 +50,51 @@ type SlackClient = {
|
|||
};
|
||||
};
|
||||
|
||||
export const getSlackHandlers = () =>
|
||||
(
|
||||
globalThis as {
|
||||
__slackHandlers?: Map<string, SlackHandler>;
|
||||
}
|
||||
).__slackHandlers;
|
||||
export const getSlackHandlers = () => ensureSlackTestRuntime().handlers;
|
||||
|
||||
export const getSlackClient = () => (globalThis as { __slackClient?: SlackClient }).__slackClient;
|
||||
export const getSlackClient = () => ensureSlackTestRuntime().client;
|
||||
|
||||
function ensureSlackTestRuntime(): {
|
||||
handlers: Map<string, SlackHandler>;
|
||||
client: SlackClient;
|
||||
} {
|
||||
const globalState = globalThis as {
|
||||
__slackHandlers?: Map<string, SlackHandler>;
|
||||
__slackClient?: SlackClient;
|
||||
};
|
||||
if (!globalState.__slackHandlers) {
|
||||
globalState.__slackHandlers = new Map<string, SlackHandler>();
|
||||
}
|
||||
if (!globalState.__slackClient) {
|
||||
globalState.__slackClient = {
|
||||
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
|
||||
conversations: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
channel: { name: "dm", is_im: true },
|
||||
}),
|
||||
replies: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
history: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
users: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
user: { profile: { display_name: "Ada" } },
|
||||
}),
|
||||
},
|
||||
assistant: {
|
||||
threads: {
|
||||
setStatus: vi.fn().mockResolvedValue({ ok: true }),
|
||||
},
|
||||
},
|
||||
reactions: {
|
||||
add: (...args: unknown[]) => slackTestState.reactMock(...args),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
handlers: globalState.__slackHandlers,
|
||||
client: globalState.__slackClient,
|
||||
};
|
||||
}
|
||||
|
||||
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
|
|
@ -78,6 +116,7 @@ export function startSlackMonitor(
|
|||
botToken: opts?.botToken ?? "bot-token",
|
||||
appToken: opts?.appToken ?? "app-token",
|
||||
abortSignal: controller.signal,
|
||||
config: slackTestState.config,
|
||||
});
|
||||
return { controller, run };
|
||||
}
|
||||
|
|
@ -193,34 +232,9 @@ vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
|
|||
});
|
||||
|
||||
vi.mock("@slack/bolt", () => {
|
||||
const handlers = new Map<string, SlackHandler>();
|
||||
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers;
|
||||
const client = {
|
||||
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
|
||||
conversations: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
channel: { name: "dm", is_im: true },
|
||||
}),
|
||||
replies: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
history: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
users: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
user: { profile: { display_name: "Ada" } },
|
||||
}),
|
||||
},
|
||||
assistant: {
|
||||
threads: {
|
||||
setStatus: vi.fn().mockResolvedValue({ ok: true }),
|
||||
},
|
||||
},
|
||||
reactions: {
|
||||
add: (...args: unknown[]) => slackTestState.reactMock(...args),
|
||||
},
|
||||
};
|
||||
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
|
||||
const { handlers, client: slackClient } = ensureSlackTestRuntime();
|
||||
class App {
|
||||
client = client;
|
||||
client = slackClient;
|
||||
event(name: string, handler: SlackHandler) {
|
||||
handlers.set(name, handler);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HISTORY_CONTEXT_MARKER } from "../../../src/auto-reply/reply/history.js";
|
||||
import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js";
|
||||
import { CURRENT_MESSAGE_MARKER } from "../../../src/auto-reply/reply/mentions.js";
|
||||
import {
|
||||
defaultSlackTestConfig,
|
||||
getSlackTestState,
|
||||
|
|
@ -15,6 +12,9 @@ import {
|
|||
stopSlackMonitor,
|
||||
} from "./monitor.test-helpers.js";
|
||||
|
||||
const { resetInboundDedupe } = await import("../../../src/auto-reply/reply/inbound-dedupe.js");
|
||||
const { HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js");
|
||||
const { CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js");
|
||||
const { monitorSlackProvider } = await import("./monitor.js");
|
||||
|
||||
const slackTestState = getSlackTestState();
|
||||
|
|
@ -209,7 +209,9 @@ describe("monitorSlackProvider tool results", () => {
|
|||
|
||||
function expectSingleSendWithThread(threadTs: string | undefined) {
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs });
|
||||
expect((sendMock.mock.calls[0]?.[2] as { threadTs?: string } | undefined)?.threadTs).toBe(
|
||||
threadTs,
|
||||
);
|
||||
}
|
||||
|
||||
async function runDefaultMessageAndExpectSentText(expectedText: string) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { resolveStorePath, updateLastRoute } from "../../../../../src/config/ses
|
|||
import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js";
|
||||
import { resolveAgentOutboundIdentity } from "../../../../../src/infra/outbound/identity.js";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../../src/security/dm-policy-shared.js";
|
||||
import { reactSlackMessage, removeSlackReaction } from "../../actions.js";
|
||||
import { editSlackMessage, reactSlackMessage, removeSlackReaction } from "../../actions.js";
|
||||
import { createSlackDraftStream } from "../../draft-stream.js";
|
||||
import { normalizeSlackOutboundText } from "../../format.js";
|
||||
import { recordSlackThreadParticipation } from "../../sent-thread-cache.js";
|
||||
|
|
@ -24,7 +24,12 @@ import type { SlackStreamSession } from "../../streaming.js";
|
|||
import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js";
|
||||
import { resolveSlackThreadTargets } from "../../threading.js";
|
||||
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
|
||||
import { createSlackReplyDeliveryPlan, deliverReplies, resolveSlackThreadTs } from "../replies.js";
|
||||
import {
|
||||
createSlackReplyDeliveryPlan,
|
||||
deliverReplies,
|
||||
readSlackReplyBlocks,
|
||||
resolveSlackThreadTs,
|
||||
} from "../replies.js";
|
||||
import type { PreparedSlackMessage } from "./types.js";
|
||||
|
||||
function hasMedia(payload: ReplyPayload): boolean {
|
||||
|
|
@ -245,7 +250,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||
};
|
||||
|
||||
const deliverWithStreaming = async (payload: ReplyPayload): Promise<void> => {
|
||||
if (streamFailed || hasMedia(payload) || !payload.text?.trim()) {
|
||||
if (
|
||||
streamFailed ||
|
||||
hasMedia(payload) ||
|
||||
readSlackReplyBlocks(payload)?.length ||
|
||||
!payload.text?.trim()
|
||||
) {
|
||||
await deliverNormally(payload, streamSession?.threadTs);
|
||||
return;
|
||||
}
|
||||
|
|
@ -302,28 +312,34 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||
}
|
||||
|
||||
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
|
||||
const slackBlocks = readSlackReplyBlocks(payload);
|
||||
const draftMessageId = draftStream?.messageId();
|
||||
const draftChannelId = draftStream?.channelId();
|
||||
const finalText = payload.text;
|
||||
const finalText = payload.text ?? "";
|
||||
const trimmedFinalText = finalText.trim();
|
||||
const canFinalizeViaPreviewEdit =
|
||||
previewStreamingEnabled &&
|
||||
streamMode !== "status_final" &&
|
||||
mediaCount === 0 &&
|
||||
!payload.isError &&
|
||||
typeof finalText === "string" &&
|
||||
finalText.trim().length > 0 &&
|
||||
(trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) &&
|
||||
typeof draftMessageId === "string" &&
|
||||
typeof draftChannelId === "string";
|
||||
|
||||
if (canFinalizeViaPreviewEdit) {
|
||||
draftStream?.stop();
|
||||
try {
|
||||
await ctx.app.client.chat.update({
|
||||
token: ctx.botToken,
|
||||
channel: draftChannelId,
|
||||
ts: draftMessageId,
|
||||
text: normalizeSlackOutboundText(finalText.trim()),
|
||||
});
|
||||
await editSlackMessage(
|
||||
draftChannelId,
|
||||
draftMessageId,
|
||||
normalizeSlackOutboundText(trimmedFinalText),
|
||||
{
|
||||
token: ctx.botToken,
|
||||
accountId: account.accountId,
|
||||
client: ctx.app.client,
|
||||
...(slackBlocks?.length ? { blocks: slackBlocks } : {}),
|
||||
},
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
|
|
|
|||
|
|
@ -53,4 +53,45 @@ describe("deliverReplies identity passthrough", () => {
|
|||
expect(sendMock).toHaveBeenCalledOnce();
|
||||
expect(sendMock.mock.calls[0][2]).not.toHaveProperty("identity");
|
||||
});
|
||||
|
||||
it("delivers block-only replies through to sendMessageSlack", async () => {
|
||||
sendMock.mockResolvedValue(undefined);
|
||||
const blocks = [
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: { type: "plain_text", text: "Option A" },
|
||||
value: "reply_1_option_a",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await deliverReplies(
|
||||
baseParams({
|
||||
replies: [
|
||||
{
|
||||
text: "",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendMock).toHaveBeenCalledOnce();
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
"C123",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
blocks,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,9 +5,22 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../../src/auto-repl
|
|||
import type { ReplyPayload } from "../../../../src/auto-reply/types.js";
|
||||
import type { MarkdownTableMode } from "../../../../src/config/types.base.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import { parseSlackBlocksInput } from "../blocks-input.js";
|
||||
import { markdownToSlackMrkdwnChunks } from "../format.js";
|
||||
import { sendMessageSlack, type SlackSendIdentity } from "../send.js";
|
||||
|
||||
export function readSlackReplyBlocks(payload: ReplyPayload) {
|
||||
const slackData = payload.channelData?.slack;
|
||||
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
|
|
@ -26,19 +39,24 @@ export async function deliverReplies(params: {
|
|||
const threadTs = inlineReplyToId ?? params.replyThreadTs;
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
if (!text && mediaList.length === 0) {
|
||||
const slackBlocks = readSlackReplyBlocks(payload);
|
||||
if (!text && mediaList.length === 0 && !slackBlocks?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
|
||||
if (!trimmed && !slackBlocks?.length) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed && isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
|
||||
continue;
|
||||
}
|
||||
await sendMessageSlack(params.target, trimmed, {
|
||||
token: params.token,
|
||||
threadTs,
|
||||
accountId: params.accountId,
|
||||
...(slackBlocks?.length ? { blocks: slackBlocks } : {}),
|
||||
...(params.identity ? { identity: params.identity } : {}),
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
|
||||
const { defaultRouteConfig } = vi.hoisted(() => ({
|
||||
defaultRouteConfig: {
|
||||
|
|
@ -20,6 +19,9 @@ vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
const { buildTelegramMessageContextForTest } =
|
||||
await import("./bot-message-context.test-harness.js");
|
||||
|
||||
describe("buildTelegramMessageContext per-topic agentId routing", () => {
|
||||
function buildForumMessage(threadId = 3) {
|
||||
return {
|
||||
|
|
@ -98,7 +100,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => {
|
|||
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:");
|
||||
});
|
||||
|
||||
it("falls back to default agent when topic agentId does not exist", async () => {
|
||||
it("preserves an unknown topic agentId in the session key", async () => {
|
||||
vi.mocked(loadConfig).mockReturnValue({
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "zu" }],
|
||||
|
|
@ -110,7 +112,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => {
|
|||
const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } });
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:");
|
||||
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:ghost:");
|
||||
});
|
||||
|
||||
it("routes DM topic to specific agent when agentId is set", async () => {
|
||||
|
|
|
|||
|
|
@ -102,73 +102,81 @@ vi.mock("./sent-message-cache.js", () => ({
|
|||
clearSentMessageCache: vi.fn(),
|
||||
}));
|
||||
|
||||
export const useSpy: MockFn<(arg: unknown) => void> = vi.fn();
|
||||
export const middlewareUseSpy: AnyMock = vi.fn();
|
||||
export const onSpy: AnyMock = vi.fn();
|
||||
export const stopSpy: AnyMock = vi.fn();
|
||||
export const commandSpy: AnyMock = vi.fn();
|
||||
export const botCtorSpy: AnyMock = vi.fn();
|
||||
export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn(async () => undefined);
|
||||
export const sendChatActionSpy: AnyMock = vi.fn();
|
||||
export const editMessageTextSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 }));
|
||||
export const editMessageReplyMarkupSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 }));
|
||||
export const sendMessageDraftSpy: AnyAsyncMock = vi.fn(async () => true);
|
||||
export const setMessageReactionSpy: AnyAsyncMock = vi.fn(async () => undefined);
|
||||
export const setMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined);
|
||||
export const getMeSpy: AnyAsyncMock = vi.fn(async () => ({
|
||||
username: "openclaw_bot",
|
||||
has_topics_enabled: true,
|
||||
// All spy variables used inside vi.mock("grammy", ...) must be created via
|
||||
// vi.hoisted() so they are available when the hoisted factory runs, regardless
|
||||
// of module evaluation order across different test files.
|
||||
const grammySpies = vi.hoisted(() => ({
|
||||
useSpy: vi.fn() as MockFn<(arg: unknown) => void>,
|
||||
middlewareUseSpy: vi.fn() as AnyMock,
|
||||
onSpy: vi.fn() as AnyMock,
|
||||
stopSpy: vi.fn() as AnyMock,
|
||||
commandSpy: vi.fn() as AnyMock,
|
||||
botCtorSpy: vi.fn() as AnyMock,
|
||||
answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock,
|
||||
sendChatActionSpy: vi.fn() as AnyMock,
|
||||
editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
|
||||
editMessageReplyMarkupSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
|
||||
sendMessageDraftSpy: vi.fn(async () => true) as AnyAsyncMock,
|
||||
setMessageReactionSpy: vi.fn(async () => undefined) as AnyAsyncMock,
|
||||
setMyCommandsSpy: vi.fn(async () => undefined) as AnyAsyncMock,
|
||||
getMeSpy: vi.fn(async () => ({
|
||||
username: "openclaw_bot",
|
||||
has_topics_enabled: true,
|
||||
})) as AnyAsyncMock,
|
||||
sendMessageSpy: vi.fn(async () => ({ message_id: 77 })) as AnyAsyncMock,
|
||||
sendAnimationSpy: vi.fn(async () => ({ message_id: 78 })) as AnyAsyncMock,
|
||||
sendPhotoSpy: vi.fn(async () => ({ message_id: 79 })) as AnyAsyncMock,
|
||||
getFileSpy: vi.fn(async () => ({ file_path: "media/file.jpg" })) as AnyAsyncMock,
|
||||
}));
|
||||
export const sendMessageSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 77 }));
|
||||
export const sendAnimationSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 78 }));
|
||||
export const sendPhotoSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 79 }));
|
||||
export const getFileSpy: AnyAsyncMock = vi.fn(async () => ({ file_path: "media/file.jpg" }));
|
||||
|
||||
type ApiStub = {
|
||||
config: { use: (arg: unknown) => void };
|
||||
answerCallbackQuery: typeof answerCallbackQuerySpy;
|
||||
sendChatAction: typeof sendChatActionSpy;
|
||||
editMessageText: typeof editMessageTextSpy;
|
||||
editMessageReplyMarkup: typeof editMessageReplyMarkupSpy;
|
||||
sendMessageDraft: typeof sendMessageDraftSpy;
|
||||
setMessageReaction: typeof setMessageReactionSpy;
|
||||
setMyCommands: typeof setMyCommandsSpy;
|
||||
getMe: typeof getMeSpy;
|
||||
sendMessage: typeof sendMessageSpy;
|
||||
sendAnimation: typeof sendAnimationSpy;
|
||||
sendPhoto: typeof sendPhotoSpy;
|
||||
getFile: typeof getFileSpy;
|
||||
};
|
||||
|
||||
const apiStub: ApiStub = {
|
||||
config: { use: useSpy },
|
||||
answerCallbackQuery: answerCallbackQuerySpy,
|
||||
sendChatAction: sendChatActionSpy,
|
||||
editMessageText: editMessageTextSpy,
|
||||
editMessageReplyMarkup: editMessageReplyMarkupSpy,
|
||||
sendMessageDraft: sendMessageDraftSpy,
|
||||
setMessageReaction: setMessageReactionSpy,
|
||||
setMyCommands: setMyCommandsSpy,
|
||||
getMe: getMeSpy,
|
||||
sendMessage: sendMessageSpy,
|
||||
sendAnimation: sendAnimationSpy,
|
||||
sendPhoto: sendPhotoSpy,
|
||||
getFile: getFileSpy,
|
||||
};
|
||||
export const {
|
||||
useSpy,
|
||||
middlewareUseSpy,
|
||||
onSpy,
|
||||
stopSpy,
|
||||
commandSpy,
|
||||
botCtorSpy,
|
||||
answerCallbackQuerySpy,
|
||||
sendChatActionSpy,
|
||||
editMessageTextSpy,
|
||||
editMessageReplyMarkupSpy,
|
||||
sendMessageDraftSpy,
|
||||
setMessageReactionSpy,
|
||||
setMyCommandsSpy,
|
||||
getMeSpy,
|
||||
sendMessageSpy,
|
||||
sendAnimationSpy,
|
||||
sendPhotoSpy,
|
||||
getFileSpy,
|
||||
} = grammySpies;
|
||||
|
||||
vi.mock("grammy", () => ({
|
||||
Bot: class {
|
||||
api = apiStub;
|
||||
use = middlewareUseSpy;
|
||||
on = onSpy;
|
||||
stop = stopSpy;
|
||||
command = commandSpy;
|
||||
api = {
|
||||
config: { use: grammySpies.useSpy },
|
||||
answerCallbackQuery: grammySpies.answerCallbackQuerySpy,
|
||||
sendChatAction: grammySpies.sendChatActionSpy,
|
||||
editMessageText: grammySpies.editMessageTextSpy,
|
||||
editMessageReplyMarkup: grammySpies.editMessageReplyMarkupSpy,
|
||||
sendMessageDraft: grammySpies.sendMessageDraftSpy,
|
||||
setMessageReaction: grammySpies.setMessageReactionSpy,
|
||||
setMyCommands: grammySpies.setMyCommandsSpy,
|
||||
getMe: grammySpies.getMeSpy,
|
||||
sendMessage: grammySpies.sendMessageSpy,
|
||||
sendAnimation: grammySpies.sendAnimationSpy,
|
||||
sendPhoto: grammySpies.sendPhotoSpy,
|
||||
getFile: grammySpies.getFileSpy,
|
||||
};
|
||||
use = grammySpies.middlewareUseSpy;
|
||||
on = grammySpies.onSpy;
|
||||
stop = grammySpies.stopSpy;
|
||||
command = grammySpies.commandSpy;
|
||||
catch = vi.fn();
|
||||
constructor(
|
||||
public token: string,
|
||||
public options?: { client?: { fetch?: typeof fetch } },
|
||||
) {
|
||||
botCtorSpy(token, options);
|
||||
grammySpies.botCtorSpy(token, options);
|
||||
}
|
||||
},
|
||||
InputFile: class {},
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ import {
|
|||
throttlerSpy,
|
||||
useSpy,
|
||||
} from "./bot.create-telegram-bot.test-harness.js";
|
||||
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
|
||||
// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals.
|
||||
const { createTelegramBot, getTelegramSequentialKey } = await import("./bot.js");
|
||||
|
||||
const loadConfig = getLoadConfigMock();
|
||||
const loadWebMedia = getLoadWebMediaMock();
|
||||
const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();
|
||||
|
|
@ -813,7 +815,7 @@ describe("createTelegramBot", () => {
|
|||
expect(payload.SessionKey).toBe("agent:opie:main");
|
||||
});
|
||||
|
||||
it("drops non-default account DMs without explicit bindings", async () => {
|
||||
it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
|
|
@ -842,7 +844,10 @@ describe("createTelegramBot", () => {
|
|||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0]?.[0];
|
||||
expect(payload.AccountId).toBe("opie");
|
||||
expect(payload.SessionKey).toContain("agent:main:telegram:opie:");
|
||||
});
|
||||
|
||||
it("applies group mention overrides and fallback behavior", async () => {
|
||||
|
|
@ -1909,9 +1914,8 @@ describe("createTelegramBot", () => {
|
|||
await flushTimer?.();
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] };
|
||||
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string };
|
||||
expect(payload.Body).toContain("album caption");
|
||||
expect(payload.MediaPaths).toHaveLength(2);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
|
|
@ -2137,9 +2141,8 @@ describe("createTelegramBot", () => {
|
|||
await flushTimer?.();
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] };
|
||||
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string };
|
||||
expect(payload.Body).toContain("partial album");
|
||||
expect(payload.MediaPaths).toHaveLength(1);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import { rm } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
} from "../../../src/auto-reply/commands-registry.js";
|
||||
import { loadSessionStore } from "../../../src/config/sessions.js";
|
||||
import { normalizeTelegramCommandName } from "../../../src/config/telegram-custom-commands.js";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
|
||||
import {
|
||||
|
|
@ -25,7 +19,14 @@ import {
|
|||
setMyCommandsSpy,
|
||||
wasSentByBot,
|
||||
} from "./bot.create-telegram-bot.test-harness.js";
|
||||
import { createTelegramBot } from "./bot.js";
|
||||
|
||||
// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals.
|
||||
const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } =
|
||||
await import("../../../src/auto-reply/commands-registry.js");
|
||||
const { loadSessionStore } = await import("../../../src/config/sessions.js");
|
||||
const { normalizeTelegramCommandName } =
|
||||
await import("../../../src/config/telegram-custom-commands.js");
|
||||
const { createTelegramBot } = await import("./bot.js");
|
||||
|
||||
const loadConfig = getLoadConfigMock();
|
||||
const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();
|
||||
|
|
@ -833,8 +834,6 @@ describe("createTelegramBot", () => {
|
|||
ReplyToBody?: string;
|
||||
};
|
||||
expect(payload.ReplyToBody).toBe("<media:image>");
|
||||
expect(payload.MediaPaths).toHaveLength(1);
|
||||
expect(payload.MediaPath).toBe(payload.MediaPaths?.[0]);
|
||||
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import {
|
|||
import {
|
||||
type OutboundSendDeps,
|
||||
resolveOutboundSendDep,
|
||||
} from "../../../src/infra/outbound/deliver.js";
|
||||
} from "../../../src/infra/outbound/send-deps.js";
|
||||
import { getTelegramRuntime } from "./runtime.js";
|
||||
|
||||
type TelegramSendFn = ReturnType<
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types
|
|||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../src/infra/outbound/deliver.js";
|
||||
} from "../../../src/infra/outbound/send-deps.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { markdownToTelegramHtmlChunks } from "./format.js";
|
||||
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
|
||||
|
|
|
|||
|
|
@ -775,10 +775,11 @@ describe("sendMessageTelegram", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("retries on transient errors with retry_after", async () => {
|
||||
it("retries pre-connect send errors and honors retry_after when present", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chatId = "123";
|
||||
const err = Object.assign(new Error("429"), {
|
||||
const err = Object.assign(new Error("getaddrinfo ENOTFOUND api.telegram.org"), {
|
||||
code: "ENOTFOUND",
|
||||
parameters: { retry_after: 0.5 },
|
||||
});
|
||||
const sendMessage = vi
|
||||
|
|
@ -823,29 +824,25 @@ describe("sendMessageTelegram", () => {
|
|||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries when grammY network envelope message includes failed-after wording", async () => {
|
||||
it("does not retry generic grammY failed-after envelopes for non-idempotent sends", async () => {
|
||||
const chatId = "123";
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new Error("Network request for 'sendMessage' failed after 1 attempts."),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
message_id: 7,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
);
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
const result = await sendMessageTelegram(chatId, "hi", {
|
||||
token: "tok",
|
||||
api,
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ messageId: "7", chatId });
|
||||
await expect(
|
||||
sendMessageTelegram(chatId, "hi", {
|
||||
token: "tok",
|
||||
api,
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
}),
|
||||
).rejects.toThrow(/failed after 1 attempts/i);
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends GIF media as animation", async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { chunkText } from "../../../src/auto-reply/chunk.js";
|
|||
import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||
import { shouldLogVerbose } from "../../../src/globals.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js";
|
||||
import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,18 @@ export type ParsedReleaseVersion = {
|
|||
date: Date;
|
||||
};
|
||||
|
||||
export type ParsedReleaseTag = {
|
||||
version: string;
|
||||
packageVersion: string;
|
||||
channel: "stable" | "beta";
|
||||
correctionNumber?: number;
|
||||
date: Date;
|
||||
};
|
||||
|
||||
const STABLE_VERSION_REGEX = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)$/;
|
||||
const BETA_VERSION_REGEX =
|
||||
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-beta\.(?<beta>[1-9]\d*)$/;
|
||||
const CORRECTION_TAG_REGEX = /^(?<base>\d{4}\.[1-9]\d?\.[1-9]\d?)-(?<correction>[1-9]\d*)$/;
|
||||
const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
|
||||
const MAX_CALVER_DISTANCE_DAYS = 2;
|
||||
|
||||
|
|
@ -107,6 +116,49 @@ export function parseReleaseVersion(version: string): ParsedReleaseVersion | nul
|
|||
return null;
|
||||
}
|
||||
|
||||
export function parseReleaseTagVersion(version: string): ParsedReleaseTag | null {
|
||||
const trimmed = version.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedVersion = parseReleaseVersion(trimmed);
|
||||
if (parsedVersion !== null) {
|
||||
return {
|
||||
version: trimmed,
|
||||
packageVersion: parsedVersion.version,
|
||||
channel: parsedVersion.channel,
|
||||
date: parsedVersion.date,
|
||||
correctionNumber: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const correctionMatch = CORRECTION_TAG_REGEX.exec(trimmed);
|
||||
if (!correctionMatch?.groups) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseVersion = correctionMatch.groups.base ?? "";
|
||||
const parsedBaseVersion = parseReleaseVersion(baseVersion);
|
||||
const correctionNumber = Number.parseInt(correctionMatch.groups.correction ?? "", 10);
|
||||
if (
|
||||
parsedBaseVersion === null ||
|
||||
parsedBaseVersion.channel !== "stable" ||
|
||||
!Number.isInteger(correctionNumber) ||
|
||||
correctionNumber < 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: trimmed,
|
||||
packageVersion: parsedBaseVersion.version,
|
||||
channel: "stable",
|
||||
correctionNumber,
|
||||
date: parsedBaseVersion.date,
|
||||
};
|
||||
}
|
||||
|
||||
function startOfUtcDay(date: Date): number {
|
||||
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
}
|
||||
|
|
@ -180,19 +232,25 @@ export function collectReleaseTagErrors(params: {
|
|||
}
|
||||
|
||||
const tagVersion = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag;
|
||||
const parsedTag = parseReleaseVersion(tagVersion);
|
||||
const parsedTag = parseReleaseTagVersion(tagVersion);
|
||||
if (parsedTag === null) {
|
||||
errors.push(
|
||||
`Release tag must match vYYYY.M.D or vYYYY.M.D-beta.N; found "${releaseTag || "<missing>"}".`,
|
||||
`Release tag must match vYYYY.M.D, vYYYY.M.D-beta.N, or fallback correction tag vYYYY.M.D-N; found "${releaseTag || "<missing>"}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const expectedTag = packageVersion ? `v${packageVersion}` : "";
|
||||
if (releaseTag !== expectedTag) {
|
||||
const expectedTag = packageVersion ? `v${packageVersion}` : "<missing>";
|
||||
const expectedCorrectionTag = parsedVersion?.channel === "stable" ? `${expectedTag}-N` : null;
|
||||
const matchesExpectedTag =
|
||||
parsedTag !== null &&
|
||||
parsedVersion !== null &&
|
||||
parsedTag.packageVersion === parsedVersion.version &&
|
||||
parsedTag.channel === parsedVersion.channel;
|
||||
if (!matchesExpectedTag) {
|
||||
errors.push(
|
||||
`Release tag ${releaseTag || "<missing>"} does not match package.json version ${
|
||||
packageVersion || "<missing>"
|
||||
}; expected ${expectedTag || "<missing>"}.`,
|
||||
}; expected ${expectedCorrectionTag ? `${expectedTag} or ${expectedCorrectionTag}` : expectedTag}.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { channelTestPrefixes } from "../vitest.channel-paths.mjs";
|
||||
|
||||
// On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell
|
||||
// (especially under GitHub Actions + Git Bash). Use `shell: true` and let the shell resolve pnpm.
|
||||
|
|
@ -303,13 +304,6 @@ const passthroughRequiresSingleRun = passthroughOptionArgs.some((arg) => {
|
|||
const [flag] = arg.split("=", 1);
|
||||
return SINGLE_RUN_ONLY_FLAGS.has(flag);
|
||||
});
|
||||
const channelPrefixes = [
|
||||
"extensions/telegram/",
|
||||
"extensions/discord/",
|
||||
"extensions/whatsapp/",
|
||||
"src/browser/",
|
||||
"src/line/",
|
||||
];
|
||||
const baseConfigPrefixes = ["src/agents/", "src/auto-reply/", "src/commands/", "test/", "ui/"];
|
||||
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
|
||||
const walkTestFiles = (rootDir) => {
|
||||
|
|
@ -353,15 +347,15 @@ const inferTarget = (fileFilter) => {
|
|||
if (fileFilter.endsWith(".e2e.test.ts")) {
|
||||
return { owner: "e2e", isolated };
|
||||
}
|
||||
if (channelTestPrefixes.some((prefix) => fileFilter.startsWith(prefix))) {
|
||||
return { owner: "channels", isolated };
|
||||
}
|
||||
if (fileFilter.startsWith("extensions/")) {
|
||||
return { owner: "extensions", isolated };
|
||||
}
|
||||
if (fileFilter.startsWith("src/gateway/")) {
|
||||
return { owner: "gateway", isolated };
|
||||
}
|
||||
if (channelPrefixes.some((prefix) => fileFilter.startsWith(prefix))) {
|
||||
return { owner: "channels", isolated };
|
||||
}
|
||||
if (baseConfigPrefixes.some((prefix) => fileFilter.startsWith(prefix))) {
|
||||
return { owner: "base", isolated };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import type { SessionEntry } from "../config/sessions.js";
|
||||
|
||||
const streamSimpleMock = vi.fn();
|
||||
const appendCustomEntryMock = vi.fn();
|
||||
const buildSessionContextMock = vi.fn();
|
||||
const getLeafEntryMock = vi.fn();
|
||||
const branchMock = vi.fn();
|
||||
|
|
@ -13,11 +12,8 @@ const discoverModelsMock = vi.fn();
|
|||
const resolveModelWithRegistryMock = vi.fn();
|
||||
const getApiKeyForModelMock = vi.fn();
|
||||
const requireApiKeyMock = vi.fn();
|
||||
const acquireSessionWriteLockMock = vi.fn();
|
||||
const resolveSessionAuthProfileOverrideMock = vi.fn();
|
||||
const getActiveEmbeddedRunSnapshotMock = vi.fn();
|
||||
const waitForEmbeddedPiRunEndMock = vi.fn();
|
||||
const diagWarnMock = vi.fn();
|
||||
const diagDebugMock = vi.fn();
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
|
|
@ -31,7 +27,6 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|||
branch: branchMock,
|
||||
resetLeaf: resetLeafMock,
|
||||
buildSessionContext: buildSessionContextMock,
|
||||
appendCustomEntry: appendCustomEntryMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
|
@ -54,13 +49,8 @@ vi.mock("./model-auth.js", () => ({
|
|||
requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./session-write-lock.js", () => ({
|
||||
acquireSessionWriteLock: (...args: unknown[]) => acquireSessionWriteLockMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./pi-embedded-runner/runs.js", () => ({
|
||||
getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args),
|
||||
waitForEmbeddedPiRunEnd: (...args: unknown[]) => waitForEmbeddedPiRunEndMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/session-override.js", () => ({
|
||||
|
|
@ -70,12 +60,11 @@ vi.mock("./auth-profiles/session-override.js", () => ({
|
|||
|
||||
vi.mock("../logging/diagnostic.js", () => ({
|
||||
diagnosticLogger: {
|
||||
warn: (...args: unknown[]) => diagWarnMock(...args),
|
||||
debug: (...args: unknown[]) => diagDebugMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const { BTW_CUSTOM_TYPE, runBtwSideQuestion } = await import("./btw.js");
|
||||
const { runBtwSideQuestion } = await import("./btw.js");
|
||||
|
||||
function makeAsyncEvents(events: unknown[]) {
|
||||
return {
|
||||
|
|
@ -99,7 +88,6 @@ function createSessionEntry(overrides: Partial<SessionEntry> = {}): SessionEntry
|
|||
describe("runBtwSideQuestion", () => {
|
||||
beforeEach(() => {
|
||||
streamSimpleMock.mockReset();
|
||||
appendCustomEntryMock.mockReset();
|
||||
buildSessionContextMock.mockReset();
|
||||
getLeafEntryMock.mockReset();
|
||||
branchMock.mockReset();
|
||||
|
|
@ -110,11 +98,8 @@ describe("runBtwSideQuestion", () => {
|
|||
resolveModelWithRegistryMock.mockReset();
|
||||
getApiKeyForModelMock.mockReset();
|
||||
requireApiKeyMock.mockReset();
|
||||
acquireSessionWriteLockMock.mockReset();
|
||||
resolveSessionAuthProfileOverrideMock.mockReset();
|
||||
getActiveEmbeddedRunSnapshotMock.mockReset();
|
||||
waitForEmbeddedPiRunEndMock.mockReset();
|
||||
diagWarnMock.mockReset();
|
||||
diagDebugMock.mockReset();
|
||||
|
||||
buildSessionContextMock.mockReturnValue({
|
||||
|
|
@ -128,15 +113,11 @@ describe("runBtwSideQuestion", () => {
|
|||
});
|
||||
getApiKeyForModelMock.mockResolvedValue({ apiKey: "secret", mode: "api-key", source: "test" });
|
||||
requireApiKeyMock.mockReturnValue("secret");
|
||||
acquireSessionWriteLockMock.mockResolvedValue({
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
resolveSessionAuthProfileOverrideMock.mockResolvedValue("profile-1");
|
||||
getActiveEmbeddedRunSnapshotMock.mockReturnValue(undefined);
|
||||
waitForEmbeddedPiRunEndMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("streams blocks and persists a non-context custom entry", async () => {
|
||||
it("streams blocks without persisting BTW data to disk", async () => {
|
||||
const onBlockReply = vi.fn().mockResolvedValue(undefined);
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
|
|
@ -212,17 +193,6 @@ describe("runBtwSideQuestion", () => {
|
|||
text: "Side answer.",
|
||||
btw: { question: "What changed?" },
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(appendCustomEntryMock).toHaveBeenCalledWith(
|
||||
BTW_CUSTOM_TYPE,
|
||||
expect.objectContaining({
|
||||
question: "What changed?",
|
||||
answer: "Side answer.",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a final payload when block streaming is unavailable", async () => {
|
||||
|
|
@ -641,14 +611,7 @@ describe("runBtwSideQuestion", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("returns the BTW answer and retries transcript persistence after a session lock", async () => {
|
||||
acquireSessionWriteLockMock
|
||||
.mockRejectedValueOnce(
|
||||
new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
it("returns the BTW answer without appending transcript custom entries", async () => {
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
|
|
@ -688,26 +651,10 @@ describe("runBtwSideQuestion", () => {
|
|||
});
|
||||
|
||||
expect(result).toEqual({ text: "323" });
|
||||
await vi.waitFor(() => {
|
||||
expect(waitForEmbeddedPiRunEndMock).toHaveBeenCalledWith("session-1", 30000);
|
||||
expect(appendCustomEntryMock).toHaveBeenCalledWith(
|
||||
BTW_CUSTOM_TYPE,
|
||||
expect.objectContaining({
|
||||
question: "What is 17 * 19?",
|
||||
answer: "323",
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(buildSessionContextMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs deferred persistence failures through the diagnostic logger", async () => {
|
||||
acquireSessionWriteLockMock
|
||||
.mockRejectedValueOnce(
|
||||
new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new Error("session file locked (timeout 10000ms): pid=123 /tmp/session.lock"),
|
||||
);
|
||||
it("does not log transcript persistence warnings because BTW no longer writes to disk", async () => {
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
|
|
@ -747,11 +694,9 @@ describe("runBtwSideQuestion", () => {
|
|||
});
|
||||
|
||||
expect(result).toEqual({ text: "323" });
|
||||
await vi.waitFor(() => {
|
||||
expect(diagWarnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("btw transcript persistence skipped: sessionId=session-1"),
|
||||
);
|
||||
});
|
||||
expect(diagDebugMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("btw transcript persistence skipped"),
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes tool results from BTW context to avoid replaying raw tool output", async () => {
|
||||
|
|
|
|||
|
|
@ -21,19 +21,10 @@ import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
|
|||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
||||
import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js";
|
||||
import {
|
||||
getActiveEmbeddedRunSnapshot,
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "./pi-embedded-runner/runs.js";
|
||||
import { getActiveEmbeddedRunSnapshot } from "./pi-embedded-runner/runs.js";
|
||||
import { mapThinkingLevel } from "./pi-embedded-runner/utils.js";
|
||||
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
|
||||
import { stripToolResultDetails } from "./session-transcript-repair.js";
|
||||
import { acquireSessionWriteLock } from "./session-write-lock.js";
|
||||
|
||||
const BTW_CUSTOM_TYPE = "openclaw:btw";
|
||||
const BTW_PERSIST_TIMEOUT_MS = 250;
|
||||
const BTW_PERSIST_RETRY_WAIT_MS = 30_000;
|
||||
const BTW_PERSIST_RETRY_LOCK_MS = 10_000;
|
||||
|
||||
type SessionManagerLike = {
|
||||
getLeafEntry?: () => {
|
||||
|
|
@ -47,97 +38,6 @@ type SessionManagerLike = {
|
|||
buildSessionContext: () => { messages?: unknown[] };
|
||||
};
|
||||
|
||||
type BtwCustomEntryData = {
|
||||
timestamp: number;
|
||||
question: string;
|
||||
answer: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingLevel: ThinkLevel | "off";
|
||||
reasoningLevel: ReasoningLevel;
|
||||
sessionKey?: string;
|
||||
authProfileId?: string;
|
||||
authProfileIdSource?: "auto" | "user";
|
||||
usage?: unknown;
|
||||
};
|
||||
|
||||
async function appendBtwCustomEntry(params: {
|
||||
sessionFile: string;
|
||||
timeoutMs: number;
|
||||
entry: BtwCustomEntryData;
|
||||
}) {
|
||||
const lock = await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
timeoutMs: params.timeoutMs,
|
||||
allowReentrant: false,
|
||||
});
|
||||
try {
|
||||
const persisted = SessionManager.open(params.sessionFile);
|
||||
persisted.appendCustomEntry(BTW_CUSTOM_TYPE, params.entry);
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionLockError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes("session file locked");
|
||||
}
|
||||
|
||||
function deferBtwCustomEntryPersist(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
entry: BtwCustomEntryData;
|
||||
}) {
|
||||
void (async () => {
|
||||
try {
|
||||
await waitForEmbeddedPiRunEnd(params.sessionId, BTW_PERSIST_RETRY_WAIT_MS);
|
||||
await appendBtwCustomEntry({
|
||||
sessionFile: params.sessionFile,
|
||||
timeoutMs: BTW_PERSIST_RETRY_LOCK_MS,
|
||||
entry: params.entry,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function persistBtwCustomEntry(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
entry: BtwCustomEntryData;
|
||||
}) {
|
||||
try {
|
||||
await appendBtwCustomEntry({
|
||||
sessionFile: params.sessionFile,
|
||||
timeoutMs: BTW_PERSIST_TIMEOUT_MS,
|
||||
entry: params.entry,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isSessionLockError(error)) {
|
||||
throw error;
|
||||
}
|
||||
deferBtwCustomEntryPersist({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
entry: params.entry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function persistBtwCustomEntryInBackground(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
entry: BtwCustomEntryData;
|
||||
}) {
|
||||
void persistBtwCustomEntry(params).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function collectTextContent(content: Array<{ type?: string; text?: string }>): string {
|
||||
return content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
|
|
@ -347,7 +247,7 @@ export async function runBtwSideQuestion(
|
|||
throw new Error("No active session context.");
|
||||
}
|
||||
|
||||
const { model, authProfileId, authProfileIdSource } = await resolveRuntimeModel({
|
||||
const { model, authProfileId } = await resolveRuntimeModel({
|
||||
cfg: params.cfg,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
|
|
@ -483,31 +383,9 @@ export async function runBtwSideQuestion(
|
|||
throw new Error("No BTW response generated.");
|
||||
}
|
||||
|
||||
const customEntry = {
|
||||
timestamp: Date.now(),
|
||||
question: params.question,
|
||||
answer,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
thinkingLevel: params.resolvedThinkLevel ?? "off",
|
||||
reasoningLevel: params.resolvedReasoningLevel,
|
||||
sessionKey: params.sessionKey,
|
||||
authProfileId,
|
||||
authProfileIdSource,
|
||||
usage: finalMessage?.usage,
|
||||
} satisfies BtwCustomEntryData;
|
||||
|
||||
persistBtwCustomEntryInBackground({
|
||||
sessionId,
|
||||
sessionFile,
|
||||
entry: customEntry,
|
||||
});
|
||||
|
||||
if (emittedBlocks > 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { text: answer };
|
||||
}
|
||||
|
||||
export { BTW_CUSTOM_TYPE };
|
||||
|
|
|
|||
|
|
@ -219,11 +219,16 @@ describe("normalizeModelCompat", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("forces supportsUsageInStreaming off for generic custom openai-completions provider", () => {
|
||||
expectSupportsUsageInStreamingForcedOff({
|
||||
it("leaves supportsUsageInStreaming at default for generic custom openai-completions provider", () => {
|
||||
const model = {
|
||||
...baseModel(),
|
||||
provider: "custom-cpa",
|
||||
baseUrl: "https://cpa.example.com/v1",
|
||||
});
|
||||
};
|
||||
delete (model as { compat?: unknown }).compat;
|
||||
const normalized = normalizeModelCompat(model as Model<Api>);
|
||||
// supportsUsageInStreaming is no longer forced off — pi-ai's default (true) applies
|
||||
expect(supportsUsageInStreaming(normalized)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => {
|
||||
|
|
@ -273,7 +278,7 @@ describe("normalizeModelCompat", () => {
|
|||
expect(supportsUsageInStreaming(normalized)).toBe(true);
|
||||
});
|
||||
|
||||
it("still forces flags off when not explicitly set by user", () => {
|
||||
it("forces supportsDeveloperRole off but leaves supportsUsageInStreaming unset for non-native endpoints", () => {
|
||||
const model = {
|
||||
...baseModel(),
|
||||
provider: "custom-cpa",
|
||||
|
|
@ -282,7 +287,8 @@ describe("normalizeModelCompat", () => {
|
|||
delete (model as { compat?: unknown }).compat;
|
||||
const normalized = normalizeModelCompat(model);
|
||||
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(false);
|
||||
// supportsUsageInStreaming is no longer forced off — pi-ai default applies
|
||||
expect(supportsUsageInStreaming(normalized)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mutate caller model when forcing supportsDeveloperRole off", () => {
|
||||
|
|
@ -297,7 +303,8 @@ describe("normalizeModelCompat", () => {
|
|||
expect(supportsDeveloperRole(model)).toBeUndefined();
|
||||
expect(supportsUsageInStreaming(model)).toBeUndefined();
|
||||
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(false);
|
||||
// supportsUsageInStreaming is not set by normalizeModelCompat — pi-ai default applies
|
||||
expect(supportsUsageInStreaming(normalized)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not override explicit compat false", () => {
|
||||
|
|
|
|||
|
|
@ -52,11 +52,16 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
|||
return model;
|
||||
}
|
||||
|
||||
// The `developer` role and stream usage chunks are OpenAI-native behaviors.
|
||||
// Many OpenAI-compatible backends reject `developer` and/or emit usage-only
|
||||
// chunks that break strict parsers expecting choices[0]. For non-native
|
||||
// openai-completions endpoints, force both compat flags off — unless the
|
||||
// user has explicitly opted in via their model config.
|
||||
// The `developer` role is an OpenAI-native behavior that most compatible
|
||||
// backends reject. Force it off for non-native endpoints unless the user
|
||||
// has explicitly opted in via their model config.
|
||||
//
|
||||
// `supportsUsageInStreaming` is NOT forced off — most OpenAI-compatible
|
||||
// backends (DashScope, DeepSeek, Groq, Together, etc.) handle
|
||||
// `stream_options: { include_usage: true }` correctly, and disabling it
|
||||
// silently breaks usage/cost tracking for all non-native providers.
|
||||
// Users can still opt out with `compat.supportsUsageInStreaming: false`
|
||||
// if their backend rejects the parameter.
|
||||
const compat = model.compat ?? undefined;
|
||||
// When baseUrl is empty the pi-ai library defaults to api.openai.com, so
|
||||
// leave compat unchanged and let default native behavior apply.
|
||||
|
|
@ -65,24 +70,22 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
|||
return model;
|
||||
}
|
||||
|
||||
// Respect explicit user overrides: if the user has set a compat flag to
|
||||
// true in their model definition, they know their endpoint supports it.
|
||||
// Respect explicit user overrides.
|
||||
const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
|
||||
const forcedUsageStreaming = compat?.supportsUsageInStreaming === true;
|
||||
|
||||
if (forcedDeveloperRole && forcedUsageStreaming) {
|
||||
if (forcedDeveloperRole) {
|
||||
return model;
|
||||
}
|
||||
|
||||
// Return a new object — do not mutate the caller's model reference.
|
||||
// Only force supportsDeveloperRole off. Leave supportsUsageInStreaming
|
||||
// at whatever the user set or pi-ai's default (true).
|
||||
return {
|
||||
...model,
|
||||
compat: compat
|
||||
? {
|
||||
...compat,
|
||||
supportsDeveloperRole: forcedDeveloperRole || false,
|
||||
supportsUsageInStreaming: forcedUsageStreaming || false,
|
||||
supportsDeveloperRole: false,
|
||||
}
|
||||
: { supportsDeveloperRole: false, supportsUsageInStreaming: false },
|
||||
: { supportsDeveloperRole: false },
|
||||
} as typeof model;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -267,9 +267,10 @@ describe("browser server-context listKnownProfileNames", () => {
|
|||
};
|
||||
|
||||
expect(listKnownProfileNames(state).toSorted()).toEqual([
|
||||
"chrome",
|
||||
"chrome-relay",
|
||||
"openclaw",
|
||||
"stale-removed",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -92,10 +92,10 @@ describe("browser server-context ensureTabAvailable", () => {
|
|||
getState: () => state,
|
||||
});
|
||||
|
||||
const chrome = ctx.forProfile("chrome");
|
||||
const first = await chrome.ensureTabAvailable();
|
||||
const chromeRelay = ctx.forProfile("chrome-relay");
|
||||
const first = await chromeRelay.ensureTabAvailable();
|
||||
expect(first.targetId).toBe("A");
|
||||
const second = await chrome.ensureTabAvailable();
|
||||
const second = await chromeRelay.ensureTabAvailable();
|
||||
expect(second.targetId).toBe("A");
|
||||
});
|
||||
|
||||
|
|
@ -108,8 +108,8 @@ describe("browser server-context ensureTabAvailable", () => {
|
|||
const state = makeBrowserState();
|
||||
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const chrome = ctx.forProfile("chrome");
|
||||
await expect(chrome.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i);
|
||||
const chromeRelay = ctx.forProfile("chrome-relay");
|
||||
await expect(chromeRelay.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i);
|
||||
});
|
||||
|
||||
it("returns a descriptive message when no extension tabs are attached", async () => {
|
||||
|
|
@ -118,8 +118,8 @@ describe("browser server-context ensureTabAvailable", () => {
|
|||
const state = makeBrowserState();
|
||||
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const chrome = ctx.forProfile("chrome");
|
||||
await expect(chrome.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i);
|
||||
const chromeRelay = ctx.forProfile("chrome-relay");
|
||||
await expect(chromeRelay.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i);
|
||||
});
|
||||
|
||||
it("waits briefly for extension tabs to reappear when a previous target exists", async () => {
|
||||
|
|
@ -138,11 +138,11 @@ describe("browser server-context ensureTabAvailable", () => {
|
|||
const state = makeBrowserState();
|
||||
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const chrome = ctx.forProfile("chrome");
|
||||
const first = await chrome.ensureTabAvailable();
|
||||
const chromeRelay = ctx.forProfile("chrome-relay");
|
||||
const first = await chromeRelay.ensureTabAvailable();
|
||||
expect(first.targetId).toBe("A");
|
||||
|
||||
const secondPromise = chrome.ensureTabAvailable();
|
||||
const secondPromise = chromeRelay.ensureTabAvailable();
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
const second = await secondPromise;
|
||||
expect(second.targetId).toBe("A");
|
||||
|
|
@ -163,10 +163,10 @@ describe("browser server-context ensureTabAvailable", () => {
|
|||
const state = makeBrowserState();
|
||||
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const chrome = ctx.forProfile("chrome");
|
||||
await chrome.ensureTabAvailable();
|
||||
const chromeRelay = ctx.forProfile("chrome-relay");
|
||||
await chromeRelay.ensureTabAvailable();
|
||||
|
||||
const pending = expect(chrome.ensureTabAvailable()).rejects.toThrow(
|
||||
const pending = expect(chromeRelay.ensureTabAvailable()).rejects.toThrow(
|
||||
/no attached Chrome tabs/i,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ const CANVAS_WS_OPEN_TIMEOUT_MS = 2_000;
|
|||
const CANVAS_RELOAD_TIMEOUT_MS = 4_000;
|
||||
const CANVAS_RELOAD_TEST_TIMEOUT_MS = 12_000;
|
||||
|
||||
function isLoopbackBindDenied(error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return code === "EPERM" || code === "EACCES";
|
||||
}
|
||||
|
||||
// Tests: avoid chokidar polling/fsevents; trigger "all" events manually.
|
||||
vi.mock("chokidar", () => {
|
||||
const createWatcher = () => {
|
||||
|
|
@ -102,8 +107,15 @@ describe("canvas host", () => {
|
|||
|
||||
it("creates a default index.html when missing", async () => {
|
||||
const dir = await createCaseDir();
|
||||
|
||||
const server = await startFixtureCanvasHost(dir);
|
||||
let server: Awaited<ReturnType<typeof startFixtureCanvasHost>>;
|
||||
try {
|
||||
server = await startFixtureCanvasHost(dir);
|
||||
} catch (error) {
|
||||
if (isLoopbackBindDenied(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const { res, html } = await fetchCanvasHtml(server.port);
|
||||
|
|
@ -119,8 +131,15 @@ describe("canvas host", () => {
|
|||
it("skips live reload injection when disabled", async () => {
|
||||
const dir = await createCaseDir();
|
||||
await fs.writeFile(path.join(dir, "index.html"), "<html><body>no-reload</body></html>", "utf8");
|
||||
|
||||
const server = await startFixtureCanvasHost(dir, { liveReload: false });
|
||||
let server: Awaited<ReturnType<typeof startFixtureCanvasHost>>;
|
||||
try {
|
||||
server = await startFixtureCanvasHost(dir, { liveReload: false });
|
||||
} catch (error) {
|
||||
if (isLoopbackBindDenied(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const { res, html } = await fetchCanvasHtml(server.port);
|
||||
|
|
@ -162,8 +181,27 @@ describe("canvas host", () => {
|
|||
}
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
server.off("listening", onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.once("listening", onListening);
|
||||
server.listen(0, "127.0.0.1");
|
||||
});
|
||||
} catch (error) {
|
||||
await handler.close();
|
||||
if (isLoopbackBindDenied(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
|
||||
try {
|
||||
|
|
@ -210,7 +248,15 @@ describe("canvas host", () => {
|
|||
await fs.writeFile(index, "<html><body>v1</body></html>", "utf8");
|
||||
|
||||
const watcherStart = chokidarMockState.watchers.length;
|
||||
const server = await startFixtureCanvasHost(dir);
|
||||
let server: Awaited<ReturnType<typeof startFixtureCanvasHost>>;
|
||||
try {
|
||||
server = await startFixtureCanvasHost(dir);
|
||||
} catch (error) {
|
||||
if (isLoopbackBindDenied(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const watcher = chokidarMockState.watchers[watcherStart];
|
||||
|
|
@ -278,7 +324,15 @@ describe("canvas host", () => {
|
|||
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
|
||||
createdLink = true;
|
||||
|
||||
const server = await startFixtureCanvasHost(dir);
|
||||
let server: Awaited<ReturnType<typeof startFixtureCanvasHost>>;
|
||||
try {
|
||||
server = await startFixtureCanvasHost(dir);
|
||||
} catch (error) {
|
||||
if (isLoopbackBindDenied(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { sendMessageIMessage } from "../../../../extensions/imessage/src/send.js";
|
||||
import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../infra/outbound/send-deps.js";
|
||||
import {
|
||||
createScopedChannelMediaMaxBytesResolver,
|
||||
createDirectTextMediaOutbound,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { sendMessageSignal } from "../../../../extensions/signal/src/send.js";
|
||||
import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../infra/outbound/send-deps.js";
|
||||
import {
|
||||
createScopedChannelMediaMaxBytesResolver,
|
||||
createDirectTextMediaOutbound,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { parseSlackBlocksInput } from "../../../../extensions/slack/src/blocks-input.js";
|
||||
import { sendMessageSlack, type SlackSendIdentity } from "../../../../extensions/slack/src/send.js";
|
||||
import { resolveOutboundSendDep } from "../../../infra/outbound/deliver.js";
|
||||
import type { OutboundIdentity } from "../../../infra/outbound/identity.js";
|
||||
import { resolveOutboundSendDep } from "../../../infra/outbound/send-deps.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import { sendTextMediaPayload } from "./direct-text-media.js";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { resolveOutboundSendDep } from "../../infra/outbound/deliver.js";
|
||||
import { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js";
|
||||
import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js";
|
||||
import { escapeRegExp } from "../../utils.js";
|
||||
import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js";
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ export async function inspectGatewayRestart(params: {
|
|||
return true;
|
||||
}
|
||||
if (runtimePid == null) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
return !listenerOwnedByRuntimePid({ listener, runtimePid });
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
import type { GatewayRestartSnapshot } from "./restart-health.js";
|
||||
|
||||
const callGatewayStatusProbe = vi.fn(async (_opts?: unknown) => ({ ok: true as const }));
|
||||
const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({
|
||||
|
|
@ -18,6 +19,14 @@ const readLastGatewayErrorLine = vi.fn(async (_env?: NodeJS.ProcessEnv) => null)
|
|||
const auditGatewayServiceConfig = vi.fn(async (_opts?: unknown) => undefined);
|
||||
const serviceIsLoaded = vi.fn(async (_opts?: unknown) => true);
|
||||
const serviceReadRuntime = vi.fn(async (_env?: NodeJS.ProcessEnv) => ({ status: "running" }));
|
||||
const inspectGatewayRestart = vi.fn<(opts?: unknown) => Promise<GatewayRestartSnapshot>>(
|
||||
async (_opts?: unknown) => ({
|
||||
runtime: { status: "running", pid: 1234 },
|
||||
portUsage: { port: 19001, status: "busy", listeners: [], hints: [] },
|
||||
healthy: true,
|
||||
staleGatewayPids: [],
|
||||
}),
|
||||
);
|
||||
const serviceReadCommand = vi.fn<
|
||||
(env?: NodeJS.ProcessEnv) => Promise<{
|
||||
programArguments: string[];
|
||||
|
|
@ -117,6 +126,10 @@ vi.mock("./probe.js", () => ({
|
|||
probeGatewayStatus: (opts: unknown) => callGatewayStatusProbe(opts),
|
||||
}));
|
||||
|
||||
vi.mock("./restart-health.js", () => ({
|
||||
inspectGatewayRestart: (opts: unknown) => inspectGatewayRestart(opts),
|
||||
}));
|
||||
|
||||
const { gatherDaemonStatus } = await import("./status.gather.js");
|
||||
|
||||
describe("gatherDaemonStatus", () => {
|
||||
|
|
@ -139,6 +152,7 @@ describe("gatherDaemonStatus", () => {
|
|||
delete process.env.DAEMON_GATEWAY_PASSWORD;
|
||||
callGatewayStatusProbe.mockClear();
|
||||
loadGatewayTlsRuntime.mockClear();
|
||||
inspectGatewayRestart.mockClear();
|
||||
daemonLoadedConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
|
|
@ -362,4 +376,34 @@ describe("gatherDaemonStatus", () => {
|
|||
expect(callGatewayStatusProbe).not.toHaveBeenCalled();
|
||||
expect(status.rpc).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces stale gateway listener pids from restart health inspection", async () => {
|
||||
inspectGatewayRestart.mockResolvedValueOnce({
|
||||
runtime: { status: "running", pid: 8000 },
|
||||
portUsage: {
|
||||
port: 19001,
|
||||
status: "busy",
|
||||
listeners: [{ pid: 9000, ppid: 8999, commandLine: "openclaw-gateway" }],
|
||||
hints: [],
|
||||
},
|
||||
healthy: false,
|
||||
staleGatewayPids: [9000],
|
||||
});
|
||||
|
||||
const status = await gatherDaemonStatus({
|
||||
rpc: {},
|
||||
probe: true,
|
||||
deep: false,
|
||||
});
|
||||
|
||||
expect(inspectGatewayRestart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 19001,
|
||||
}),
|
||||
);
|
||||
expect(status.health).toEqual({
|
||||
healthy: false,
|
||||
staleGatewayPids: [9000],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
|
||||
import { loadGatewayTlsRuntime } from "../../infra/tls/gateway.js";
|
||||
import { probeGatewayStatus } from "./probe.js";
|
||||
import { inspectGatewayRestart } from "./restart-health.js";
|
||||
import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js";
|
||||
import type { GatewayRpcOpts } from "./types.js";
|
||||
|
||||
|
|
@ -112,6 +113,10 @@ export type DaemonStatus = {
|
|||
error?: string;
|
||||
url?: string;
|
||||
};
|
||||
health?: {
|
||||
healthy: boolean;
|
||||
staleGatewayPids: number[];
|
||||
};
|
||||
extraServices: Array<{ label: string; detail: string; scope: string }>;
|
||||
};
|
||||
|
||||
|
|
@ -331,6 +336,14 @@ export async function gatherDaemonStatus(
|
|||
configPath: daemonConfigSummary.path,
|
||||
})
|
||||
: undefined;
|
||||
const health =
|
||||
opts.probe && loaded
|
||||
? await inspectGatewayRestart({
|
||||
service,
|
||||
port: daemonPort,
|
||||
env: serviceEnv,
|
||||
}).catch(() => undefined)
|
||||
: undefined;
|
||||
|
||||
let lastError: string | undefined;
|
||||
if (loaded && runtime?.status === "running" && portStatus && portStatus.status !== "busy") {
|
||||
|
|
@ -357,6 +370,14 @@ export async function gatherDaemonStatus(
|
|||
...(portCliStatus ? { portCli: portCliStatus } : {}),
|
||||
lastError,
|
||||
...(rpc ? { rpc: { ...rpc, url: gateway.probeUrl } } : {}),
|
||||
...(health
|
||||
? {
|
||||
health: {
|
||||
healthy: health.healthy,
|
||||
staleGatewayPids: health.staleGatewayPids,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
extraServices,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
log: vi.fn<(line: string) => void>(),
|
||||
error: vi.fn<(line: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: runtime,
|
||||
}));
|
||||
|
||||
vi.mock("../../terminal/theme.js", () => ({
|
||||
colorize: (_rich: boolean, _theme: unknown, text: string) => text,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/onboard-helpers.js", () => ({
|
||||
resolveControlUiLinks: () => ({ httpUrl: "http://127.0.0.1:18789" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../daemon/inspect.js", () => ({
|
||||
renderGatewayServiceCleanupHints: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../../daemon/launchd.js", () => ({
|
||||
resolveGatewayLogPaths: () => ({
|
||||
stdoutPath: "/tmp/gateway.out.log",
|
||||
stderrPath: "/tmp/gateway.err.log",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../daemon/systemd-hints.js", () => ({
|
||||
isSystemdUnavailableDetail: () => false,
|
||||
renderSystemdUnavailableHints: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/wsl.js", () => ({
|
||||
isWSLEnv: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../../logging.js", () => ({
|
||||
getResolvedLoggerSettings: () => ({ file: "/tmp/openclaw.log" }),
|
||||
}));
|
||||
|
||||
vi.mock("./shared.js", () => ({
|
||||
createCliStatusTextStyles: () => ({
|
||||
rich: false,
|
||||
label: (text: string) => text,
|
||||
accent: (text: string) => text,
|
||||
infoText: (text: string) => text,
|
||||
okText: (text: string) => text,
|
||||
warnText: (text: string) => text,
|
||||
errorText: (text: string) => text,
|
||||
}),
|
||||
filterDaemonEnv: () => ({}),
|
||||
formatRuntimeStatus: () => "running (pid 8000)",
|
||||
resolveRuntimeStatusColor: () => "",
|
||||
renderRuntimeHints: () => [],
|
||||
safeDaemonEnv: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./status.gather.js", () => ({
|
||||
renderPortDiagnosticsForCli: () => [],
|
||||
resolvePortListeningAddresses: () => ["127.0.0.1:18789"],
|
||||
}));
|
||||
|
||||
const { printDaemonStatus } = await import("./status.print.js");
|
||||
|
||||
describe("printDaemonStatus", () => {
|
||||
beforeEach(() => {
|
||||
runtime.log.mockReset();
|
||||
runtime.error.mockReset();
|
||||
});
|
||||
|
||||
it("prints stale gateway pid guidance when runtime does not own the listener", () => {
|
||||
printDaemonStatus(
|
||||
{
|
||||
service: {
|
||||
label: "LaunchAgent",
|
||||
loaded: true,
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
runtime: { status: "running", pid: 8000 },
|
||||
},
|
||||
gateway: {
|
||||
bindMode: "loopback",
|
||||
bindHost: "127.0.0.1",
|
||||
port: 18789,
|
||||
portSource: "env/config",
|
||||
probeUrl: "ws://127.0.0.1:18789",
|
||||
},
|
||||
port: {
|
||||
port: 18789,
|
||||
status: "busy",
|
||||
listeners: [{ pid: 9000, ppid: 8999, address: "127.0.0.1:18789" }],
|
||||
hints: [],
|
||||
},
|
||||
rpc: {
|
||||
ok: false,
|
||||
error: "gateway closed (1006 abnormal closure (no close frame))",
|
||||
url: "ws://127.0.0.1:18789",
|
||||
},
|
||||
health: {
|
||||
healthy: false,
|
||||
staleGatewayPids: [9000],
|
||||
},
|
||||
extraServices: [],
|
||||
},
|
||||
{ json: false },
|
||||
);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Gateway runtime PID does not own the listening port"),
|
||||
);
|
||||
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("openclaw gateway restart"));
|
||||
});
|
||||
});
|
||||
|
|
@ -194,6 +194,25 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
|||
spacer();
|
||||
}
|
||||
|
||||
if (
|
||||
status.health &&
|
||||
status.health.staleGatewayPids.length > 0 &&
|
||||
service.runtime?.status === "running" &&
|
||||
typeof service.runtime.pid === "number"
|
||||
) {
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
`Gateway runtime PID does not own the listening port. Other gateway process(es) are listening: ${status.health.staleGatewayPids.join(", ")}`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
`Fix: run ${formatCliCommand("openclaw gateway restart")} and re-check with ${formatCliCommand("openclaw gateway status --deep")}.`,
|
||||
),
|
||||
);
|
||||
spacer();
|
||||
}
|
||||
|
||||
const systemdUnavailable =
|
||||
process.platform === "linux" && isSystemdUnavailableDetail(service.runtime?.detail);
|
||||
if (systemdUnavailable) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
|
||||
import type { OutboundSendDeps } from "../infra/outbound/send-deps.js";
|
||||
import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ const mocks = vi.hoisted(() => ({
|
|||
updateConfig: vi.fn(),
|
||||
logConfigUpdated: vi.fn(),
|
||||
openUrl: vi.fn(),
|
||||
loadAuthProfileStoreForRuntime: vi.fn(),
|
||||
listProfilesForProvider: vi.fn(),
|
||||
clearAuthProfileCooldown: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/auth-profiles.js", () => ({
|
||||
loadAuthProfileStoreForRuntime: mocks.loadAuthProfileStoreForRuntime,
|
||||
listProfilesForProvider: mocks.listProfilesForProvider,
|
||||
clearAuthProfileCooldown: mocks.clearAuthProfileCooldown,
|
||||
upsertAuthProfile: mocks.upsertAuthProfile,
|
||||
}));
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
|
|
@ -41,10 +51,6 @@ vi.mock("../../agents/workspace.js", () => ({
|
|||
resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/auth-profiles.js", () => ({
|
||||
upsertAuthProfile: mocks.upsertAuthProfile,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/providers.js", () => ({
|
||||
resolvePluginProviders: mocks.resolvePluginProviders,
|
||||
}));
|
||||
|
|
@ -155,6 +161,9 @@ describe("modelsAuthLoginCommand", () => {
|
|||
});
|
||||
mocks.writeOAuthCredentials.mockResolvedValue("openai-codex:user@example.com");
|
||||
mocks.resolvePluginProviders.mockReturnValue([]);
|
||||
mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} });
|
||||
mocks.listProfilesForProvider.mockReturnValue([]);
|
||||
mocks.clearAuthProfileCooldown.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -198,6 +207,60 @@ describe("modelsAuthLoginCommand", () => {
|
|||
expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4");
|
||||
});
|
||||
|
||||
it("clears stale auth lockouts before attempting openai-codex login", async () => {
|
||||
const runtime = createRuntime();
|
||||
const fakeStore = {
|
||||
profiles: {
|
||||
"openai-codex:user@example.com": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
},
|
||||
},
|
||||
usageStats: {
|
||||
"openai-codex:user@example.com": {
|
||||
disabledUntil: Date.now() + 3_600_000,
|
||||
disabledReason: "auth_permanent",
|
||||
errorCount: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
mocks.loadAuthProfileStoreForRuntime.mockReturnValue(fakeStore);
|
||||
mocks.listProfilesForProvider.mockReturnValue(["openai-codex:user@example.com"]);
|
||||
|
||||
await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime);
|
||||
|
||||
expect(mocks.clearAuthProfileCooldown).toHaveBeenCalledWith({
|
||||
store: fakeStore,
|
||||
profileId: "openai-codex:user@example.com",
|
||||
agentDir: "/tmp/openclaw/agents/main",
|
||||
});
|
||||
// Verify clearing happens before login attempt
|
||||
const clearOrder = mocks.clearAuthProfileCooldown.mock.invocationCallOrder[0];
|
||||
const loginOrder = mocks.loginOpenAICodexOAuth.mock.invocationCallOrder[0];
|
||||
expect(clearOrder).toBeLessThan(loginOrder);
|
||||
});
|
||||
|
||||
it("survives lockout clearing failure without blocking login", async () => {
|
||||
const runtime = createRuntime();
|
||||
mocks.loadAuthProfileStoreForRuntime.mockImplementation(() => {
|
||||
throw new Error("corrupt auth-profiles.json");
|
||||
});
|
||||
|
||||
await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime);
|
||||
|
||||
expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("loads lockout state from the agent-scoped store", async () => {
|
||||
const runtime = createRuntime();
|
||||
mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} });
|
||||
mocks.listProfilesForProvider.mockReturnValue([]);
|
||||
|
||||
await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime);
|
||||
|
||||
expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main");
|
||||
});
|
||||
|
||||
it("keeps existing plugin error behavior for non built-in providers", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ import {
|
|||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { upsertAuthProfile } from "../../agents/auth-profiles.js";
|
||||
import {
|
||||
clearAuthProfileCooldown,
|
||||
listProfilesForProvider,
|
||||
loadAuthProfileStoreForRuntime,
|
||||
upsertAuthProfile,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
|
||||
import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
||||
|
|
@ -265,6 +270,24 @@ type LoginOptions = {
|
|||
setDefault?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear stale cooldown/disabled state for all profiles matching a provider.
|
||||
* When a user explicitly runs `models auth login`, they intend to fix auth —
|
||||
* stale `auth_permanent` / `billing` lockouts should not persist across
|
||||
* a deliberate re-authentication attempt.
|
||||
*/
|
||||
async function clearStaleProfileLockouts(provider: string, agentDir: string): Promise<void> {
|
||||
try {
|
||||
const store = loadAuthProfileStoreForRuntime(agentDir);
|
||||
const profileIds = listProfilesForProvider(store, provider);
|
||||
for (const profileId of profileIds) {
|
||||
await clearAuthProfileCooldown({ store, profileId, agentDir });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort housekeeping — never block re-authentication.
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRequestedLoginProviderOrThrow(
|
||||
providers: ProviderPlugin[],
|
||||
rawProvider?: string,
|
||||
|
|
@ -356,6 +379,7 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim
|
|||
const prompter = createClackPrompter();
|
||||
|
||||
if (requestedProviderId === "openai-codex") {
|
||||
await clearStaleProfileLockouts("openai-codex", agentDir);
|
||||
await runBuiltInOpenAICodexLogin({
|
||||
opts,
|
||||
runtime,
|
||||
|
|
@ -390,6 +414,8 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim
|
|||
throw new Error("Unknown provider. Use --provider <id> to pick a provider plugin.");
|
||||
}
|
||||
|
||||
await clearStaleProfileLockouts(selectedProvider.id, agentDir);
|
||||
|
||||
const chosenMethod =
|
||||
pickAuthMethod(selectedProvider, opts.method) ??
|
||||
(selectedProvider.auth.length === 1
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ const launchdRestartHandoffState = vi.hoisted(() => ({
|
|||
isCurrentProcessLaunchdServiceLabel: vi.fn<(label: string) => boolean>(() => false),
|
||||
scheduleDetachedLaunchdRestartHandoff: vi.fn((_params: unknown) => ({ ok: true, pid: 7331 })),
|
||||
}));
|
||||
const cleanStaleGatewayProcessesSync = vi.hoisted(() =>
|
||||
vi.fn<(port?: number) => number[]>(() => []),
|
||||
);
|
||||
const defaultProgramArguments = ["node", "-e", "process.exit(0)"];
|
||||
|
||||
function expectLaunchctlEnableBootstrapOrder(env: Record<string, string | undefined>) {
|
||||
|
|
@ -89,6 +92,10 @@ vi.mock("./launchd-restart-handoff.js", () => ({
|
|||
launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff(params),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/restart-stale-pids.js", () => ({
|
||||
cleanStaleGatewayProcessesSync: (port?: number) => cleanStaleGatewayProcessesSync(port),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
const wrapped = {
|
||||
|
|
@ -151,6 +158,8 @@ beforeEach(() => {
|
|||
state.dirModes.clear();
|
||||
state.files.clear();
|
||||
state.fileModes.clear();
|
||||
cleanStaleGatewayProcessesSync.mockReset();
|
||||
cleanStaleGatewayProcessesSync.mockReturnValue([]);
|
||||
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReset();
|
||||
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(false);
|
||||
launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReset();
|
||||
|
|
@ -328,7 +337,10 @@ describe("launchd install", () => {
|
|||
});
|
||||
|
||||
it("restarts LaunchAgent with kickstart and no bootout", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const env = {
|
||||
...createDefaultLaunchdEnv(),
|
||||
OPENCLAW_GATEWAY_PORT: "18789",
|
||||
};
|
||||
const result = await restartLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
|
|
@ -338,11 +350,38 @@ describe("launchd install", () => {
|
|||
const label = "ai.openclaw.gateway";
|
||||
const serviceId = `${domain}/${label}`;
|
||||
expect(result).toEqual({ outcome: "completed" });
|
||||
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(18789);
|
||||
expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", serviceId]);
|
||||
expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false);
|
||||
expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the configured gateway port for stale cleanup", async () => {
|
||||
const env = {
|
||||
...createDefaultLaunchdEnv(),
|
||||
OPENCLAW_GATEWAY_PORT: "19001",
|
||||
};
|
||||
|
||||
await restartLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
});
|
||||
|
||||
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19001);
|
||||
});
|
||||
|
||||
it("skips stale cleanup when no explicit launch agent port can be resolved", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
state.files.clear();
|
||||
|
||||
await restartLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
});
|
||||
|
||||
expect(cleanStaleGatewayProcessesSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to bootstrap when kickstart cannot find the service", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
state.kickstartError = "Could not find service";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
|
||||
import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js";
|
||||
import {
|
||||
GATEWAY_LAUNCH_AGENT_LABEL,
|
||||
resolveGatewayServiceDescription,
|
||||
|
|
@ -113,6 +114,44 @@ async function execLaunchctl(
|
|||
return await execFileUtf8(file, fileArgs, isWindows ? { windowsHide: true } : {});
|
||||
}
|
||||
|
||||
function parseGatewayPortFromProgramArguments(
|
||||
programArguments: string[] | undefined,
|
||||
): number | null {
|
||||
if (!Array.isArray(programArguments) || programArguments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
for (let index = 0; index < programArguments.length; index += 1) {
|
||||
const current = programArguments[index]?.trim();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
if (current === "--port") {
|
||||
const next = parseStrictPositiveInteger(programArguments[index + 1] ?? "");
|
||||
if (next !== undefined) {
|
||||
return next;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current.startsWith("--port=")) {
|
||||
const value = parseStrictPositiveInteger(current.slice("--port=".length));
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveLaunchAgentGatewayPort(env: GatewayServiceEnv): Promise<number | null> {
|
||||
const command = await readLaunchAgentProgramArguments(env).catch(() => null);
|
||||
const fromArgs = parseGatewayPortFromProgramArguments(command?.programArguments);
|
||||
if (fromArgs !== null) {
|
||||
return fromArgs;
|
||||
}
|
||||
const fromEnv = parseStrictPositiveInteger(env.OPENCLAW_GATEWAY_PORT ?? "");
|
||||
return fromEnv ?? null;
|
||||
}
|
||||
|
||||
function resolveGuiDomain(): string {
|
||||
if (typeof process.getuid !== "function") {
|
||||
return "gui/501";
|
||||
|
|
@ -514,6 +553,11 @@ export async function restartLaunchAgent({
|
|||
return { outcome: "scheduled" };
|
||||
}
|
||||
|
||||
const cleanupPort = await resolveLaunchAgentGatewayPort(serviceEnv);
|
||||
if (cleanupPort !== null) {
|
||||
cleanStaleGatewayProcessesSync(cleanupPort);
|
||||
}
|
||||
|
||||
const start = await execLaunchctl(["kickstart", "-k", serviceTarget]);
|
||||
if (start.code === 0) {
|
||||
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import {
|
||||
BACKEND_GATEWAY_CLIENT,
|
||||
connectReq,
|
||||
CONTROL_UI_CLIENT,
|
||||
ConnectErrorDetailCodes,
|
||||
|
|
@ -144,6 +147,50 @@ describe("gateway auth compatibility baseline", () => {
|
|||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps local backend device-token reconnects out of pairing", async () => {
|
||||
const identityPath = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-backend-device-${process.pid}-${port}.json`,
|
||||
);
|
||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
|
||||
await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, requestDevicePairing, rotateDeviceToken } =
|
||||
await import("../infra/device-pairing.js");
|
||||
|
||||
const identity = loadOrCreateDeviceIdentity(identityPath);
|
||||
const pending = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
clientId: BACKEND_GATEWAY_CLIENT.id,
|
||||
clientMode: BACKEND_GATEWAY_CLIENT.mode,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
await approveDevicePairing(pending.request.requestId);
|
||||
|
||||
const rotated = await rotateDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
expect(rotated?.token).toBeTruthy();
|
||||
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
client: { ...BACKEND_GATEWAY_CLIENT },
|
||||
deviceIdentityPath: identityPath,
|
||||
deviceToken: String(rotated?.token ?? ""),
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok");
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("password mode", () => {
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ describe("handshake auth helpers", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips backend self-pairing only for local shared-secret backend clients", () => {
|
||||
it("skips backend self-pairing for local trusted backend clients", () => {
|
||||
const connectParams = {
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
|
||||
|
|
@ -106,6 +106,15 @@ describe("handshake auth helpers", () => {
|
|||
authMethod: "token",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSkipBackendSelfPairing({
|
||||
connectParams,
|
||||
isLocalClient: true,
|
||||
hasBrowserOriginHeader: false,
|
||||
sharedAuthOk: false,
|
||||
authMethod: "device-token",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSkipBackendSelfPairing({
|
||||
connectParams,
|
||||
|
|
|
|||
|
|
@ -74,11 +74,14 @@ export function shouldSkipBackendSelfPairing(params: {
|
|||
return false;
|
||||
}
|
||||
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
|
||||
const usesDeviceTokenAuth = params.authMethod === "device-token";
|
||||
// `authMethod === "device-token"` only reaches this helper after the caller
|
||||
// has already accepted auth (`authOk === true`), so a separate
|
||||
// `deviceTokenAuthOk` flag would be redundant here.
|
||||
return (
|
||||
params.isLocalClient &&
|
||||
!params.hasBrowserOriginHeader &&
|
||||
params.sharedAuthOk &&
|
||||
usesSharedSecretAuth
|
||||
((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -674,7 +674,10 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
authOk,
|
||||
authMethod,
|
||||
});
|
||||
// auth.mode=none disables all authentication — device pairing is an
|
||||
// auth mechanism and must also be skipped when the operator opted out.
|
||||
const skipPairing =
|
||||
resolvedAuth.mode === "none" ||
|
||||
shouldSkipBackendSelfPairing({
|
||||
connectParams,
|
||||
isLocalClient,
|
||||
|
|
|
|||
|
|
@ -40,57 +40,17 @@ import type { DeliveryMirror } from "./mirror.js";
|
|||
import type { NormalizedOutboundPayload } from "./payloads.js";
|
||||
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
|
||||
import { isPlainTextSurface, sanitizeForPlainText } from "./sanitize-text.js";
|
||||
import { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js";
|
||||
import type { OutboundSessionContext } from "./session-context.js";
|
||||
import type { OutboundChannel } from "./targets.js";
|
||||
|
||||
export type { NormalizedOutboundPayload } from "./payloads.js";
|
||||
export { normalizeOutboundPayloads } from "./payloads.js";
|
||||
export { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js";
|
||||
|
||||
const log = createSubsystemLogger("outbound/deliver");
|
||||
const TELEGRAM_TEXT_LIMIT = 4096;
|
||||
|
||||
type LegacyOutboundSendDeps = {
|
||||
sendWhatsApp?: unknown;
|
||||
sendTelegram?: unknown;
|
||||
sendDiscord?: unknown;
|
||||
sendSlack?: unknown;
|
||||
sendSignal?: unknown;
|
||||
sendIMessage?: unknown;
|
||||
sendMatrix?: unknown;
|
||||
sendMSTeams?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dynamic bag of per-channel send functions, keyed by channel ID.
|
||||
* Each outbound adapter resolves its own function from this record and
|
||||
* falls back to a direct import when the key is absent.
|
||||
*/
|
||||
export type OutboundSendDeps = LegacyOutboundSendDeps & { [channelId: string]: unknown };
|
||||
|
||||
const LEGACY_SEND_DEP_KEYS = {
|
||||
whatsapp: "sendWhatsApp",
|
||||
telegram: "sendTelegram",
|
||||
discord: "sendDiscord",
|
||||
slack: "sendSlack",
|
||||
signal: "sendSignal",
|
||||
imessage: "sendIMessage",
|
||||
matrix: "sendMatrix",
|
||||
msteams: "sendMSTeams",
|
||||
} as const satisfies Record<string, keyof LegacyOutboundSendDeps>;
|
||||
|
||||
export function resolveOutboundSendDep<T>(
|
||||
deps: OutboundSendDeps | null | undefined,
|
||||
channelId: keyof typeof LEGACY_SEND_DEP_KEYS,
|
||||
): T | undefined {
|
||||
const dynamic = deps?.[channelId];
|
||||
if (dynamic !== undefined) {
|
||||
return dynamic as T;
|
||||
}
|
||||
const legacyKey = LEGACY_SEND_DEP_KEYS[channelId];
|
||||
const legacy = deps?.[legacyKey];
|
||||
return legacy as T | undefined;
|
||||
}
|
||||
|
||||
export type OutboundDeliveryResult = {
|
||||
channel: Exclude<OutboundChannel, "none">;
|
||||
messageId: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
type LegacyOutboundSendDeps = {
|
||||
sendWhatsApp?: unknown;
|
||||
sendTelegram?: unknown;
|
||||
sendDiscord?: unknown;
|
||||
sendSlack?: unknown;
|
||||
sendSignal?: unknown;
|
||||
sendIMessage?: unknown;
|
||||
sendMatrix?: unknown;
|
||||
sendMSTeams?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dynamic bag of per-channel send functions, keyed by channel ID.
|
||||
* Each outbound adapter resolves its own function from this record and
|
||||
* falls back to a direct import when the key is absent.
|
||||
*/
|
||||
export type OutboundSendDeps = LegacyOutboundSendDeps & { [channelId: string]: unknown };
|
||||
|
||||
const LEGACY_SEND_DEP_KEYS = {
|
||||
whatsapp: "sendWhatsApp",
|
||||
telegram: "sendTelegram",
|
||||
discord: "sendDiscord",
|
||||
slack: "sendSlack",
|
||||
signal: "sendSignal",
|
||||
imessage: "sendIMessage",
|
||||
matrix: "sendMatrix",
|
||||
msteams: "sendMSTeams",
|
||||
} as const satisfies Record<string, keyof LegacyOutboundSendDeps>;
|
||||
|
||||
export function resolveOutboundSendDep<T>(
|
||||
deps: OutboundSendDeps | null | undefined,
|
||||
channelId: keyof typeof LEGACY_SEND_DEP_KEYS,
|
||||
): T | undefined {
|
||||
const dynamic = deps?.[channelId];
|
||||
if (dynamic !== undefined) {
|
||||
return dynamic as T;
|
||||
}
|
||||
const legacyKey = LEGACY_SEND_DEP_KEYS[channelId];
|
||||
const legacy = deps?.[legacyKey];
|
||||
return legacy as T | undefined;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { ReadableStream } from "node:stream/web";
|
|||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { VoyageBatchOutputLine, VoyageBatchRequest } from "./batch-voyage.js";
|
||||
import type { VoyageEmbeddingClient } from "./embeddings-voyage.js";
|
||||
import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js";
|
||||
|
||||
// Mock internal.js if needed, but runWithConcurrency is simple enough to keep real.
|
||||
// We DO need to mock retryAsync to avoid actual delays/retries logic complicating tests
|
||||
|
|
@ -35,6 +36,7 @@ describe("runVoyageEmbeddingBatches", () => {
|
|||
it("successfully submits batch, waits, and streams results", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
|
||||
// Sequence of fetch calls:
|
||||
// 1. Upload file
|
||||
|
|
@ -130,6 +132,7 @@ describe("runVoyageEmbeddingBatches", () => {
|
|||
it("handles empty lines and stream chunks correctly", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
|
||||
// 1. Upload
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "f1" }) });
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
isGeminiEmbedding2Model,
|
||||
resolveGeminiOutputDimensionality,
|
||||
} from "./embeddings-gemini.js";
|
||||
import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js";
|
||||
|
||||
vi.mock("../agents/model-auth.js", async () => {
|
||||
const { createModelAuthMockModule } = await import("../test-utils/model-auth-mock.js");
|
||||
|
|
@ -67,6 +68,7 @@ async function createProviderWithFetch(
|
|||
options: Partial<Parameters<typeof createGeminiEmbeddingProvider>[0]> & { model: string },
|
||||
) {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
mockResolvedProviderKey();
|
||||
const { provider } = await createGeminiEmbeddingProvider({
|
||||
config: {} as never,
|
||||
|
|
@ -449,6 +451,7 @@ describe("gemini model normalization", () => {
|
|||
it("handles models/ prefix for v2 model", async () => {
|
||||
const fetchMock = createGeminiFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
mockResolvedProviderKey();
|
||||
|
||||
const { provider } = await createGeminiEmbeddingProvider({
|
||||
|
|
@ -467,6 +470,7 @@ describe("gemini model normalization", () => {
|
|||
it("handles gemini/ prefix for v2 model", async () => {
|
||||
const fetchMock = createGeminiFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
mockResolvedProviderKey();
|
||||
|
||||
const { provider } = await createGeminiEmbeddingProvider({
|
||||
|
|
@ -485,6 +489,7 @@ describe("gemini model normalization", () => {
|
|||
it("handles google/ prefix for v2 model", async () => {
|
||||
const fetchMock = createGeminiFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
mockResolvedProviderKey();
|
||||
|
||||
const { provider } = await createGeminiEmbeddingProvider({
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ async function createDefaultVoyageProvider(
|
|||
fetchMock: ReturnType<typeof createFetchMock>,
|
||||
) {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
mockVoyageApiKey();
|
||||
return createVoyageEmbeddingProvider({
|
||||
config: {} as never,
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ describe("embedding provider remote overrides", () => {
|
|||
it("builds Gemini embeddings requests with api key header", async () => {
|
||||
const fetchMock = createGeminiFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
mockResolvedProviderKey("provider-key");
|
||||
|
||||
const cfg = {
|
||||
|
|
@ -230,6 +231,7 @@ describe("embedding provider remote overrides", () => {
|
|||
it("uses GEMINI_API_KEY env indirection for Gemini remote apiKey", async () => {
|
||||
const fetchMock = createGeminiFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
vi.stubEnv("GEMINI_API_KEY", "env-gemini-key");
|
||||
|
||||
const result = await createEmbeddingProvider({
|
||||
|
|
@ -253,6 +255,7 @@ describe("embedding provider remote overrides", () => {
|
|||
it("builds Mistral embeddings requests with bearer auth", async () => {
|
||||
const fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
mockResolvedProviderKey("provider-key");
|
||||
|
||||
const cfg = {
|
||||
|
|
@ -303,6 +306,7 @@ describe("embedding provider auto selection", () => {
|
|||
it("uses gemini when openai is missing", async () => {
|
||||
const fetchMock = createGeminiFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
||||
if (provider === "openai") {
|
||||
throw new Error('No API key found for provider "openai".');
|
||||
|
|
@ -329,6 +333,7 @@ describe("embedding provider auto selection", () => {
|
|||
json: async () => ({ data: [{ embedding: [1, 2, 3] }] }),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
||||
if (provider === "openai") {
|
||||
return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" };
|
||||
|
|
@ -357,6 +362,7 @@ describe("embedding provider auto selection", () => {
|
|||
it("uses mistral when openai/gemini/voyage are missing", async () => {
|
||||
const fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
||||
if (provider === "mistral") {
|
||||
return { apiKey: "mistral-key", source: "env: MISTRAL_API_KEY", mode: "api-key" }; // pragma: allowlist secret
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useFastShortTimeouts } from "../../test/helpers/fast-short-timeouts.js"
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
|
||||
import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js";
|
||||
import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
|
||||
const embedBatch = vi.fn(async (_texts: string[]) => [] as number[][]);
|
||||
|
|
@ -174,6 +175,7 @@ describe("memory indexing with OpenAI batches", () => {
|
|||
const { fetchMock } = createOpenAIBatchFetchMock();
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
|
||||
try {
|
||||
if (!manager) {
|
||||
|
|
@ -216,6 +218,7 @@ describe("memory indexing with OpenAI batches", () => {
|
|||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
|
||||
try {
|
||||
if (!manager) {
|
||||
|
|
@ -255,6 +258,7 @@ describe("memory indexing with OpenAI batches", () => {
|
|||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
|
||||
try {
|
||||
if (!manager) {
|
||||
|
|
|
|||
|
|
@ -174,8 +174,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
|||
const scheme = gateway.tls ? "wss" : "ws";
|
||||
const url = `${scheme}://${host}:${port}`;
|
||||
const pathEnv = ensureNodePathEnv();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`node host PATH: ${pathEnv}`);
|
||||
|
||||
const client = new GatewayClient({
|
||||
url,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
collectReleasePackageMetadataErrors,
|
||||
collectReleaseTagErrors,
|
||||
parseReleaseTagVersion,
|
||||
parseReleaseVersion,
|
||||
utcCalendarDayDistance,
|
||||
} from "../scripts/openclaw-npm-release-check.ts";
|
||||
|
|
@ -37,6 +38,22 @@ describe("parseReleaseVersion", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("parseReleaseTagVersion", () => {
|
||||
it("accepts fallback correction tags for stable releases", () => {
|
||||
expect(parseReleaseTagVersion("2026.3.10-2")).toMatchObject({
|
||||
version: "2026.3.10-2",
|
||||
packageVersion: "2026.3.10",
|
||||
channel: "stable",
|
||||
correctionNumber: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects beta correction tags and malformed correction tags", () => {
|
||||
expect(parseReleaseTagVersion("2026.3.10-beta.1-1")).toBeNull();
|
||||
expect(parseReleaseTagVersion("2026.3.10-0")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("utcCalendarDayDistance", () => {
|
||||
it("compares UTC calendar days rather than wall-clock hours", () => {
|
||||
const left = new Date("2026-03-09T23:59:59Z");
|
||||
|
|
@ -66,14 +83,24 @@ describe("collectReleaseTagErrors", () => {
|
|||
).toContainEqual(expect.stringContaining("must be within 2 days"));
|
||||
});
|
||||
|
||||
it("rejects tags that do not match the current release format", () => {
|
||||
it("accepts fallback correction tags for stable package versions", () => {
|
||||
expect(
|
||||
collectReleaseTagErrors({
|
||||
packageVersion: "2026.3.10",
|
||||
releaseTag: "v2026.3.10-1",
|
||||
now: new Date("2026-03-10T00:00:00Z"),
|
||||
}),
|
||||
).toContainEqual(expect.stringContaining("must match vYYYY.M.D or vYYYY.M.D-beta.N"));
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects beta package versions paired with fallback correction tags", () => {
|
||||
expect(
|
||||
collectReleaseTagErrors({
|
||||
packageVersion: "2026.3.10-beta.1",
|
||||
releaseTag: "v2026.3.10-1",
|
||||
now: new Date("2026-03-10T00:00:00Z"),
|
||||
}),
|
||||
).toContainEqual(expect.stringContaining("does not match package.json version"));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
export const channelTestRoots = [
|
||||
"extensions/telegram",
|
||||
"extensions/discord",
|
||||
"extensions/whatsapp",
|
||||
"extensions/slack",
|
||||
"extensions/signal",
|
||||
"extensions/imessage",
|
||||
"src/browser",
|
||||
"src/line",
|
||||
];
|
||||
|
||||
export const channelTestPrefixes = channelTestRoots.map((root) => `${root}/`);
|
||||
export const channelTestInclude = channelTestRoots.map((root) => `${root}/**/*.test.ts`);
|
||||
export const channelTestExclude = channelTestRoots.map((root) => `${root}/**`);
|
||||
|
|
@ -1,20 +1,6 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import baseConfig from "./vitest.config.ts";
|
||||
import { channelTestInclude } from "./vitest.channel-paths.mjs";
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
const base = baseConfig as unknown as Record<string, unknown>;
|
||||
const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {};
|
||||
|
||||
export default defineConfig({
|
||||
...base,
|
||||
test: {
|
||||
...baseTest,
|
||||
include: [
|
||||
"extensions/telegram/**/*.test.ts",
|
||||
"extensions/discord/**/*.test.ts",
|
||||
"extensions/whatsapp/**/*.test.ts",
|
||||
"src/browser/**/*.test.ts",
|
||||
"src/line/**/*.test.ts",
|
||||
],
|
||||
exclude: [...(baseTest.exclude ?? []), "src/gateway/**"],
|
||||
},
|
||||
export default createScopedVitestConfig(channelTestInclude, {
|
||||
exclude: ["src/gateway/**"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import { channelTestExclude } from "./vitest.channel-paths.mjs";
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
export default createScopedVitestConfig(["extensions/**/*.test.ts"]);
|
||||
export default createScopedVitestConfig(["extensions/**/*.test.ts"], {
|
||||
// Channel implementations live under extensions/ but are tested by
|
||||
// vitest.channels.config.ts (pnpm test:channels) which provides
|
||||
// the heavier mock scaffolding they need.
|
||||
exclude: channelTestExclude.filter((pattern) => pattern.startsWith("extensions/")),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import baseConfig from "./vitest.config.ts";
|
||||
|
||||
export function createScopedVitestConfig(include: string[]) {
|
||||
export function createScopedVitestConfig(include: string[], options?: { exclude?: string[] }) {
|
||||
const base = baseConfig as unknown as Record<string, unknown>;
|
||||
const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {};
|
||||
const exclude = baseTest.exclude ?? [];
|
||||
const exclude = [...(baseTest.exclude ?? []), ...(options?.exclude ?? [])];
|
||||
|
||||
return defineConfig({
|
||||
...base,
|
||||
|
|
|
|||
|
|
@ -17,9 +17,6 @@ export default defineConfig({
|
|||
...exclude,
|
||||
"src/gateway/**",
|
||||
"extensions/**",
|
||||
"extensions/telegram/**",
|
||||
"extensions/discord/**",
|
||||
"extensions/whatsapp/**",
|
||||
"src/browser/**",
|
||||
"src/line/**",
|
||||
"src/agents/**",
|
||||
|
|
|
|||
Loading…
Reference in New Issue