mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-15 02:28:52 +08:00
Compare commits
63 Commits
codex/secu
...
v2026.4.25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a410f05a09 | ||
|
|
55d1a2e0e0 | ||
|
|
c8972376cb | ||
|
|
377041cd75 | ||
|
|
d32a7916bd | ||
|
|
2c625f9368 | ||
|
|
5ea41fe40c | ||
|
|
cec1d46b30 | ||
|
|
a8ba87ee90 | ||
|
|
3f821a8888 | ||
|
|
1a3c480155 | ||
|
|
683437fe61 | ||
|
|
095e1a90f5 | ||
|
|
227a07558b | ||
|
|
773e302179 | ||
|
|
ec71b01f71 | ||
|
|
ca9fb36d53 | ||
|
|
1f194f1d55 | ||
|
|
a188d486dd | ||
|
|
a4266be808 | ||
|
|
90c40e9f90 | ||
|
|
b77514b6d9 | ||
|
|
a813219b6b | ||
|
|
4ac1406644 | ||
|
|
4d0e1470df | ||
|
|
6ecae22943 | ||
|
|
2c5ac5c0e2 | ||
|
|
8c309aa3de | ||
|
|
3c89b16fb0 | ||
|
|
ef447c43c7 | ||
|
|
ddb66a71af | ||
|
|
9b1583112a | ||
|
|
865fde8f72 | ||
|
|
ccc8d71461 | ||
|
|
a947464403 | ||
|
|
63803d78f4 | ||
|
|
dcad0256b2 | ||
|
|
12b1a63b84 | ||
|
|
6ea3f30b9b | ||
|
|
660dcf2c94 | ||
|
|
26ab654da2 | ||
|
|
5bc728d480 | ||
|
|
3779853ef9 | ||
|
|
b4ff947206 | ||
|
|
1e464867e7 | ||
|
|
ea9da71f03 | ||
|
|
1dbc246e29 | ||
|
|
41c7256420 | ||
|
|
b7733c48c0 | ||
|
|
50565b05aa | ||
|
|
2e10d87919 | ||
|
|
0ca3fae91a | ||
|
|
308ba59151 | ||
|
|
6ca5907692 | ||
|
|
b9758bf44a | ||
|
|
b923421129 | ||
|
|
c6276d6b19 | ||
|
|
399b41bbdb | ||
|
|
1ce1713139 | ||
|
|
1768995c37 | ||
|
|
ced0e96cf2 | ||
|
|
dd13141903 | ||
|
|
072a5ae4b0 |
@@ -41,9 +41,16 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
recommended replacement can shift as plugin ownership, externalization, and
|
||||
config footprint move, so do not blindly copy stale replacement annotations
|
||||
into release notes.
|
||||
- Do not delete or rewrite beta tags after they leave the machine. If a
|
||||
published or pushed beta needs a fix, commit the fix on the release branch and
|
||||
- Do not delete or rewrite any beta tag after the matching npm package has been
|
||||
published, or after a GitHub release/prerelease was created from that tag. If
|
||||
an npm-published beta needs a fix, commit the fix on the release branch and
|
||||
increment to the next `-beta.N`.
|
||||
- Beta-only Git tags that were pushed for preflight but never published to npm
|
||||
may be moved or replaced when the operator explicitly approves it. Before
|
||||
retagging, verify `npm view openclaw@YYYY.M.D-beta.N version` is unpublished
|
||||
and no GitHub release/prerelease exists for the tag; after retagging, push the
|
||||
updated tag intentionally and rerun npm preflight because older preflight
|
||||
artifacts are tied to the previous tag SHA.
|
||||
- For a beta release train, run the fast local preflight first, publish the
|
||||
beta to npm `beta`, then run the expensive published-package roster focused
|
||||
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
|
||||
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -6,22 +6,25 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways. Thanks @codex.
|
||||
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex.
|
||||
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.
|
||||
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways.
|
||||
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`.
|
||||
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
|
||||
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
|
||||
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
|
||||
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries. Thanks @codex.
|
||||
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex.
|
||||
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex.
|
||||
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries.
|
||||
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds.
|
||||
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries.
|
||||
- WebChat/Control UI: support non-video file attachments in chat uploads while preserving the existing image attachment path and MIME-sniff fallback for generic image uploads. (#70947) Thanks @IAMSamuelRodda.
|
||||
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
|
||||
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @codex.
|
||||
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans.
|
||||
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
|
||||
|
||||
## 2026.4.26
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation. Thanks @codex.
|
||||
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation.
|
||||
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
|
||||
- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd.
|
||||
- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd.
|
||||
@@ -118,13 +121,37 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.
|
||||
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
|
||||
- Agents/OpenAI: keep Responses web search compatible with minimal thinking by raising `web_search` requests to the lowest supported reasoning effort instead of sending a rejected minimal payload.
|
||||
- Agents/tools: honor the `bundle-mcp` allowlist token when deciding whether bundled MCP tools are available, so restricted tool policies can still enable bundled MCP without exposing unrelated tools.
|
||||
- Agents/model fallback: jump directly to a known later live-session model redirect instead of walking unrelated fallback candidates, while preserving the already-landed live-session/fallback loop guard. Fixes #57471; related loop family already closed via #58496. Thanks @yuxiaoyang2007-prog.
|
||||
- Skills/memory: restore Chokidar v5 hot reloads by watching concrete skill and memory roots with filters, including SKILL.md removals and deleted skill folders without broad workspace recursion. Fixes #27404, #33585, and #41606. Thanks @shelvenzhou, @08820048, and @rocke2020.
|
||||
- Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.
|
||||
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation.
|
||||
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`.
|
||||
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
|
||||
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
|
||||
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
|
||||
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries.
|
||||
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds.
|
||||
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries.
|
||||
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
|
||||
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans.
|
||||
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
|
||||
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
|
||||
- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd.
|
||||
- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd.
|
||||
- Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd.
|
||||
- Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create.
|
||||
- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.
|
||||
- Agents/subagents: deliver completed yielded-subagent results back to no-thread requester routes via direct fallback when the dormant parent announce turn produces no visible reply, and add QA-lab coverage for the regression. Thanks @vincentkoc.
|
||||
- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul.
|
||||
- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd.
|
||||
- CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd.
|
||||
- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd.
|
||||
- Plugins/uninstall: migrate and reset `plugins.slots.contextEngine` alongside memory slots when plugin ids change or selected plugins are removed. Thanks @shakkernerd.
|
||||
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex.
|
||||
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled.
|
||||
- UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns.
|
||||
- Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys.
|
||||
- Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd.
|
||||
@@ -226,7 +253,7 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS/remote SSH: keep discovered gateway hosts in `gateway.remote.sshTarget` while pinning SSH transport URLs to the local loopback tunnel, so browser automation does not regress into blocked non-loopback `ws://` endpoints. Fixes #67336.
|
||||
- Gateway/proxy: bootstrap env proxy dispatching from direct Gateway startup so provider and plugin network requests honor `HTTPS_PROXY`/`HTTP_PROXY` before the first embedded agent attempt runs. (#71833) Thanks @mjamiv.
|
||||
- Plugins/runtime deps: verify clean npm installs actually place requested bundled runtime packages in the managed install root, reporting exact missing specs instead of a false successful repair. (#71883) Thanks @Solvely-Colin.
|
||||
- Plugins/discovery: ignore stale `plugins.load.paths` aliases that point back at packaged bundled plugin directories and have doctor remove them, keeping bundled plugins on the runtime-deps staging path. Thanks @codex.
|
||||
- Plugins/discovery: ignore stale `plugins.load.paths` aliases that point back at packaged bundled plugin directories and have doctor remove them, keeping bundled plugins on the runtime-deps staging path.
|
||||
- Models/LM Studio: preserve `@iq*` quant suffixes in model refs and provider matching so `/model lmstudio/...@iq3_xxs` keeps the exact LM Studio variant. Fixes #71474. (#71486) Thanks @Bartok9, @XinwuC, and @Sanjays2402.
|
||||
- Matrix/cron: preserve the live Matrix delivery target when creating implicit announce reminder jobs so mixed-case room IDs are not reconstructed from lowercased session keys. Fixes #71798.
|
||||
- Feishu: accept Schema 2.0 card action callbacks that report `context.open_chat_id` instead of legacy `context.chat_id`, so button callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068.
|
||||
@@ -4584,7 +4611,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728.
|
||||
- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
|
||||
- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
|
||||
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
|
||||
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881)
|
||||
- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025).
|
||||
- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007).
|
||||
- Agents/Tool-call dispatch: trim whitespace-padded tool names in both transcript repair and live streamed embedded-runner responses so exact-match tool lookup no longer fails with `Tool .. not found` for model outputs like `" read "`. (#27094) Thanks @openperf and @Sid-Qin.
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026042600
|
||||
versionName = "2026.4.26"
|
||||
versionCode = 2026042500
|
||||
versionName = "2026.4.25"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.4.26 - 2026-04-26
|
||||
|
||||
Maintenance update for the current OpenClaw development release.
|
||||
|
||||
## 2026.4.25 - 2026-04-25
|
||||
|
||||
Maintenance update for the current OpenClaw development release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.4.26
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.26
|
||||
OPENCLAW_IOS_VERSION = 2026.4.25
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.25
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.26"
|
||||
"version": "2026.4.25"
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.4.26</string>
|
||||
<string>2026.4.25</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026042600</string>
|
||||
<string>2026042500</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
7fa6e35bb9f9d3096d6281f141488be0dcfe15de40dc4f5c0305eb1ff2bc60b6 config-baseline.json
|
||||
5f5fb87fd46f9cbb84d8af17e00ae3c4b74062e8ad517bc2260ba83da2e9014f config-baseline.core.json
|
||||
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
|
||||
a62ead999508b18d9ea3e1c129e3cdd44244af0ff0e6f81653dfced9aa52019a config-baseline.json
|
||||
3245c9a013c55ee8a24db52d5e88c42bc86e26f822d4a144fc7f37fc71e05fa8 config-baseline.core.json
|
||||
080c0a4f2d4175d6d7ab1e38f76b21de32669055c518d75c96e784865d89bf25 config-baseline.channel.json
|
||||
f9e0174988718959fe1923a54496ec5b9262721fe1e7306f32ccb1316d9d9c3f config-baseline.plugin.json
|
||||
|
||||
@@ -146,6 +146,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
|
||||
## Runtime model
|
||||
|
||||
- Gateway owns the WhatsApp socket and reconnect loop.
|
||||
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window.
|
||||
- Outbound sends require an active WhatsApp listener for the target account.
|
||||
- Status and broadcast chats are ignored (`@status`, `@broadcast`).
|
||||
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
|
||||
@@ -510,6 +511,10 @@ Behavior notes:
|
||||
<Accordion title="Linked but disconnected / reconnect loop">
|
||||
Symptom: linked account with repeated disconnects or reconnect attempts.
|
||||
|
||||
Quiet accounts can stay connected past the normal message timeout; the watchdog
|
||||
restarts when WhatsApp Web transport activity stops, the socket closes, or
|
||||
application-level activity stays silent beyond the longer safety window.
|
||||
|
||||
Fix:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -859,6 +859,7 @@ Notes:
|
||||
- Set `logging.file` for a stable path.
|
||||
- `consoleLevel` bumps to `debug` when `--verbose`.
|
||||
- `maxFileBytes`: maximum active log file size in bytes before rotation (positive integer; default: `104857600` = 100 MB). OpenClaw keeps up to five numbered archives beside the active file.
|
||||
- `redactSensitive` / `redactPatterns`: best-effort masking for console output, file logs, OTLP log records, and persisted session transcript text.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -52,10 +52,12 @@ You can tune console verbosity independently via:
|
||||
- `logging.consoleLevel` (default `info`)
|
||||
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
|
||||
|
||||
## Tool summary redaction
|
||||
## Redaction
|
||||
|
||||
Verbose tool summaries (e.g. `🛠️ Exec: ...`) can mask sensitive tokens before they hit the
|
||||
console stream. This is **tools-only** and does not alter file logs.
|
||||
OpenClaw can mask sensitive tokens before log or transcript output leaves the
|
||||
process. The same redaction policy is applied at console, file-log, OTLP
|
||||
log-record, and session transcript text sinks, so matching secret values are
|
||||
masked before JSONL lines or messages are written to disk.
|
||||
|
||||
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
|
||||
- `logging.redactPatterns`: array of regex strings (overrides defaults)
|
||||
|
||||
@@ -999,7 +999,7 @@ Logs and transcripts can leak sensitive info even when access controls are corre
|
||||
|
||||
Recommendations:
|
||||
|
||||
- Keep tool summary redaction on (`logging.redactSensitive: "tools"`; default).
|
||||
- Keep log and transcript redaction on (`logging.redactSensitive: "tools"`; default).
|
||||
- Add custom patterns for your environment via `logging.redactPatterns` (tokens, hostnames, internal URLs).
|
||||
- When sharing diagnostics, prefer `openclaw status --all` (pasteable, secrets redacted) over raw logs.
|
||||
- Prune old session transcripts and log files if you don’t need long retention.
|
||||
|
||||
@@ -227,10 +227,12 @@ Notes:
|
||||
- `OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL=gpt-5.2`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL=opencode/kimi-k2.6`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_REQUIRE_TRANSCRIPT=1`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON=1`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_PARENT_MODEL=openai/gpt-5.2`
|
||||
- Notes:
|
||||
- This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally.
|
||||
- When `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND` is unset, the test uses the embedded `acpx` plugin's built-in agent registry for the selected ACP harness agent.
|
||||
- Bound-session cron MCP creation is best-effort by default because external ACP harnesses can cancel MCP calls after the bind/image proof has passed; set `OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON=1` to make that post-bind cron probe strict.
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -167,14 +167,16 @@ file log levels.
|
||||
|
||||
### Redaction
|
||||
|
||||
Tool summaries can redact sensitive tokens before they hit the console:
|
||||
OpenClaw can redact sensitive tokens before they hit console output, file logs,
|
||||
OTLP log records, or persisted session transcript text:
|
||||
|
||||
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
|
||||
- `logging.redactPatterns`: list of regex strings to override the default set
|
||||
|
||||
Redaction applies at the logging sinks for **console output**, **stderr-routed
|
||||
console diagnostics**, and **file logs**. File logs stay JSONL, but matching
|
||||
secret values are masked before the line is written to disk.
|
||||
File logs and session transcripts stay JSONL, but matching secret values are
|
||||
masked before the line or message is written to disk. Redaction is best-effort:
|
||||
it applies to text-bearing message content and log strings, not every
|
||||
identifier or binary payload field.
|
||||
|
||||
## Diagnostics and OpenTelemetry
|
||||
|
||||
|
||||
@@ -5,11 +5,7 @@ import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import chokidar, { FSWatcher } from "chokidar";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
buildCaseInsensitiveExtensionGlob,
|
||||
classifyMemoryMultimodalPath,
|
||||
getMemoryMultimodalExtensions,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { classifyMemoryMultimodalPath } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
createSubsystemLogger,
|
||||
onSessionTranscriptUpdate,
|
||||
@@ -105,6 +101,9 @@ function shouldIgnoreMemoryWatchPath(
|
||||
if (stats?.isDirectory?.()) {
|
||||
return false;
|
||||
}
|
||||
if (!stats) {
|
||||
return false;
|
||||
}
|
||||
const extension = normalizeLowercaseStringOrEmpty(path.extname(normalized));
|
||||
if (extension.length === 0 || extension === ".md") {
|
||||
return false;
|
||||
@@ -383,16 +382,7 @@ export abstract class MemoryManagerSyncOps {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
watchPaths.add(path.join(entry, "**", "*.md"));
|
||||
if (this.settings.multimodal.enabled) {
|
||||
for (const modality of this.settings.multimodal.modalities) {
|
||||
for (const extension of getMemoryMultimodalExtensions(modality)) {
|
||||
watchPaths.add(
|
||||
path.join(entry, "**", buildCaseInsensitiveExtensionGlob(extension)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
watchPaths.add(entry);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -422,6 +412,7 @@ export abstract class MemoryManagerSyncOps {
|
||||
this.watcher.on("add", markDirty);
|
||||
this.watcher.on("change", markDirty);
|
||||
this.watcher.on("unlink", markDirty);
|
||||
this.watcher.on("unlinkDir", markDirty);
|
||||
}
|
||||
|
||||
protected ensureSessionListener() {
|
||||
|
||||
@@ -11,12 +11,35 @@ import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js"
|
||||
|
||||
type WatchIgnoredFn = (watchPath: string, stats?: { isDirectory?: () => boolean }) => boolean;
|
||||
|
||||
const { watchMock } = vi.hoisted(() => ({
|
||||
watchMock: vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
close: vi.fn(async () => undefined),
|
||||
})),
|
||||
}));
|
||||
const { createdWatchers, watchMock } = vi.hoisted(() => {
|
||||
type WatchEvent = "add" | "change" | "unlink" | "unlinkDir";
|
||||
type WatchCallback = () => void;
|
||||
function createMockWatcher() {
|
||||
const handlers = new Map<WatchEvent, WatchCallback[]>();
|
||||
const watcher = {
|
||||
on: vi.fn((event: WatchEvent, callback: WatchCallback) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), callback]);
|
||||
return watcher;
|
||||
}),
|
||||
close: vi.fn(async () => undefined),
|
||||
emit: (event: WatchEvent) => {
|
||||
for (const callback of handlers.get(event) ?? []) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
};
|
||||
return watcher;
|
||||
}
|
||||
const watchers: Array<ReturnType<typeof createMockWatcher>> = [];
|
||||
return {
|
||||
createdWatchers: watchers,
|
||||
watchMock: vi.fn(() => {
|
||||
const watcher = createMockWatcher();
|
||||
watchers.push(watcher);
|
||||
return watcher;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("chokidar", () => ({
|
||||
default: { watch: watchMock },
|
||||
@@ -69,7 +92,9 @@ describe("memory watcher config", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
watchMock.mockClear();
|
||||
createdWatchers.length = 0;
|
||||
if (manager) {
|
||||
await manager.close();
|
||||
manager = null;
|
||||
@@ -140,9 +165,10 @@ describe("memory watcher config", () => {
|
||||
expect.arrayContaining([
|
||||
path.join(workspaceDir, "MEMORY.md"),
|
||||
path.join(workspaceDir, "memory"),
|
||||
path.join(extraDir, "**", "*.md"),
|
||||
extraDir,
|
||||
]),
|
||||
);
|
||||
expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true);
|
||||
expect(options.ignoreInitial).toBe(true);
|
||||
expect(options.awaitWriteFinish).toEqual({ stabilityThreshold: 25, pollInterval: 100 });
|
||||
|
||||
@@ -152,15 +178,19 @@ describe("memory watcher config", () => {
|
||||
true,
|
||||
);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", ".venv", "lib", "python.md"))).toBe(true);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"))).toBe(true);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"))).toBe(true);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"), {})).toBe(true);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), {})).toBe(true);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), undefined)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"))).toBe(false);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"), {})).toBe(false);
|
||||
expect(
|
||||
ignored?.(path.join(workspaceDir, "memory", "project"), { isDirectory: () => true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("watches multimodal extensions with case-insensitive globs", async () => {
|
||||
it("watches multimodal extra directories with filtered extensions", async () => {
|
||||
await setupWatcherWorkspace({ name: "PHOTO.PNG", contents: "png" });
|
||||
const cfg = createWatcherConfig({
|
||||
provider: "gemini",
|
||||
@@ -177,16 +207,40 @@ describe("memory watcher config", () => {
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(watchedPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
path.join(extraDir, "**", "*.[pP][nN][gG]"),
|
||||
path.join(extraDir, "**", "*.[wW][aA][vV]"),
|
||||
]),
|
||||
expect.arrayContaining([path.join(workspaceDir, "MEMORY.md"), path.join(extraDir)]),
|
||||
);
|
||||
expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true);
|
||||
|
||||
const ignored = options.ignored as WatchIgnoredFn | undefined;
|
||||
expect(ignored).toBeTypeOf("function");
|
||||
expect(ignored?.(path.join(extraDir, "nested", "PHOTO.PNG"))).toBe(false);
|
||||
expect(ignored?.(path.join(extraDir, "nested", "PHOTO.PNG"), {})).toBe(false);
|
||||
expect(ignored?.(path.join(extraDir, "nested", "voice.WAV"))).toBe(false);
|
||||
expect(ignored?.(path.join(extraDir, "nested", "metadata.json"))).toBe(true);
|
||||
expect(ignored?.(path.join(extraDir, "nested", "voice.WAV"), {})).toBe(false);
|
||||
expect(ignored?.(path.join(extraDir, "nested", "metadata.json"), {})).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["add", "change", "unlink", "unlinkDir"] as const)(
|
||||
"schedules watch sync on %s",
|
||||
async (event) => {
|
||||
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
|
||||
const cfg = createWatcherConfig();
|
||||
|
||||
await expectWatcherManager(cfg);
|
||||
vi.useFakeTimers();
|
||||
const syncSpy = vi
|
||||
.spyOn(
|
||||
manager as unknown as {
|
||||
sync: (params?: { reason?: string }) => Promise<void>;
|
||||
},
|
||||
"sync",
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
createdWatchers[0]?.emit(event);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./test-helpers.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -42,25 +43,57 @@ type WebAutoReplyMonitorHarness = {
|
||||
controller: AbortController;
|
||||
run: Promise<unknown>;
|
||||
};
|
||||
type MockSessionSocket = {
|
||||
ev: { on: ReturnType<typeof vi.fn>; off: ReturnType<typeof vi.fn> };
|
||||
ws: EventEmitter & { close: ReturnType<typeof vi.fn> };
|
||||
user: { id: string };
|
||||
};
|
||||
|
||||
export const TEST_NET_IP = "93.184.216.34";
|
||||
const WEB_AUTO_REPLY_SOCKETS_KEY = Symbol.for("openclaw:webAutoReplySessionSockets");
|
||||
|
||||
function getSessionSockets(): MockSessionSocket[] {
|
||||
const store = globalThis as Record<PropertyKey, unknown>;
|
||||
if (!Array.isArray(store[WEB_AUTO_REPLY_SOCKETS_KEY])) {
|
||||
store[WEB_AUTO_REPLY_SOCKETS_KEY] = [];
|
||||
}
|
||||
return store[WEB_AUTO_REPLY_SOCKETS_KEY] as MockSessionSocket[];
|
||||
}
|
||||
|
||||
vi.mock("./session.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
|
||||
return {
|
||||
...actual,
|
||||
createWaSocket: vi.fn(async () => ({
|
||||
ev: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
ws: { close: vi.fn() },
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
})),
|
||||
createWaSocket: vi.fn(async () => {
|
||||
const ws = new EventEmitter() as MockSessionSocket["ws"];
|
||||
ws.close = vi.fn();
|
||||
const sock: MockSessionSocket = {
|
||||
ev: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
ws,
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
};
|
||||
getSessionSockets().push(sock);
|
||||
return sock;
|
||||
}),
|
||||
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
export function getLastWebAutoReplySessionSocket(): MockSessionSocket {
|
||||
const last = getSessionSockets().at(-1);
|
||||
if (!last) {
|
||||
throw new Error("No WhatsApp Web auto-reply test socket created");
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
export function resetWebAutoReplySessionSockets() {
|
||||
getSessionSockets().length = 0;
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
appendCronStyleCurrentTimeLine: (text: string) => text,
|
||||
@@ -166,6 +199,7 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
resetWebAutoReplySessionSockets();
|
||||
_resetBaileysMocks();
|
||||
_resetLoadConfigMock();
|
||||
if (opts?.pinDns) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createMockWebListener,
|
||||
createScriptedWebListenerFactory,
|
||||
createWebListenerFactoryCapture,
|
||||
getLastWebAutoReplySessionSocket,
|
||||
installWebAutoReplyTestHomeHooks,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
makeSessionStore,
|
||||
@@ -255,6 +256,92 @@ describe("web auto-reply connection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps quiet linked-device sessions open when transport frames keep arriving", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const sleep = vi.fn(async () => {});
|
||||
const scripted = createScriptedWebListenerFactory();
|
||||
const { controller, run } = startWebAutoReplyMonitor({
|
||||
monitorWebChannelFn: monitorWebChannel as never,
|
||||
listenerFactory: scripted.listenerFactory,
|
||||
sleep,
|
||||
heartbeatSeconds: 60,
|
||||
messageTimeoutMs: 30,
|
||||
watchdogCheckMs: 5,
|
||||
});
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(scripted.getListenerCount()).toBe(1);
|
||||
},
|
||||
{ timeout: 250, interval: 2 },
|
||||
);
|
||||
|
||||
const socket = getLastWebAutoReplySessionSocket();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
socket.ws.emit("frame");
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
socket.ws.emit("frame");
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
|
||||
expect(scripted.getListenerCount()).toBe(1);
|
||||
|
||||
controller.abort();
|
||||
scripted.resolveClose(0, { status: 499, isLoggedOut: false });
|
||||
await Promise.resolve();
|
||||
await run;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let transport frames mask application silence forever", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const sleep = vi.fn(async () => {});
|
||||
const scripted = createScriptedWebListenerFactory();
|
||||
const { controller, run } = startWebAutoReplyMonitor({
|
||||
monitorWebChannelFn: monitorWebChannel as never,
|
||||
listenerFactory: scripted.listenerFactory,
|
||||
sleep,
|
||||
heartbeatSeconds: 60,
|
||||
messageTimeoutMs: 30,
|
||||
watchdogCheckMs: 5,
|
||||
});
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(scripted.getListenerCount()).toBe(1);
|
||||
},
|
||||
{ timeout: 250, interval: 2 },
|
||||
);
|
||||
|
||||
const socket = getLastWebAutoReplySessionSocket();
|
||||
for (let elapsedMs = 0; elapsedMs < 140; elapsedMs += 20) {
|
||||
socket.ws.emit("frame");
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
}
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
{ timeout: 250, interval: 2 },
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
scripted.resolveClose(scripted.getListenerCount() - 1, {
|
||||
status: 499,
|
||||
isLoggedOut: false,
|
||||
error: "aborted",
|
||||
});
|
||||
await Promise.resolve();
|
||||
await run;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a reconnected listener a fresh watchdog window", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -280,6 +280,7 @@ export async function monitorWebChannel(
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
messagesHandled: snapshot.handledMessages,
|
||||
lastInboundAt: snapshot.lastInboundAt,
|
||||
lastTransportActivityAt: snapshot.lastTransportActivityAt,
|
||||
authAgeMs,
|
||||
uptimeMs: snapshot.uptimeMs,
|
||||
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
|
||||
@@ -297,20 +298,28 @@ export async function monitorWebChannel(
|
||||
}
|
||||
},
|
||||
onWatchdogTimeout: (snapshot) => {
|
||||
const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
|
||||
const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000);
|
||||
const now = Date.now();
|
||||
const transportSilentMs = now - snapshot.lastTransportActivityAt;
|
||||
const appBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
|
||||
const minutesSinceTransportActivity = Math.floor(transportSilentMs / 60000);
|
||||
const minutesSinceAppActivity = Math.floor((now - appBaselineAt) / 60000);
|
||||
const watchdogReason =
|
||||
transportSilentMs > messageTimeoutMs ? "transport-inactive" : "app-silent";
|
||||
statusController.noteWatchdogStale();
|
||||
heartbeatLogger.warn(
|
||||
{
|
||||
connectionId: snapshot.connectionId,
|
||||
minutesSinceLastMessage,
|
||||
watchdogReason,
|
||||
minutesSinceTransportActivity,
|
||||
minutesSinceAppActivity,
|
||||
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
|
||||
lastTransportActivityAt: new Date(snapshot.lastTransportActivityAt),
|
||||
messagesHandled: snapshot.handledMessages,
|
||||
},
|
||||
"Message timeout detected - forcing reconnect",
|
||||
"WhatsApp watchdog timeout detected - forcing reconnect",
|
||||
);
|
||||
whatsappHeartbeatLog.warn(
|
||||
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
|
||||
`WhatsApp watchdog timeout (${watchdogReason}) - restarting connection`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,8 +40,10 @@ export type WhatsAppLiveConnection = {
|
||||
heartbeat: TimerHandle | null;
|
||||
watchdogTimer: TimerHandle | null;
|
||||
lastInboundAt: number | null;
|
||||
lastTransportActivityAt: number;
|
||||
handledMessages: number;
|
||||
unregisterUnhandled: (() => void) | null;
|
||||
unregisterTransportActivity: (() => void) | null;
|
||||
backgroundTasks: Set<Promise<unknown>>;
|
||||
closePromise: Promise<WebListenerCloseReason>;
|
||||
resolveClose: (reason: WebListenerCloseReason) => void;
|
||||
@@ -51,6 +53,7 @@ export type WhatsAppConnectionSnapshot = {
|
||||
connectionId: string;
|
||||
startedAt: number;
|
||||
lastInboundAt: number | null;
|
||||
lastTransportActivityAt: number;
|
||||
handledMessages: number;
|
||||
reconnectAttempts: number;
|
||||
uptimeMs: number;
|
||||
@@ -83,6 +86,12 @@ function createNeverResolvePromise<T>(): Promise<T> {
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
|
||||
type SocketActivityEmitter = {
|
||||
on?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
|
||||
function createLiveConnection(params: {
|
||||
connectionId: string;
|
||||
sock: WASocket;
|
||||
@@ -108,8 +117,10 @@ function createLiveConnection(params: {
|
||||
heartbeat: null,
|
||||
watchdogTimer: null,
|
||||
lastInboundAt: null,
|
||||
lastTransportActivityAt: Date.now(),
|
||||
handledMessages: 0,
|
||||
unregisterUnhandled: null,
|
||||
unregisterTransportActivity: null,
|
||||
backgroundTasks: new Set<Promise<unknown>>(),
|
||||
closePromise,
|
||||
resolveClose: resolveClosePromise,
|
||||
@@ -232,6 +243,7 @@ export class WhatsAppConnectionController {
|
||||
private readonly heartbeatSeconds: number;
|
||||
private readonly keepAlive: boolean;
|
||||
private readonly messageTimeoutMs: number;
|
||||
private readonly appSilenceTimeoutMs: number;
|
||||
private readonly watchdogCheckMs: number;
|
||||
private readonly verbose: boolean;
|
||||
private readonly abortSignal?: AbortSignal;
|
||||
@@ -262,6 +274,7 @@ export class WhatsAppConnectionController {
|
||||
this.keepAlive = params.keepAlive;
|
||||
this.heartbeatSeconds = params.heartbeatSeconds;
|
||||
this.messageTimeoutMs = params.messageTimeoutMs;
|
||||
this.appSilenceTimeoutMs = Math.max(params.messageTimeoutMs, params.messageTimeoutMs * 4);
|
||||
this.watchdogCheckMs = params.watchdogCheckMs;
|
||||
this.reconnectPolicy = params.reconnectPolicy;
|
||||
this.abortSignal = params.abortSignal;
|
||||
@@ -311,6 +324,14 @@ export class WhatsAppConnectionController {
|
||||
}
|
||||
this.current.handledMessages += 1;
|
||||
this.current.lastInboundAt = timestamp;
|
||||
this.current.lastTransportActivityAt = timestamp;
|
||||
}
|
||||
|
||||
noteTransportActivity(timestamp = Date.now()): void {
|
||||
if (!this.current) {
|
||||
return;
|
||||
}
|
||||
this.current.lastTransportActivityAt = timestamp;
|
||||
}
|
||||
|
||||
getCurrentSnapshot(
|
||||
@@ -323,6 +344,7 @@ export class WhatsAppConnectionController {
|
||||
connectionId: connection.connectionId,
|
||||
startedAt: connection.startedAt,
|
||||
lastInboundAt: connection.lastInboundAt,
|
||||
lastTransportActivityAt: connection.lastTransportActivityAt,
|
||||
handledMessages: connection.handledMessages,
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
uptimeMs: Date.now() - connection.startedAt,
|
||||
@@ -369,6 +391,7 @@ export class WhatsAppConnectionController {
|
||||
const listener = await params.createListener({ sock, connection });
|
||||
connection.listener = listener;
|
||||
this.current = connection;
|
||||
connection.unregisterTransportActivity = this.attachTransportActivityListener(sock);
|
||||
registerWhatsAppConnectionController(this.accountId, this);
|
||||
this.startTimers(connection, {
|
||||
onHeartbeat: params.onHeartbeat,
|
||||
@@ -383,6 +406,7 @@ export class WhatsAppConnectionController {
|
||||
if (connection?.unregisterUnhandled) {
|
||||
connection.unregisterUnhandled();
|
||||
}
|
||||
connection?.unregisterTransportActivity?.();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -515,6 +539,7 @@ export class WhatsAppConnectionController {
|
||||
this.socketRef.current = null;
|
||||
}
|
||||
connection.unregisterUnhandled?.();
|
||||
connection.unregisterTransportActivity?.();
|
||||
if (connection.heartbeat) {
|
||||
clearInterval(connection.heartbeat);
|
||||
}
|
||||
@@ -563,9 +588,14 @@ export class WhatsAppConnectionController {
|
||||
}, this.heartbeatSeconds * 1000);
|
||||
|
||||
connection.watchdogTimer = setInterval(() => {
|
||||
const baselineAt = connection.lastInboundAt ?? connection.startedAt;
|
||||
const staleForMs = Date.now() - baselineAt;
|
||||
if (staleForMs <= this.messageTimeoutMs) {
|
||||
const now = Date.now();
|
||||
const transportStaleForMs = now - connection.lastTransportActivityAt;
|
||||
const appBaselineAt = connection.lastInboundAt ?? connection.startedAt;
|
||||
const appSilentForMs = now - appBaselineAt;
|
||||
if (
|
||||
transportStaleForMs <= this.messageTimeoutMs &&
|
||||
appSilentForMs <= this.appSilenceTimeoutMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const snapshot = this.getCurrentSnapshot(connection);
|
||||
@@ -581,6 +611,24 @@ export class WhatsAppConnectionController {
|
||||
}, this.watchdogCheckMs);
|
||||
}
|
||||
|
||||
private attachTransportActivityListener(sock: WASocket): (() => void) | null {
|
||||
const ws = sock.ws as SocketActivityEmitter | undefined;
|
||||
if (!ws || typeof ws.on !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noteActivity = () => this.noteTransportActivity();
|
||||
ws.on("frame", noteActivity);
|
||||
|
||||
return () => {
|
||||
if (typeof ws.off === "function") {
|
||||
ws.off("frame", noteActivity);
|
||||
return;
|
||||
}
|
||||
ws.removeListener?.("frame", noteActivity);
|
||||
};
|
||||
}
|
||||
|
||||
private stopDisconnectRetries(): void {
|
||||
if (!this.disconnectRetryController.signal.aborted) {
|
||||
this.disconnectRetryController.abort();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.4.26",
|
||||
"version": "2026.4.25-beta.10",
|
||||
"description": "Multi-channel AI gateway with extensible messaging integrations",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/openclaw/openclaw#readme",
|
||||
|
||||
@@ -18,7 +18,7 @@ $ErrorActionPreference = "Stop"
|
||||
$ACCENT = "`e[38;2;255;77;77m" # coral-bright
|
||||
$SUCCESS = "`e[38;2;0;229;204m" # cyan-bright
|
||||
$WARN = "`e[38;2;255;176;32m" # amber
|
||||
$ERROR = "`e[38;2;230;57;70m" # coral-mid
|
||||
$ERROR_COLOR = "`e[38;2;230;57;70m" # coral-mid
|
||||
$MUTED = "`e[38;2;90;100;128m" # text-muted
|
||||
$NC = "`e[0m" # No Color
|
||||
|
||||
@@ -27,7 +27,7 @@ function Write-Host {
|
||||
$msg = switch ($Level) {
|
||||
"success" { "$SUCCESS✓$NC $Message" }
|
||||
"warn" { "$WARN!$NC $Message" }
|
||||
"error" { "$ERROR✗$NC $Message" }
|
||||
"error" { "$ERROR_COLOR✗$NC $Message" }
|
||||
default { "$MUTED·$NC $Message" }
|
||||
}
|
||||
Microsoft.PowerShell.Utility\Write-Host $msg
|
||||
|
||||
@@ -148,6 +148,7 @@ exec "\$script_dir/claude-real" "\$@"
|
||||
WRAP
|
||||
chmod +x "$NPM_CONFIG_PREFIX/bin/claude"
|
||||
fi
|
||||
export CLAUDE_CODE_EXECUTABLE="$NPM_CONFIG_PREFIX/bin/claude"
|
||||
claude auth status || true
|
||||
;;
|
||||
codex)
|
||||
@@ -162,8 +163,8 @@ WRAP
|
||||
fi
|
||||
droid --version
|
||||
if [ -z "${FACTORY_API_KEY:-}" ]; then
|
||||
echo "Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
|
||||
exit 1
|
||||
echo "SKIP: Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
gemini)
|
||||
@@ -262,6 +263,16 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do
|
||||
DOCKER_AUTH_PRESTAGED=1
|
||||
fi
|
||||
|
||||
if [[ "$ACP_AGENT" == "droid" && -z "${FACTORY_API_KEY:-}" ]]; then
|
||||
echo "==> Run ACP bind live test in Docker"
|
||||
echo "==> Agent: $ACP_AGENT"
|
||||
echo "==> Profile file: $PROFILE_STATUS"
|
||||
echo "==> Auth dirs: ${AUTH_DIRS_CSV:-none}"
|
||||
echo "==> Auth files: ${AUTH_FILES_CSV:-none}"
|
||||
echo "SKIP: Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
EXTERNAL_AUTH_MOUNTS=()
|
||||
if ((${#AUTH_DIRS[@]} > 0)); then
|
||||
for auth_dir in "${AUTH_DIRS[@]}"; do
|
||||
|
||||
@@ -707,6 +707,55 @@ describe("runWithModelFallback", () => {
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("jumps directly to a later live-session model switch candidate (#57471)", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: [
|
||||
"anthropic/claude-haiku-3-5",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"openrouter/deepseek-chat",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const switchError = new LiveSessionModelSwitchError({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
const run = vi.fn(async (provider: string, model: string) => {
|
||||
if (provider === "openai" && model === "gpt-4.1-mini") {
|
||||
throw switchError;
|
||||
}
|
||||
if (provider === "anthropic" && model === "claude-sonnet-4-6") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error(`unexpected fallback candidate: ${provider}/${model}`);
|
||||
});
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(result.provider).toBe("anthropic");
|
||||
expect(result.model).toBe("claude-sonnet-4-6");
|
||||
expect(result.attempts).toEqual([]);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
expect(run.mock.calls).toEqual([
|
||||
["openai", "gpt-4.1-mini"],
|
||||
["anthropic", "claude-sonnet-4-6"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back on auth errors", async () => {
|
||||
await expectFallsBackToHaiku({
|
||||
provider: "openai",
|
||||
|
||||
@@ -326,6 +326,18 @@ function recordFailedCandidateAttempt(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function findLaterLiveSessionModelSwitchCandidateIndex(params: {
|
||||
error: LiveSessionModelSwitchError;
|
||||
candidates: ModelCandidate[];
|
||||
currentIndex: number;
|
||||
}): number | null {
|
||||
const targetKey = modelKey(params.error.provider, params.error.model);
|
||||
const targetIndex = params.candidates.findIndex(
|
||||
(candidate) => modelKey(candidate.provider, candidate.model) === targetKey,
|
||||
);
|
||||
return targetIndex > params.currentIndex ? targetIndex : null;
|
||||
}
|
||||
|
||||
function throwFallbackFailureSummary(params: {
|
||||
attempts: FallbackAttempt[];
|
||||
candidates: ModelCandidate[];
|
||||
@@ -924,6 +936,16 @@ export async function runWithModelFallback<T>(params: {
|
||||
// instead of re-throwing and triggering infinite retry loops in the
|
||||
// outer runner. (#58466)
|
||||
if (err instanceof LiveSessionModelSwitchError) {
|
||||
const liveSwitchTargetIndex = findLaterLiveSessionModelSwitchCandidateIndex({
|
||||
error: err,
|
||||
candidates,
|
||||
currentIndex: i,
|
||||
});
|
||||
if (liveSwitchTargetIndex !== null) {
|
||||
i = liveSwitchTargetIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const switchMsg = err.message;
|
||||
const switchNormalized = new FailoverError(switchMsg, {
|
||||
reason: "overloaded",
|
||||
|
||||
@@ -700,6 +700,36 @@ describe("applyExtraParamsToAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps OpenAI Responses web_search compatible when thinking is minimal", () => {
|
||||
const payload = runResponsesPayloadMutationCase({
|
||||
applyProvider: "openai",
|
||||
applyModelId: "gpt-5",
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
id: "gpt-5",
|
||||
baseUrl: "http://127.0.0.1:19191/v1",
|
||||
reasoning: true,
|
||||
} as Model<"openai-responses">,
|
||||
payload: {
|
||||
model: "gpt-5",
|
||||
input: [],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "web_search",
|
||||
description: "Search the web",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
reasoning: { effort: "low", summary: "auto" },
|
||||
},
|
||||
thinkingLevel: "minimal",
|
||||
});
|
||||
|
||||
expect(payload.reasoning).toEqual({ effort: "low", summary: "auto" });
|
||||
});
|
||||
|
||||
it("strips disabled reasoning payloads for proxied OpenAI responses routes", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
|
||||
import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
|
||||
|
||||
@@ -35,4 +36,46 @@ describe("guardSessionManager integration", () => {
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("redacts configured text patterns before persisting transcript messages", () => {
|
||||
const cfg = {
|
||||
logging: {
|
||||
redactSensitive: "tools",
|
||||
redactPatterns: [String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const sm = guardSessionManager(SessionManager.inMemory(), { config: cfg });
|
||||
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
|
||||
|
||||
appendMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "the email is peter@dc.io", thinkingSignature: "sig" },
|
||||
{ type: "text", text: "contact peter@dc.io" },
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "/tmp/peter@dc.io" } },
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
} as AgentMessage);
|
||||
appendMessage({
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "peter@dc.io\n" }],
|
||||
isError: false,
|
||||
} as AgentMessage);
|
||||
|
||||
const messages = sm
|
||||
.getEntries()
|
||||
.filter((e) => e.type === "message")
|
||||
.map((e) => (e as { message: AgentMessage }).message);
|
||||
const serialized = JSON.stringify(messages);
|
||||
|
||||
expect(serialized).not.toContain("the email is peter@dc.io");
|
||||
expect(serialized).not.toContain("contact peter@dc.io");
|
||||
expect(serialized).not.toContain("peter@dc.io\\n");
|
||||
expect(serialized).toContain('"thinking":"the email is peter@d***.io"');
|
||||
expect(serialized).toContain('"text":"contact peter@d***.io"');
|
||||
expect(serialized).toContain('"text":"peter@d***.io\\n"');
|
||||
expect(serialized).toContain('"/tmp/peter@dc.io"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +159,32 @@ describe("createOpenAIThinkingLevelWrapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("raises minimal reasoning for web_search on loopback Responses routes", () => {
|
||||
const payloads: Array<Record<string, unknown>> = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
reasoning: { effort: "minimal", summary: "auto" },
|
||||
tools: [{ type: "function", name: "web_search" }],
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
payloads.push(structuredClone(payload));
|
||||
return createAssistantMessageEventStream();
|
||||
};
|
||||
const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "minimal");
|
||||
void wrapped(
|
||||
{
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
id: "gpt-5",
|
||||
baseUrl: "http://127.0.0.1:19191/v1",
|
||||
} as Model<"openai-responses">,
|
||||
{ messages: [] },
|
||||
{},
|
||||
);
|
||||
|
||||
expect(payloads[0]?.reasoning).toEqual({ effort: "low", summary: "auto" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
api: "openai-responses",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolveCodexNativeSearchActivation,
|
||||
} from "../codex-native-web-search.js";
|
||||
import { flattenCompletionMessagesToStringContent } from "../openai-completions-string-content.js";
|
||||
import { resolveOpenAIReasoningEffortForModel } from "../openai-reasoning-effort.js";
|
||||
import {
|
||||
applyOpenAIResponsesPayloadPolicy,
|
||||
resolveOpenAIResponsesPayloadPolicy,
|
||||
@@ -85,6 +86,66 @@ function shouldFlattenOpenAICompletionMessages(model: {
|
||||
return model.api === "openai-completions" && compat?.requiresStringContent === true;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function hasResponsesWebSearchTool(tools: unknown): boolean {
|
||||
if (!Array.isArray(tools)) {
|
||||
return false;
|
||||
}
|
||||
return tools.some((tool) => {
|
||||
if (!isRecord(tool)) {
|
||||
return false;
|
||||
}
|
||||
if (tool.type === "web_search") {
|
||||
return true;
|
||||
}
|
||||
if (tool.type === "function" && tool.name === "web_search") {
|
||||
return true;
|
||||
}
|
||||
const fn = tool.function;
|
||||
return isRecord(fn) && fn.name === "web_search";
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOpenAIThinkingPayloadEffort(params: {
|
||||
model: { provider?: unknown; id?: unknown; baseUrl?: unknown; api?: unknown; compat?: unknown };
|
||||
payloadObj: Record<string, unknown>;
|
||||
thinkingLevel: ThinkLevel;
|
||||
}) {
|
||||
const mapped = mapThinkingLevelToReasoningEffort(params.thinkingLevel);
|
||||
if (mapped !== "minimal" || !hasResponsesWebSearchTool(params.payloadObj.tools)) {
|
||||
return mapped;
|
||||
}
|
||||
return (
|
||||
resolveOpenAIReasoningEffortForModel({
|
||||
model: params.model,
|
||||
effort: "low",
|
||||
}) ?? mapped
|
||||
);
|
||||
}
|
||||
|
||||
function raiseMinimalReasoningForResponsesWebSearchPayload(params: {
|
||||
model: { provider?: unknown; id?: unknown; baseUrl?: unknown; api?: unknown; compat?: unknown };
|
||||
payloadObj: Record<string, unknown>;
|
||||
}): void {
|
||||
const reasoning = params.payloadObj.reasoning;
|
||||
if (!isRecord(reasoning) || reasoning.effort !== "minimal") {
|
||||
return;
|
||||
}
|
||||
if (!hasResponsesWebSearchTool(params.payloadObj.tools)) {
|
||||
return;
|
||||
}
|
||||
const nextEffort = resolveOpenAIReasoningEffortForModel({
|
||||
model: params.model,
|
||||
effort: "low",
|
||||
});
|
||||
if (nextEffort && nextEffort !== "minimal" && nextEffort !== "none") {
|
||||
reasoning.effort = nextEffort;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpenAIServiceTier(value: unknown): OpenAIServiceTier | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
@@ -240,7 +301,12 @@ export function createOpenAIThinkingLevelWrapper(
|
||||
}
|
||||
return (model, context, options) => {
|
||||
if (!shouldApplyOpenAIReasoningCompatibility(model)) {
|
||||
return underlying(model, context, options);
|
||||
if (thinkingLevel === "off") {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
raiseMinimalReasoningForResponsesWebSearchPayload({ model, payloadObj });
|
||||
});
|
||||
}
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
const existingReasoning = payloadObj.reasoning;
|
||||
@@ -251,8 +317,13 @@ export function createOpenAIThinkingLevelWrapper(
|
||||
return;
|
||||
}
|
||||
|
||||
const reasoningEffort = resolveOpenAIThinkingPayloadEffort({
|
||||
model,
|
||||
payloadObj,
|
||||
thinkingLevel,
|
||||
});
|
||||
if (existingReasoning === "none") {
|
||||
payloadObj.reasoning = { effort: mapThinkingLevelToReasoningEffort(thinkingLevel) };
|
||||
payloadObj.reasoning = { effort: reasoningEffort };
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -260,8 +331,8 @@ export function createOpenAIThinkingLevelWrapper(
|
||||
typeof existingReasoning === "object" &&
|
||||
!Array.isArray(existingReasoning)
|
||||
) {
|
||||
(existingReasoning as Record<string, unknown>).effort =
|
||||
mapThinkingLevelToReasoningEffort(thinkingLevel);
|
||||
(existingReasoning as Record<string, unknown>).effort = reasoningEffort;
|
||||
raiseMinimalReasoningForResponsesWebSearchPayload({ model, payloadObj });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -117,6 +117,12 @@ describe("shouldCreateBundleMcpRuntimeForAttempt", () => {
|
||||
toolsAllow: ["memory_search", "memory_get"],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldCreateBundleMcpRuntimeForAttempt({
|
||||
toolsEnabled: true,
|
||||
toolsAllow: ["bundle-mcp"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldCreateBundleMcpRuntimeForAttempt({
|
||||
toolsEnabled: true,
|
||||
|
||||
@@ -490,7 +490,9 @@ export function shouldCreateBundleMcpRuntimeForAttempt(params: {
|
||||
if (!params.toolsAllow || params.toolsAllow.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return params.toolsAllow.some((toolName) => toolName.includes(TOOL_NAME_SEPARATOR));
|
||||
return params.toolsAllow.some(
|
||||
(toolName) => toolName === "bundle-mcp" || toolName.includes(TOOL_NAME_SEPARATOR),
|
||||
);
|
||||
}
|
||||
|
||||
function collectAttemptExplicitToolAllowlistSources(params: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import {
|
||||
applyInputProvenanceToUserMessage,
|
||||
@@ -16,6 +17,71 @@ export type GuardedSessionManager = SessionManager & {
|
||||
clearPendingToolResults?: () => void;
|
||||
};
|
||||
|
||||
function redactTranscriptText(value: string, cfg?: OpenClawConfig): string {
|
||||
if (cfg?.logging?.redactSensitive === "off") {
|
||||
return value;
|
||||
}
|
||||
return redactSensitiveText(value, {
|
||||
mode: cfg?.logging?.redactSensitive,
|
||||
patterns: cfg?.logging?.redactPatterns,
|
||||
});
|
||||
}
|
||||
|
||||
function redactTranscriptContentBlock(block: unknown, cfg?: OpenClawConfig): unknown {
|
||||
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
||||
return block;
|
||||
}
|
||||
const source = block as Record<string, unknown>;
|
||||
let next: Record<string, unknown> | null = null;
|
||||
const assign = (key: string, value: string) => {
|
||||
const redacted = redactTranscriptText(value, cfg);
|
||||
if (redacted === value) {
|
||||
return;
|
||||
}
|
||||
next ??= { ...source };
|
||||
next[key] = redacted;
|
||||
};
|
||||
|
||||
if (typeof source.text === "string") {
|
||||
assign("text", source.text);
|
||||
}
|
||||
if (typeof source.thinking === "string") {
|
||||
assign("thinking", source.thinking);
|
||||
}
|
||||
if (typeof source.partialJson === "string") {
|
||||
assign("partialJson", source.partialJson);
|
||||
}
|
||||
return next ?? block;
|
||||
}
|
||||
|
||||
function redactTranscriptContent(content: unknown, cfg?: OpenClawConfig): unknown {
|
||||
if (typeof content === "string") {
|
||||
return redactTranscriptText(content, cfg);
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
let changed = false;
|
||||
const redacted = content.map((block) => {
|
||||
const next = redactTranscriptContentBlock(block, cfg);
|
||||
changed ||= next !== block;
|
||||
return next;
|
||||
});
|
||||
return changed ? redacted : content;
|
||||
}
|
||||
|
||||
function redactTranscriptMessage(message: AgentMessage, cfg?: OpenClawConfig): AgentMessage {
|
||||
const source = message as unknown as Record<string, unknown>;
|
||||
const redactedContent = redactTranscriptContent(source.content, cfg);
|
||||
if (redactedContent === source.content) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
content: redactedContent,
|
||||
} as unknown as AgentMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the tool-result guard to a SessionManager exactly once and expose
|
||||
* a flush method on the instance for easy teardown handling.
|
||||
@@ -38,14 +104,31 @@ export function guardSessionManager(
|
||||
}
|
||||
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const beforeMessageWrite = hookRunner?.hasHooks("before_message_write")
|
||||
? (event: { message: import("@mariozechner/pi-agent-core").AgentMessage }) => {
|
||||
return hookRunner.runBeforeMessageWrite(event, {
|
||||
agentId: opts?.agentId,
|
||||
sessionKey: opts?.sessionKey,
|
||||
});
|
||||
const beforeMessageWrite = (event: {
|
||||
message: import("@mariozechner/pi-agent-core").AgentMessage;
|
||||
}) => {
|
||||
let message = event.message;
|
||||
let changed = false;
|
||||
if (hookRunner?.hasHooks("before_message_write")) {
|
||||
const result = hookRunner.runBeforeMessageWrite(event, {
|
||||
agentId: opts?.agentId,
|
||||
sessionKey: opts?.sessionKey,
|
||||
});
|
||||
if (result?.block) {
|
||||
return result;
|
||||
}
|
||||
: undefined;
|
||||
if (result?.message) {
|
||||
message = result.message;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const redacted = redactTranscriptMessage(message, opts?.config);
|
||||
if (redacted !== message) {
|
||||
message = redacted;
|
||||
changed = true;
|
||||
}
|
||||
return changed ? { message } : undefined;
|
||||
};
|
||||
|
||||
const transform = hookRunner?.hasHooks("tool_result_persist")
|
||||
? (
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SkillsChangeEvent } from "./refresh.js";
|
||||
|
||||
const watchMock = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
close: vi.fn(async () => undefined),
|
||||
}));
|
||||
type WatchEvent = "add" | "change" | "unlink" | "unlinkDir" | "error";
|
||||
type WatchCallback = (watchPath: string) => void;
|
||||
|
||||
function createMockWatcher() {
|
||||
const handlers = new Map<WatchEvent, WatchCallback[]>();
|
||||
const watcher = {
|
||||
on: vi.fn((event: WatchEvent, callback: WatchCallback) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), callback]);
|
||||
return watcher;
|
||||
}),
|
||||
close: vi.fn(async () => undefined),
|
||||
emit: (event: WatchEvent, watchPath: string) => {
|
||||
for (const callback of handlers.get(event) ?? []) {
|
||||
callback(watchPath);
|
||||
}
|
||||
},
|
||||
};
|
||||
return watcher;
|
||||
}
|
||||
|
||||
const createdWatchers: Array<ReturnType<typeof createMockWatcher>> = [];
|
||||
const watchMock = vi.fn(() => {
|
||||
const watcher = createMockWatcher();
|
||||
createdWatchers.push(watcher);
|
||||
return watcher;
|
||||
});
|
||||
|
||||
let refreshModule: typeof import("./refresh.js");
|
||||
|
||||
@@ -24,13 +47,15 @@ describe("ensureSkillsWatcher", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
watchMock.mockClear();
|
||||
createdWatchers.length = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await refreshModule.resetSkillsRefreshForTest();
|
||||
});
|
||||
|
||||
it("ignores node_modules, dist, .git, and Python venvs by default", async () => {
|
||||
it("watches skill roots and filters non-skill churn", async () => {
|
||||
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
|
||||
|
||||
expect(watchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -40,49 +65,64 @@ describe("ensureSkillsWatcher", () => {
|
||||
const targets = firstCall?.[0] ?? [];
|
||||
const opts = firstCall?.[1] ?? {};
|
||||
|
||||
expect(opts.ignored).toBe(refreshModule.DEFAULT_SKILLS_WATCH_IGNORED);
|
||||
expect(opts.ignored).toBe(refreshModule.shouldIgnoreSkillsWatchPath);
|
||||
const posix = (p: string) => p.replaceAll("\\", "/");
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
posix(path.join("/tmp/workspace", "skills", "SKILL.md")),
|
||||
posix(path.join("/tmp/workspace", "skills", "*", "SKILL.md")),
|
||||
posix(path.join("/tmp/workspace", ".agents", "skills", "SKILL.md")),
|
||||
posix(path.join("/tmp/workspace", ".agents", "skills", "*", "SKILL.md")),
|
||||
posix(path.join(os.homedir(), ".agents", "skills", "SKILL.md")),
|
||||
posix(path.join(os.homedir(), ".agents", "skills", "*", "SKILL.md")),
|
||||
posix(path.join("/tmp/workspace", "skills")),
|
||||
posix(path.join("/tmp/workspace", ".agents", "skills")),
|
||||
posix(path.join(os.homedir(), ".agents", "skills")),
|
||||
]),
|
||||
);
|
||||
expect(targets.every((target) => target.includes("SKILL.md"))).toBe(true);
|
||||
const ignored = refreshModule.DEFAULT_SKILLS_WATCH_IGNORED;
|
||||
expect(targets.every((target) => !target.includes("*"))).toBe(true);
|
||||
const ignored = refreshModule.shouldIgnoreSkillsWatchPath;
|
||||
|
||||
// Node/JS paths
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/dist/index.js"))).toBe(true);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.git/config"))).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/node_modules/pkg/index.js")).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/dist/index.js")).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/.git/config")).toBe(true);
|
||||
|
||||
// Python virtual environments and caches
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/scripts/.venv/bin/python"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/venv/lib/python3.10/site.py"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/__pycache__/module.pyc"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.mypy_cache/3.10/foo.json"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.pytest_cache/v/cache"))).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/scripts/.venv/bin/python")).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/venv/lib/python3.10/site.py")).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/__pycache__/module.pyc")).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/.mypy_cache/3.10/foo.json")).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/.pytest_cache/v/cache")).toBe(true);
|
||||
|
||||
// Build artifacts and caches
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/build/output.js"))).toBe(true);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.cache/data.json"))).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/build/output.js")).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/.cache/data.json")).toBe(true);
|
||||
|
||||
// Should NOT ignore normal skill files
|
||||
expect(ignored.some((re) => re.test("/tmp/.hidden/skills/index.md"))).toBe(false);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/my-skill/SKILL.md"))).toBe(false);
|
||||
expect(ignored("/tmp/.hidden/skills/index.md")).toBe(false);
|
||||
expect(ignored("/tmp/workspace/skills/my-skill", { isDirectory: () => true })).toBe(false);
|
||||
expect(ignored("/tmp/workspace/skills/my-skill/README.md", {})).toBe(true);
|
||||
expect(ignored("/tmp/workspace/skills/my-skill/SKILL.md", {})).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["add", "change", "unlink", "unlinkDir"] as const)(
|
||||
"refreshes skills snapshots on %s",
|
||||
async (event) => {
|
||||
vi.useFakeTimers();
|
||||
const seen: SkillsChangeEvent[] = [];
|
||||
refreshModule.registerSkillsChangeListener((change) => {
|
||||
seen.push(change);
|
||||
});
|
||||
refreshModule.ensureSkillsWatcher({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
config: { skills: { load: { watchDebounceMs: 10 } } },
|
||||
});
|
||||
|
||||
createdWatchers[0]?.emit(event, "/tmp/workspace/skills/demo/SKILL.md");
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
workspaceDir: "/tmp/workspace",
|
||||
reason: "watch",
|
||||
changedPath: "/tmp/workspace/skills/demo/SKILL.md",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,26 +72,36 @@ function resolveWatchPaths(workspaceDir: string, config?: OpenClawConfig): strin
|
||||
return paths;
|
||||
}
|
||||
|
||||
function toWatchGlobRoot(raw: string): string {
|
||||
// Chokidar treats globs as POSIX-ish patterns. Normalize Windows separators
|
||||
// so `*` works consistently across platforms.
|
||||
return raw.replaceAll("\\", "/").replace(/\/+$/, "");
|
||||
function toWatchRoot(raw: string): string {
|
||||
const normalized = raw.replaceAll("\\", "/");
|
||||
return normalized.replace(/\/+$/, "") || normalized;
|
||||
}
|
||||
|
||||
function resolveWatchTargets(workspaceDir: string, config?: OpenClawConfig): string[] {
|
||||
// Skills are defined by SKILL.md; watch only those files to avoid traversing
|
||||
// or watching unrelated large trees (e.g. datasets) that can exhaust FDs.
|
||||
const targets = new Set<string>();
|
||||
for (const root of resolveWatchPaths(workspaceDir, config)) {
|
||||
const globRoot = toWatchGlobRoot(root);
|
||||
// Some configs point directly at a skill folder.
|
||||
targets.add(`${globRoot}/SKILL.md`);
|
||||
// Standard layout: <skillsRoot>/<skillName>/SKILL.md
|
||||
targets.add(`${globRoot}/*/SKILL.md`);
|
||||
targets.add(toWatchRoot(root));
|
||||
}
|
||||
return Array.from(targets).toSorted();
|
||||
}
|
||||
|
||||
export function shouldIgnoreSkillsWatchPath(
|
||||
watchPath: string,
|
||||
stats?: { isDirectory?: () => boolean },
|
||||
): boolean {
|
||||
if (DEFAULT_SKILLS_WATCH_IGNORED.some((re) => re.test(watchPath))) {
|
||||
return true;
|
||||
}
|
||||
if (stats?.isDirectory?.()) {
|
||||
return false;
|
||||
}
|
||||
if (!stats) {
|
||||
return false;
|
||||
}
|
||||
const normalized = watchPath.replaceAll("\\", "/");
|
||||
return path.posix.basename(normalized) !== "SKILL.md";
|
||||
}
|
||||
|
||||
export function ensureSkillsWatcher(params: { workspaceDir: string; config?: OpenClawConfig }) {
|
||||
const workspaceDir = params.workspaceDir.trim();
|
||||
if (!workspaceDir) {
|
||||
@@ -135,9 +145,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
|
||||
stabilityThreshold: debounceMs,
|
||||
pollInterval: 100,
|
||||
},
|
||||
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
|
||||
// This watcher only needs to react to SKILL.md changes.
|
||||
ignored: DEFAULT_SKILLS_WATCH_IGNORED,
|
||||
ignored: shouldIgnoreSkillsWatchPath,
|
||||
});
|
||||
|
||||
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
|
||||
@@ -162,6 +170,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
|
||||
watcher.on("add", (p) => schedule(p));
|
||||
watcher.on("change", (p) => schedule(p));
|
||||
watcher.on("unlink", (p) => schedule(p));
|
||||
watcher.on("unlinkDir", (p) => schedule(p));
|
||||
watcher.on("error", (err) => {
|
||||
log.warn(`skills watcher error (${workspaceDir}): ${String(err)}`);
|
||||
});
|
||||
|
||||
@@ -3392,6 +3392,95 @@ describe("dispatchReplyFromConfig", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("poisons inbound dedupe when dispatch fails after a block reply", async () => {
|
||||
setNoAbort();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+15555550125",
|
||||
To: "whatsapp:+15555550125",
|
||||
AccountId: "default",
|
||||
MessageSid: "msg-dup-block-error",
|
||||
SessionKey: "agent:main:whatsapp:direct:+15555550125",
|
||||
CommandBody: "hello",
|
||||
RawBody: "hello",
|
||||
Body: "hello",
|
||||
});
|
||||
const firstDispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(
|
||||
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
|
||||
await opts?.onBlockReply?.({ text: "partial answer" });
|
||||
throw new Error("provider failed after block");
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
dispatchReplyFromConfig({
|
||||
ctx,
|
||||
cfg: emptyConfig,
|
||||
dispatcher: firstDispatcher,
|
||||
replyResolver,
|
||||
}),
|
||||
).rejects.toThrow("provider failed after block");
|
||||
|
||||
await dispatchReplyFromConfig({
|
||||
ctx,
|
||||
cfg: emptyConfig,
|
||||
dispatcher: createDispatcher(),
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(firstDispatcher.sendBlockReply).toHaveBeenCalledWith({ text: "partial answer" });
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("poisons inbound dedupe when dispatch fails after a suppressed tool result", async () => {
|
||||
setNoAbort();
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
sendPolicy: "deny",
|
||||
};
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+15555550126",
|
||||
To: "whatsapp:+15555550126",
|
||||
AccountId: "default",
|
||||
MessageSid: "msg-dup-tool-error",
|
||||
SessionKey: "agent:main:whatsapp:direct:+15555550126",
|
||||
CommandBody: "hello",
|
||||
RawBody: "hello",
|
||||
Body: "hello",
|
||||
});
|
||||
const firstDispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(
|
||||
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
|
||||
await opts?.onToolResult?.({ text: "tool touched external state" });
|
||||
throw new Error("provider failed after tool");
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
dispatchReplyFromConfig({
|
||||
ctx,
|
||||
cfg: emptyConfig,
|
||||
dispatcher: firstDispatcher,
|
||||
replyResolver,
|
||||
}),
|
||||
).rejects.toThrow("provider failed after tool");
|
||||
|
||||
await dispatchReplyFromConfig({
|
||||
ctx,
|
||||
cfg: emptyConfig,
|
||||
dispatcher: createDispatcher(),
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(firstDispatcher.sendToolResult).not.toHaveBeenCalled();
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes configOverride to replyResolver when provided", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
|
||||
@@ -343,6 +343,10 @@ export async function dispatchReplyFromConfig(
|
||||
recordProcessed("skipped", { reason: "duplicate" });
|
||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||
}
|
||||
let inboundDedupeReplayUnsafe = false;
|
||||
const markInboundDedupeReplayUnsafe = () => {
|
||||
inboundDedupeReplayUnsafe = true;
|
||||
};
|
||||
|
||||
const initialSessionStoreEntry = resolveSessionStoreLookup(ctx, cfg);
|
||||
const boundAcpDispatchSessionKey = resolveBoundAcpDispatchSessionKey({ ctx, cfg });
|
||||
@@ -473,6 +477,7 @@ export async function dispatchReplyFromConfig(
|
||||
if (!shouldRouteToOriginating || !routeReplyChannel || !routeReplyTo || !routeReplyRuntime) {
|
||||
return null;
|
||||
}
|
||||
markInboundDedupeReplayUnsafe();
|
||||
return await routeReplyRuntime.routeReply({
|
||||
payload,
|
||||
channel: routeReplyChannel,
|
||||
@@ -538,6 +543,7 @@ export async function dispatchReplyFromConfig(
|
||||
}
|
||||
return result.ok;
|
||||
}
|
||||
markInboundDedupeReplayUnsafe();
|
||||
return mode === "additive"
|
||||
? dispatcher.sendToolResult(payload)
|
||||
: dispatcher.sendFinalReply(payload);
|
||||
@@ -721,6 +727,7 @@ export async function dispatchReplyFromConfig(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
queuedFinal = dispatcher.sendFinalReply(payload);
|
||||
}
|
||||
} else {
|
||||
@@ -744,6 +751,9 @@ export async function dispatchReplyFromConfig(
|
||||
const sendFinalPayload = async (
|
||||
payload: ReplyPayload,
|
||||
): Promise<{ queuedFinal: boolean; routedFinalCount: number }> => {
|
||||
if (resolveSendableOutboundReplyParts(payload).hasContent) {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
}
|
||||
const ttsPayload = await maybeApplyTtsToReplyPayload({
|
||||
payload,
|
||||
cfg,
|
||||
@@ -767,6 +777,7 @@ export async function dispatchReplyFromConfig(
|
||||
routedFinalCount: result.ok ? 1 : 0,
|
||||
};
|
||||
}
|
||||
markInboundDedupeReplayUnsafe();
|
||||
return {
|
||||
queuedFinal: dispatcher.sendFinalReply(normalizedPayload),
|
||||
routedFinalCount: 0,
|
||||
@@ -898,6 +909,7 @@ export async function dispatchReplyFromConfig(
|
||||
await sendPayloadAsync(payload, undefined, false);
|
||||
return;
|
||||
}
|
||||
markInboundDedupeReplayUnsafe();
|
||||
dispatcher.sendToolResult(payload);
|
||||
};
|
||||
const sendPlanUpdate = async (payload: {
|
||||
@@ -914,6 +926,7 @@ export async function dispatchReplyFromConfig(
|
||||
await sendPayloadAsync(replyPayload, undefined, false);
|
||||
return;
|
||||
}
|
||||
markInboundDedupeReplayUnsafe();
|
||||
dispatcher.sendToolResult(replyPayload);
|
||||
};
|
||||
const summarizeApprovalLabel = (payload: {
|
||||
@@ -1019,6 +1032,7 @@ export async function dispatchReplyFromConfig(
|
||||
suppressTyping: typing.suppressTyping,
|
||||
onToolResult: (payload: ReplyPayload) => {
|
||||
const run = async () => {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
await onToolResultFromReplyOptions?.(payload);
|
||||
if (suppressDelivery) {
|
||||
return;
|
||||
@@ -1055,12 +1069,14 @@ export async function dispatchReplyFromConfig(
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(deliveryPayload, undefined, false);
|
||||
} else {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
dispatcher.sendToolResult(deliveryPayload);
|
||||
}
|
||||
};
|
||||
return run();
|
||||
},
|
||||
onPlanUpdate: async (payload) => {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
await onPlanUpdateFromReplyOptions?.(payload);
|
||||
if (payload.phase !== "update" || suppressDefaultToolProgressMessages) {
|
||||
return;
|
||||
@@ -1068,6 +1084,7 @@ export async function dispatchReplyFromConfig(
|
||||
await sendPlanUpdate({ explanation: payload.explanation, steps: payload.steps });
|
||||
},
|
||||
onApprovalEvent: async (payload) => {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
await onApprovalEventFromReplyOptions?.(payload);
|
||||
if (payload.phase !== "requested" || suppressDefaultToolProgressMessages) {
|
||||
return;
|
||||
@@ -1083,6 +1100,7 @@ export async function dispatchReplyFromConfig(
|
||||
await maybeSendWorkingStatus(label);
|
||||
},
|
||||
onPatchSummary: async (payload) => {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
await onPatchSummaryFromReplyOptions?.(payload);
|
||||
if (payload.phase !== "end" || suppressDefaultToolProgressMessages) {
|
||||
return;
|
||||
@@ -1095,6 +1113,12 @@ export async function dispatchReplyFromConfig(
|
||||
},
|
||||
onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => {
|
||||
const run = async () => {
|
||||
if (
|
||||
payload.isReasoning !== true &&
|
||||
resolveSendableOutboundReplyParts(payload).hasContent
|
||||
) {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
}
|
||||
if (suppressDelivery) {
|
||||
return;
|
||||
}
|
||||
@@ -1156,6 +1180,7 @@ export async function dispatchReplyFromConfig(
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(normalizedPayload, context?.abortSignal, false);
|
||||
} else {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
dispatcher.sendBlockReply(normalizedPayload);
|
||||
}
|
||||
};
|
||||
@@ -1268,6 +1293,7 @@ export async function dispatchReplyFromConfig(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
markInboundDedupeReplayUnsafe();
|
||||
const didQueue = dispatcher.sendFinalReply(normalizedTtsOnlyPayload);
|
||||
queuedFinal = didQueue || queuedFinal;
|
||||
}
|
||||
@@ -1293,7 +1319,11 @@ export async function dispatchReplyFromConfig(
|
||||
return { queuedFinal, counts };
|
||||
} catch (err) {
|
||||
if (inboundDedupeClaim.status === "claimed") {
|
||||
releaseInboundDedupe(inboundDedupeClaim.key);
|
||||
if (inboundDedupeReplayUnsafe) {
|
||||
commitInboundDedupe(inboundDedupeClaim.key);
|
||||
} else {
|
||||
releaseInboundDedupe(inboundDedupeClaim.key);
|
||||
}
|
||||
}
|
||||
recordProcessed("error", { error: String(err) });
|
||||
markIdle("message_error");
|
||||
|
||||
@@ -72,4 +72,33 @@ describe("inbound dedupe", () => {
|
||||
inboundB.resetInboundDedupe();
|
||||
}
|
||||
});
|
||||
|
||||
it("shares claim/commit state across distinct module instances", async () => {
|
||||
const inboundA = await importFreshModule<typeof import("./inbound-dedupe.js")>(
|
||||
import.meta.url,
|
||||
"./inbound-dedupe.js?scope=commit-a",
|
||||
);
|
||||
const inboundB = await importFreshModule<typeof import("./inbound-dedupe.js")>(
|
||||
import.meta.url,
|
||||
"./inbound-dedupe.js?scope=commit-b",
|
||||
);
|
||||
|
||||
inboundA.resetInboundDedupe();
|
||||
inboundB.resetInboundDedupe();
|
||||
|
||||
try {
|
||||
const firstClaim = inboundA.claimInboundDedupe(sharedInboundContext);
|
||||
expect(firstClaim).toMatchObject({ status: "claimed" });
|
||||
if (firstClaim.status !== "claimed") {
|
||||
throw new Error("expected claimed inbound dedupe result");
|
||||
}
|
||||
inboundA.commitInboundDedupe(firstClaim.key);
|
||||
expect(inboundB.claimInboundDedupe(sharedInboundContext)).toMatchObject({
|
||||
status: "duplicate",
|
||||
});
|
||||
} finally {
|
||||
inboundA.resetInboundDedupe();
|
||||
inboundB.resetInboundDedupe();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1502,6 +1502,181 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
persona: {
|
||||
type: "string",
|
||||
},
|
||||
personas: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: {
|
||||
type: "string",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
fallbackPolicy: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "preserve-persona",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "provider-defaults",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "fail",
|
||||
},
|
||||
],
|
||||
},
|
||||
prompt: {
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
type: "string",
|
||||
},
|
||||
scene: {
|
||||
type: "string",
|
||||
},
|
||||
sampleContext: {
|
||||
type: "string",
|
||||
},
|
||||
style: {
|
||||
type: "string",
|
||||
},
|
||||
accent: {
|
||||
type: "string",
|
||||
},
|
||||
pacing: {
|
||||
type: "string",
|
||||
},
|
||||
constraints: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
providers: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
const: "env",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
pattern: "^[a-z][a-z0-9_-]{0,63}$",
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
|
||||
},
|
||||
},
|
||||
required: ["source", "provider", "id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
const: "file",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
pattern: "^[a-z][a-z0-9_-]{0,63}$",
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: ["source", "provider", "id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
const: "exec",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
pattern: "^[a-z][a-z0-9_-]{0,63}$",
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: ["source", "provider", "id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
{
|
||||
type: "array",
|
||||
items: {},
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
summaryModel: {
|
||||
type: "string",
|
||||
},
|
||||
@@ -2682,6 +2857,181 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
persona: {
|
||||
type: "string",
|
||||
},
|
||||
personas: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: {
|
||||
type: "string",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
fallbackPolicy: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "preserve-persona",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "provider-defaults",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "fail",
|
||||
},
|
||||
],
|
||||
},
|
||||
prompt: {
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
type: "string",
|
||||
},
|
||||
scene: {
|
||||
type: "string",
|
||||
},
|
||||
sampleContext: {
|
||||
type: "string",
|
||||
},
|
||||
style: {
|
||||
type: "string",
|
||||
},
|
||||
accent: {
|
||||
type: "string",
|
||||
},
|
||||
pacing: {
|
||||
type: "string",
|
||||
},
|
||||
constraints: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
providers: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
const: "env",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
pattern: "^[a-z][a-z0-9_-]{0,63}$",
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
|
||||
},
|
||||
},
|
||||
required: ["source", "provider", "id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
const: "file",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
pattern: "^[a-z][a-z0-9_-]{0,63}$",
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: ["source", "provider", "id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
const: "exec",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
pattern: "^[a-z][a-z0-9_-]{0,63}$",
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: ["source", "provider", "id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
{
|
||||
type: "array",
|
||||
items: {},
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
summaryModel: {
|
||||
type: "string",
|
||||
},
|
||||
@@ -3792,6 +4142,78 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
default: true,
|
||||
type: "boolean",
|
||||
},
|
||||
tts: {
|
||||
type: "object",
|
||||
properties: {
|
||||
auto: {
|
||||
type: "string",
|
||||
enum: ["off", "always", "inbound", "tagged"],
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["final", "all"],
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
},
|
||||
persona: {
|
||||
type: "string",
|
||||
},
|
||||
personas: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
},
|
||||
summaryModel: {
|
||||
type: "string",
|
||||
},
|
||||
modelOverrides: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
providers: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
},
|
||||
prefsPath: {
|
||||
type: "string",
|
||||
},
|
||||
maxTextLength: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "integer",
|
||||
minimum: 1000,
|
||||
maximum: 120000,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
groupSessionScope: {
|
||||
type: "string",
|
||||
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
|
||||
@@ -4345,6 +4767,78 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
resolveSenderNames: {
|
||||
type: "boolean",
|
||||
},
|
||||
tts: {
|
||||
type: "object",
|
||||
properties: {
|
||||
auto: {
|
||||
type: "string",
|
||||
enum: ["off", "always", "inbound", "tagged"],
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["final", "all"],
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
},
|
||||
persona: {
|
||||
type: "string",
|
||||
},
|
||||
personas: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
},
|
||||
summaryModel: {
|
||||
type: "string",
|
||||
},
|
||||
modelOverrides: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
providers: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
},
|
||||
prefsPath: {
|
||||
type: "string",
|
||||
},
|
||||
maxTextLength: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "integer",
|
||||
minimum: 1000,
|
||||
maximum: 120000,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
groupSessionScope: {
|
||||
type: "string",
|
||||
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
|
||||
|
||||
@@ -466,7 +466,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
],
|
||||
title: "Sensitive Data Redaction Mode",
|
||||
description:
|
||||
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields. Keep "tools" in shared logs unless you have isolated secure log sinks.',
|
||||
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields in log sinks and persisted transcript text. Keep "tools" enabled unless logs and transcripts are isolated.',
|
||||
},
|
||||
redactPatterns: {
|
||||
type: "array",
|
||||
@@ -475,7 +475,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
},
|
||||
title: "Custom Redaction Patterns",
|
||||
description:
|
||||
"Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
|
||||
"Additional custom redact regex patterns applied to log output and persisted transcript text before storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -23982,12 +23982,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
},
|
||||
"logging.redactSensitive": {
|
||||
label: "Sensitive Data Redaction Mode",
|
||||
help: 'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields. Keep "tools" in shared logs unless you have isolated secure log sinks.',
|
||||
help: 'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields in log sinks and persisted transcript text. Keep "tools" enabled unless logs and transcripts are isolated.',
|
||||
tags: ["privacy", "observability"],
|
||||
},
|
||||
"logging.redactPatterns": {
|
||||
label: "Custom Redaction Patterns",
|
||||
help: "Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
|
||||
help: "Additional custom redact regex patterns applied to log output and persisted transcript text before storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
|
||||
tags: ["privacy", "observability"],
|
||||
},
|
||||
"cli.banner": {
|
||||
@@ -28617,6 +28617,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
tags: ["advanced", "url-secret"],
|
||||
},
|
||||
},
|
||||
version: "2026.4.26",
|
||||
version: "2026.4.25-beta.10",
|
||||
generatedAt: "2026-03-22T21:17:33.302Z",
|
||||
};
|
||||
|
||||
@@ -43,9 +43,9 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"logging.consoleStyle":
|
||||
'Console output format style: "pretty", "compact", or "json" based on operator and ingestion needs. Use json for machine parsing pipelines and pretty/compact for human-first terminal workflows.',
|
||||
"logging.redactSensitive":
|
||||
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields. Keep "tools" in shared logs unless you have isolated secure log sinks.',
|
||||
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields in log sinks and persisted transcript text. Keep "tools" enabled unless logs and transcripts are isolated.',
|
||||
"logging.redactPatterns":
|
||||
"Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
|
||||
"Additional custom redact regex patterns applied to log output and persisted transcript text before storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
|
||||
cli: "CLI presentation controls for local command output behavior such as banner and tagline style. Use this section to keep startup output aligned with operator preference without changing runtime behavior.",
|
||||
"cli.banner":
|
||||
"CLI startup banner controls for title/version line and tagline style behavior. Keep banner enabled for fast version/context checks, then tune tagline mode to your preferred noise level.",
|
||||
|
||||
@@ -225,9 +225,9 @@ export type LoggingConfig = {
|
||||
maxFileBytes?: number;
|
||||
consoleLevel?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
||||
consoleStyle?: "pretty" | "compact" | "json";
|
||||
/** Redact sensitive tokens in tool summaries. Default: "tools". */
|
||||
/** Redact sensitive tokens in log sinks and persisted transcript text. Default: "tools". */
|
||||
redactSensitive?: "off" | "tools";
|
||||
/** Regex patterns used to redact sensitive tokens (defaults apply when unset). */
|
||||
/** Regex patterns used to redact sensitive tokens from logs and transcripts. */
|
||||
redactPatterns?: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -437,6 +437,31 @@ describe("channel-health-monitor", () => {
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("counts failed restart attempts toward cooldown and hourly caps", async () => {
|
||||
const manager = createSnapshotManager(
|
||||
{
|
||||
discord: {
|
||||
default: managedStoppedAccount("keeps crashing"),
|
||||
},
|
||||
},
|
||||
{
|
||||
startChannel: vi.fn(async () => {
|
||||
throw new Error("startup failed");
|
||||
}),
|
||||
},
|
||||
);
|
||||
const monitor = startDefaultMonitor(manager, {
|
||||
checkIntervalMs: 1_000,
|
||||
cooldownCycles: 1,
|
||||
maxRestartsPerHour: 1,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_001);
|
||||
|
||||
expect(manager.startChannel).toHaveBeenCalledTimes(1);
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("runs checks single-flight when restart work is still in progress", async () => {
|
||||
let releaseStart: (() => void) | undefined;
|
||||
const startGate = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -157,15 +157,16 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
|
||||
|
||||
log.info?.(`[${channelId}:${accountId}] health-monitor: restarting (reason: ${reason})`);
|
||||
|
||||
record.lastRestartAt = now;
|
||||
record.restartsThisHour.push({ at: now });
|
||||
restartRecords.set(key, record);
|
||||
|
||||
try {
|
||||
if (status.running) {
|
||||
await channelManager.stopChannel(channelId as ChannelId, accountId);
|
||||
}
|
||||
channelManager.resetRestartAttempts(channelId as ChannelId, accountId);
|
||||
await channelManager.startChannel(channelId as ChannelId, accountId);
|
||||
record.lastRestartAt = now;
|
||||
record.restartsThisHour.push({ at: now });
|
||||
restartRecords.set(key, record);
|
||||
} catch (err) {
|
||||
log.error?.(
|
||||
`[${channelId}:${accountId}] health-monitor: restart failed: ${String(err)}`,
|
||||
|
||||
@@ -36,6 +36,9 @@ const describeLive = LIVE && ACP_BIND_LIVE ? describe : describe.skip;
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 90_000;
|
||||
const LIVE_TIMEOUT_MS = 240_000;
|
||||
const ACP_CRON_MCP_PROBE_MAX_ATTEMPTS = 2;
|
||||
const ACP_CRON_MCP_PROBE_VERIFY_POLLS = 5;
|
||||
const ACP_CRON_MCP_PROBE_VERIFY_POLL_MS = 1_000;
|
||||
const DEFAULT_LIVE_CODEX_MODEL = "gpt-5.5";
|
||||
const DEFAULT_LIVE_PARENT_MODEL = "openai/gpt-5.4";
|
||||
type LiveAcpAgent = "claude" | "codex" | "droid" | "gemini" | "opencode";
|
||||
@@ -150,6 +153,10 @@ function shouldRequireBoundAssistantTranscript(liveAgent: LiveAcpAgent): boolean
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRequireCronMcpProbe(): boolean {
|
||||
return isTruthyEnvValue(process.env.OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON);
|
||||
}
|
||||
|
||||
function normalizeOpenAiModelRef(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
@@ -287,24 +294,30 @@ async function bindConversationAndWait(params: {
|
||||
doctor?: () => Promise<{ message?: string; details?: string[] }>;
|
||||
}
|
||||
| undefined;
|
||||
if (runtime?.probeAvailability) {
|
||||
await runtime.probeAvailability().catch(() => {});
|
||||
}
|
||||
if (!(backend?.healthy?.() ?? false)) {
|
||||
if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) {
|
||||
const report = await runtime.doctor().catch((error) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: [],
|
||||
}));
|
||||
logLiveStep(
|
||||
`acpx doctor before bind attempt ${attempt}: ${report.message ?? "unknown"}${
|
||||
report.details?.length ? ` (${report.details.join("; ")})` : ""
|
||||
}`,
|
||||
);
|
||||
const backendUnavailable = !backend || (backend.healthy && !backend.healthy());
|
||||
if (backendUnavailable) {
|
||||
if (runtime?.probeAvailability) {
|
||||
await runtime.probeAvailability().catch(() => {});
|
||||
}
|
||||
const backendReadyAfterProbe = backend && (!backend.healthy || backend.healthy());
|
||||
if (backendReadyAfterProbe) {
|
||||
logLiveStep(`acpx backend became healthy before bind attempt ${attempt}`);
|
||||
} else {
|
||||
if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) {
|
||||
const report = await runtime.doctor().catch((error) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: [],
|
||||
}));
|
||||
logLiveStep(
|
||||
`acpx doctor before bind attempt ${attempt}: ${report.message ?? "unknown"}${
|
||||
report.details?.length ? ` (${report.details.join("; ")})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
logLiveStep(`acpx backend still unhealthy before bind attempt ${attempt}`);
|
||||
await sleep(5_000);
|
||||
continue;
|
||||
}
|
||||
logLiveStep(`acpx backend still unhealthy before bind attempt ${attempt}`);
|
||||
await sleep(5_000);
|
||||
continue;
|
||||
}
|
||||
|
||||
await sendChatAndWait({
|
||||
@@ -463,6 +476,25 @@ async function waitForAssistantTurn(params: {
|
||||
);
|
||||
}
|
||||
|
||||
async function pollCronJobVisibleViaCli(params: {
|
||||
port: number;
|
||||
token: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
expectedName: string;
|
||||
expectedMessage: string;
|
||||
}): Promise<{ job?: Awaited<ReturnType<typeof assertCronJobVisibleViaCli>>; pollsUsed: number }> {
|
||||
for (let verifyAttempt = 0; verifyAttempt < ACP_CRON_MCP_PROBE_VERIFY_POLLS; verifyAttempt += 1) {
|
||||
const job = await assertCronJobVisibleViaCli(params);
|
||||
if (job) {
|
||||
return { job, pollsUsed: verifyAttempt + 1 };
|
||||
}
|
||||
if (verifyAttempt < ACP_CRON_MCP_PROBE_VERIFY_POLLS - 1) {
|
||||
await sleep(ACP_CRON_MCP_PROBE_VERIFY_POLL_MS);
|
||||
}
|
||||
}
|
||||
return { pollsUsed: ACP_CRON_MCP_PROBE_VERIFY_POLLS };
|
||||
}
|
||||
|
||||
describeLive("gateway live (ACP bind)", () => {
|
||||
it(
|
||||
"binds a synthetic Slack DM conversation to a live ACP session and reroutes the next turn",
|
||||
@@ -852,9 +884,10 @@ describeLive("gateway live (ACP bind)", () => {
|
||||
agentId: liveAgent,
|
||||
sessionKey: spawnedSessionKey,
|
||||
});
|
||||
const requireCronMcpProbe = shouldRequireCronMcpProbe();
|
||||
let cronJobId: string | undefined;
|
||||
let lastCronAssistantText = "";
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
for (let attempt = 0; attempt < ACP_CRON_MCP_PROBE_MAX_ATTEMPTS; attempt += 1) {
|
||||
await sendChatAndWait({
|
||||
client,
|
||||
sessionKey: originalSessionKey,
|
||||
@@ -876,7 +909,7 @@ describeLive("gateway live (ACP bind)", () => {
|
||||
cronHistory = await waitForAssistantText({
|
||||
client,
|
||||
sessionKey: spawnedSessionKey,
|
||||
timeoutMs: liveAgent === "claude" ? 90_000 : 45_000,
|
||||
timeoutMs: 20_000,
|
||||
contains: cronProbe.name,
|
||||
});
|
||||
} catch {
|
||||
@@ -885,13 +918,14 @@ describeLive("gateway live (ACP bind)", () => {
|
||||
if (cronHistory) {
|
||||
lastCronAssistantText = cronHistory.lastAssistantText;
|
||||
}
|
||||
const createdJob = await assertCronJobVisibleViaCli({
|
||||
const verifyResult = await pollCronJobVisibleViaCli({
|
||||
port,
|
||||
token,
|
||||
env: process.env,
|
||||
expectedName: cronProbe.name,
|
||||
expectedMessage: cronProbe.message,
|
||||
});
|
||||
const createdJob = verifyResult.job;
|
||||
if (createdJob) {
|
||||
assertCronJobMatches({
|
||||
job: createdJob,
|
||||
@@ -906,10 +940,15 @@ describeLive("gateway live (ACP bind)", () => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (attempt === 1) {
|
||||
if (liveAgent !== "claude") {
|
||||
logLiveStep(
|
||||
`cron mcp job not observed after attempt ${String(
|
||||
attempt + 1,
|
||||
)}; polls=${String(verifyResult.pollsUsed)}`,
|
||||
);
|
||||
if (attempt === ACP_CRON_MCP_PROBE_MAX_ATTEMPTS - 1) {
|
||||
if (!requireCronMcpProbe) {
|
||||
logLiveStep(
|
||||
`cron mcp job ${cronProbe.name} not observed for ${liveAgent}; continuing after bind/image verification`,
|
||||
`cron mcp job ${cronProbe.name} not observed; continuing after bind/image verification`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -921,7 +960,7 @@ describeLive("gateway live (ACP bind)", () => {
|
||||
}
|
||||
}
|
||||
if (!cronJobId) {
|
||||
if (liveAgent !== "claude") {
|
||||
if (!requireCronMcpProbe) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`acp cron cli verify did not create job ${cronProbe.name}`);
|
||||
|
||||
@@ -31,6 +31,7 @@ import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js";
|
||||
import { ensureOpenClawModelsJson } from "../agents/models-config.js";
|
||||
import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js";
|
||||
import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js";
|
||||
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
|
||||
import { clearRuntimeConfigSnapshot, loadConfig } from "../config/io.js";
|
||||
import type { ModelsConfig, ModelProviderConfig, OpenClawConfig } from "../config/types.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
@@ -736,6 +737,16 @@ describe("shouldSkipEmptyResponseForLiveModel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("isEmptyStreamText", () => {
|
||||
it.each([
|
||||
{ text: "request ended without sending any chunks", expected: true },
|
||||
{ text: `not meaningful: ${STREAM_ERROR_FALLBACK_TEXT}`, expected: true },
|
||||
{ text: "not meaningful: let me think", expected: false },
|
||||
])("returns $expected for $text", ({ text, expected }) => {
|
||||
expect(isEmptyStreamText(text)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPromptProbeMiss", () => {
|
||||
it.each([
|
||||
{ error: "not meaningful: let me think", expected: true },
|
||||
@@ -763,7 +774,10 @@ function isMissingProfileError(error: string): boolean {
|
||||
}
|
||||
|
||||
function isEmptyStreamText(text: string): boolean {
|
||||
return text.includes("request ended without sending any chunks");
|
||||
return (
|
||||
text.includes("request ended without sending any chunks") ||
|
||||
text.includes(STREAM_ERROR_FALLBACK_TEXT)
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnthropicRefusalToken(): string {
|
||||
|
||||
@@ -74,6 +74,7 @@ export function buildLiveCronProbeMessage(params: {
|
||||
if (params.attempt === 0) {
|
||||
return (
|
||||
"Use the OpenClaw MCP tool `openclaw-tools/cron` (server `openclaw-tools`, tool `cron`). " +
|
||||
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
|
||||
`Call it with JSON arguments ${params.argsJson}. ` +
|
||||
"Preserve the JSON exactly, including job.sessionTarget and job.sessionKey; do not omit, rename, or flatten those fields. " +
|
||||
"Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " +
|
||||
@@ -83,6 +84,7 @@ export function buildLiveCronProbeMessage(params: {
|
||||
if (claudeLike) {
|
||||
return (
|
||||
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
|
||||
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
|
||||
`Use these exact JSON arguments: ${params.argsJson}. ` +
|
||||
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
|
||||
`If the cron job is created, reply exactly: ${params.exactReply}. ` +
|
||||
@@ -94,6 +96,7 @@ export function buildLiveCronProbeMessage(params: {
|
||||
return (
|
||||
"Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " +
|
||||
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
|
||||
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
|
||||
`Use these exact JSON arguments: ${params.argsJson}. ` +
|
||||
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
|
||||
`If the cron job is created, reply exactly: ${params.exactReply}. ` +
|
||||
|
||||
@@ -50,6 +50,7 @@ function createTestPlugin(params?: {
|
||||
order?: number;
|
||||
account?: TestAccount;
|
||||
startAccount?: NonNullable<ChannelPlugin<TestAccount>["gateway"]>["startAccount"];
|
||||
listAccountIds?: ChannelPlugin<TestAccount>["config"]["listAccountIds"];
|
||||
includeDescribeAccount?: boolean;
|
||||
describeAccount?: ChannelPlugin<TestAccount>["config"]["describeAccount"];
|
||||
resolveAccount?: ChannelPlugin<TestAccount>["config"]["resolveAccount"];
|
||||
@@ -59,7 +60,7 @@ function createTestPlugin(params?: {
|
||||
const account = params?.account ?? { enabled: true, configured: true };
|
||||
const includeDescribeAccount = params?.includeDescribeAccount !== false;
|
||||
const config: ChannelPlugin<TestAccount>["config"] = {
|
||||
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||
listAccountIds: params?.listAccountIds ?? (() => [DEFAULT_ACCOUNT_ID]),
|
||||
resolveAccount: params?.resolveAccount ?? (() => account),
|
||||
isEnabled: (resolved) => resolved.enabled !== false,
|
||||
...(params?.isConfigured ? { isConfigured: params.isConfigured } : {}),
|
||||
@@ -436,6 +437,35 @@ describe("server-channels auto restart", () => {
|
||||
expect(succeedingStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("evicts stale account lifecycle state during whole-channel reload", async () => {
|
||||
let accountIds = [DEFAULT_ACCOUNT_ID];
|
||||
const startAccount = vi.fn(
|
||||
async ({ abortSignal }: { abortSignal: AbortSignal }) =>
|
||||
await new Promise<void>((resolve) => {
|
||||
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
}),
|
||||
);
|
||||
installTestRegistry(createTestPlugin({ startAccount, listAccountIds: () => accountIds }));
|
||||
const manager = createManager();
|
||||
|
||||
await manager.startChannel("discord");
|
||||
|
||||
accountIds = [];
|
||||
await manager.stopChannel("discord");
|
||||
await manager.startChannel("discord");
|
||||
|
||||
accountIds = [DEFAULT_ACCOUNT_ID];
|
||||
await manager.startChannel("discord");
|
||||
|
||||
const snapshot = manager.getRuntimeSnapshot();
|
||||
const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
|
||||
expect(startAccount).toHaveBeenCalledTimes(2);
|
||||
expect(account?.reconnectAttempts).toBe(0);
|
||||
expect(account?.lastStopAt).toBeUndefined();
|
||||
|
||||
await manager.stopChannel("discord");
|
||||
});
|
||||
|
||||
it("reuses plugin account resolution for health monitor overrides", () => {
|
||||
installTestRegistry(
|
||||
createTestPlugin({
|
||||
|
||||
@@ -282,6 +282,27 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
return channelRuntime ?? resolveChannelRuntime?.();
|
||||
};
|
||||
|
||||
const evictStaleChannelAccountState = (
|
||||
channelId: ChannelId,
|
||||
store: ChannelRuntimeStore,
|
||||
accountIds: readonly string[],
|
||||
) => {
|
||||
const activeAccountIds = new Set(accountIds);
|
||||
for (const id of store.runtimes.keys()) {
|
||||
if (
|
||||
activeAccountIds.has(id) ||
|
||||
store.aborts.has(id) ||
|
||||
store.starting.has(id) ||
|
||||
store.tasks.has(id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
store.runtimes.delete(id);
|
||||
restartAttempts.delete(restartKey(channelId, id));
|
||||
manuallyStopped.delete(restartKey(channelId, id));
|
||||
}
|
||||
};
|
||||
|
||||
const startChannelInternal = async (
|
||||
channelId: ChannelId,
|
||||
accountId?: string,
|
||||
@@ -297,6 +318,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
resetDirectoryCache({ channel: channelId, accountId });
|
||||
const store = getStore(channelId);
|
||||
const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg);
|
||||
if (!accountId) {
|
||||
evictStaleChannelAccountState(channelId, store, accountIds);
|
||||
}
|
||||
if (accountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,16 @@ describe("redactSensitiveText", () => {
|
||||
expect(output).toBe("token=abcdef…ghij");
|
||||
});
|
||||
|
||||
it("honors escaped character classes in custom patterns", () => {
|
||||
const input = "contact peter@dc.io";
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "tools",
|
||||
patterns: [String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`],
|
||||
});
|
||||
expect(output).toBe("contact peter@d***.io");
|
||||
expect(output).not.toContain("peter@dc.io");
|
||||
});
|
||||
|
||||
it("ignores unsafe nested-repetition custom patterns", () => {
|
||||
const input = `${"a".repeat(28)}!`;
|
||||
const output = redactSensitiveText(input, {
|
||||
|
||||
@@ -312,10 +312,6 @@ describe("getCachedPluginJitiLoader", () => {
|
||||
|
||||
const loose = loader as unknown as (t: string, ...a: unknown[]) => unknown;
|
||||
loose("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
|
||||
expect(jitiLoader).toHaveBeenCalledWith(
|
||||
"/repo/dist/extensions/demo/api.js",
|
||||
{ hint: "x" },
|
||||
42,
|
||||
);
|
||||
expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("safe regex", () => {
|
||||
["(a|aa)+$", true],
|
||||
["^(?:foo|bar)$", false],
|
||||
["^(ab|cd)+$", false],
|
||||
[String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`, false],
|
||||
] as const)("classifies nested repetition for %s", (pattern, expected) => {
|
||||
expect(hasNestedRepetition(pattern)).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -140,19 +140,23 @@ function tokenizePattern(source: string): PatternToken[] {
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
|
||||
if (ch === "\\") {
|
||||
i += 1;
|
||||
tokens.push({ kind: "simple-token" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCharClass) {
|
||||
if (ch === "\\") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "]") {
|
||||
inCharClass = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\") {
|
||||
i += 1;
|
||||
tokens.push({ kind: "simple-token" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "[") {
|
||||
inCharClass = true;
|
||||
tokens.push({ kind: "simple-token" });
|
||||
|
||||
@@ -363,7 +363,7 @@ export async function waitForChatFinalEvent(params: {
|
||||
sessionKey: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<ChatEventPayload> {
|
||||
const deadline = Date.now() + (params.timeoutMs ?? 15_000);
|
||||
const deadline = Date.now() + (params.timeoutMs ?? 45_000);
|
||||
while (Date.now() < deadline) {
|
||||
const match = params.events.find(
|
||||
(evt) =>
|
||||
|
||||
Reference in New Issue
Block a user