Merge branch 'main' into fix/followup-audio-transcription

This commit is contained in:
Joseph Krug 2026-03-14 16:28:35 -04:00 committed by GitHub
commit 2c946bf2cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 1321 additions and 502 deletions

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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!

View File

@ -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

View File

@ -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`

View File

@ -780,7 +780,7 @@ Subcommands:
Notes:
- `gateway status` probes the Gateway RPC by default using the services 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.

View File

@ -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
```

View File

@ -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

View File

@ -1009,7 +1009,8 @@
"tools/loop-detection",
"tools/reactions",
"tools/thinking",
"tools/web"
"tools/web",
"tools/btw"
]
},
{

View File

@ -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`).

View File

@ -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.

View File

@ -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**

142
docs/tools/btw.md Normal file
View File

@ -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)

View File

@ -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.

View File

@ -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<

View File

@ -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";

View File

@ -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;
}
});
});

View File

@ -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
}
})();
};

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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 = {

View File

@ -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 () => {

View File

@ -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";

View File

@ -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);
}

View File

@ -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) {

View File

@ -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(

View File

@ -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,
}),
);
});
});

View File

@ -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 {

View File

@ -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 () => {

View File

@ -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 {},

View File

@ -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();

View File

@ -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();

View File

@ -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<

View File

@ -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";

View File

@ -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 () => {

View File

@ -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";

View File

@ -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}.`,
);
}

View File

@ -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 };
}

View File

@ -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 () => {

View File

@ -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 };

View File

@ -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", () => {

View File

@ -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;
}

View File

@ -267,9 +267,10 @@ describe("browser server-context listKnownProfileNames", () => {
};
expect(listKnownProfileNames(state).toSorted()).toEqual([
"chrome",
"chrome-relay",
"openclaw",
"stale-removed",
"user",
]);
});
});

View File

@ -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);

View File

@ -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/`);

View File

@ -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,

View File

@ -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,

View File

@ -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";

View File

@ -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";

View File

@ -182,7 +182,7 @@ export async function inspectGatewayRestart(params: {
return true;
}
if (runtimePid == null) {
return true;
return false;
}
return !listenerOwnedByRuntimePid({ listener, runtimePid });
})

View File

@ -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],
});
});
});

View File

@ -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,
};
}

View File

@ -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"));
});
});

View File

@ -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) {

View File

@ -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";
/**

View File

@ -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();

View File

@ -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

View File

@ -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";

View File

@ -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);

View File

@ -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", () => {

View File

@ -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,

View File

@ -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)
);
}

View File

@ -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,

View File

@ -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;

View File

@ -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;
}

View File

@ -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" }) });

View File

@ -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({

View File

@ -33,6 +33,7 @@ async function createDefaultVoyageProvider(
fetchMock: ReturnType<typeof createFetchMock>,
) {
vi.stubGlobal("fetch", fetchMock);
mockPublicPinnedHostname();
mockVoyageApiKey();
return createVoyageEmbeddingProvider({
config: {} as never,

View File

@ -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

View File

@ -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) {

View File

@ -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,

View File

@ -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"));
});
});

14
vitest.channel-paths.mjs Normal file
View File

@ -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}/**`);

View File

@ -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/**"],
});

View File

@ -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/")),
});

View File

@ -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,

View File

@ -17,9 +17,6 @@ export default defineConfig({
...exclude,
"src/gateway/**",
"extensions/**",
"extensions/telegram/**",
"extensions/discord/**",
"extensions/whatsapp/**",
"src/browser/**",
"src/line/**",
"src/agents/**",