Compare commits

...

168 Commits

Author SHA1 Message Date
Mariano Belinky
feebf7a1e6 docs: clarify command cron admin boundary 2026-06-03 20:34:30 +02:00
Mariano Belinky
732ceadfb7 fix(doctor): flag cron agent shell prompts 2026-06-03 19:41:10 +02:00
Mariano Belinky
41339c6370 test cron command edge cases 2026-06-03 19:33:02 +02:00
Mariano Belinky
25f3c2a22b feat: support command cron jobs 2026-06-03 19:33:02 +02:00
Vincent Koc
6d5061c234 fix(testing): harden bundled channel contract loading 2026-06-03 19:22:57 +02:00
Vincent Koc
286e5ffe07 test(startup): make cli startup budgets arch-aware 2026-06-03 09:50:04 -07:00
Vincent Koc
158c4d7540 fix(discord): match libopus error shape 2026-06-03 18:37:18 +02:00
Vincent Koc
344e04b5d5 fix(testing): route source targets through test planner 2026-06-03 18:07:32 +02:00
Vincent Koc
ec47d1cdd5 fix(canvas): restore A2UI compatibility assets 2026-06-03 17:31:15 +02:00
Vincent Koc
8c89d35a8a fix(gateway): cancel stop terminate fallback 2026-06-03 17:28:00 +02:00
Vincent Koc
d358294f89 test(plugins): anchor provider family inventory to source roots 2026-06-03 17:20:10 +02:00
Vincent Koc
3480832614 test(ui): defer control ui vite import 2026-06-03 08:13:30 -07:00
Vincent Koc
e0ab71d3dc fix(scripts): guard codex protocol generation disk headroom 2026-06-03 17:01:16 +02:00
Vincent Koc
21b262f507 fix(e2e): fail timed rpc commands 2026-06-03 16:48:50 +02:00
Vincent Koc
3a64302585 test(canvas): cover A2UI static asset compatibility 2026-06-03 16:42:55 +02:00
Vincent Koc
38f1db6d67 fix(e2e): rethrow lifecycle shutdown promptly 2026-06-03 16:36:37 +02:00
Vincent Koc
8f6f2617ec test(vitest): extend full agentic watchdog 2026-06-03 07:35:39 -07:00
Vincent Koc
f4868b79e3 fix(testing): keep plugin gauntlet pnpm noninteractive 2026-06-03 16:34:54 +02:00
Vincent Koc
d3ab7e92ef fix(ci): harden ARM smoke and browser checks 2026-06-03 07:30:12 -07:00
Vincent Koc
acacd32415 test(codex): cover bad dynamic tool schemas 2026-06-03 16:20:49 +02:00
Ayaan Zaidi
0b26a1bca7 fix(telegram): cancel clean restart stop timers 2026-06-03 19:49:12 +05:30
Ayaan Zaidi
0bcdb9c0d1 refactor(telegram): distill polling restart stops 2026-06-03 19:49:12 +05:30
Andy Ye
946eed685d fix(telegram): slow polling restart storms 2026-06-03 19:49:12 +05:30
Vincent Koc
c219c62598 refactor(gateway): share duplicated test helpers
Consolidate repeated gateway test setup into shared helpers and keep the preauth WebSocket fixture bounded with maxPayload.\n\nVerification: focused gateway Vitest passed, autoreview clean, and ready-state GitHub Actions CI passed on c6f6957e55.
2026-06-03 06:57:18 -07:00
Pavan Kumar Gondhi
5483ff705f fix(telegram): require admin for target writeback [AI] (#88973)
* fix: require admin for Telegram target writeback

* fix(telegram): preserve internal target writeback

* fix: scope Telegram target writeback authority

* fix: infer internal writeback for durable sends

* fix: preserve scoped Telegram writeback boundaries

* fix: preserve direct Telegram writeback

* test: make Telegram writeback scope intent explicit

* fix(telegram): keep target writeback authority local

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-03 19:25:40 +05:30
Vincent Koc
70a989a97a test(e2e): tighten onboard status assertions 2026-06-03 15:54:03 +02:00
Vincent Koc
b7450f83a1 ci(docker): disable alpha image publishes 2026-06-03 06:46:42 -07:00
Vincent Koc
ff5667a582 fix(installer): fail on onboarding exit code 2026-06-03 15:39:31 +02:00
Vincent Koc
d6bea4c5ac fix(e2e): clean clawhub install temp home 2026-06-03 15:30:02 +02:00
clawsweeper[bot]
79896a24d9 fix(outbound): keep channel send durable when transcript mirror fails (#89626) (#89812)
Summary:
- The PR wraps outbound post-delivery transcript mirroring in warning-only error handling and adds regression tests for thrown and not-ok mirror append failures.
- PR surface: Source +16, Tests +61. Total +77 across 2 files.
- Reproducibility: yes. A high-confidence source reproduction is to make appendAssistantMessageToSessionTransc ... a/outbound/deliver.ts:1970 and the caller retry path treats that exception as a failed direct announcement.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(outbound): keep channel send durable when transcript mirror fails…

Validation:
- ClawSweeper review passed for head dfe0fd7119.
- Required merge gates passed before the squash merge.

Prepared head SHA: dfe0fd7119
Review: https://github.com/openclaw/openclaw/pull/89812#issuecomment-4611974387

Co-authored-by: harjoth <harjoth.khara@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-03 13:20:52 +00:00
Vincent Koc
a7d5ae1872 fix(scripts): force stop memory fd gateway child 2026-06-03 15:19:29 +02:00
Vincent Koc
446a2b24c3 fix(e2e): require kitchen sink command rss samples 2026-06-03 15:11:40 +02:00
jmao
e4993ec00f fix(telegram): prevent preview duplication in partial and block streaming modes
Fix Telegram streamed replies so preview chunks are finalized once in partial and block streaming modes.

Fixes #87624. Thanks @jmao0001.
2026-06-03 18:36:08 +05:30
Vincent Koc
90493ee8e2 fix(scripts): stop rpc rtt process groups 2026-06-03 15:03:32 +02:00
zhang-guiping
60dcaa3cf5 fix #88773: [Bug]: Telegram DM exec requires approval despite allowlist + ask:off — works in webchat, not in Telegram (#89035)
* fix exec ask policy source

* fix gateway test type fixtures

* docs: update exec ask parameter docs to match runtime behavior

* fix: preserve trusted per-call exec ask hardening while blocking model-supplied overrides for channel runs

* docs: align exec ask contract with runtime

* refactor(agents): simplify exec ask policy cleanup

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-06-03 18:33:08 +05:30
Ayaan Zaidi
b3b203bf67 test(telegram): simplify preview race harness 2026-06-03 18:28:13 +05:30
张贵萍0668001030
0a4927d0b8 fix(telegram): retain preview on generation race 2026-06-03 18:28:13 +05:30
clawsweeper[bot]
a61c94b1f1 fix(feishu): wire setup runtime setter (#89814)
Summary:
- The PR adds a narrow Feishu runtime-setter entrypoint, wires it into the Feishu setup entry, and adds regression coverage for setup-only runtime registration.
- PR surface: Source +7, Tests +22. Total +29 across 4 files.
- Reproducibility: yes. source inspection gives a high-confidence reproduction path: current Feishu setup-only ... ate when that setter is present. I did not run a live Feishu tenant message repro in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(feishu): wire setup runtime setter

Validation:
- ClawSweeper review passed for head befd074ca6.
- Required merge gates passed before the squash merge.

Prepared head SHA: befd074ca6
Review: https://github.com/openclaw/openclaw/pull/89814#issuecomment-4612032021

Co-authored-by: Glenn-Agent <glenn_agent@163.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-03 12:36:42 +00:00
Vincent Koc
a9f099d279 test(qa): require channel scenario markers 2026-06-03 14:27:25 +02:00
Vincent Koc
2fa60af960 test(vitest): make channel helper config runnable 2026-06-03 05:23:44 -07:00
clawsweeper[bot]
07006943de fix(telegram): isolate verbose status after streamed finals (#89813)
Summary:
- The branch updates Telegram dispatch so a verbose/status final arriving after a streamed final answer uses a fresh answer-lane message, with default and progress-mode regression tests.
- PR surface: Source +14, Tests +52. Total +66 across 2 files.
- Reproducibility: yes. The linked bug report gives a concrete Telegram `/reset`, `/v on`, short-prompt path, and source inspection shows current main can route a second final payload through the finalized answer lane.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(telegram): isolate verbose status after streamed finals

Validation:
- ClawSweeper review passed for head 4d476a957f.
- Required merge gates passed before the squash merge.

Prepared head SHA: 4d476a957f
Review: https://github.com/openclaw/openclaw/pull/89813#issuecomment-4612006920

Co-authored-by: kesslerio <martin@kessler.io>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-03 12:21:08 +00:00
Vincent Koc
9dc1694eb7 test: lengthen ARM contracts shard watchdog 2026-06-03 05:05:35 -07:00
Vincent Koc
98ff56d70e perf(ui): trace chat send server milestones
Add operator-only Control UI chat send timing milestones across gateway dispatch, model selection, agent-run start, dispatch completion, and post-dispatch completion. The Control UI records these server phases into the existing chat send timing buffer, and the gateway broadcast guard now scopes the new timing event with other read-visible chat events.
2026-06-03 05:02:06 -07:00
Vincent Koc
03ccdb9fbc test(e2e): assert mcp reconnect temp state 2026-06-03 13:59:34 +02:00
Vincent Koc
6d7b80fa1c test(gateway): shard default gateway vitest config 2026-06-03 04:57:27 -07:00
clawsweeper[bot]
409d1a7135 fix(agents): release session write lock if fence read throws on prompt release (#89811)
Summary:
- The PR makes prompt-release fence bookkeeping exception-safe so the session write lock is released even when fence reads throw, and adds a regression test for that path.
- PR surface: Source +6, Tests +27. Total +33 across 2 files.
- Reproducibility: yes. source-reproducible with provided real-output proof: current main clears `heldLock` be ... ire timing out after an injected `EIO`. I did not run the harness locally because this review is read-only.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): release session write lock if fence read throws on promp…

Validation:
- ClawSweeper review passed for head 394d978437.
- Required merge gates passed before the squash merge.

Prepared head SHA: 394d978437
Review: https://github.com/openclaw/openclaw/pull/89811#issuecomment-4611966479

Co-authored-by: Spencer Fuller <spencer.p.fuller@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-03 11:51:43 +00:00
Vincent Koc
d31f4e2d62 fix(e2e): stop interrupted docker builds 2026-06-03 13:48:31 +02:00
Ayaan Zaidi
e5e6cf04a2 fix(android): hide nav under command palette 2026-06-03 17:02:10 +05:30
Ayaan Zaidi
4f8740029a refactor(android): distill companion shell cleanup 2026-06-03 17:02:10 +05:30
Tosko4
9159b3bf8e Improve Android companion-first shell UX 2026-06-03 17:02:10 +05:30
Vincent Koc
eddf1c776d test(e2e): require kitchen sink tool coverage 2026-06-03 13:25:50 +02:00
Vincent Koc
6ec579a0c2 docs(web): document chat ack timing metadata (#89802) 2026-06-03 04:18:51 -07:00
Vincent Koc
87eaac4010 fix(e2e): bound image auth mock bodies 2026-06-03 13:15:51 +02:00
Val Alexander
529282dcff fix(ui): harden Workboard dialog accessibility
Harden Workboard modal and drawer accessibility.

Summary:
- Add Workboard dialog focus lifecycle handling for initial focus, Tab/Shift+Tab containment, Escape close, and opener restore.
- Mark Workboard background content inert/aria-hidden while modal or drawer dialogs are active.
- Add focused unit and Chromium browser smoke coverage for the audited modal/drawer accessibility requirements.
- Keep UI browser test aliases able to resolve shared workspace packages used by the Workboard view.

Verification:
- node scripts/run-vitest.mjs ui/src/ui/views/workboard.test.ts
- node scripts/run-vitest.mjs ui/src/ui/views/workboard.browser.test.ts
- (cd ui && pnpm exec vitest run --config vitest.config.ts --project browser src/ui/views/workboard.browser.test.ts)
- GitHub checks green at 6557012430
2026-06-03 06:14:40 -05:00
Vincent Koc
b1fccd0605 perf(ui): surface chat ack server timing (#89801) 2026-06-03 04:11:14 -07:00
Vincent Koc
287dee4593 fix(e2e): settle credential shutdown promptly 2026-06-03 13:01:58 +02:00
Vincent Koc
b96c0d932f test(codex): stabilize app-server startup races 2026-06-03 03:48:45 -07:00
Vincent Koc
a46181f168 test: stabilize timing-sensitive ARM suites 2026-06-03 03:47:48 -07:00
Vincent Koc
1b5cb4a0d3 fix(e2e): bound clickclack fixture bodies 2026-06-03 12:45:59 +02:00
Vincent Koc
9947a26768 test(ui): cover control chat send timing phases 2026-06-03 03:44:44 -07:00
Vincent Koc
2accf3875b test(e2e): assert channel credential fields 2026-06-03 12:25:14 +02:00
Vincent Koc
76c8b36031 fix(e2e): stop tracked process groups 2026-06-03 12:17:05 +02:00
Vincent Koc
44fea3c94a fix(tooling): cancel oversized audit responses 2026-06-03 12:05:39 +02:00
Vincent Koc
c68938c19e perf(gateway): overlap chat catalog startup
Start optional model catalog loading earlier during chat history/startup hydration so catalog discovery overlaps history projection without changing the metadata contract. The response still awaits catalog-backed session/default/agents metadata before replying.

Verification:
- git diff --check
- autoreview local caught and rejected the short-timeout variant; fixed to overlap-only
- autoreview commit clean
- Testbox tbx_01kt6edf5d328vqr43epy0cs0b targeted gateway/UI shards passed
- Testbox tbx_01kt6eh4fk409g4ar1kpa0edhz check:changed lanes core, coreTests passed
2026-06-03 03:02:47 -07:00
Vincent Koc
a7c8b2a46a fix(e2e): bound mock readiness probes 2026-06-03 11:58:45 +02:00
Vincent Koc
5a0d9d6326 fix(codex): retire abandoned app-server startups 2026-06-03 02:55:12 -07:00
Vincent Koc
7cee0bca0b fix(e2e): isolate plugin lifecycle artifacts 2026-06-03 11:50:33 +02:00
Vincent Koc
7074cf8e23 perf(ui): label delayed chat sends in telemetry (#89777) 2026-06-03 02:41:58 -07:00
Vincent Koc
26301f318f fix(ui): scroll pending sends into view 2026-06-03 02:30:22 -07:00
Vincent Koc
f49f5973b0 perf(ui): start chat refresh before bootstrap
Start the active Control UI chat refresh after Gateway hello without waiting for the slower bootstrap fetch. Keep startup canvas embeds fail-closed until bootstrap config arrives, and recreate preview iframes when sandbox policy changes.
2026-06-03 02:27:25 -07:00
Vincent Koc
1e4ff80604 fix(e2e): clean failed tarball extracts 2026-06-03 11:18:54 +02:00
Vincent Koc
84dca54ef2 fix(e2e): fail package worktree cleanup leaks 2026-06-03 11:12:02 +02:00
Vincent Koc
4a67e4b976 fix(test): avoid empty script changed runs 2026-06-03 11:05:04 +02:00
Ayaan Zaidi
41ee6b1dd6 feat(telegram): show commentary in progress drafts 2026-06-03 14:30:30 +05:30
Ayaan Zaidi
04f93c2fb4 refactor(channels): share progress draft primitives 2026-06-03 14:30:30 +05:30
Vincent Koc
3cdb87be86 fix(test): route parallels helper changes 2026-06-03 10:57:44 +02:00
Onur Solmaz
17a285f298 fix(ui): preserve visible chat stream text
Fix WebChat stream/history reconciliation so visible assistant text survives stale history reloads, tool-history catch-up, and terminal final/error/abort events.\n\nRefactors the UI path into stream reconciliation, stream text, and typed tool-message helpers so persisted history and live stream state use the same matching rules.\n\nCloses #67035.
2026-06-03 16:56:33 +08:00
zhang-guiping
c2d7b4a486 fix(ui): clear chat stream before terminal commits
Fix the Control UI WebChat race where terminal assistant messages could be committed while chatStream was still live, causing history and active stream to render the same reply twice. Terminal final/aborted handling now snapshots fallback text, clears the active run/stream through the lifecycle owner, then appends the visible assistant message.\n\nFixes #71992.\n\nVerification: node scripts/run-vitest.mjs run ui/src/ui/controllers/chat.test.ts ui/src/ui/chat/run-lifecycle.test.ts ui/src/ui/chat/build-chat-items.test.ts; node scripts/run-vitest.mjs run ui/src/ui/app-chat.test.ts ui/src/ui/controllers/sessions.test.ts; node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner ui/src/ui/e2e/chat-flow.e2e.test.ts; Blacksmith Testbox tbx_01kt6a4zn7awkdy12d6b0q2d1q / run 26873514898; autoreview clean; PR CI 121 pass / 10 skipped.
2026-06-03 01:45:59 -07:00
zhang-guiping
0b98aea71a fix(ui): reconcile completed chat sends
Fixes #87699.\n\nRoutes ACK-completed Control UI chat sends through the existing run lifecycle reconciliation path so stale selected-session rows cannot re-enable the composer/Stop state after the conversation has already completed.\n\nVerification: focused UI/unit tests, Control UI E2E chat-flow test, autoreview clean, Testbox changed gate tbx_01kt68xvz17fcnmd3wj6f7pk6f, and PR CI run 26872484363 green after failed-job rerun for transient runner setup failures.
2026-06-03 01:34:13 -07:00
Vincent Koc
114864185b fix(e2e): fail kitchen sink cleanup leaks 2026-06-03 10:28:19 +02:00
Ayaan Zaidi
1bd1483b62 refactor(auto-reply): unify transient failure visibility 2026-06-03 13:55:36 +05:30
FullerStackDev
a5ef086e3c test(auto-reply): cover channel-agnostic failure routing 2026-06-03 13:55:36 +05:30
FullerStackDev
a10faca06f fix(auto-reply): surface fatal channel errors 2026-06-03 13:55:36 +05:30
Vincent Koc
380a8f140e fix(e2e): fail rpc rtt cleanup leaks 2026-06-03 10:20:22 +02:00
Vincent Koc
34c3827290 fix(e2e): close rpc rtt gateway log handles 2026-06-03 10:10:39 +02:00
Vincent Koc
54fe0e7f71 fix(e2e): keep cleanup retries covered 2026-06-03 10:10:39 +02:00
Yzx
932d6ea8e5 fix(webchat): show sessions_send handoffs as forwarded
Fix WebChat display projection for sessions_send inter-session handoffs. Forwarded messages now render assistant-side with source attribution while keeping transcript user-role semantics, stripping generated inter-session envelopes from display text, and preserving heartbeat/TTS/message-tool cleanup boundaries. Fixes #89161.
2026-06-03 01:09:45 -07:00
Vincent Koc
d004b80c91 fix(e2e): surface secret proof cleanup failures 2026-06-03 09:48:54 +02:00
Vincent Koc
5820378b90 fix(e2e): isolate telegram package artifacts 2026-06-03 09:43:16 +02:00
Vincent Koc
d5df1a1cd6 fix(e2e): isolate multi-node artifacts 2026-06-03 09:36:43 +02:00
Vincent Koc
175cfe4846 fix(gateway): stabilize webchat prompt cache affinity
Keep WebChat run/idempotency ids per message while threading a stable hashed promptCacheKey through chat.send into embedded runs. Fixes #89139.
2026-06-03 00:33:02 -07:00
Alexzhu
85e5d486df perf(control-ui): render chat history incrementally
Render dashboard chat history incrementally; preserve Talk settings callback contracts, native Talk select labels, and raw-copy baseline after rebase.
2026-06-03 00:16:32 -07:00
Vincent Koc
b6cee3fc35 fix(scripts): clean run-with-env process groups 2026-06-03 09:10:09 +02:00
Dallin Romney
d48b9274d8 fix: report gateway health auth diagnostics (#89337)
* fix: handle gateway health credential errors

* fix: diagnose gateway health credential state
2026-06-03 00:04:47 -07:00
Vincent Koc
6d788a237c fix(ci): isolate ARM Testbox workflow 2026-06-03 00:04:12 -07:00
Vincent Koc
7ccbffcb1b fix(testing): bound rpc readiness probes 2026-06-03 08:46:17 +02:00
Vincent Koc
2c92973398 fix(release): bound cross-os discord fetches 2026-06-03 08:35:14 +02:00
Vincent Koc
ed4c4afc0f fix(release): bound candidate GitHub requests 2026-06-03 08:19:03 +02:00
Vincent Koc
a462601f05 fix(e2e): isolate release journey artifacts 2026-06-03 08:08:44 +02:00
Vincent Koc
f472778717 fix(codex): close startup client on timeout 2026-06-02 23:04:41 -07:00
Vincent Koc
7c1a83ff2e fix(build): externalize optional baileys image backends 2026-06-03 07:50:25 +02:00
Vincent Koc
f8fcb35064 fix(ui): lazy load usage dashboard 2026-06-03 07:41:43 +02:00
Vincent Koc
c0b05a2100 perf(control-ui): coalesce chat metadata startup
Add a coalesced chat.metadata Gateway method so the Control UI can fetch model and command metadata without blocking a clean first message path. Reuses existing models/commands builders, keeps compatibility fallback for older gateways, updates protocol artifacts, and adds focused gateway/UI/e2e coverage.
2026-06-02 22:34:54 -07:00
Ayaan Zaidi
2a512025ad feat(telegram): compose progress draft reasoning 2026-06-03 10:54:19 +05:30
Ayaan Zaidi
7f79bd8683 refactor(discord): use shared progress compositor 2026-06-03 10:54:19 +05:30
Ayaan Zaidi
a4b09d72b9 refactor(channels): share progress draft compositor 2026-06-03 10:54:19 +05:30
Dallin Romney
58160094e8 fix: allowlist pending agent sqlite scaffold (#89705) 2026-06-02 22:22:13 -07:00
Dallin Romney
c0c4156b6d fix(exec): reject corrupt shell snapshots (#89701) 2026-06-02 21:58:28 -07:00
Vincent Koc
3f66797578 Merge branch 'main' of https://github.com/openclaw/openclaw
* 'main' of https://github.com/openclaw/openclaw:
  fix(ci): trim docker e2e heartbeat latency
2026-06-02 21:57:21 -07:00
Vincent Koc
f02c1209aa fix(ui): narrow workboard dependency fixtures 2026-06-02 21:56:51 -07:00
Vincent Koc
5056dd47ca chore(scripts): add gateway rpc rtt probe 2026-06-02 21:56:51 -07:00
Vincent Koc
97dde19577 test(extensions): reset fake timers before tests 2026-06-02 21:56:51 -07:00
Vincent Koc
7cbdebc4ed feat(ui): tighten workboard card operations 2026-06-02 21:56:50 -07:00
Vincent Koc
17795c6c4c fix(ci): trim docker e2e heartbeat latency 2026-06-03 06:54:52 +02:00
Vincent Koc
6b25b78800 fix(ci): show docker build heartbeats 2026-06-03 06:36:06 +02:00
Vincent Koc
78b3f60dbd fix(ci): reset crabbox pnpm hydrate state 2026-06-03 05:33:59 +02:00
Ayaan Zaidi
8f1ae5967e fix(discord): sanitize tool progress scaffolding 2026-06-03 08:03:57 +05:30
Ayaan Zaidi
d82bfcecb1 fix(discord): cover compact gh failure traces 2026-06-03 08:03:57 +05:30
FullerStackDev
5629c44547 fix(discord): preserve channel-label suppression 2026-06-03 08:03:57 +05:30
FullerStackDev
a8bf14da84 fix(discord): suppress internal agent failure traces 2026-06-03 08:03:57 +05:30
Ayaan Zaidi
a9f014e9df refactor(telegram): fold reset boundary lookup 2026-06-03 08:01:57 +05:30
Ted Li
d76f2c0c3b perf: avoid broad Telegram reset boundary scan 2026-06-03 08:01:57 +05:30
Vincent Koc
f2a46b0661 fix(tooling): bound deadcode knip subprocesses 2026-06-03 03:47:27 +02:00
Vincent Koc
0fa384c6f6 fix(tooling): run knip through pnpm package dlx 2026-06-03 02:52:16 +02:00
Vincent Koc
6d643ccd11 fix(tooling): reject malformed release command limits 2026-06-03 02:52:16 +02:00
Gabriel F.
8b546facaf fix(outbound): stop schema-padded poll modifiers from blocking send (#89601)
Summary:
- The PR changes shared poll-intent detection so `pollDurationHours` and `pollMulti` alone no longer make `send` actions fail, with focused unit and outbound validation coverage.
- PR surface: Source -2, Tests +40. Total +38 across 3 files.
- Reproducibility: yes. Source inspection shows current main and `v2026.5.28` expose `pollDurationHours` throu ... d message schema, classify non-zero shared duration as poll intent, and throw before a `send` can dispatch.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 0fd95756cd.
- Required merge gates passed before the squash merge.

Prepared head SHA: 0fd95756cd
Review: https://github.com/openclaw/openclaw/pull/89601#issuecomment-4606487310

Co-authored-by: Gabriel Fratica <gabriel@codez.ro>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-03 00:30:02 +00:00
Vincent Koc
1f35ad12b3 fix(test): reject malformed parallels smoke limits 2026-06-03 02:19:49 +02:00
Vincent Koc
3d4d30fd5a fix(release): reject malformed beta smoke limits 2026-06-03 02:06:40 +02:00
Vincent Koc
dd46fd36a3 fix(tooling): reject malformed cross-os release timeouts 2026-06-03 01:59:48 +02:00
Vincent Koc
85633eb615 chore(tooling): drop stale deadcode allowlist entries 2026-06-03 01:49:25 +02:00
Vincent Koc
2a3421a0da fix(tooling): reject malformed crabbox sync limits 2026-06-03 01:07:41 +02:00
Vincent Koc
e38b8f6a20 fix(test): reject malformed cron cleanup limits 2026-06-03 00:07:24 +02:00
Gio Della-Libera
646974b7d8 fix(policy): reject unsupported policy keys (#87074)
Merged via squash.

Prepared head SHA: 3ab4ff1d8f
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-02 15:01:57 -07:00
Vincent Koc
a86a1de849 fix(tooling): reject malformed tsdown watchdog limits 2026-06-02 23:43:09 +02:00
Val Alexander
be336cc1e4 feat(ui): add workboard keyboard movement controls
Add compact keyboard-accessible Workboard status movement controls for writable operators. The control reuses the existing workboard.cards.move path, preserves drag/drop as the pointer enhancement, and suppresses mutation controls for read-only operators.\n\nVerification:\n- node scripts/run-vitest.mjs ui/src/ui/views/workboard.test.ts\n- corepack pnpm exec oxfmt --check --threads=1 ui/src/ui/views/workboard.ts ui/src/ui/views/workboard.test.ts ui/src/styles/workboard.css docs/plugins/workboard.md\n- git diff --check origin/main...HEAD\n- Chromium Control UI mock Gateway keyboard movement proof\n- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --no-web-search
2026-06-02 16:08:29 -05:00
Vincent Koc
8cecf2c7ea fix(test): reject malformed local check limits 2026-06-02 22:48:12 +02:00
Vincent Koc
6af047c7f6 fix(test): reject malformed boundary prep timeouts 2026-06-02 22:26:15 +02:00
Vincent Koc
ac8338bb02 fix(tooling): reject malformed topology limits 2026-06-02 22:19:10 +02:00
Vincent Koc
0188c541de fix(test): reject malformed extension boundary concurrency 2026-06-02 22:12:01 +02:00
Vincent Koc
97509ed1d7 fix(test): reject malformed extension batch parallelism 2026-06-02 22:05:44 +02:00
Vincent Koc
432a5978b9 fix(test): reject malformed extension shard counts 2026-06-02 21:59:42 +02:00
Vincent Koc
5f6a8083bf fix(perf): reject malformed cpuprofile limits 2026-06-02 21:53:34 +02:00
Vincent Koc
36d7ac31c2 fix(ci): reject malformed ci timing limits 2026-06-02 21:47:28 +02:00
Vincent Koc
aed3743630 fix(docker): reject malformed timing limits 2026-06-02 21:38:21 +02:00
Vincent Koc
28b1ea7c0d fix(test): reject malformed group report numeric flags 2026-06-02 21:31:16 +02:00
Vincent Koc
661c763b28 fix(docs): reject malformed mdx max error limits 2026-06-02 21:25:23 +02:00
Vincent Koc
36a596aa9f fix(ci): reject malformed targeted docker group size 2026-06-02 21:18:52 +02:00
Michael Appel
c208a10619 Harden node exec approval precheck env [AI] (#81488)
* fix: align node exec approval precheck env

* addressing ci

* fix: preserve node allow-always prechecks

* fix: finalize node exec approval port

* fix: align node prepare approval env

* test: tighten node marker reuse coverage proof

* test: fix node allow-always coverage mock typing

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
2026-06-02 13:15:41 -06:00
Vincent Koc
e59e65be67 fix(test): reject malformed boundary check env 2026-06-02 21:12:42 +02:00
Vincent Koc
054e734e53 fix(lint): reject malformed oxlint shard env 2026-06-02 21:06:14 +02:00
Vincent Koc
d007b9aba3 fix(test): reject malformed full-suite parallel env 2026-06-02 20:59:02 +02:00
Vincent Koc
5d4868c036 fix(scripts): validate gateway watch numeric options 2026-06-02 20:36:46 +02:00
Vincent Koc
8bf6206a3e test(rpc): enforce kitchen sink command rss ceiling 2026-06-02 20:09:10 +02:00
Gio Della-Libera
1d3cfc4b01 Policy: add data handling conformance checks (#87056)
Merged via squash.

Prepared head SHA: 6a0e9730aa
Co-authored-by: giodl73-repo <
>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-02 10:48:07 -07:00
Vincent Koc
1ff2ffa160 chore(scripts): drop legacy moltbot rpc alias 2026-06-02 19:37:07 +02:00
Vincent Koc
d07ba5f265 fix(providers): avoid custom provider runtime fanout 2026-06-02 19:23:38 +02:00
Vincent Koc
f789081bae test(gateway): abort accepted agent run in e2e 2026-06-02 18:12:54 +02:00
Vincent Koc
388dc56ba5 test(gateway): defer sidecars in tools invoke e2e 2026-06-02 17:41:19 +02:00
Vincent Koc
6c7644268f fix(test): stabilize ARM extension timer tests 2026-06-02 07:53:25 -07:00
Peter Steinberger
c8d21fe7f0 fix: recover suspicious gateway startup configs (#89480) 2026-06-02 10:12:35 -04:00
Bryan Tegomoh, MD, MPH
00d846daf7 fix(kimi): strip anthropic cache markers
Closes #76612

Co-authored-by: Bryan Tegomoh <bryan.tegomoh@gmail.com>
2026-06-02 09:59:36 -04:00
Shakker
1b9860aa56 fix: restore Skill Workshop view switcher 2026-06-02 14:59:19 +01:00
Peter Steinberger
97d4d5effb docs(changelog): note update repair stall handling 2026-06-02 14:57:01 +01:00
Peter Steinberger
12c6ef6d57 fix(update): keep plugin repair fetch failures nonblocking 2026-06-02 14:55:55 +01:00
Vincent Koc
96277245dc fix(test): isolate gateway CPU QA state 2026-06-02 15:27:16 +02:00
Peter Steinberger
eef24d452f fix(models): preserve provider prompt cache boundaries
Split Anthropic system prompts at the cache boundary so only stable prefixes get cache_control, strip the internal marker when cache control is disabled, and keep OpenAI-compatible Anthropic cache-control routes from caching dynamic suffixes.\n\nFixes #89386.
2026-06-02 09:19:52 -04:00
Peter Steinberger
c3baec7136 docs: clarify autoreview follow-up scope 2026-06-02 06:15:51 -07:00
Coder
4bb86877e2 fix(google): forward Gemini stop sequences
Forward configured stop sequences to Gemini generationConfig.stopSequences in the bundled Google transport, matching the shared Google provider behavior and the @google/genai request contract.\n\nThanks @coder999999999.
2026-06-02 09:02:27 -04:00
489 changed files with 26378 additions and 4434 deletions

View File

@@ -22,6 +22,8 @@ Use when:
- Read dependency docs/source/types when the finding depends on external behavior.
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
- Keep going until structured review returns no accepted/actionable findings.
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.

View File

@@ -0,0 +1,156 @@
name: Blacksmith ARM Testbox
on:
workflow_dispatch:
inputs:
testbox_id:
type: string
description: "Testbox session ID"
required: true
pull_request:
paths:
- ".github/workflows/**"
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
jobs:
check-arm:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
permissions:
contents: read
name: "check-arm"
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
timeout-minutes: 120
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Verify ARM runner
shell: bash
run: |
set -euo pipefail
runner_arch="$(uname -m)"
echo "check-arm runner architecture: ${runner_arch}"
case "$runner_arch" in
aarch64 | arm64)
;;
*)
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
exit 1
;;
esac
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
if [[ -z "$CHECKOUT_TOKEN" ]]; then
echo "checkout token is missing" >&2
exit 1
fi
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 30s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
PNPM
sudo chmod 0755 /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -139,139 +139,3 @@ jobs:
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
check-arm:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
permissions:
contents: read
name: "check-arm"
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
timeout-minutes: 120
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Verify ARM runner
shell: bash
run: |
set -euo pipefail
runner_arch="$(uname -m)"
echo "check-arm runner architecture: ${runner_arch}"
case "$runner_arch" in
aarch64 | arm64)
;;
*)
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
exit 1
;;
esac
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
if [[ -z "$CHECKOUT_TOKEN" ]]; then
echo "checkout token is missing" >&2
exit 1
fi
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 30s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
PNPM
sudo chmod 0755 /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -120,6 +120,21 @@ jobs:
append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir
append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency
append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir
reset_crabbox_pnpm_path() {
local path="$1"
if [ -z "$path" ]; then
return
fi
case "$path" in
/var/tmp/openclaw-pnpm-*) rm -rf "$path" ;;
esac
}
reset_crabbox_pnpm_path "${PNPM_CONFIG_MODULES_DIR:-}"
reset_crabbox_pnpm_path "${PNPM_CONFIG_STORE_DIR:-}"
reset_crabbox_pnpm_path "${PNPM_CONFIG_VIRTUAL_STORE_DIR:-}"
if [ -L node_modules ] && [ "$(readlink node_modules)" = "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
rm -f node_modules
fi
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
- "!v*-alpha.*"
paths-ignore:
- "docs/**"
- "**/*.md"
@@ -38,7 +39,11 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-(alpha|beta)\.[1-9][0-9]*)?$ ]]; then
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
echo "Docker alpha image publishing is disabled."
exit 1
fi
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
echo "Invalid release tag: ${RELEASE_TAG}"
exit 1
fi

View File

@@ -45,7 +45,49 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord: match the shipped `libopus-wasm` error shape so corrupt voice packets are treated as decode noise instead of crashing receive recovery.
- Canvas: restore A2UI Google, X, and legacy Granola compatibility image assets in the bundled host payload.
- Agents/providers: avoid loading owner plugin runtimes for explicitly configured custom provider models during OpenAI-compatible transport setup.
- Tooling: fail Codex app-server protocol generation before invoking Cargo when local disk headroom is too low.
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
- Release/CI/E2E: reset shared Crabbox pnpm hydrate state before installs so stale `/var/tmp` stores cannot leave `pnpm install` spinning after completion.
- Release/CI/E2E: print heartbeat progress during centralized Docker builds while keeping successful build logs quiet.
- Release/CI/E2E: avoid heartbeat-tail delays in Docker E2E log wrappers while reporting captured log bytes during long runs.
- Release/CI/E2E: keep release user-journey logs and temporary plugin fixtures under per-run scratch roots so parallel runs cannot collide or leak artifacts.
- Release/CI/E2E: bound release candidate GitHub API calls so stalled network requests cannot wedge workflow and artifact polling.
- Release/CI/E2E: bound Discord smoke API calls in cross-OS release checks so host-side round trips cannot hang on stalled fetches.
- Release/CI/E2E: bound RPC RTT gateway readiness probes so a half-open local HTTP response cannot stall cleanup past the readiness deadline.
- Release/CI/E2E: stop RPC RTT gateway process groups so pnpm wrapper children cannot survive measurement cleanup.
- Release/CI/E2E: fail the kitchen-sink RPC walk when command RSS sampling captures no process samples.
- Release/CI/E2E: fail kitchen-sink RPC commands that exit cleanly only after their timeout expires.
- Release/CI/E2E: force-stop memory/fd repro gateway children that survive listener cleanup.
- Release/CI/E2E: remove fallback ClawHub skill-install home directories when proof runs fail.
- Release/CI/E2E: let plugin lifecycle measurement wrappers exit promptly after external shutdown while preserving descendant cleanup.
- Gateway: cancel client stop fallback termination when the socket closes normally during shutdown.
- Installers: fail the PowerShell installer when interactive onboarding exits non-zero.
- Scripts/UI: stop descendant processes from wrapped non-interactive commands when `run-with-env` receives shutdown signals.
- Release/CI/E2E: write multi-node update Docker artifacts to unique per-run directories by default so parallel runs cannot overwrite evidence.
- Release/CI/E2E: write package Telegram Docker artifacts to unique per-run directories by default so parallel live/RTT runs cannot overwrite evidence.
- Release/CI/E2E: keep plugin lifecycle matrix resource artifacts under a unique per-run scratch root so parallel runs cannot overwrite tarballs or inspect output.
- Release/CI/E2E: bound mock OpenAI readiness probes in web-search and Telegram RTT Docker smokes so stalled HTTP accepts cannot hang cleanup or fall through.
- Tooling: cancel oversized pnpm audit advisory responses before failing so registry error paths do not leave response bodies open.
- Release/CI/E2E: stop tracked gateway and mock service process groups so descendant helpers do not survive E2E cleanup.
- Release/CI/E2E: exit Telegram credential proof wrappers promptly after forwarded shutdown signals while keeping the descendant force-kill guard armed.
- Release/CI/E2E: reject oversized ClickClack fixture request bodies before release journey smokes can accumulate unbounded payloads.
- Release/CI/E2E: reject oversized OpenAI image-auth mock request bodies before Docker proof runs can accumulate unbounded payloads.
- Release/CI/E2E: require the Kitchen Sink RPC walk to prove every expected plugin tool is cataloged and effective before invoking tool fixtures.
- Release/CI/E2E: stop tracked Docker build commands when centralized build wrappers receive shutdown signals.
- Release/CI/E2E: cover MCP channel pairing reconnects by asserting the same temporary client state is reused across reconnects.
- Release/CI/E2E: require QA channel baseline and reconnect scenarios to assert their scenario markers instead of accepting any outbound reply.
- Release/CI/E2E: fail secret-provider proof runs when temporary state cleanup still fails after retries instead of hiding the cleanup error.
- Release/CI/E2E: fail package-candidate ref proofs when temporary source worktree cleanup fails instead of leaving stale worktrees behind.
- Release/CI/E2E: remove package tarball extract directories when tar extraction fails before validation can continue.
- Release/CI/E2E: retry generated temp-state cleanup after removal failures and route plugin lifecycle measurement edits to their owner tests.
- Release/CI/E2E: close parent gateway log handles after spawning RPC RTT probes so repeated measurements do not leak file descriptors.
- Release/CI/E2E: fail RPC RTT probes when temporary state cleanup fails instead of hiding leftover scratch directories.
- Release/CI/E2E: fail Kitchen Sink RPC walks when temporary state cleanup still fails after retries instead of silently preserving scratch roots.
- Control UI: lazy-load the usage view so the initial app bundle stays below the chunk warning threshold.
- Build: keep Baileys optional image backends external so source builds do not warn about missing `jimp` or `sharp`.
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
@@ -54,6 +96,7 @@ Docs: https://docs.openclaw.ai
- Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.
- Release/CI/E2E: normalize inherited Linux `C.UTF-8` locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.
- Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.
- Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.
- Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as `null` or arrays.
- Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.
- Talk: preserve explicit `null` payloads on controller-created turn and output-audio lifecycle events.

View File

@@ -6,6 +6,7 @@ import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.ui.design.ClawDesignTheme
import ai.openclaw.app.ui.design.ClawErrorState
import ai.openclaw.app.ui.design.ClawListItem
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
@@ -473,6 +474,14 @@ private fun GatewaySetupScreen(
onClick = { advancedOpen = true },
)
}
error?.let { message ->
item {
ClawErrorState(
title = "Setup code issue",
body = message,
)
}
}
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Surface(
@@ -505,9 +514,6 @@ private fun GatewaySetupScreen(
}
ClawTextField(value = token, onValueChange = onTokenChange, placeholder = "Token optional")
ClawTextField(value = password, onValueChange = onPasswordChange, placeholder = "Password optional")
error?.let {
Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
}
}
}
}

View File

@@ -18,11 +18,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -78,9 +82,16 @@ internal fun ProvidersModelsScreen(
}
}
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 13.dp)) {
ClawScaffold(
contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 6.dp),
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp), contentPadding = PaddingValues(bottom = 112.dp)) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(7.dp),
contentPadding = PaddingValues(bottom = 4.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(

View File

@@ -13,11 +13,14 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -88,8 +91,15 @@ internal fun SessionsScreen(
}
}
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp)) {
ClawScaffold(
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(7.dp),
contentPadding = PaddingValues(bottom = 4.dp),
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -133,11 +143,16 @@ internal fun SessionsScreen(
if (visibleSessions.isEmpty()) {
item {
ClawEmptyState(
title = emptySessionTitle(filter),
body = emptySessionBody(filter),
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
)
Box(
modifier = Modifier.fillParentMaxHeight(0.56f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
ClawEmptyState(
title = emptySessionTitle(filter),
body = emptySessionBody(filter),
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
)
}
}
} else {
items(visibleSessions, key = { it.key }) { session ->
@@ -155,10 +170,6 @@ internal fun SessionsScreen(
)
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}

View File

@@ -44,11 +44,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
@@ -1028,8 +1032,11 @@ internal fun SettingsDetailFrame(
onBack: () -> Unit,
content: @Composable () -> Unit,
) {
ClawScaffold(contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 20.dp)) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ClawScaffold(
contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 6.dp),
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
) {
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
item {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
SettingsBackButton(onClick = onBack)
@@ -1045,9 +1052,6 @@ internal fun SettingsDetailFrame(
content()
}
}
item {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}

View File

@@ -9,11 +9,14 @@ import ai.openclaw.app.HomeDestination
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.NodeRuntime
import ai.openclaw.app.ui.chat.ChatScreen
import ai.openclaw.app.ui.design.ClawBottomNav
import ai.openclaw.app.ui.design.ClawDesignTheme
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawNavItem
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
@@ -24,20 +27,26 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Notifications
@@ -54,6 +63,7 @@ import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -69,23 +79,32 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private enum class Tab(
internal enum class Tab(
val key: String,
val label: String,
val icon: ImageVector,
) {
Overview(key = "overview", label = "Home"),
Chat(key = "chat", label = "Chat"),
Voice(key = "voice", label = "Voice"),
Sessions(key = "sessions", label = "Sessions"),
Settings(key = "settings", label = "Settings"),
ProvidersModels(key = "providers-models", label = "Providers"),
Overview(key = "overview", label = "Home", icon = Icons.Default.Home),
Chat(key = "chat", label = "Chat", icon = Icons.Outlined.ChatBubbleOutline),
Voice(key = "voice", label = "Voice", icon = Icons.Outlined.MicNone),
Sessions(key = "sessions", label = "Sessions", icon = Icons.Outlined.AccessTime),
Settings(key = "settings", label = "Settings", icon = Icons.Outlined.Settings),
ProvidersModels(key = "providers-models", label = "Providers", icon = Icons.Outlined.Inventory2),
}
private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Voice, Tab.Settings)
private val shellContentInsets: WindowInsets
@Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
internal fun shellBottomNavVisible(keyboardVisible: Boolean, commandOpen: Boolean): Boolean = !keyboardVisible && !commandOpen
/** Main post-onboarding shell that owns top-level Android navigation state. */
@Composable
fun ShellScreen(
@@ -131,117 +150,144 @@ fun ShellScreen(
commandOpen = false
}
Box(modifier = modifier.fillMaxSize()) {
when (activeTab) {
Tab.Overview ->
OverviewScreen(
viewModel = viewModel,
onSelectTab = { activeTab = it },
onOpenSettingsRoute = {
settingsRoute = it
returnToOverviewFromSettings = true
activeTab = Tab.Settings
},
onOpenCommand = { commandOpen = true },
)
Tab.Chat ->
ChatShellScreen(
viewModel = viewModel,
onBack = { activeTab = Tab.Overview },
onVoice = { activeTab = Tab.Voice },
)
Tab.Voice ->
VoiceShellScreen(
viewModel = viewModel,
onOpenCommand = { commandOpen = true },
onOpenGatewaySettings = {
settingsRoute = SettingsRoute.Gateway
returnToOverviewFromSettings = false
activeTab = Tab.Settings
},
onOpenVoiceSettings = {
settingsRoute = SettingsRoute.Voice
returnToOverviewFromSettings = false
activeTab = Tab.Settings
},
)
Tab.ProvidersModels ->
ProvidersModelsScreen(
viewModel = viewModel,
onBack = { activeTab = Tab.Overview },
onAddProvider = {
settingsRoute = SettingsRoute.Gateway
returnToOverviewFromSettings = false
activeTab = Tab.Settings
},
)
Tab.Sessions ->
SessionsScreen(
viewModel = viewModel,
onOpenCommand = { commandOpen = true },
onOpenChat = { activeTab = Tab.Chat },
)
Tab.Settings ->
SettingsShellScreen(
viewModel = viewModel,
route = settingsRoute,
onRouteChange = {
settingsRoute = it
returnToOverviewFromSettings = false
},
onRouteBack = {
settingsRoute = SettingsRoute.Home
if (returnToOverviewFromSettings) {
val density = LocalDensity.current
val keyboardVisible = WindowInsets.ime.getBottom(density) > 0
val showBottomNav = shellBottomNavVisible(keyboardVisible = keyboardVisible, commandOpen = commandOpen)
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = ClawTheme.colors.canvas,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
bottomBar = {
if (showBottomNav) {
ClawBottomNav(
items = shellNavTabs.map { ClawNavItem(key = it.key, label = it.label, icon = it.icon) },
selectedKey = if (activeTab in shellNavTabs) activeTab.key else Tab.Overview.key,
onSelect = { key ->
val next = shellNavTabs.firstOrNull { it.key == key } ?: Tab.Overview
if (next == Tab.Settings) {
settingsRoute = SettingsRoute.Home
returnToOverviewFromSettings = false
activeTab = Tab.Overview
}
activeTab = next
},
onOpenCommand = { commandOpen = true },
)
}
}
},
) { shellPadding ->
Box(modifier = Modifier.fillMaxSize().padding(shellPadding)) {
when (activeTab) {
Tab.Overview ->
OverviewScreen(
viewModel = viewModel,
onSelectTab = { activeTab = it },
onOpenSettingsRoute = {
settingsRoute = it
returnToOverviewFromSettings = true
activeTab = Tab.Settings
},
onOpenCommand = { commandOpen = true },
)
Tab.Chat ->
ChatShellScreen(
viewModel = viewModel,
onVoice = { activeTab = Tab.Voice },
onOpenSessions = { activeTab = Tab.Sessions },
)
Tab.Voice ->
VoiceShellScreen(
viewModel = viewModel,
onOpenCommand = { commandOpen = true },
onOpenGatewaySettings = {
settingsRoute = SettingsRoute.Gateway
returnToOverviewFromSettings = false
activeTab = Tab.Settings
},
onOpenVoiceSettings = {
settingsRoute = SettingsRoute.Voice
returnToOverviewFromSettings = false
activeTab = Tab.Settings
},
)
Tab.ProvidersModels ->
ProvidersModelsScreen(
viewModel = viewModel,
onBack = { activeTab = Tab.Overview },
onAddProvider = {
settingsRoute = SettingsRoute.Gateway
returnToOverviewFromSettings = false
activeTab = Tab.Settings
},
)
Tab.Sessions ->
SessionsScreen(
viewModel = viewModel,
onOpenCommand = { commandOpen = true },
onOpenChat = { activeTab = Tab.Chat },
)
Tab.Settings ->
SettingsShellScreen(
viewModel = viewModel,
route = settingsRoute,
onRouteChange = {
settingsRoute = it
returnToOverviewFromSettings = false
},
onRouteBack = {
settingsRoute = SettingsRoute.Home
if (returnToOverviewFromSettings) {
returnToOverviewFromSettings = false
activeTab = Tab.Overview
}
},
onBackHome = { activeTab = Tab.Overview },
onOpenCommand = { commandOpen = true },
)
}
if (commandOpen) {
CommandPalette(
viewModel = viewModel,
onDismiss = { commandOpen = false },
onOpenChat = {
activeTab = Tab.Chat
commandOpen = false
},
onOpenVoice = {
activeTab = Tab.Voice
commandOpen = false
},
onOpenSessions = {
activeTab = Tab.Sessions
commandOpen = false
},
onOpenProviders = {
activeTab = Tab.ProvidersModels
commandOpen = false
},
onOpenSettings = {
settingsRoute = SettingsRoute.Home
returnToOverviewFromSettings = false
activeTab = Tab.Settings
commandOpen = false
},
onOpenSession = { sessionKey ->
viewModel.switchChatSession(sessionKey)
activeTab = Tab.Chat
commandOpen = false
},
)
}
if (commandOpen) {
CommandPalette(
viewModel = viewModel,
onDismiss = { commandOpen = false },
onOpenChat = {
activeTab = Tab.Chat
commandOpen = false
},
onOpenVoice = {
activeTab = Tab.Voice
commandOpen = false
},
onOpenSessions = {
activeTab = Tab.Sessions
commandOpen = false
},
onOpenProviders = {
activeTab = Tab.ProvidersModels
commandOpen = false
},
onOpenSettings = {
settingsRoute = SettingsRoute.Home
returnToOverviewFromSettings = false
activeTab = Tab.Settings
commandOpen = false
},
onOpenSession = { sessionKey ->
viewModel.switchChatSession(sessionKey)
activeTab = Tab.Chat
commandOpen = false
},
)
}
pendingTrust?.let { prompt ->
// Gateway certificate trust is modal across the shell so navigation
// cannot hide a changed TLS identity prompt.
GatewayTrustDialog(
prompt = prompt,
onAccept = viewModel::acceptGatewayTrustPrompt,
onDecline = viewModel::declineGatewayTrustPrompt,
)
pendingTrust?.let { prompt ->
// Gateway certificate trust is modal across the shell so navigation
// cannot hide a changed TLS identity prompt.
GatewayTrustDialog(
prompt = prompt,
onAccept = viewModel::acceptGatewayTrustPrompt,
onDecline = viewModel::declineGatewayTrustPrompt,
)
}
}
}
}
@@ -289,33 +335,39 @@ private fun OverviewScreen(
val isConnected by viewModel.isConnected.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val models by viewModel.modelCatalog.collectAsState()
val providers by viewModel.modelAuthProviders.collectAsState()
val agents by viewModel.gatewayAgents.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val cronStatus by viewModel.cronStatus.collectAsState()
val usageSummary by viewModel.usageSummary.collectAsState()
val skillsSummary by viewModel.skillsSummary.collectAsState()
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
val channelsSummary by viewModel.channelsSummary.collectAsState()
val readyProviderCount = providers.count { modelProviderReady(it.status) }
val attentionRows =
homeAttentionRows(
isConnected = isConnected,
pendingApprovals = pendingToolCalls.size,
channelsSummary = channelsSummary,
nodesDevicesSummary = nodesDevicesSummary,
readyProviderCount = readyProviderCount,
)
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshChatSessions(limit = 20)
viewModel.refreshModelCatalog()
viewModel.refreshAgents()
viewModel.refreshCronJobs()
viewModel.refreshUsage()
viewModel.refreshSkills()
viewModel.refreshNodesDevices()
viewModel.refreshChannels()
}
}
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
ClawScaffold(
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
contentWindowInsets = shellContentInsets,
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 104.dp)) {
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -334,41 +386,20 @@ private fun OverviewScreen(
}
item {
SectionLabel(title = "MODULES")
CompanionHeroPanel(
statusText = gatewaySummary(statusText, isConnected),
isConnected = isConnected,
pendingRunCount = pendingRunCount,
onOpenChat = { onSelectTab(Tab.Chat) },
onOpenVoice = { onSelectTab(Tab.Voice) },
onOpenGateway = { onOpenSettingsRoute(SettingsRoute.Gateway) },
)
}
item {
ModuleList(
rows =
listOf(
ModuleRow("Chat", null, null, Icons.Outlined.ChatBubbleOutline, Tab.Chat),
ModuleRow("Sessions", null, if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
ModuleRow("Voice", null, if (isConnected) "Ready" else "Offline", Icons.Outlined.MicNone, Tab.Voice),
ModuleRow(
title = "Providers & Models",
subtitle = null,
metadata =
when {
!isConnected -> "Offline"
readyProviderCount > 0 -> "$readyProviderCount ready"
models.isNotEmpty() -> "${models.size} models"
else -> "Setup"
},
icon = Icons.Outlined.Inventory2,
tab = Tab.ProvidersModels,
),
ModuleRow("Channels", null, channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels),
ModuleRow("Agents", null, if (agents.isEmpty()) "Load" else "${agents.size} ready", Icons.Default.Person, Tab.Settings, SettingsRoute.Agents),
ModuleRow("Approvals", null, approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals),
ModuleRow("Cron Jobs", null, cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, Tab.Settings, SettingsRoute.CronJobs),
ModuleRow("Skills", null, skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, Tab.Settings, SettingsRoute.Skills),
ModuleRow("Nodes & Devices", null, nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices),
ModuleRow("Usage", null, usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, Tab.Settings, SettingsRoute.Usage),
ModuleRow("Settings", null, null, Icons.Outlined.Settings, Tab.Settings, SettingsRoute.Home),
),
onSelectTab = onSelectTab,
onOpenSettingsRoute = onOpenSettingsRoute,
)
if (attentionRows.isNotEmpty()) {
item {
HomeAttentionPanel(rows = attentionRows, onSelectTab = onSelectTab, onOpenSettingsRoute = onOpenSettingsRoute)
}
}
item {
@@ -397,7 +428,7 @@ private fun OverviewScreen(
item {
RecentSessionList(
rows =
sessions.take(7).map { session ->
sessions.take(5).map { session ->
RecentSessionListItem(
key = session.key,
title = displaySessionTitle(session.displayName),
@@ -412,8 +443,39 @@ private fun OverviewScreen(
)
}
}
item {
SectionLabel(title = "Control center")
}
item {
ModuleList(
rows =
listOf(
ModuleRow("Sessions", "Conversation history", if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
ModuleRow(
title = "Providers & Models",
subtitle = "Model setup",
metadata =
when {
!isConnected -> "Offline"
readyProviderCount > 0 -> "$readyProviderCount ready"
models.isNotEmpty() -> "${models.size} models"
else -> "Setup"
},
icon = Icons.Outlined.Inventory2,
tab = Tab.ProvidersModels,
),
ModuleRow("Channels", "Connected messengers", channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels),
ModuleRow("Nodes & Devices", "Phone and node health", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices),
ModuleRow("Approvals", "Tool decisions", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals),
ModuleRow("Settings", "More runtime controls", null, Icons.Outlined.Settings, Tab.Settings, SettingsRoute.Home),
),
onSelectTab = onSelectTab,
onOpenSettingsRoute = onOpenSettingsRoute,
)
}
}
OverviewChatButton(onClick = { onSelectTab(Tab.Chat) }, modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 20.dp))
}
}
}
@@ -427,26 +489,109 @@ private data class ModuleRow(
val settingsRoute: SettingsRoute? = null,
)
/** Floating overview shortcut that keeps chat one tap away from module lists. */
@Composable
private fun OverviewChatButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
private fun CompanionHeroPanel(
statusText: String,
isConnected: Boolean,
pendingRunCount: Int,
onOpenChat: () -> Unit,
onOpenVoice: () -> Unit,
onOpenGateway: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = modifier.height(ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(ClawTheme.radii.button),
color = ClawTheme.colors.primary,
contentColor = ClawTheme.colors.primaryText,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(18.dp))
Text(text = "Chat", style = ClawTheme.type.label.copy(fontSize = 16.sp, lineHeight = 20.sp))
ClawPanel(contentPadding = PaddingValues(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Surface(
modifier = Modifier.size(38.dp),
shape = CircleShape,
color = if (isConnected) ClawTheme.colors.successSoft else ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, if (isConnected) ClawTheme.colors.success else ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(19.dp), tint = if (isConnected) ClawTheme.colors.success else ClawTheme.colors.text)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = if (pendingRunCount > 0) "OpenClaw is working" else "Ready when you are", style = ClawTheme.type.title.copy(fontSize = 20.sp, lineHeight = 24.sp), color = ClawTheme.colors.text)
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(9.dp)) {
ClawPrimaryButton(text = "Start chat", icon = Icons.Outlined.ChatBubbleOutline, onClick = onOpenChat, modifier = Modifier.weight(1f))
ClawSecondaryButton(text = "Voice", icon = Icons.Outlined.MicNone, onClick = onOpenVoice, modifier = Modifier.weight(1f))
}
if (!isConnected) {
ClawSecondaryButton(text = "Reconnect gateway", icon = Icons.Default.Cloud, onClick = onOpenGateway, modifier = Modifier.fillMaxWidth())
}
}
}
}
internal data class HomeAttentionRow(
val title: String,
val subtitle: String,
val icon: ImageVector,
val tab: Tab,
val settingsRoute: SettingsRoute? = null,
)
internal fun homeAttentionRows(
isConnected: Boolean,
pendingApprovals: Int,
channelsSummary: GatewayChannelsSummary,
nodesDevicesSummary: GatewayNodesDevicesSummary,
readyProviderCount: Int,
): List<HomeAttentionRow> =
listOfNotNull(
if (!isConnected) {
HomeAttentionRow("Gateway", "Connect before chat, voice, and live status.", Icons.Default.Cloud, Tab.Settings, SettingsRoute.Gateway)
} else {
null
},
if (pendingApprovals > 0) {
HomeAttentionRow("Approvals", approvalsSummary(pendingApprovals), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals)
} else {
null
},
if (channelsSummary.channels.any { it.error != null }) {
HomeAttentionRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels)
} else {
null
},
if (nodesDevicesSummary.pendingDevices.isNotEmpty()) {
HomeAttentionRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices)
} else {
null
},
if (isConnected && readyProviderCount == 0) {
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.ProvidersModels)
} else {
null
},
)
@Composable
private fun HomeAttentionPanel(
rows: List<HomeAttentionRow>,
onSelectTab: (Tab) -> Unit,
onOpenSettingsRoute: (SettingsRoute) -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = "Needs attention", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.warning)
rows.forEach { row ->
ModuleListRow(
row = ModuleRow(row.title, row.subtitle, null, row.icon, row.tab, row.settingsRoute),
onClick = {
val route = row.settingsRoute
if (route == null) {
onSelectTab(row.tab)
} else {
onOpenSettingsRoute(route)
}
},
)
}
}
}
}
@@ -527,14 +672,18 @@ private fun ModuleListRow(
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
Text(
text = row.title,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
text = row.title,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
row.subtitle?.let {
Text(text = it, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
row.metadata?.let {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(statusDotColor(it)))
@@ -638,11 +787,18 @@ private fun RecentSessionRowContent(
@Composable
private fun ChatShellScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
onVoice: () -> Unit,
onOpenSessions: () -> Unit,
) {
ClawScaffold(contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp)) {
ChatScreen(viewModel = viewModel, onBack = onBack, onVoice = onVoice)
ClawScaffold(
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
contentWindowInsets = shellContentInsets,
) {
ChatScreen(
viewModel = viewModel,
onVoice = onVoice,
onOpenSessions = onOpenSessions,
)
}
}
@@ -653,7 +809,10 @@ private fun VoiceShellScreen(
onOpenGatewaySettings: () -> Unit,
onOpenVoiceSettings: () -> Unit,
) {
ClawScaffold(contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp)) {
ClawScaffold(
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
contentWindowInsets = shellContentInsets,
) {
VoiceScreen(
viewModel = viewModel,
onOpenCommand = onOpenCommand,
@@ -669,6 +828,7 @@ private fun SettingsShellScreen(
route: SettingsRoute,
onRouteChange: (SettingsRoute) -> Unit,
onRouteBack: () -> Unit,
onBackHome: () -> Unit,
onOpenCommand: () -> Unit,
) {
val displayName by viewModel.displayName.collectAsState()
@@ -707,14 +867,18 @@ private fun SettingsShellScreen(
return
}
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(13.dp)) {
ClawScaffold(
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
contentWindowInsets = shellContentInsets,
) {
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(13.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
PlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to home", onClick = onBackHome)
Text(text = "Settings", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
SettingsSearchButton(onClick = onOpenCommand)
}

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.MainViewModel
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.ui.design.ClawListItem
import ai.openclaw.app.ui.design.ClawLoadingState
@@ -37,11 +38,11 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -78,8 +79,8 @@ import java.util.Locale
@Composable
fun ChatScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
onVoice: () -> Unit,
onOpenSessions: () -> Unit,
) {
val messages by viewModel.chatMessages.collectAsState()
val historyLoading by viewModel.chatHistoryLoading.collectAsState()
@@ -158,13 +159,23 @@ fun ChatScreen(
thinkingLevel = thinkingLevel,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onBack = onBack,
onMore = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 100)
},
)
ChatSessionSwitcher(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
onSelectSession = { key ->
viewModel.switchChatSession(key)
viewModel.refreshChatSessions(limit = 100)
},
onOpenSessions = onOpenSessions,
)
errorText?.takeIf { it.isNotBlank() }?.let { error ->
ChatNotice(title = "Chat needs attention", body = userFacingChatError(error))
}
@@ -214,13 +225,88 @@ fun ChatScreen(
}
}
@Composable
private fun ChatSessionSwitcher(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
onSelectSession: (String) -> Unit,
onOpenSessions: () -> Unit,
) {
val choices =
remember(sessionKey, sessions, mainSessionKey) {
resolveCompactSessionChoices(
currentSessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
)
}
if (choices.size <= 1 && sessions.size <= 1) return
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
choices.forEach { entry ->
ChatSessionChip(
text = chatSessionChipText(entry = entry, mainSessionKey = mainSessionKey),
active = isActiveSessionChoice(entry.key, sessionKey, mainSessionKey),
onClick = { onSelectSession(entry.key) },
)
}
if (sessions.size > choices.size) {
Surface(
onClick = onOpenSessions,
modifier = Modifier.heightIn(min = 36.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.canvas,
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Icon(imageVector = Icons.Default.MoreHoriz, contentDescription = null, modifier = Modifier.size(16.dp))
Text(text = "All", style = ClawTheme.type.caption, maxLines = 1)
}
}
}
}
}
@Composable
private fun ChatSessionChip(
text: String,
active: Boolean,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.heightIn(min = 36.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = ClawTheme.type.caption,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ChatHeader(
sessionTitle: String,
thinkingLevel: String,
healthOk: Boolean,
pendingRunCount: Int,
onBack: () -> Unit,
onMore: () -> Unit,
) {
Row(
@@ -228,7 +314,7 @@ private fun ChatHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
HeaderIcon(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
Column(
modifier = Modifier.weight(1f),
@@ -786,13 +872,33 @@ private fun AttachmentChip(
private fun currentSessionTitle(
sessionKey: String,
sessions: List<ai.openclaw.app.chat.ChatSessionEntry>,
sessions: List<ChatSessionEntry>,
): String {
val entry = sessions.firstOrNull { it.key == sessionKey }
val name = entry?.displayName?.takeIf { it.isNotBlank() } ?: return "New chat"
return friendlySessionName(name)
}
private fun chatSessionChipText(
entry: ChatSessionEntry,
mainSessionKey: String,
): String {
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
if (entry.key == mainKey || (entry.key == "main" && mainKey == "main")) return "Main"
val name = entry.displayName?.takeIf { it.isNotBlank() } ?: entry.key.takeIf { entry.updatedAtMs != null } ?: "Current"
return friendlySessionName(name)
}
private fun isActiveSessionChoice(
choiceKey: String,
sessionKey: String,
mainSessionKey: String,
): Boolean {
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
val current = sessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
return choiceKey == current
}
@Composable
private fun SendButton(
enabled: Boolean,

View File

@@ -4,22 +4,9 @@ import ai.openclaw.app.chat.ChatSessionEntry
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
/**
* Derive a human-friendly label from a raw session key.
* Examples:
* "telegram:g-agent-main-main" -> "Main"
* "agent:main:main" -> "Main"
* "discord:g-server-channel" -> "Server Channel"
* "my-custom-session" -> "My Custom Session"
*/
fun friendlySessionName(key: String): String {
// Strip common prefixes like "telegram:", "agent:", "discord:" etc.
val stripped = key.substringAfterLast(":")
// Remove leading "g-" prefix (gateway artifact)
val cleaned = if (stripped.startsWith("g-")) stripped.removePrefix("g-") else stripped
// Split on hyphens/underscores, title-case each word, collapse "main main" -> "Main"
val words =
cleaned
.split('-', '_')
@@ -78,3 +65,29 @@ fun resolveSessionChoices(
return result
}
fun resolveCompactSessionChoices(
currentSessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
nowMs: Long = System.currentTimeMillis(),
maxOptions: Int = 5,
): List<ChatSessionEntry> {
val allChoices =
resolveSessionChoices(
currentSessionKey = currentSessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
nowMs = nowMs,
)
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
val pinnedRank = listOf(mainKey, current).filter { it.isNotBlank() }.distinct().withIndex().associate { it.value to it.index }
val unpinnedRank = pinnedRank.size
return allChoices
.withIndex()
.sortedWith(compareBy({ pinnedRank[it.value.key] ?: unpinnedRank }, { it.index }))
.take(maxOptions)
.map { it.value }
}

View File

@@ -61,6 +61,7 @@ internal enum class ClawStatus {
internal fun ClawScaffold(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.lg),
contentWindowInsets: WindowInsets = WindowInsets.safeDrawing,
content: @Composable () -> Unit,
) {
Box(
@@ -68,7 +69,7 @@ internal fun ClawScaffold(
modifier
.fillMaxSize()
.background(ClawTheme.colors.canvas)
.windowInsetsPadding(WindowInsets.safeDrawing)
.windowInsetsPadding(contentWindowInsets)
.padding(contentPadding),
) {
content()

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.ui.design
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -91,27 +92,29 @@ internal fun ClawBottomNav(
) {
val safeInsets = WindowInsets.navigationBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Bottom)
Surface(
modifier = modifier.fillMaxWidth(),
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
border = BorderStroke(1.dp, ClawTheme.colors.border),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
) {
Row(
modifier =
Modifier
.windowInsetsPadding(safeInsets)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
border = BorderStroke(1.dp, ClawTheme.colors.border),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
) {
items.forEach { item ->
ClawBottomNavItem(
item = item,
selected = item.key == selectedKey,
onClick = { onSelect(item.key) },
modifier = Modifier.weight(1f),
)
Row(
modifier =
Modifier
.windowInsetsPadding(safeInsets)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEach { item ->
ClawBottomNavItem(
item = item,
selected = item.key == selectedKey,
onClick = { onSelect(item.key) },
modifier = Modifier.weight(1f),
)
}
}
}
}
@@ -129,7 +132,7 @@ private fun ClawBottomNavItem(
modifier = modifier.heightIn(min = 48.dp),
shape = RoundedCornerShape(ClawTheme.radii.control),
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textSubtle,
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
) {
Column(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),

View File

@@ -0,0 +1,98 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ShellScreenLogicTest {
@Test
fun bottomNavHidesForKeyboardAndCommandPalette() {
assertTrue(shellBottomNavVisible(keyboardVisible = false, commandOpen = false))
assertFalse(shellBottomNavVisible(keyboardVisible = true, commandOpen = false))
assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true))
}
@Test
fun homeAttentionRowsSurfaceGatewayWhenDisconnected() {
val rows =
homeAttentionRows(
isConnected = false,
pendingApprovals = 0,
channelsSummary = emptyChannels(),
nodesDevicesSummary = emptyNodesDevices(),
readyProviderCount = 0,
)
assertEquals(listOf("Gateway"), rows.map { it.title })
}
@Test
fun homeAttentionRowsSurfaceOnlyActionableConnectedIssues() {
val rows =
homeAttentionRows(
isConnected = true,
pendingApprovals = 2,
channelsSummary =
GatewayChannelsSummary(
channels =
listOf(
GatewayChannelSummary(
id = "telegram",
label = "Telegram",
accountCount = 1,
enabled = true,
configured = true,
linked = true,
running = false,
connected = false,
error = "offline",
),
),
),
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes = emptyList(),
pendingDevices =
listOf(
GatewayPendingDeviceSummary(
requestId = "request-1",
deviceId = "device-1",
displayName = "Phone",
remoteIp = null,
roles = emptyList(),
scopes = emptyList(),
requestedAtMs = null,
repair = false,
),
),
pairedDevices = emptyList(),
),
readyProviderCount = 0,
)
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
}
@Test
fun homeAttentionRowsStayQuietWhenConnectedAndHealthy() {
val rows =
homeAttentionRows(
isConnected = true,
pendingApprovals = 0,
channelsSummary = emptyChannels(),
nodesDevicesSummary = emptyNodesDevices(),
readyProviderCount = 1,
)
assertEquals(emptyList<String>(), rows.map { it.title })
}
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
}

View File

@@ -32,4 +32,29 @@ class SessionFiltersTest {
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
assertEquals(listOf("main", "custom"), result)
}
@Test
fun compactChoicesKeepMainAndCurrentWhileCappingRecentSessions() {
val now = 1_700_000_000_000L
val sessions =
listOf(
ChatSessionEntry(key = "recent-1", updatedAtMs = now - 1),
ChatSessionEntry(key = "recent-2", updatedAtMs = now - 2),
ChatSessionEntry(key = "recent-3", updatedAtMs = now - 3),
ChatSessionEntry(key = "recent-4", updatedAtMs = now - 4),
ChatSessionEntry(key = "main", updatedAtMs = now - 5),
ChatSessionEntry(key = "active-old", updatedAtMs = now - 30 * 60 * 60 * 1000L),
)
val result =
resolveCompactSessionChoices(
currentSessionKey = "active-old",
sessions = sessions,
mainSessionKey = "main",
nowMs = now,
maxOptions = 4,
).map { it.key }
assertEquals(listOf("main", "active-old", "recent-1", "recent-2"), result)
}
}

View File

@@ -6896,6 +6896,20 @@ public struct ChatHistoryParams: Codable, Sendable {
}
}
public struct ChatMetadataParams: Codable, Sendable {
public let agentid: String?
public init(
agentid: String? = nil)
{
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
}
}
public struct ChatMessageGetParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?

View File

@@ -1,2 +1,2 @@
f3e0379cbe0e584a8c9658253d4a808356fe80fb5ec775bbee9e968e8d815380 plugin-sdk-api-baseline.json
601b55acafbd1e00b850c9b0c15d587029050906960071d448d37538b223e226 plugin-sdk-api-baseline.jsonl
a9501e226bb26befb02072cf5e60c3dc124cbd5dc0b16eb281789d0843f72f71 plugin-sdk-api-baseline.json
b106090dc12bf7e46beac4ed160f0cff0ef8039291f24172b693e8d8b752d571 plugin-sdk-api-baseline.jsonl

View File

@@ -122,6 +122,33 @@ This fires ~56 times per month instead of 01 times per month. OpenClaw use
</Accordion>
</AccordionGroup>
### Command payloads
Use command payloads for deterministic scripts that should run inside the Gateway scheduler without starting a model-backed isolated agent turn. Command jobs execute on the Gateway host, capture stdout/stderr, record the run in cron history, and reuse the same `announce`, `webhook`, and `none` delivery modes as isolated jobs.
<Note>
Command cron is an operator-admin Gateway automation surface, not an agent
`tools.exec` call. Creating, updating, removing, or manually running cron jobs
requires `operator.admin`; scheduled command runs later execute inside the
Gateway process as that admin-authored automation. Agent exec policy such as
`tools.exec.mode`, approval prompts, and per-agent tool allowlists governs
model-visible exec tools, not command cron payloads.
</Note>
```bash
openclaw cron create "*/15 * * * *" \
--name "Queue depth probe" \
--command "scripts/check-queue.sh" \
--command-cwd "/srv/app" \
--announce \
--channel telegram \
--to "-1001234567890"
```
`--command <shell>` stores `argv: ["sh", "-lc", <shell>]`. Use `--command-argv '["node","scripts/report.mjs"]'` when you want exact argv execution without shell parsing. Optional `--command-env KEY=VALUE`, `--command-input`, `--timeout-seconds`, `--no-output-timeout-seconds`, and `--output-max-bytes` fields control the process environment, stdin, and output bounds.
If stdout is non-empty, that text is the delivered result. If stdout is empty and stderr is non-empty, stderr is delivered. If both streams are present, cron delivers a small `stdout:` / `stderr:` block. A zero exit code records the run as `ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and can trigger failure alerts. A command that prints only `NO_REPLY` uses the normal cron silent-token suppression and posts nothing back to chat.
### Payload options for isolated jobs
<ParamField path="--message" type="string" required>
@@ -246,6 +273,17 @@ Failure notifications follow a separate destination path:
--webhook "https://example.invalid/openclaw/cron"
```
</Tab>
<Tab title="Command output">
```bash
openclaw cron create "*/15 * * * *" \
--name "Queue depth probe" \
--command "scripts/check-queue.sh" \
--command-cwd "/srv/app" \
--announce \
--channel telegram \
--to "-1001234567890"
```
</Tab>
</Tabs>
## Webhooks

View File

@@ -319,6 +319,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.

View File

@@ -34,6 +34,27 @@ openclaw cron create "0 18 * * 1-5" \
--webhook "https://example.invalid/openclaw/cron"
```
Use `--command` for deterministic shell-style jobs that should run inside OpenClaw cron without starting an isolated agent/model run:
<Note>
Command cron jobs are admin-authored Gateway automation. Creating, editing,
removing, or manually running them requires `operator.admin`; the scheduled run
later executes in the Gateway process, not as an agent `tools.exec` tool call.
`tools.exec.*` and exec approvals still govern model-visible exec tools.
</Note>
```bash
openclaw cron create "*/15 * * * *" \
--name "Queue depth probe" \
--command "scripts/check-queue.sh" \
--command-cwd "/srv/app" \
--announce \
--channel telegram \
--to "-1001234567890"
```
`--command <shell>` stores `argv: ["sh", "-lc", <shell>]`. Use `--command-argv '["node","scripts/report.mjs"]'` for exact argv execution. Command jobs capture stdout/stderr, record normal cron history, and route output through the same `announce`, `webhook`, or `none` delivery modes as isolated jobs. A command that prints only `NO_REPLY` is suppressed.
## Sessions
`--session` accepts `main`, `isolated`, `current`, or `session:<id>`.
@@ -92,6 +113,10 @@ Note: isolated cron runs treat run-level agent failures as job errors even when
no reply payload is produced, so model/provider failures still increment error
counters and trigger failure notifications.
Command cron jobs do not start an isolated agent turn. A zero exit code records
`ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and
can trigger the same failure notification path.
If an isolated run times out before the first model request, `openclaw cron show`
and `openclaw cron runs` include a phase-specific error such as
`setup timed out before runner start` or
@@ -252,6 +277,21 @@ openclaw cron create "0 7 * * *" \
`--light-context` applies to isolated agent-turn jobs only. For cron runs, lightweight mode keeps bootstrap context empty instead of injecting the full workspace bootstrap set.
Create a command job with exact argv, cwd, env, stdin, and output limits:
```bash
openclaw cron create "*/30 * * * *" \
--name "Position export" \
--command-argv '["node","scripts/export-position.mjs"]' \
--command-cwd "/srv/app" \
--command-env "NODE_ENV=production" \
--command-input '{"mode":"summary"}' \
--timeout-seconds 120 \
--no-output-timeout-seconds 30 \
--output-max-bytes 65536 \
--webhook "https://example.invalid/openclaw/cron"
```
## Common admin commands
Manual run and inspection:

View File

@@ -19,7 +19,7 @@ instead of creating a separate health gate.
Policy currently manages configured channels, MCP servers, model providers,
network SSRF posture, ingress/channel access posture, Gateway exposure posture, agent workspace posture,
OpenClaw config secret provider/auth profile posture, and governed tool
data-handling posture, OpenClaw config secret provider/auth profile posture, and governed tool
declarations. For example, IT or a workspace operator can record that Telegram
is not an approved channel provider, restrict MCP servers and model refs to
approved entries, require private-network fetch/browser access to remain
@@ -28,7 +28,9 @@ to stay within reviewed bounds, require Gateway bind/auth/HTTP exposure to stay
bounds, require agent workspace access and tool denies to stay in a reviewed
posture, require OpenClaw config SecretRefs to use managed providers, require
config auth profiles to carry provider/mode metadata, require governed tools to
carry risk and sensitivity metadata, then use `doctor --lint` as the shared
carry risk and sensitivity metadata, require sensitive logging redaction, deny
telemetry content capture, require session retention maintenance, deny session
transcript memory indexing, then use `doctor --lint` as the shared
conformance gate.
Use policy when a workspace needs a durable statement such as "these channels
@@ -52,7 +54,7 @@ doctor can report the missing artifact.
Policy is authored, not generated from the user's current settings. A minimal
policy for channels, MCP servers, model providers, network posture, ingress/channel access, Gateway
exposure, agent workspace posture, configured sandbox runtime posture, OpenClaw
config secret provider/auth profile posture, and tool metadata looks like this:
data-handling posture, config secret provider/auth profile posture, and tool metadata looks like this:
```jsonc
{
@@ -118,6 +120,20 @@ config secret provider/auth profile posture, and tool metadata looks like this:
"denyTools": ["exec", "process", "write", "edit", "apply_patch"],
},
},
"dataHandling": {
"sensitiveLogging": {
"requireRedaction": true,
},
"telemetry": {
"denyContentCapture": true,
},
"retention": {
"requireSessionMaintenance": true,
},
"memory": {
"denySessionTranscriptIndexing": true,
},
},
"secrets": {
"requireManagedProviders": true,
"denySources": ["exec"],
@@ -155,7 +171,8 @@ when a concrete rule is present. OpenClaw reads current `channels.*` settings
`mcp.servers.*`, `models.providers.*`, selected agent model refs, network SSRF
settings, direct-message session scope, channel DM policy, channel group policy,
channel/group mention gates, Gateway bind/auth/Control UI/Tailscale/remote/HTTP
posture, OpenClaw config agent sandbox workspace access and tool deny posture, config secret
posture, OpenClaw config agent sandbox workspace access and tool deny posture,
data-handling config posture, config secret
provider and SecretRef provenance, config auth profile metadata, configured
global/per-agent tool posture, and `TOOLS.md` declarations as evidence, then
reports observed state that does not conform. If a policy denies non-loopback
@@ -176,6 +193,11 @@ runtime. Secret evidence records
provider/source posture and SecretRef metadata, never raw secret values. Policy
does not read or attest per-agent credential stores such as `auth-profiles.json`;
those stores remain owned by the existing auth and credential flows.
Data-handling evidence is config-level posture only: it checks configured
redaction mode, telemetry content-capture toggles, session maintenance mode, and
session-transcript memory indexing settings. It does not inspect raw logs,
telemetry exports, transcript contents, memory files, or prove that no personal
data or secrets exist.
### Policy rule reference
@@ -183,6 +205,8 @@ Each policy field below is optional. A check runs only when the matching rule is
present in `policy.jsonc`. The observed state is existing OpenClaw config or
workspace metadata; policy reports drift but does not rewrite runtime behavior
unless a repair path is explicitly available and enabled.
Policy files are strict: unsupported sections or rule keys are reported as
`policy/policy-jsonc-invalid` instead of being ignored.
Policy overlays keep broad top-level rules global, then let named scope blocks
add stricter normal policy sections for explicit selectors. A scope name is a
@@ -194,7 +218,8 @@ its own finding against the same observed config.
Use `scopes.<scopeName>` when one set of agents or channels needs stricter
policy than the top-level baseline. Agent-scoped sections use `agentIds`, which
supports `tools.*`, `agents.workspace.*`, and `sandbox.*`. Channel-scoped
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, and
`dataHandling.memory.*`. Channel-scoped
ingress uses `channelIds`, which supports `ingress.channels.*`. Unsupported
sections are rejected instead of being ignored. If an `agentIds` entry is not
present in `agents.list[]`, OpenClaw evaluates the scoped rule against inherited
@@ -233,6 +258,11 @@ global/default posture for that runtime agent id.
"requireMode": ["all"],
"allowBackends": ["docker"],
},
"dataHandling": {
"memory": {
"denySessionTranscriptIndexing": true,
},
},
},
"shell-sandbox": {
"agentIds": ["shell-agent"],
@@ -274,10 +304,10 @@ groups where those fields cannot be observed.
Top-level `ingress.session.requireDmScope` remains global because
`session.dmScope` is not channel-attributable evidence.
| Selector | Supported sections | Use when |
| ------------ | ------------------------------------------ | ------------------------------------------------- |
| `agentIds` | `tools`, `agents.workspace`, and `sandbox` | One or more runtime agents need stricter rules. |
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
| Selector | Supported sections | Use when |
| ------------ | ----------------------------------------------------------------- | ------------------------------------------------- |
| `agentIds` | `tools`, `agents.workspace`, `sandbox`, and `dataHandling.memory` | One or more runtime agents need stricter rules. |
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
Every scope present in `policy.jsonc` must be valid and enforceable.
@@ -354,6 +384,15 @@ Policy treats missing `sandbox.mode` as the implicit default `off`, so
`sandbox.requireMode` reports a fresh or unconfigured sandbox as outside an
allowlist such as `["all"]`.
#### Data Handling
| Policy field | Observed state | Use when |
| --------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| `dataHandling.sensitiveLogging.requireRedaction` | `logging.redactSensitive` | Set to `true` to reject `logging.redactSensitive: "off"`. |
| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. |
| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. |
| `dataHandling.memory.denySessionTranscriptIndexing` | `memory.qmd.sessions.enabled` and `agents.*.memorySearch.experimental.sessionMemory` | Set to `true` to reject session transcript indexing into memory. |
#### Secrets
| Policy field | Observed state | Use when |
@@ -674,63 +713,67 @@ choose a different interval.
Policy currently verifies:
| Check id | Finding |
| ------------------------------------------------- | --------------------------------------------------------------------------------- |
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or contains malformed rule entries. |
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
| `policy/policy-conformance-invalid` | A baseline or checked policy file has invalid comparison syntax. |
| `policy/policy-conformance-missing` | A checked policy file is missing a rule required by the baseline policy file. |
| `policy/policy-conformance-weaker` | A checked policy file has a weaker value than the baseline policy file. |
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
| `policy/mcp-denied-server` | A configured MCP server is denied by policy. |
| `policy/mcp-unapproved-server` | A configured MCP server is outside the allowlist. |
| `policy/models-denied-provider` | A configured model provider or model ref uses a denied provider. |
| `policy/models-unapproved-provider` | A configured model provider or model ref is outside the allowlist. |
| `policy/network-private-access-enabled` | A private-network SSRF escape hatch is enabled when policy denies it. |
| `policy/ingress-dm-policy-unapproved` | A channel DM policy is outside the policy allowlist. |
| `policy/ingress-dm-scope-unapproved` | `session.dmScope` does not match the policy-required DM isolation scope. |
| `policy/ingress-open-groups-denied` | A channel group policy is `open` while policy denies open group ingress. |
| `policy/ingress-group-mention-required` | A channel or group entry disables mention gates while policy requires them. |
| `policy/gateway-non-loopback-bind` | Gateway bind posture permits non-loopback exposure when policy denies it. |
| `policy/gateway-auth-disabled` | Gateway authentication is disabled when policy requires auth. |
| `policy/gateway-rate-limit-missing` | Gateway auth rate-limit posture is not explicit when policy requires it. |
| `policy/gateway-control-ui-insecure` | Gateway Control UI insecure exposure toggles are enabled. |
| `policy/gateway-tailscale-funnel` | Gateway Tailscale Funnel exposure is enabled when policy denies it. |
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |
| `policy/tools-fs-workspace-only-required` | Filesystem tools are not configured with workspace-only path posture. |
| `policy/tools-exec-security-unapproved` | Exec security mode is outside the policy allowlist. |
| `policy/tools-exec-ask-unapproved` | Exec ask mode is outside the policy allowlist. |
| `policy/tools-exec-host-unapproved` | Exec host routing is outside the policy allowlist. |
| `policy/tools-elevated-enabled` | Elevated tool mode is enabled when policy denies it. |
| `policy/tools-also-allow-missing` | A configured `alsoAllow` list is missing an entry required by policy. |
| `policy/tools-also-allow-unexpected` | A configured `alsoAllow` list includes an entry not expected by policy. |
| `policy/tools-required-deny-missing` | A global or per-agent tool deny list does not include a required denied tool. |
| `policy/sandbox-mode-unapproved` | Sandbox mode is outside the policy allowlist. |
| `policy/sandbox-backend-unapproved` | Sandbox backend is outside the policy allowlist. |
| `policy/sandbox-container-posture-unobservable` | A container posture rule is enabled for a backend that cannot observe it. |
| `policy/sandbox-container-host-network-denied` | A container-backed sandbox or browser uses host network mode. |
| `policy/sandbox-container-namespace-join-denied` | A container-backed sandbox or browser joins another container namespace. |
| `policy/sandbox-container-mount-mode-required` | A container-backed sandbox or browser mount is not read-only. |
| `policy/sandbox-container-runtime-socket-mount` | A container-backed sandbox or browser mount exposes the container runtime socket. |
| `policy/sandbox-container-unconfined-profile` | Container sandbox profile is unconfined when policy denies it. |
| `policy/sandbox-browser-cdp-source-range-missing` | Sandbox browser CDP source range is missing when policy requires one. |
| `policy/secrets-unmanaged-provider` | A config SecretRef references a provider not declared under `secrets.providers`. |
| `policy/secrets-denied-provider-source` | A config secret provider or SecretRef uses a source denied by policy. |
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
| `policy/tools-missing-owner` | A governed tool declaration is missing owner metadata. |
| `policy/tools-unknown-sensitivity-token` | A governed tool declaration uses an unknown sensitivity value. |
| Check id | Finding |
| -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or contains malformed rule entries. |
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
| `policy/policy-conformance-invalid` | A baseline or checked policy file has invalid comparison syntax. |
| `policy/policy-conformance-missing` | A checked policy file is missing a rule required by the baseline policy file. |
| `policy/policy-conformance-weaker` | A checked policy file has a weaker value than the baseline policy file. |
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
| `policy/mcp-denied-server` | A configured MCP server is denied by policy. |
| `policy/mcp-unapproved-server` | A configured MCP server is outside the allowlist. |
| `policy/models-denied-provider` | A configured model provider or model ref uses a denied provider. |
| `policy/models-unapproved-provider` | A configured model provider or model ref is outside the allowlist. |
| `policy/network-private-access-enabled` | A private-network SSRF escape hatch is enabled when policy denies it. |
| `policy/ingress-dm-policy-unapproved` | A channel DM policy is outside the policy allowlist. |
| `policy/ingress-dm-scope-unapproved` | `session.dmScope` does not match the policy-required DM isolation scope. |
| `policy/ingress-open-groups-denied` | A channel group policy is `open` while policy denies open group ingress. |
| `policy/ingress-group-mention-required` | A channel or group entry disables mention gates while policy requires them. |
| `policy/gateway-non-loopback-bind` | Gateway bind posture permits non-loopback exposure when policy denies it. |
| `policy/gateway-auth-disabled` | Gateway authentication is disabled when policy requires auth. |
| `policy/gateway-rate-limit-missing` | Gateway auth rate-limit posture is not explicit when policy requires it. |
| `policy/gateway-control-ui-insecure` | Gateway Control UI insecure exposure toggles are enabled. |
| `policy/gateway-tailscale-funnel` | Gateway Tailscale Funnel exposure is enabled when policy denies it. |
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |
| `policy/tools-fs-workspace-only-required` | Filesystem tools are not configured with workspace-only path posture. |
| `policy/tools-exec-security-unapproved` | Exec security mode is outside the policy allowlist. |
| `policy/tools-exec-ask-unapproved` | Exec ask mode is outside the policy allowlist. |
| `policy/tools-exec-host-unapproved` | Exec host routing is outside the policy allowlist. |
| `policy/tools-elevated-enabled` | Elevated tool mode is enabled when policy denies it. |
| `policy/tools-also-allow-missing` | A configured `alsoAllow` list is missing an entry required by policy. |
| `policy/tools-also-allow-unexpected` | A configured `alsoAllow` list includes an entry not expected by policy. |
| `policy/tools-required-deny-missing` | A global or per-agent tool deny list does not include a required denied tool. |
| `policy/sandbox-mode-unapproved` | Sandbox mode is outside the policy allowlist. |
| `policy/sandbox-backend-unapproved` | Sandbox backend is outside the policy allowlist. |
| `policy/sandbox-container-posture-unobservable` | A container posture rule is enabled for a backend that cannot observe it. |
| `policy/sandbox-container-host-network-denied` | A container-backed sandbox or browser uses host network mode. |
| `policy/sandbox-container-namespace-join-denied` | A container-backed sandbox or browser joins another container namespace. |
| `policy/sandbox-container-mount-mode-required` | A container-backed sandbox or browser mount is not read-only. |
| `policy/sandbox-container-runtime-socket-mount` | A container-backed sandbox or browser mount exposes the container runtime socket. |
| `policy/sandbox-container-unconfined-profile` | Container sandbox profile is unconfined when policy denies it. |
| `policy/sandbox-browser-cdp-source-range-missing` | Sandbox browser CDP source range is missing when policy requires one. |
| `policy/data-handling-redaction-disabled` | Sensitive logging redaction is disabled when policy requires it. |
| `policy/data-handling-telemetry-content-capture` | Telemetry content capture is enabled when policy denies it. |
| `policy/data-handling-session-retention-not-enforced` | Session retention maintenance is not enforced when policy requires it. |
| `policy/data-handling-session-transcript-memory-enabled` | Session transcript memory indexing is enabled when policy denies it. |
| `policy/secrets-unmanaged-provider` | A config SecretRef references a provider not declared under `secrets.providers`. |
| `policy/secrets-denied-provider-source` | A config secret provider or SecretRef uses a source denied by policy. |
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
| `policy/tools-missing-owner` | A governed tool declaration is missing owner metadata. |
| `policy/tools-unknown-sensitivity-token` | A governed tool declaration uses an unknown sensitivity value. |
Policy findings can include both `target` and `requirement`. `target` is the
observed workspace thing that does not conform. `requirement` is the authored

View File

@@ -194,10 +194,12 @@ OpenClaw resolves that behavior by conversation type:
`message(action=send)`.
- Internal orchestration allows silence by default.
OpenClaw also uses silent replies for internal runner failures that happen
before any assistant reply in non-direct chats, so groups/channels do not see
gateway error boilerplate. Direct chats show compact failure copy by default;
raw runner details are shown only when `/verbose full` is enabled.
OpenClaw also uses silent replies for generic internal runner failures in
non-direct chats, so groups/channels do not see gateway error boilerplate.
Classified failures with user-facing recovery copy, such as missing auth,
rate-limit, or overload notices, can still be delivered. Direct chats show
compact failure copy by default; raw runner details are shown only when
`/verbose full` is enabled.
Defaults live under `agents.defaults.silentReply`; `surfaces.<id>.silentReply`
can override group/internal policy per surface.

View File

@@ -110,8 +110,8 @@ writes.
## Session maintenance
OpenClaw automatically bounds session storage over time. By default, it runs
in `warn` mode (reports what would be cleaned). Set `session.maintenance.mode`
to `"enforce"` for automatic cleanup:
in `enforce` mode and applies cleanup during maintenance. Set
`session.maintenance.mode` to `"warn"` to report what would be cleaned without mutating the store/files:
```json5
{

View File

@@ -1272,7 +1272,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
resetTriggers: ["/new", "/reset"],
store: "~/.openclaw/agents/{agentId}/sessions/sessions.json",
maintenance: {
mode: "warn", // warn | enforce
mode: "enforce", // enforce (default) | warn
pruneAfter: "30d",
maxEntries: 500,
resetArchiveRetention: "30d", // duration or false
@@ -1311,7 +1311,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- **`agentToAgent.maxPingPongTurns`**: maximum reply-back turns between agents during agent-to-agent exchanges (integer, range: `0`-`20`, default: `5`). `0` disables ping-pong chaining.
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.
- **`maintenance`**: session-store cleanup + retention controls.
- `mode`: `warn` emits warnings only; `enforce` applies cleanup.
- `mode`: `enforce` applies cleanup and is the default; `warn` emits warnings only.
- `pruneAfter`: age cutoff for stale entries (default `30d`).
- `maxEntries`: maximum number of entries in `sessions.json` (default `500`). Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the cap immediately.
- `rotateBytes`: deprecated and ignored; `openclaw doctor --fix` removes it from older configs.

View File

@@ -27,7 +27,7 @@ settings and governed workspace declarations. Policy currently covers channel
conformance, governed tool metadata, MCP server posture, model-provider posture,
private-network access posture, Gateway exposure posture, agent workspace/tool
posture, configured global/per-agent tool posture, configured sandbox runtime
posture, ingress/channel access posture, and OpenClaw config secret
posture, ingress/channel access posture, data-handling posture, and OpenClaw config secret
provider/auth profile posture.
Policy stores authored requirements in `policy.jsonc`, observes existing
@@ -55,9 +55,16 @@ and require sandbox browser CDP source ranges.
These checks observe config conformance only; they do not read runtime approval
state, inspect live containers, or add runtime enforcement.
Data-handling rules can require sensitive logging redaction, deny telemetry
content capture, require session retention maintenance, and deny session
transcript memory indexing. These checks observe config conformance only; they
do not inspect raw logs, telemetry exports, transcripts, memory files, secrets,
or personal data.
Named policy scopes under `scopes.<scopeName>` can add stricter normal policy
sections for the selector they list. `agentIds` supports `tools`,
`agents.workspace`, and `sandbox`; `channelIds` supports `ingress.channels`.
`agents.workspace`, `sandbox`, and `dataHandling.memory`; `channelIds` supports
`ingress.channels`.
Runtime agent ids that are not explicitly listed in `agents.list[]` are checked
against inherited global/default posture rather than silently passing with no
evidence. Every scope present in `policy.jsonc` must be valid and enforceable

View File

@@ -292,7 +292,8 @@ Workboard stops auto-moving that card until you move it back to `todo` or
2. Create a card with a title, notes, priority, labels, optional agent, and
optional linked session.
3. Or open Sessions and choose Add to Workboard for an existing session.
4. Drag the card between columns or use the column controls.
4. Drag the card between columns or focus the compact status control on the card
and use its menu or ArrowLeft/ArrowRight.
5. Start work from the card to create or reuse a dashboard session.
6. Open the linked session from the card while the agent works.
7. Let lifecycle sync move running work into review or blocked, then manually

View File

@@ -78,7 +78,7 @@ OpenClaw resolves these via `src/config/sessions.ts`.
Session persistence has automatic maintenance controls (`session.maintenance`) for `sessions.json`, transcript artifacts, and trajectory sidecars:
- `mode`: `warn` (default) or `enforce`
- `mode`: `enforce` (default) or `warn`
- `pruneAfter`: stale-entry age cutoff (default `30d`)
- `maxEntries`: cap entries in `sessions.json` (default `500`)
- `resetArchiveRetention`: retention for `*.reset.<timestamp>` transcript archives (default: same as `pruneAfter`; `false` disables cleanup)

View File

@@ -144,9 +144,15 @@ when set at the narrower session or agent scope.
### `exec.ask`
<ParamField path="ask" type='"off" | "on-miss" | "always"'>
- `off` - never prompt.
- `on-miss` - prompt only when the allowlist does not match.
- `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
Configured ask policy for host exec. Controls the baseline approval
prompt behavior from `tools.exec.ask` and host approvals defaults. The
per-call `ask` tool parameter (see [Exec tool](/tools/exec#parameters))
can only harden that baseline, and channel-origin model calls ignore it
when the effective host ask is `off`.
- `off` - never prompt.
- `on-miss` - prompt only when the allowlist does not match.
- `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
</ParamField>

View File

@@ -52,7 +52,11 @@ force `security=full` only when the operator explicitly grants elevated access.
</ParamField>
<ParamField path="ask" type="'off' | 'on-miss' | 'always'">
Approval prompt behavior for `gateway` / `node` execution.
The baseline ask mode comes from `tools.exec.ask` and host approvals.
For channel-origin model calls, per-call `ask` is ignored when the
effective host ask is `off`; otherwise it can only harden to a stricter
mode. Trusted internal/API callers that construct exec tools with an
explicit `ask` value are unchanged.
</ParamField>
<ParamField path="node" type="string">

View File

@@ -180,7 +180,7 @@ Activity entries keep only sanitized summaries and redacted, truncated output pr
<AccordionGroup>
<Accordion title="Send and history semantics">
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events. Trusted Control UI clients may also receive optional ACK timing metadata for local diagnostics.
- Chat uploads accept images plus non-video files. Images keep the native image path; other files are stored as managed media and shown in history as attachment links.
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).

View File

@@ -34,6 +34,23 @@ describe("canvas a2ui copy", () => {
);
}
it("ships provider assets and the legacy granola compatibility image", async () => {
const srcDir = path.join(process.cwd(), "extensions", "canvas", "src", "host", "a2ui");
const requiredAssets = [
path.join("assets", "providers", "google.png"),
path.join("assets", "providers", "x.png"),
"granola.png",
];
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (const asset of requiredAssets) {
const bytes = await fs.readFile(path.join(srcDir, asset));
expect([...bytes.subarray(0, pngSignature.length)]).toEqual(pngSignature);
expect(bytes.length).toBeGreaterThan(64);
}
});
it("throws a helpful error when assets are missing", async () => {
await withA2uiFixture(async (dir) => {
await expect(copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") })).rejects.toThrow(
@@ -78,4 +95,30 @@ describe("canvas a2ui copy", () => {
);
});
});
it("preserves provider assets and the legacy granola compatibility image", async () => {
await withA2uiFixture(async (dir) => {
const srcDir = path.join(dir, "src");
const outDir = path.join(dir, "dist");
const providerAssetDir = path.join(srcDir, "assets", "providers");
await fs.mkdir(providerAssetDir, { recursive: true });
await fs.writeFile(path.join(srcDir, "index.html"), "<html></html>", "utf8");
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
await fs.writeFile(path.join(providerAssetDir, "google.png"), "google-asset", "utf8");
await fs.writeFile(path.join(providerAssetDir, "x.png"), "x-asset", "utf8");
await fs.writeFile(path.join(srcDir, "granola.png"), "legacy-granola-asset", "utf8");
await copyA2uiAssets({ srcDir, outDir });
await expect(
fs.readFile(path.join(outDir, "assets", "providers", "google.png"), "utf8"),
).resolves.toBe("google-asset");
await expect(
fs.readFile(path.join(outDir, "assets", "providers", "x.png"), "utf8"),
).resolves.toBe("x-asset");
await expect(fs.readFile(path.join(outDir, "granola.png"), "utf8")).resolves.toBe(
"legacy-granola-asset",
);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View File

@@ -1,5 +1,5 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
import type { CodexAppServerClient } from "./src/app-server/client.js";
import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js";
@@ -174,6 +174,11 @@ function createFakeClient(options?: {
}
describe("codex media understanding provider", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("runs image understanding through a bounded Codex app-server turn", async () => {
const { client, requests } = createFakeClient();
const provider = buildCodexMediaUnderstandingProvider({
@@ -231,9 +236,8 @@ describe("codex media understanding provider", () => {
});
it("clamps oversized image understanding turn timeouts", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const { client } = createFakeClient();
const provider = buildCodexMediaUnderstandingProvider({
clientFactory: async () => client,

View File

@@ -7,7 +7,11 @@ import { startCodexAttemptThread } from "./attempt-startup.js";
import { defaultLeasedCodexAppServerClientFactory } from "./client-factory.js";
import { CodexAppServerClient } from "./client.js";
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import { clearSharedCodexAppServerClient } from "./shared-client.js";
import {
clearSharedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./shared-client.js";
import { createClientHarness, createCodexTestModel } from "./test-support.js";
type ClientHarness = ReturnType<typeof createClientHarness>;
@@ -44,6 +48,8 @@ const bundleMcpThreadConfig = {
fingerprint: undefined,
} satisfies CodexBundleMcpThreadConfig;
const HARNESS_REQUEST_TIMEOUT_MS = 15_000;
function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: string }> {
return writes.map((write) => JSON.parse(write) as { id?: number; method?: string });
}
@@ -51,14 +57,24 @@ function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: st
function startThreadWithHarness(
startupTimeoutMs: number,
signal = new AbortController().signal,
overrides?: { pluginConfig?: CodexPluginConfig },
overrides?: {
pluginConfig?: CodexPluginConfig;
attemptClientFactory?: (
harness: ClientHarness,
) => Parameters<typeof startCodexAttemptThread>[0]["attemptClientFactory"];
harness?: ClientHarness;
skipStartSpy?: boolean;
},
) {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const harness = overrides?.harness ?? createClientHarness();
if (!overrides?.skipStartSpy) {
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
}
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
const run = startCodexAttemptThread({
attemptClientFactory: defaultLeasedCodexAppServerClientFactory,
attemptClientFactory:
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
pluginConfig: effectivePluginConfig,
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
@@ -91,7 +107,7 @@ function startThreadWithHarness(
async function answerInitialize(harness: ClientHarness): Promise<void> {
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1), {
interval: 1,
timeout: 5_000,
timeout: HARNESS_REQUEST_TIMEOUT_MS,
});
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.125.0 (macOS; test)" } });
@@ -106,7 +122,7 @@ async function waitForRequest(
expect(readHarnessMessages(harness.writes).some((write) => write.method === method)).toBe(
true,
),
{ interval: 1, timeout: 5_000 },
{ interval: 1, timeout: HARNESS_REQUEST_TIMEOUT_MS },
);
const request = readHarnessMessages(harness.writes).find((write) => write.method === method);
if (!request) {
@@ -147,8 +163,50 @@ describe("startCodexAttemptThread", () => {
expect(harness.process.stdin.destroyed).toBe(true);
});
it("retires a failed startup client after another active lease releases", async () => {
const retained = createClientHarness();
const replacement = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(retained.client)
.mockReturnValueOnce(replacement.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: "/tmp/agent",
});
await answerInitialize(retained);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
harness: retained,
skipStartSpy: true,
});
const threadStart = await waitForThreadStart(retained);
retained.send({
id: threadStart.id,
error: { code: -32000, message: "401 authentication_error: Invalid bearer token" },
});
await expect(run).rejects.toThrow("Invalid bearer token");
expect(retained.process.stdin.destroyed).toBe(false);
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
const replacementLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: "/tmp/agent",
});
await answerInitialize(replacement);
await expect(replacementLease).resolves.toBe(replacement.client);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
});
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
const { harness, run } = startThreadWithHarness(200);
const { harness, run } = startThreadWithHarness(2_000);
const runError = run.then(
() => undefined,
(error: unknown) => error,
@@ -166,9 +224,99 @@ describe("startCodexAttemptThread", () => {
expect(harness.stdinDestroyed).toBe(true);
});
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
const retained = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: "/tmp/agent",
});
await answerInitialize(retained);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(100, new AbortController().signal, {
harness: retained,
skipStartSpy: true,
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
const threadStart = await waitForThreadStart(retained);
await rejected;
expect(retained.process.stdin.destroyed).toBe(false);
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
});
it("closes the shared app-server when startup times out during initialize", async () => {
const { harness, run } = startThreadWithHarness(2_000);
const runError = run.then(
() => undefined,
(error: unknown) => error,
);
const initialize = await waitForRequest(harness, "initialize");
expect(initialize.id).toBeDefined();
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("codex app-server startup timed out");
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
});
expect(
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
).toBe(false);
});
it("closes a startup client that arrives after startup timeout", async () => {
let observedFactoryOptions:
| {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
}
| undefined;
let resolveFactoryDone: () => void = () => undefined;
const factoryDone = new Promise<void>((resolve) => {
resolveFactoryDone = resolve;
});
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
attemptClientFactory:
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
try {
observedFactoryOptions = options;
await new Promise<void>((resolve) => {
setTimeout(resolve, 250);
});
options?.onStartedClient?.(factoryHarness.client);
return factoryHarness.client;
} finally {
resolveFactoryDone();
}
},
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await rejected;
await factoryDone;
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
});
expect(
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
).toBe(false);
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
});
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {
const abortController = new AbortController();
const { harness, run } = startThreadWithHarness(5_000, abortController.signal);
const { harness, run } = startThreadWithHarness(30_000, abortController.signal);
const runError = run.then(
() => undefined,
(error: unknown) => error,

View File

@@ -44,8 +44,10 @@ import {
type CodexSandboxExecEnvironment,
} from "./sandbox-exec-server.js";
import {
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
clearSharedCodexAppServerClientIfCurrent,
releaseLeasedSharedCodexAppServerClient,
retireSharedCodexAppServerClientIfCurrent,
} from "./shared-client.js";
import {
startOrResumeThread,
@@ -102,13 +104,23 @@ export async function startCodexAttemptThread(params: {
let releaseSharedClientLease: (() => void) | undefined;
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined;
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
let startupAbandoned = false;
const startupAbandonController = new AbortController();
const abandonStartupAcquire = () => startupAbandonController.abort();
params.signal.addEventListener("abort", abandonStartupAcquire, { once: true });
try {
const startupResult = await withCodexStartupTimeout({
timeoutMs: params.startupTimeoutMs,
signal: params.signal,
onTimeout: async () => {
startupAbandoned = true;
startupAbandonController.abort();
await params.onStartupTimeout();
await releaseStartupResourcesOnTimeout?.();
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
},
operation: async () => {
const threadConfig = mergeCodexThreadConfigs(
@@ -172,25 +184,48 @@ export async function startCodexAttemptThread(params: {
let attemptedClient: CodexAppServerClient | undefined;
const startupAttempt = async () => {
let startupClientLease: (() => void) | undefined;
let startupClient: CodexAppServerClient | undefined;
let startupAttemptError: unknown;
let startupAttemptSucceeded = false;
try {
const startupClient = await params.attemptClientFactory(
startupClient = await params.attemptClientFactory(
params.appServer.start,
params.startupAuthProfileId,
params.agentDir,
params.config,
{
onStartedClient: (client) => {
startupClientForAbandonedRequestCleanup = client;
if (startupAbandoned || startupAbandonController.signal.aborted) {
void closeAbandonedStartupClient(client);
}
},
abandonSignal: startupAbandonController.signal,
},
);
const activeStartupClient = startupClient;
let startupClientLeaseReleased = false;
startupClientLease = () => {
releaseLeasedSharedCodexAppServerClient(startupClient);
if (startupClientLeaseReleased) {
return;
}
startupClientLeaseReleased = true;
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
};
releaseSharedClientLease = startupClientLease;
attemptedClient = startupClient;
startupClientForAbandonedRequestCleanup = startupClient;
attemptedClient = activeStartupClient;
startupClientForAbandonedRequestCleanup = activeStartupClient;
if (startupAbandoned) {
throw new Error("codex app-server startup timed out");
}
if (startupAbandonController.signal.aborted) {
throw new Error("codex app-server startup aborted");
}
await ensureCodexComputerUse({
client: startupClient,
client: activeStartupClient,
pluginConfig: params.pluginConfig,
timeoutMs: params.appServer.requestTimeoutMs,
signal: params.signal,
signal: startupAbandonController.signal,
});
let startupSandboxEnvironment: CodexSandboxExecEnvironment | undefined;
let startupSandboxEnvironmentAcquired = false;
@@ -208,15 +243,15 @@ export async function startCodexAttemptThread(params: {
sandboxExecServerEnabled: params.sandboxExecServerEnabled,
})
? await ensureCodexSandboxExecServerEnvironment({
client: startupClient,
client: activeStartupClient,
sandbox: params.sandbox ?? null,
appServerStartOptions: params.appServer.start,
timeoutMs: params.appServer.requestTimeoutMs,
signal: params.signal,
signal: startupAbandonController.signal,
})
: undefined;
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
if (params.signal.aborted) {
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
@@ -246,9 +281,9 @@ export async function startCodexAttemptThread(params: {
const startupSandboxPolicy = startupSandboxEnvironment
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
: undefined;
const buildThreadLifecycleParams = () =>
const buildThreadLifecycleParams = (signal: AbortSignal) =>
({
client: startupClient,
client: activeStartupClient,
params: params.buildAttemptParams(),
agentId: params.sessionAgentId,
cwd: startupExecutionCwd,
@@ -266,7 +301,7 @@ export async function startCodexAttemptThread(params: {
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
environmentSelection: startupEnvironmentSelection,
contextEngineProjection: params.contextEngineProjection,
signal: params.signal,
signal,
pluginThreadConfig: pluginThreadConfigRequired
? {
enabled: true,
@@ -276,9 +311,9 @@ export async function startCodexAttemptThread(params: {
buildCodexPluginThreadConfig({
pluginConfig: pluginThreadConfigPluginConfig,
request: (method, requestParams) =>
startupClient.request(method, requestParams, {
activeStartupClient.request(method, requestParams, {
timeoutMs: params.appServer.requestTimeoutMs,
signal: params.signal,
signal,
}),
appCache: defaultCodexAppInventoryCache,
appCacheKey: pluginAppCacheKey,
@@ -287,22 +322,24 @@ export async function startCodexAttemptThread(params: {
: undefined,
}) satisfies Parameters<typeof startOrResumeThread>[0];
try {
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
if (params.signal.aborted) {
const startupThread = await startOrResumeThread(
buildThreadLifecycleParams(startupAbandonController.signal),
);
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
startupSandboxEnvironmentAcquired = false;
startupAttemptSucceeded = true;
return {
client: startupClient,
client: activeStartupClient,
thread: startupThread,
sandboxEnvironment: startupSandboxEnvironment,
environmentSelection: startupEnvironmentSelection,
executionCwd: startupExecutionCwd,
sandboxPolicy: startupSandboxPolicy,
restartContextEngineCodexThread: () =>
startOrResumeThread(buildThreadLifecycleParams()),
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
};
} catch (error) {
await releaseStartupSandboxEnvironment();
@@ -312,12 +349,32 @@ export async function startCodexAttemptThread(params: {
releaseStartupResourcesOnTimeout = undefined;
}
}
} catch (error) {
startupAttemptError = error;
throw error;
} finally {
if (!startupAttemptSucceeded) {
if (releaseSharedClientLease === startupClientLease) {
releaseSharedClientLease = undefined;
}
startupClientLease?.();
if (startupAbandoned || params.signal.aborted) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await closeAbandonedStartupClient(startupClient);
} else if (
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
shouldClearSharedClientAfterStartupFailure({
error: startupAttemptError,
spawnedBy: params.spawnedBy,
})
) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await evictFailedStartupClient(startupClient);
}
}
}
};
@@ -375,26 +432,115 @@ export async function startCodexAttemptThread(params: {
releaseSharedClientLease,
};
} catch (error) {
if (
params.signal.aborted ||
if (params.signal.aborted || shouldClearSharedClientAfterStartupAbandon(error)) {
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
} else if (
shouldClearSharedClientAfterStartupRace(error) ||
shouldClearSharedClientAfterStartupFailure({
error,
spawnedBy: params.spawnedBy,
})
) {
clearSharedCodexAppServerClientIfCurrent(startupClientForAbandonedRequestCleanup);
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await evictFailedStartupClient(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
}
throw error;
} finally {
params.signal.removeEventListener("abort", abandonStartupAcquire);
}
}
async function closeAbandonedStartupClient(
client: CodexAppServerClient | undefined,
): Promise<void> {
if (!client) {
return;
}
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
if (unclaimedSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
return;
}
if (unclaimedSharedClient.found) {
const retired = retireSharedCodexAppServerClientIfCurrent(client);
if (retired?.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
if (retiredSharedClient) {
if (retiredSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
if (clearSharedCodexAppServerClientIfCurrent(client)) {
await closeClientAndWaitIfAvailable(client);
return;
}
await closeClientAndWaitIfAvailable(client);
}
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
const closeable = client as {
close?: CodexAppServerClient["close"];
closeAndWait?: CodexAppServerClient["closeAndWait"];
};
if (typeof closeable.closeAndWait === "function") {
await closeable.closeAndWait();
return;
}
closeable.close?.();
}
async function evictFailedStartupClient(client: CodexAppServerClient | undefined): Promise<void> {
if (!client) {
return;
}
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
if (unclaimedSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
return;
}
if (unclaimedSharedClient.found) {
const retired = retireSharedCodexAppServerClientIfCurrent(client);
if (retired?.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
if (retiredSharedClient) {
if (retiredSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
if (clearSharedCodexAppServerClientIfCurrent(client)) {
await closeClientAndWaitIfAvailable(client);
return;
}
await closeClientAndWaitIfAvailable(client);
}
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
return (
error instanceof Error &&
(error.message === "codex app-server startup timed out" ||
error.message === "codex app-server startup aborted")
);
}
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
return (
error instanceof Error &&
(error.message === "codex app-server startup timed out" ||
error.message === "codex app-server startup aborted" ||
error.message.endsWith(" timed out"))
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
);
}

View File

@@ -54,6 +54,34 @@ describe("Codex app-server steering queue", () => {
});
});
it("batches queued steering after a nonzero debounce while the turn is active", async () => {
vi.useFakeTimers();
const request = vi.fn(async () => ({ turnId: "turn-1" }));
const queue = createCodexSteeringQueue({
client: { request } as never,
threadId: "thread-1",
turnId: "turn-1",
answerPendingUserInput: () => false,
signal: new AbortController().signal,
});
const firstQueued = queue.queue("first", { debounceMs: 5 });
const secondQueued = queue.queue("second", { debounceMs: 5 });
expect(request).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5);
await Promise.all([firstQueued, secondQueued]);
expect(request).toHaveBeenCalledWith("turn/steer", {
threadId: "thread-1",
expectedTurnId: "turn-1",
input: [
{ type: "text", text: "first", text_elements: [] },
{ type: "text", text: "second", text_elements: [] },
],
});
});
it("rejects queued steering when the run aborts before debounce flush", async () => {
const controller = new AbortController();
const request = vi.fn(async () => ({ turnId: "turn-1" }));

View File

@@ -11,6 +11,10 @@ export type CodexAppServerClientFactory = (
authProfileId?: string,
agentDir?: string,
config?: AuthProfileOrderConfig,
options?: {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
},
) => Promise<CodexAppServerClient>;
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
@@ -25,9 +29,17 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
getSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
}),
);
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
@@ -35,7 +47,15 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
getLeasedSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
}),
);

View File

@@ -408,9 +408,10 @@ describe("CodexAppServerClient", () => {
// Start a pending request so we can verify it gets properly rejected.
const pending = harness.client.request("test/method");
// Simulate the child process closing its pipe — a write to the now-dead
// stdin emits an asynchronous EPIPE error on the stream.
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
// Simulate the child process closing its pipe: stdin emits an asynchronous
// EPIPE error before the transport observes a process exit.
const pipeError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
harness.process.stdin.emit("error", pipeError);
// The pending request must be rejected with the pipe error rather than
// an unhandled exception tearing down the gateway.

View File

@@ -4,13 +4,14 @@ import {
type EmbeddedAgentCompactResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
defaultCodexAppServerClientFactory,
defaultLeasedCodexAppServerClientFactory,
type CodexAppServerClientFactory,
} from "./client-factory.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import type { JsonObject } from "./protocol.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import { readCodexAppServerBinding } from "./session-binding.js";
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
const warnedIgnoredCompactionOverrides = new Set<string>();
@@ -177,7 +178,8 @@ async function compactCodexNativeThread(
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
}
const clientFactory = options.clientFactory ?? defaultCodexAppServerClientFactory;
const shouldReleaseDefaultLease = !options.clientFactory;
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
@@ -211,6 +213,10 @@ async function compactCodexNativeThread(
compacted: false,
reason: formatCompactionError(error),
};
} finally {
if (shouldReleaseDefaultLease) {
releaseLeasedSharedCodexAppServerClient(client);
}
}
const resultDetails: JsonObject = {
backend: "codex-app-server",

View File

@@ -1,7 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
embeddedAgentLog,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
addSandboxShellDynamicToolsIfAvailable,
@@ -223,6 +226,38 @@ describe("Codex app-server dynamic tool build", () => {
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
});
it("quarantines non-object plugin schemas before Codex-specific filtering", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const messageTool = createRuntimeDynamicTool("message");
const brokenTool = {
...createRuntimeDynamicTool("dofbot_move_angles"),
parameters: { type: "array", items: { type: "number" } },
};
setOpenClawCodingToolsFactoryForTests(() => [brokenTool, messageTool]);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
expect(warn).toHaveBeenCalledWith(
"codex app-server quarantined 1 unsupported runtime tool schema before dynamic tool registration",
expect.objectContaining({
runId: "run-1",
sessionId: "session-1",
diagnostics: [
{
index: 0,
tool: "dofbot_move_angles",
violations: ['dofbot_move_angles.parameters.type must be "object"'],
violationCount: 1,
},
],
}),
);
});
it("limits Codex memory flush runs to managed read and write tools", async () => {
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {

View File

@@ -1,5 +1,4 @@
import path from "node:path";
import { abortAgentHarnessRun } from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it, vi } from "vitest";
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
import type { CodexServerNotification } from "./protocol.js";
@@ -18,48 +17,52 @@ import {
setupRunAttemptTestHooks();
function createSteeringParams(name: string) {
let steeringSessionIndex = 0;
function createSteeringParams() {
const sessionId = `steering-session-${++steeringSessionIndex}`;
const params = createParams(
path.join(tempDir, `${name}.jsonl`),
path.join(tempDir, `${name}-workspace`),
path.join(tempDir, `${sessionId}.jsonl`),
path.join(tempDir, `${sessionId}-workspace`),
);
params.sessionId = `session-${name}`;
params.sessionKey = `agent:main:session-${name}`;
params.sessionId = sessionId;
params.sessionKey = `agent:main:${sessionId}`;
params.runId = `run-${sessionId}`;
return params;
}
async function queueActiveRunMessageEventually(
async function waitAndQueueActiveRunMessage(
sessionId: string,
text: string,
options?: Parameters<typeof queueActiveRunMessageForTest>[2],
) {
await vi.waitFor(
() => expect(queueActiveRunMessageForTest(sessionId, text, options)).toBe(true),
fastWait,
);
let queued = false;
await vi.waitFor(() => {
if (!queued) {
queued = queueActiveRunMessageForTest(sessionId, text, options);
}
expect(queued).toBe(true);
}, fastWait);
}
describe("runCodexAppServerAttempt steering", () => {
it("forwards queued user input and aborts the active app-server turn", async () => {
const { requests, waitForMethod } = createStartedThreadHarness();
const params = createSteeringParams("steering-forward");
it("forwards queued user input to the active app-server turn", async () => {
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const params = createSteeringParams();
const run = runCodexAppServerAttempt(params, { pluginConfig: { appServer: { mode: "yolo" } } });
const run = runCodexAppServerAttempt(params, {
pluginConfig: { appServer: { mode: "yolo" } },
});
await waitForMethod("turn/start");
await queueActiveRunMessageEventually(params.sessionId, "more context", { debounceMs: 1 });
await waitAndQueueActiveRunMessage(params.sessionId, "more context", { debounceMs: 0 });
await vi.waitFor(
() => expect(requests.map((entry) => entry.method)).toContain("turn/steer"),
fastWait,
);
expect(abortAgentHarnessRun(params.sessionId)).toBe(true);
await vi.waitFor(
() => expect(requests.map((entry) => entry.method)).toContain("turn/interrupt"),
fastWait,
);
const result = await run;
expect(result.aborted).toBe(true);
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const threadStart = requests.find((entry) => entry.method === "thread/start");
const threadStartParams = threadStart?.params as
| {
@@ -81,27 +84,21 @@ describe("runCodexAppServerAttempt steering", () => {
expectedTurnId: "turn-1",
input: [{ type: "text", text: "more context", text_elements: [] }],
});
const interrupt = requests.find((entry) => entry.method === "turn/interrupt");
expect(interrupt?.params).toEqual({ threadId: "thread-1", turnId: "turn-1" });
});
it("accepts message-tool-only steering for active Codex app-server source replies", async () => {
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const params = createSteeringParams("steering-message-tool");
const params = createSteeringParams();
params.sourceReplyDeliveryMode = "message_tool_only";
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
await queueActiveRunMessageEventually(
params.sessionId,
"subagent complete",
{
debounceMs: 1,
steeringMode: "all",
sourceReplyDeliveryMode: "message_tool_only",
},
);
await waitAndQueueActiveRunMessage(params.sessionId, "subagent complete", {
debounceMs: 0,
steeringMode: "all",
sourceReplyDeliveryMode: "message_tool_only",
});
await vi.waitFor(
() =>
@@ -115,53 +112,51 @@ describe("runCodexAppServerAttempt steering", () => {
},
},
]),
fastWait,
{ interval: 1 },
);
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
});
it("batches default queued steering before sending turn/steer", async () => {
it("flushes batched default queued steering during normal turn cleanup", async () => {
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const params = createSteeringParams("steering-batch-default");
const params = createSteeringParams();
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
await queueActiveRunMessageEventually(params.sessionId, "first", { debounceMs: 5 });
expect(queueActiveRunMessageForTest(params.sessionId, "second", { debounceMs: 5 })).toBe(true);
await vi.waitFor(
() =>
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
{
method: "turn/steer",
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
input: [
{ type: "text", text: "first", text_elements: [] },
{ type: "text", text: "second", text_elements: [] },
],
},
},
]),
fastWait,
await waitAndQueueActiveRunMessage(params.sessionId, "first", { debounceMs: 30_000 });
expect(queueActiveRunMessageForTest(params.sessionId, "second", { debounceMs: 30_000 })).toBe(
true,
);
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
{
method: "turn/steer",
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
input: [
{ type: "text", text: "first", text_elements: [] },
{ type: "text", text: "second", text_elements: [] },
],
},
},
]);
});
it("flushes pending default queued steering during normal turn cleanup", async () => {
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const params = createSteeringParams("steering-flush");
const params = createSteeringParams();
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
await queueActiveRunMessageEventually(params.sessionId, "late steer", { debounceMs: 30_000 });
await waitAndQueueActiveRunMessage(params.sessionId, "late steer", { debounceMs: 30_000 });
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -178,44 +173,40 @@ describe("runCodexAppServerAttempt steering", () => {
]);
});
it("batches explicit all-mode steering before sending turn/steer", async () => {
it("flushes batched explicit all-mode steering during normal turn cleanup", async () => {
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const params = createSteeringParams("steering-batch-all");
const params = createSteeringParams();
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
await queueActiveRunMessageEventually(params.sessionId, "first", {
debounceMs: 5,
await waitAndQueueActiveRunMessage(params.sessionId, "first", {
debounceMs: 30_000,
steeringMode: "all",
});
expect(
queueActiveRunMessageForTest(params.sessionId, "second", {
debounceMs: 5,
debounceMs: 30_000,
steeringMode: "all",
}),
).toBe(true);
await vi.waitFor(
() =>
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
{
method: "turn/steer",
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
input: [
{ type: "text", text: "first", text_elements: [] },
{ type: "text", text: "second", text_elements: [] },
],
},
},
]),
fastWait,
);
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
{
method: "turn/steer",
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
input: [
{ type: "text", text: "first", text_elements: [] },
{ type: "text", text: "second", text_elements: [] },
],
},
},
]);
});
it("routes request_user_input prompts through the active run follow-up queue", async () => {
@@ -253,7 +244,7 @@ describe("runCodexAppServerAttempt steering", () => {
}) as never,
);
const params = createSteeringParams("steering-request-input");
const params = createSteeringParams();
params.onBlockReply = vi.fn();
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
@@ -286,7 +277,7 @@ describe("runCodexAppServerAttempt steering", () => {
});
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1), fastWait);
await queueActiveRunMessageEventually(params.sessionId, "2");
await waitAndQueueActiveRunMessage(params.sessionId, "2");
await expect(response).resolves.toEqual({
answers: { mode: { answers: ["Deep"] } },
});

View File

@@ -189,6 +189,28 @@ describe("shared Codex app-server client", () => {
expect(startSpy).toHaveBeenCalledTimes(2);
});
it("keeps a pending shared app-server alive when another acquire still owns startup", async () => {
const harness = createClientHarness();
const abandonController = new AbortController();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const abandonedAcquire = getSharedCodexAppServerClient({
timeoutMs: 1000,
abandonSignal: abandonController.signal,
});
const activeAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
abandonController.abort();
expect(harness.process.stdin.destroyed).toBe(false);
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(abandonedAcquire).resolves.toBe(harness.client);
await expect(activeAcquire).resolves.toBe(harness.client);
expect(harness.process.stdin.destroyed).toBe(false);
});
it("does not wait for isolated initialize after a timeout closes the client", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);

View File

@@ -18,6 +18,7 @@ type SharedCodexAppServerClientEntry = {
client?: CodexAppServerClient;
promise?: Promise<CodexAppServerClient>;
activeLeases: number;
pendingAcquires: number;
closeWhenIdle: boolean;
};
@@ -48,6 +49,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
const clients = keyedState.clients as Map<string, SharedCodexAppServerClientEntry>;
for (const entry of clients.values()) {
entry.activeLeases ??= 0;
entry.pendingAcquires ??= 0;
entry.closeWhenIdle ??= false;
}
const nextState: SharedCodexAppServerClientState = {
@@ -66,6 +68,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
client: legacyState.client,
promise: legacyState.promise,
activeLeases: 0,
pendingAcquires: 0,
closeWhenIdle: false,
});
legacyState.client?.addCloseHandler((closedClient) =>
@@ -102,6 +105,8 @@ type CodexAppServerClientOptions = {
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
};
type ResolvedCodexAppServerClientStartContext = {
@@ -194,11 +199,27 @@ async function acquireSharedCodexAppServerClient(
});
const state = getSharedCodexAppServerClientState();
const entry = getOrCreateSharedClientEntry(state, key);
const releasePendingAcquire = retainPendingSharedClientAcquire(entry);
let cleanupAbandonSignal: (() => void) | undefined;
if (options?.abandonSignal) {
const abandon = () => {
// Release this acquire before cleanup checks ownership; only other
// pending callers should keep the startup client alive.
releasePendingAcquire();
closeSharedClientEntryIfUnclaimed(key, entry);
};
options.abandonSignal.addEventListener("abort", abandon, { once: true });
cleanupAbandonSignal = () => options.abandonSignal?.removeEventListener("abort", abandon);
if (options.abandonSignal.aborted) {
abandon();
}
}
const sharedPromise =
entry.promise ??
(entry.promise = (async () => {
const client = CodexAppServerClient.start(startOptions);
entry.client = client;
options?.onStartedClient?.(client);
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
try {
@@ -233,6 +254,9 @@ async function acquireSharedCodexAppServerClient(
clearSharedClientEntry(key, currentEntry);
}
throw error;
} finally {
cleanupAbandonSignal?.();
releasePendingAcquire();
}
}
@@ -386,7 +410,7 @@ function getOrCreateSharedClientEntry(
): SharedCodexAppServerClientEntry {
let entry = state.clients.get(key);
if (!entry) {
entry = { activeLeases: 0, closeWhenIdle: false };
entry = { activeLeases: 0, pendingAcquires: 0, closeWhenIdle: false };
state.clients.set(key, entry);
}
return entry;
@@ -409,6 +433,39 @@ function clearSharedClientEntryIfCurrent(key: string, client: CodexAppServerClie
}
}
export function clearSharedCodexAppServerClientIfCurrentAndUnclaimed(
client: CodexAppServerClient | undefined,
): { found: boolean; closed: boolean; activeLeases: number; pendingAcquires: number } {
if (!client) {
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
}
const state = getSharedCodexAppServerClientState();
for (const [key, entry] of state.clients) {
if (entry.client === client) {
return {
found: true,
closed: closeSharedClientEntryIfUnclaimed(key, entry),
activeLeases: entry.activeLeases,
pendingAcquires: entry.pendingAcquires,
};
}
}
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
}
function retainPendingSharedClientAcquire(entry: SharedCodexAppServerClientEntry): () => void {
let released = false;
entry.pendingAcquires += 1;
return () => {
if (released) {
return;
}
released = true;
entry.pendingAcquires = Math.max(0, entry.pendingAcquires - 1);
closeRetiredSharedClientEntryIfIdle(entry);
};
}
function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () => void {
let released = false;
entry.activeLeases += 1;
@@ -423,7 +480,12 @@ function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () =>
}
function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEntry): boolean {
if (!entry.closeWhenIdle || entry.activeLeases > 0 || !entry.client) {
if (
!entry.closeWhenIdle ||
entry.activeLeases > 0 ||
entry.pendingAcquires > 0 ||
!entry.client
) {
return false;
}
const client = entry.client;
@@ -433,6 +495,22 @@ function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEn
return true;
}
function closeSharedClientEntryIfUnclaimed(
key: string,
entry: SharedCodexAppServerClientEntry,
): boolean {
if (entry.activeLeases > 0 || entry.pendingAcquires > 0) {
return false;
}
const state = getSharedCodexAppServerClientState();
if (state.clients.get(key) !== entry) {
return false;
}
state.clients.delete(key);
entry.client?.close();
return Boolean(entry.client);
}
function collectSharedClients(state: SharedCodexAppServerClientState): CodexAppServerClient[] {
return [
...new Set(

View File

@@ -22,6 +22,15 @@ export function createClientHarness() {
const stdout = new PassThrough();
const writes: string[] = [];
let stdinDestroyed = false;
let exitEmitted = false;
let emitProcessExit: () => void = () => undefined;
type HarnessProcess = EventEmitter & {
stdin: Writable;
stdout: PassThrough;
stderr: PassThrough;
killed: boolean;
kill: (signal?: NodeJS.Signals) => unknown;
};
const stdin = new Writable({
write(chunk, _encoding, callback) {
writes.push(chunk.toString());
@@ -31,17 +40,27 @@ export function createClientHarness() {
const destroyStdin = stdin.destroy.bind(stdin);
stdin.destroy = ((error?: Error) => {
stdinDestroyed = true;
return destroyStdin(error);
const result = destroyStdin(error);
if (!exitEmitted) {
exitEmitted = true;
// Let stdin surface pipe errors before the harness emits the fake child exit.
// Otherwise close-reason tests can race EPIPE against a synthetic clean exit.
setImmediate(emitProcessExit);
}
return result;
}) as typeof stdin.destroy;
const process = Object.assign(new EventEmitter(), {
const process: HarnessProcess = Object.assign(new EventEmitter(), {
stdin,
stdout,
stderr: new PassThrough(),
killed: false,
kill: vi.fn(() => {
kill: vi.fn((_signal?: NodeJS.Signals) => {
process.killed = true;
}),
});
emitProcessExit = () => {
process.emit("exit", 0, null);
};
const client = CodexAppServerClient.fromTransportForTests(process);
return {
client,

View File

@@ -1,8 +1,13 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCodexConversationTurnCollector } from "./conversation-turn-collector.js";
describe("codex conversation turn collector", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("collects streamed assistant deltas for the active turn", async () => {
const collector = createCodexConversationTurnCollector("thread-1");
collector.setTurnId("turn-1");
@@ -192,9 +197,8 @@ describe("codex conversation turn collector", () => {
});
it("clamps oversized turn wait timers", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const collector = createCodexConversationTurnCollector("thread-1");
collector.setTurnId("turn-1");
const completion = collector.wait({ timeoutMs: MAX_TIMER_TIMEOUT_MS + 1 });

View File

@@ -1,15 +1,8 @@
import { EmbeddedBlockChunker, formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
import {
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftMaxLines,
createChannelProgressDraftCompositor,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingProgressCommentary,
resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
} from "openclaw/plugin-sdk/channel-outbound";
@@ -79,86 +72,48 @@ export function createDiscordDraftPreviewController(params: {
let draftText = "";
let hasStreamedMessage = false;
let finalizedViaPreviewMessage = false;
let finalReplyStarted = false;
let finalReplyDelivered = false;
const previewToolProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
const commentaryProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingProgressCommentary(params.discordConfig);
const suppressDefaultToolProgressMessages =
Boolean(draftStream) &&
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.discordConfig, {
draftStreamActive: true,
previewToolProgressEnabled,
});
let previewToolProgressSuppressed = false;
let previewToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
let reasoningProgressRawText = "";
let lastReasoningProgressLine: string | undefined;
const progressSeed = `${params.accountId}:${params.deliverChannelId}`;
const renderProgressDraft = async (options?: { flush?: boolean }) => {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
if (!previewText || previewText === lastPartialText) {
return;
}
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream.update(previewText);
if (options?.flush) {
await draftStream.flush();
}
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: () => renderProgressDraft({ flush: true }),
const progressDraft = createChannelProgressDraftCompositor({
entry: params.discordConfig,
mode: discordStreamMode,
active: Boolean(draftStream),
seed: progressSeed,
update: async (previewText, options) => {
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream?.update(previewText);
if (options?.flush) {
await draftStream?.flush();
}
},
deleteCurrent: async () => {
lastPartialText = "";
draftText = "";
hasStreamedMessage = false;
if (draftStream?.messageId()) {
await draftStream.deleteCurrentMessage();
}
},
isEmptyLine: isEmptyDiscordProgressLine,
shouldStartNow: shouldStartDiscordProgressDraftNow,
});
const clearProgressDraftLine = async (lineId: string) => {
const nextLines = previewToolProgressLines.filter(
(line) => typeof line !== "object" || line.id?.trim() !== lineId,
);
if (nextLines.length === previewToolProgressLines.length) {
return;
}
previewToolProgressLines = nextLines;
if (!progressDraftGate.hasStarted) {
return;
}
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
if (previewText) {
await renderProgressDraft();
return;
}
lastPartialText = "";
draftText = "";
hasStreamedMessage = false;
if (draftStream?.messageId()) {
await draftStream.deleteCurrentMessage();
}
};
const resetProgressState = () => {
lastPartialText = "";
draftText = "";
draftChunker?.reset();
previewToolProgressSuppressed = false;
previewToolProgressLines = [];
reasoningProgressRawText = "";
lastReasoningProgressLine = undefined;
progressDraft.reset();
};
const forceNewMessageIfNeeded = () => {
@@ -172,22 +127,23 @@ export function createDiscordDraftPreviewController(params: {
return {
draftStream,
previewToolProgressEnabled,
commentaryProgressEnabled,
commentaryProgressEnabled: progressDraft.commentaryProgressEnabled,
suppressDefaultToolProgressMessages,
get isProgressMode() {
return discordStreamMode === "progress";
},
get hasProgressDraftStarted() {
return progressDraftGate.hasStarted;
return progressDraft.hasStarted;
},
get finalizedViaPreviewMessage() {
return finalizedViaPreviewMessage;
},
markFinalReplyStarted() {
finalReplyStarted = true;
progressDraft.markFinalReplyStarted();
},
markFinalReplyDelivered() {
finalReplyDelivered = true;
progressDraft.markFinalReplyDelivered();
},
markPreviewFinalized() {
finalizedViaPreviewMessage = true;
@@ -197,149 +153,19 @@ export function createDiscordDraftPreviewController(params: {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
await progressDraftGate.startNow();
await progressDraft.start();
},
async pushToolProgress(
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },
) {
if (!draftStream) {
return;
}
if (finalReplyStarted || finalReplyDelivered) {
return;
}
if (
options?.toolName !== undefined &&
!isChannelProgressDraftWorkToolName(options.toolName)
) {
return;
}
if (isEmptyDiscordProgressLine(line)) {
return;
}
const normalized = normalizeChannelProgressDraftLineIdentity(line);
if (!normalized) {
return;
}
const progressLine: string | ChannelProgressDraftLine =
typeof line === "object" && line !== undefined ? line : normalized;
if (discordStreamMode !== "progress") {
if (!previewToolProgressEnabled || previewToolProgressSuppressed) {
return;
}
const nextLines = mergeChannelProgressDraftLine(previewToolProgressLines, progressLine, {
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
});
if (nextLines === previewToolProgressLines) {
return;
}
previewToolProgressLines = nextLines;
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream.update(previewText);
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
previewToolProgressLines = mergeChannelProgressDraftLine(
previewToolProgressLines,
progressLine,
{
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
},
);
}
const alreadyStarted = progressDraftGate.hasStarted;
let progressActive;
if (shouldStartDiscordProgressDraftNow(line)) {
await progressDraftGate.startNow();
progressActive = progressDraftGate.hasStarted;
} else {
progressActive = await progressDraftGate.noteWork();
}
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
await renderProgressDraft();
}
await progressDraft.pushToolProgress(line, options);
},
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
if (!draftStream || discordStreamMode !== "progress" || !text) {
return;
}
if (finalReplyDelivered) {
return;
}
reasoningProgressRawText = mergeReasoningProgressText(reasoningProgressRawText, text, {
snapshot: options?.snapshot === true,
});
const normalized = normalizeReasoningProgressLine(reasoningProgressRawText);
if (!normalized) {
return;
}
const displayLine = formatReasoningProgressDisplayLine(
normalized,
resolveChannelProgressDraftMaxLineChars(params.discordConfig),
);
if (!displayLine) {
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
const priorIndex =
lastReasoningProgressLine === undefined
? -1
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
if (priorIndex >= 0) {
previewToolProgressLines = [...previewToolProgressLines];
previewToolProgressLines[priorIndex] = displayLine;
} else {
previewToolProgressLines = [...previewToolProgressLines, displayLine].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
}
lastReasoningProgressLine = displayLine;
}
const progressActive = await progressDraftGate.noteWork();
if (progressActive && progressDraftGate.hasStarted) {
await renderProgressDraft();
}
await progressDraft.pushReasoningProgress(text, options);
},
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
if (!draftStream || discordStreamMode !== "progress" || !commentaryProgressEnabled) {
return;
}
if (finalReplyStarted || finalReplyDelivered) {
return;
}
const itemId = options?.itemId?.trim();
if (!text && !itemId) {
return;
}
const normalized = normalizeCommentaryProgressText(text ?? "");
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
if (!normalized) {
if (lineId) {
await clearProgressDraftLine(lineId);
}
return;
}
const line: ChannelProgressDraftLine = {
id: lineId,
kind: "item",
text: normalized,
label: "Commentary",
prefix: false,
};
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
});
await progressDraftGate.startNow();
await renderProgressDraft();
await progressDraft.pushCommentaryProgress(text, options);
},
resolvePreviewFinalText(text?: string) {
if (typeof text !== "string") {
@@ -390,8 +216,7 @@ export function createDiscordDraftPreviewController(params: {
if (discordStreamMode === "progress") {
return;
}
previewToolProgressSuppressed = true;
previewToolProgressLines = [];
progressDraft.suppress();
hasStreamedMessage = true;
if (discordStreamMode === "partial") {
if (
@@ -457,7 +282,7 @@ export function createDiscordDraftPreviewController(params: {
},
async cleanup() {
try {
progressDraftGate.cancel();
progressDraft.cancel();
if (!finalReplyDelivered) {
await draftStream?.discardPending();
}
@@ -471,106 +296,6 @@ export function createDiscordDraftPreviewController(params: {
};
}
function normalizeReasoningProgressLine(text: string): string {
return text
.replace(
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
"",
)
.replace(/\s+/g, " ")
.trim();
}
function normalizeReasoningProgressInput(text: string): string {
const normalized = normalizeReasoningProgressLine(text);
const italic = normalized.match(/^_(.*)_$/u);
return (italic?.[1] ?? normalized).trim();
}
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalizedText = normalizeReasoningProgressInput(text);
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
if (!formatted) {
return "";
}
if (Array.from(formatted).length <= maxChars) {
return formatted;
}
const italic = formatted.match(/^_(.*)_$/u);
if (!italic) {
return compactReasoningProgressDisplayLine(formatted, maxChars);
}
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
return body ? `_${body}_` : "";
}
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalized = text.replace(/\s+/g, " ").trim();
const chars = Array.from(normalized);
if (chars.length <= maxChars) {
return normalized;
}
if (maxChars <= 1) {
return "…";
}
const head = chars
.slice(0, maxChars - 1)
.join("")
.trimEnd();
const boundary = head.search(/\s+\S*$/u);
if (boundary > Math.floor(maxChars * 0.6)) {
return `${head.slice(0, boundary).trimEnd()}`;
}
return `${head}`;
}
function normalizeCommentaryProgressText(text: string): string {
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
return "";
}
return cleaned
.split(/\r?\n/u)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.map((line) => `_${line}_`)
.join("\n");
}
function isSilentCommentaryProgressText(text: string): boolean {
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
return /^NO_REPLY$/iu.test(normalized);
}
function mergeReasoningProgressText(
current: string,
incoming: string,
options?: { snapshot?: boolean },
): string {
if (!current) {
return incoming;
}
const normalizedCurrent = normalizeReasoningProgressLine(current);
const normalizedIncoming = normalizeReasoningProgressLine(incoming);
if (!normalizedIncoming || normalizedIncoming === normalizedCurrent) {
return current;
}
if (
options?.snapshot === true ||
isReasoningSnapshotText(incoming) ||
normalizedIncoming.startsWith(normalizedCurrent)
) {
return incoming;
}
return `${current}${incoming}`;
}
function isReasoningSnapshotText(text: string): boolean {
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
text,
);
}
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {
if (!line || typeof line === "string") {
return false;

View File

@@ -2504,7 +2504,7 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers tool warning finals when no recovered reply is available", async () => {
it("suppresses pure tool warning finals when no recovered reply is available", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply(createNonTerminalToolWarningPayload());
@@ -2519,18 +2519,10 @@ describe("processDiscordMessage draft streaming", () => {
expect(editMessageDiscord).not.toHaveBeenCalled();
expect(draftStream.clear).toHaveBeenCalledTimes(1);
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [
{
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
isError: true,
},
],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers tool warning finals when the recovered reply fails to send", async () => {
it("suppresses tool warning finals when the recovered reply fails to send", async () => {
deliverDiscordReply.mockRejectedValueOnce(new Error("send failed"));
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({ text: "delivery failed" });
@@ -2549,21 +2541,13 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(deliverDiscordReply).toHaveBeenCalledTimes(2);
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "delivery failed" }],
});
expect(deliverDiscordReply.mock.calls[1]?.[0]).toMatchObject({
replies: [
{
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
isError: true,
},
],
});
});
it("keeps mutating tool warning finals after successful-looking replies", async () => {
it("suppresses mutating tool warning finals after successful-looking replies", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({ text: "Done." });
@@ -2582,15 +2566,7 @@ describe("processDiscordMessage draft streaming", () => {
expectPreviewEditContent("Done.");
expect(draftStream.clear).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [
{
text: "⚠️ 🛠️ `write file (agent)` failed",
isError: true,
},
],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("suppresses reasoning payload delivery to Discord", async () => {

View File

@@ -66,6 +66,7 @@ import { createDiscordDraftPreviewController } from "./message-handler.draft-pre
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { resolveForwardedMediaList, resolveMediaList } from "./message-utils.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
import {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
@@ -111,7 +112,10 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
return !resolveSendableOutboundReplyParts(payload).hasMedia;
}
type DiscordReplySkipReason = "aborted before delivery" | "reasoning payload";
type DiscordReplySkipReason =
| "aborted before delivery"
| "reasoning payload"
| "internal-only payload";
export function formatDiscordReplySkip(params: {
kind: "tool" | "block" | "final";
@@ -621,7 +625,7 @@ async function processDiscordMessageInner(
) => {
if (isProcessAborted(abortSignal)) {
// Surface so operators don't chase missing replies when an abort
// drops a model-produced text payload (see PR for the incident).
// drops a model-produced text payload.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
@@ -669,10 +673,24 @@ async function processDiscordMessageInner(
})
: payload.text;
const effectivePayload = finalText !== payload.text ? { ...payload, text: finalText } : payload;
const [deliverablePayload] = sanitizeDiscordFrontChannelReplyPayloads([effectivePayload], {
kind: info.kind,
});
if (!deliverablePayload) {
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "internal-only payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return { visibleReplySent: false };
}
const draftStream = draftPreview.draftStream;
if (draftStream && draftPreview.isProgressMode && info.kind === "block") {
const reply = resolveSendableOutboundReplyParts(effectivePayload);
if (!reply.hasMedia && !payload.isError) {
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
if (!reply.hasMedia && !deliverablePayload.isError) {
return { visibleReplySent: false };
}
}
@@ -680,22 +698,22 @@ async function processDiscordMessageInner(
draftStream &&
isFinal &&
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted) &&
!payload.isError;
!deliverablePayload.isError;
if (shouldFinalizeDraftPreview) {
const reply = resolveSendableOutboundReplyParts(effectivePayload);
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
const hasMedia = reply.hasMedia;
const ttsSupplement = getReplyPayloadTtsSupplement(effectivePayload);
const previewSourceText = finalText ?? ttsSupplement?.spokenText;
const ttsSupplement = getReplyPayloadTtsSupplement(deliverablePayload);
const previewSourceText = deliverablePayload.text ?? ttsSupplement?.spokenText;
const previewFinalText = draftPreview.resolvePreviewFinalText(previewSourceText);
const previewReplyToId = replyReference.peek();
const hasExplicitReplyDirective =
Boolean(effectivePayload.replyToTag || effectivePayload.replyToCurrent) ||
Boolean(deliverablePayload.replyToTag || deliverablePayload.replyToCurrent) ||
(typeof previewSourceText === "string" &&
/\[\[\s*reply_to(?:_current|\s*:)/i.test(previewSourceText));
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: info.kind,
payload: effectivePayload,
payload: deliverablePayload,
adapter: defineFinalizableLivePreviewAdapter({
draft: {
flush: () => draftPreview.flush(),
@@ -710,7 +728,7 @@ async function processDiscordMessageInner(
(hasMedia && !ttsSupplement) ||
typeof previewFinalText !== "string" ||
hasExplicitReplyDirective ||
payload.isError
deliverablePayload.isError
) {
return undefined;
}
@@ -747,7 +765,7 @@ async function processDiscordMessageInner(
replyReference.markSent();
},
buildSupplementalPayload: () =>
ttsSupplement ? buildTtsSupplementMediaPayload(effectivePayload) : undefined,
ttsSupplement ? buildTtsSupplementMediaPayload(deliverablePayload) : undefined,
deliverSupplemental: async (supplementalPayload) => {
if (isProcessAborted(abortSignal)) {
return false;
@@ -794,9 +812,9 @@ async function processDiscordMessageInner(
const fallbackPayload =
ttsSupplement &&
ttsSupplement.visibleTextAlreadyDelivered !== true &&
!effectivePayload.text?.trim()
? { ...effectivePayload, text: ttsSupplement.spokenText }
: effectivePayload;
!deliverablePayload.text?.trim()
? { ...deliverablePayload, text: ttsSupplement.spokenText }
: deliverablePayload;
const replyToId = replyReference.use();
notifyFinalReplyStart();
await deliverDiscordReply({
@@ -849,7 +867,7 @@ async function processDiscordMessageInner(
}
await deliverDiscordReply({
cfg,
replies: [effectivePayload],
replies: [deliverablePayload],
target: deliverTarget,
token,
accountId,
@@ -867,7 +885,7 @@ async function processDiscordMessageInner(
kind: info.kind,
});
replyReference.markSent();
if (isFinal && payload.isError !== true) {
if (isFinal && deliverablePayload.isError !== true) {
markUserFacingFinalDelivered();
}
return { visibleReplySent: true };

View File

@@ -176,6 +176,33 @@ describe("deliverDiscordReply", () => {
);
});
it("strips assistant scaffolding from explicit tool progress payloads", async () => {
await deliverDiscordReply({
replies: [
{
text: [
"<think>private reasoning</think>",
'<tool_call>{"name":"x"}</tool_call>',
"🛠️ run git status",
].join("\n"),
},
],
target: "channel:101",
token: "token",
accountId: "default",
runtime,
cfg,
textLimit: 2000,
kind: "tool",
});
expect(sendDurableMessageBatchMock).toHaveBeenCalledWith(
expect.objectContaining({
payloads: [{ text: "🛠️ run git status" }],
}),
);
});
it("strips internal execution trace lines at the final Discord send boundary", async () => {
await deliverDiscordReply({
replies: [
@@ -183,6 +210,7 @@ describe("deliverDiscordReply", () => {
text: [
"📊 Session Status: current",
"🛠️ run git status",
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
"🛠️ `gh pr view`",
"🛠️ `docker compose up`",
"🛠️ elevated · `cd /tmp && pnpm test`",
@@ -204,6 +232,26 @@ describe("deliverDiscordReply", () => {
expect(firstDeliverParams().payloads).toEqual([{ text: "Visible reply." }]);
});
it("drops pure internal tool failure warnings at the final Discord send boundary", async () => {
await deliverDiscordReply({
replies: [
{
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
isError: true,
},
],
target: "channel:101",
token: "token",
accountId: "default",
runtime,
cfg,
textLimit: 2000,
kind: "final",
});
expect(sendDurableMessageBatchMock).not.toHaveBeenCalled();
});
it("strips serialized tool call blocks at the final Discord send boundary", async () => {
await deliverDiscordReply({
replies: [

View File

@@ -1,14 +1,13 @@
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import {
sanitizeAssistantVisibleText,
sanitizeAssistantVisibleTextWithProfile,
} from "openclaw/plugin-sdk/text-chunking";
import { stripPlainTextToolCallBlocks } from "openclaw/plugin-sdk/tool-payload";
const DISCORD_INTERNAL_TRACE_LINE_RE =
/^(?:>\s*)?(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i;
const DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE =
/^(?:>\s*)?🛠️\s*(?:(?:(?:elevated|pty)\b\s*(?:·|,)\s*)+)?(?:`{1,2}\s*\S|(?:run|check|fetch|pull|push|view|show|list|switch|create|merge|rebase|stage|restore|reset|stash|search|find|print|copy|move|remove|install|start|cd|git|pnpm|npm|yarn|bun|node|python|python3|bash|sh)\b)/i;
const DISCORD_INTERNAL_CHANNEL_LINE_RE =
/^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i;
/^(?:>\s*)?(?:analysis|commentary|thinking|reasoning)\s*[:=]/i;
function hasNonEmptyRecord(value: unknown): value is Record<string, unknown> {
return Boolean(
@@ -36,7 +35,11 @@ function hasNonTextReplyPayloadContent(payload: ReplyPayload): boolean {
);
}
function stripDiscordInternalTraceLines(text: string): string {
function collapseExcessBlankLines(text: string): string {
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
}
function stripDiscordInternalChannelLines(text: string): string {
let inFence = false;
const kept: string[] = [];
for (const line of text.split(/\r?\n/)) {
@@ -45,31 +48,20 @@ function stripDiscordInternalTraceLines(text: string): string {
kept.push(line);
continue;
}
if (!inFence) {
const trimmed = line.trim();
if (
DISCORD_INTERNAL_TRACE_LINE_RE.test(trimmed) ||
DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE.test(trimmed) ||
DISCORD_INTERNAL_CHANNEL_LINE_RE.test(trimmed)
) {
continue;
}
if (!inFence && DISCORD_INTERNAL_CHANNEL_LINE_RE.test(line.trim())) {
continue;
}
kept.push(line);
}
return kept.join("\n");
}
function collapseExcessBlankLines(text: string): string {
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
}
export function sanitizeDiscordFrontChannelText(text: string): string {
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(text);
const withoutAssistantScaffolding = sanitizeAssistantVisibleText(withoutToolCallBlocks);
const withoutResidualToolCallBlocks = stripPlainTextToolCallBlocks(withoutAssistantScaffolding);
const withoutTraceLines = stripDiscordInternalTraceLines(withoutResidualToolCallBlocks);
return collapseExcessBlankLines(withoutTraceLines).trim();
const withoutChannelLines = stripDiscordInternalChannelLines(withoutResidualToolCallBlocks);
return collapseExcessBlankLines(withoutChannelLines).trim();
}
export function sanitizeDiscordFrontChannelReplyPayloads(
@@ -82,7 +74,9 @@ export function sanitizeDiscordFrontChannelReplyPayloads(
const safeText =
typeof payload.text === "string"
? preserveVerboseToolProgress
? collapseExcessBlankLines(sanitizeAssistantVisibleText(payload.text)).trim()
? collapseExcessBlankLines(
sanitizeAssistantVisibleTextWithProfile(payload.text, "tool-progress"),
).trim()
: sanitizeDiscordFrontChannelText(payload.text)
: payload.text;
const nextPayload =

View File

@@ -1,4 +1,4 @@
import { OpusError, OpusErrorCode } from "libopus-wasm";
import { OpusError } from "libopus-wasm";
import { describe, expect, it, vi } from "vitest";
import {
analyzeVoiceReceiveError,
@@ -7,6 +7,8 @@ import {
noteVoiceDecryptFailure,
} from "./receive-recovery.js";
const OPUS_INVALID_PACKET_CODE = -4;
describe("voice receive recovery", () => {
it("treats passthrough-disabled decrypt errors as decrypt failures", () => {
expect(
@@ -34,9 +36,7 @@ describe("voice receive recovery", () => {
it("treats corrupt Opus packets as non-recoverable decode noise", () => {
expect(
analyzeVoiceReceiveError(
new OpusError(OpusErrorCode.InvalidPacket, "not inspected", "decode"),
),
analyzeVoiceReceiveError(new OpusError(OPUS_INVALID_PACKET_CODE, "not inspected", "decode")),
).toEqual({
message: "not inspected",
isAbortLike: false,
@@ -50,7 +50,7 @@ describe("voice receive recovery", () => {
const analysis = analyzeVoiceReceiveError({
name: "OpusError",
message: "libopus decode failed (-4): corrupted stream",
code: OpusErrorCode.InvalidPacket,
code: OPUS_INVALID_PACKET_CODE,
codeName: "InvalidPacket",
operation: "decode",
});

View File

@@ -1,4 +1,4 @@
import { OpusErrorCode, isOpusError } from "libopus-wasm";
import { OpusError } from "libopus-wasm";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
const DECRYPT_FAILURE_WINDOW_MS = 30_000;
@@ -6,6 +6,7 @@ const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3;
const DECRYPT_FAILURE_MARKER = "DecryptionFailed(";
const DAVE_PASSTHROUGH_DISABLED_MARKER = "UnencryptedWhenPassthroughDisabled";
const WASM_MEMORY_ACCESS_MARKER = "memory access out of bounds";
const OPUS_INVALID_PACKET_CODE = -4;
export const DAVE_RECEIVE_PASSTHROUGH_INITIAL_EXPIRY_SECONDS = 30;
export const DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS = 15;
@@ -83,10 +84,24 @@ function isAbortLikeReceiveError(err: unknown): boolean {
}
function isOpusDecodeInvalidPacketError(err: unknown): boolean {
if (!err || typeof err !== "object") {
return false;
}
const maybeOpusError = err as {
name?: unknown;
code?: unknown;
codeName?: unknown;
operation?: unknown;
};
const isDecodeOperation =
maybeOpusError.operation === "decode" || maybeOpusError.operation === "decodeFloat";
const isInvalidPacket =
maybeOpusError.code === OPUS_INVALID_PACKET_CODE ||
maybeOpusError.codeName === "InvalidPacket";
return (
isOpusError(err) &&
err.code === OpusErrorCode.InvalidPacket &&
(err.operation === "decode" || err.operation === "decodeFloat")
isDecodeOperation &&
isInvalidPacket &&
(err instanceof OpusError || maybeOpusError.name === "OpusError")
);
}

View File

@@ -0,0 +1,3 @@
// Narrow entry point for setFeishuRuntime. Keep setup/runtime registration
// from pulling in the broader Feishu runtime-api barrel.
export { setFeishuRuntime } from "./src/runtime.js";

View File

@@ -17,5 +17,16 @@ describe("feishu setup entry", () => {
expect(setupEntry.features).toEqual({ legacyStateMigrations: true });
expect(typeof setupEntry.loadSetupPlugin).toBe("function");
expect(setupEntry.loadLegacyStateMigrationDetector?.()).toBeTypeOf("function");
expect(typeof setupEntry.setChannelRuntime).toBe("function");
});
it("wires the Feishu runtime from setup-only registration", async () => {
const { default: setupEntry } = await import("./setup-entry.js");
const runtime = { channel: { inbound: { run: vi.fn() } } };
setupEntry.setChannelRuntime?.(runtime as never);
const { getFeishuRuntime } = await import("./src/runtime.js");
expect(getFeishuRuntime()).toBe(runtime);
});
});

View File

@@ -17,4 +17,8 @@ export default defineBundledChannelSetupEntry({
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-setter-api.js",
exportName: "setFeishuRuntime",
},
});

View File

@@ -1628,6 +1628,76 @@ describe("google transport stream", () => {
expect(generationConfig).not.toHaveProperty("thinkingConfig");
});
it("forwards configured stop sequences to the Gemini generationConfig", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
stop: ["</tool>", "\n\nObservation:"],
} as never,
);
const generationConfig = requireGenerationConfig(params);
expect(generationConfig.stopSequences).toEqual(["</tool>", "\n\nObservation:"]);
});
it("omits stopSequences when the stop list is empty", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
stop: [],
} as never,
);
expect(params.generationConfig ?? {}).not.toHaveProperty("stopSequences");
});
it("sends stopSequences in the serialized Gemini request body via the guarded fetch transport", async () => {
guardedFetchMock.mockResolvedValueOnce(buildSseResponse([]));
const model = attachModelProviderRequestTransport(
{
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
} satisfies Model<"google-generative-ai">,
{},
);
const streamFn = createGoogleGenerativeAiTransportStreamFn();
const stream = await Promise.resolve(
streamFn(
model,
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as Parameters<typeof streamFn>[1],
{
apiKey: "gemini-api-key",
stop: ["</tool>", "\n\nObservation:"],
} as Parameters<typeof streamFn>[2],
),
);
await stream.result();
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
const init = requireRequestInit(guardedCall, "guarded fetch");
const payload = parseRequestJsonBody(init);
const generationConfig = requireGenerationConfig(payload);
expect(generationConfig.stopSequences).toEqual(["</tool>", "\n\nObservation:"]);
});
it("strips explicit thinkingBudget=0 but preserves includeThoughts for Gemini 2.5 Pro", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),

View File

@@ -703,6 +703,9 @@ export function buildGoogleGenerativeAiParams(
if (typeof options?.maxTokens === "number") {
generationConfig.maxOutputTokens = options.maxTokens;
}
if (options?.stop !== undefined && options.stop.length > 0) {
generationConfig.stopSequences = options.stop;
}
const thinkingConfig = resolveGoogleThinkingConfig(model, options);
if (thinkingConfig) {
generationConfig.thinkingConfig = thinkingConfig;

View File

@@ -281,6 +281,82 @@ describe("kimi tool-call markup wrapper", () => {
});
});
it("strips Anthropic cache_control markers before Kimi requests are sent", () => {
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral", ttl: "1h" } }],
messages: [
{
role: "user",
content: [
{ type: "text", text: "hello", cache_control: { type: "ephemeral" } },
{
type: "tool_result",
tool_use_id: "tool_1",
content: [
{
type: "text",
text: "done",
cache_control: { type: "ephemeral" },
},
],
cache_control: { type: "ephemeral" },
},
{
type: "tool_use",
id: "tool_2",
name: "persist",
input: {
cache_control: "tool argument",
nested: { cache_control: "nested argument" },
},
cache_control: { type: "ephemeral" },
},
{ type: "text", text: "bye" },
],
},
],
});
const wrapped = createKimiThinkingWrapper(baseStreamFn, "enabled");
void wrapped(
{
api: "anthropic-messages",
provider: "kimi",
id: "kimi-code",
} as Model<"anthropic-messages">,
{ messages: [] } as Context,
{},
);
expect(getCapturedPayload()).toEqual({
system: [{ type: "text", text: "stable" }],
messages: [
{
role: "user",
content: [
{ type: "text", text: "hello" },
{
type: "tool_result",
tool_use_id: "tool_1",
content: [{ type: "text", text: "done" }],
},
{
type: "tool_use",
id: "tool_2",
name: "persist",
input: {
cache_control: "tool argument",
nested: { cache_control: "nested argument" },
},
},
{ type: "text", text: "bye" },
],
},
],
thinking: { type: "enabled" },
});
});
it("lets explicit model params keep Kimi thinking disabled even when session thinking is on", () => {
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();

View File

@@ -394,9 +394,51 @@ export function createKimiThinkingWrapper(
delete payloadObj.reasoning;
delete payloadObj.reasoning_effort;
delete payloadObj.reasoningEffort;
stripAnthropicCacheControlMarkers(payloadObj);
});
}
function stripContentBlockCacheControl(block: unknown): void {
if (!block || typeof block !== "object") {
return;
}
const record = block as Record<string, unknown>;
delete record.cache_control;
if (record.type === "tool_result" && Array.isArray(record.content)) {
for (const nestedBlock of record.content) {
stripContentBlockCacheControl(nestedBlock);
}
}
}
function stripContentArrayCacheControl(value: unknown): void {
if (!Array.isArray(value)) {
return;
}
for (const block of value) {
stripContentBlockCacheControl(block);
}
}
function stripAnthropicCacheControlMarkers(payloadObj: Record<string, unknown>): void {
stripContentArrayCacheControl(payloadObj.system);
if (!Array.isArray(payloadObj.messages)) {
return;
}
for (const message of payloadObj.messages) {
if (!message || typeof message !== "object") {
continue;
}
stripContentArrayCacheControl((message as Record<string, unknown>).content);
}
}
export function wrapKimiProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn {
const thinkingConfig = resolveKimiThinkingConfig({
configuredThinking: ctx.extraParams?.thinking,

View File

@@ -1,5 +1,5 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resolveEmbeddingTimeoutMs,
resolveMemoryIndexConcurrency,
@@ -97,52 +97,61 @@ describe("local embedding worker failure detection", () => {
describe("memory embedding timeout abort", () => {
beforeEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("aborts the provider operation when the timeout fires", async () => {
vi.useFakeTimers();
let signalSeen: AbortSignal | undefined;
const result = expect(
runEmbeddingOperationWithTimeout({
timeoutMs: 1,
message: "memory embeddings query timed out after 0s",
run: async (signal) => {
signalSeen = signal;
return await new Promise<number[]>((resolve, reject) => {
signal.addEventListener(
"abort",
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
{ once: true },
);
});
},
}),
).rejects.toThrow("memory embeddings query timed out after 0s");
const resultPromise = runEmbeddingOperationWithTimeout({
timeoutMs: 1,
message: "memory embeddings query timed out after 0s",
run: async (signal) => {
signalSeen = signal;
return await new Promise<number[]>((resolve, reject) => {
signal.addEventListener(
"abort",
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
{ once: true },
);
});
},
});
const rejection = expect(resultPromise).rejects.toThrow(
"memory embeddings query timed out after 0s",
);
await vi.advanceTimersByTimeAsync(1);
await result;
await rejection;
expect(signalSeen?.aborted).toBe(true);
});
it("keeps the timeout error when a provider abort listener rejects generically", async () => {
vi.useFakeTimers();
const result = expect(
runEmbeddingOperationWithTimeout({
timeoutMs: 1,
message: "memory embeddings batch timed out after 0s",
run: async (signal) =>
await new Promise<number[]>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
once: true,
});
}),
}),
).rejects.toThrow("memory embeddings batch timed out after 0s");
const resultPromise = runEmbeddingOperationWithTimeout({
timeoutMs: 1,
message: "memory embeddings batch timed out after 0s",
run: async (signal) =>
await new Promise<number[]>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
once: true,
});
}),
});
const rejection = expect(resultPromise).rejects.toThrow(
"memory embeddings batch timed out after 0s",
);
await vi.advanceTimersByTimeAsync(1);
await result;
await rejection;
});
it("caps operation watchdog timers before scheduling", async () => {

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { compileMemoryWikiVault } from "./compile.js";
import { renderWikiMarkdown } from "./markdown.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
@@ -16,6 +16,16 @@ describe("compileMemoryWikiVault", () => {
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-compile-suite-"));
});
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
afterAll(async () => {
if (suiteRoot) {
await fs.rm(suiteRoot, { recursive: true, force: true });
@@ -148,9 +158,7 @@ describe("compileMemoryWikiVault", () => {
activePageReads += 1;
maxActivePageReads = Math.max(maxActivePageReads, activePageReads);
try {
await new Promise((resolve) => {
setTimeout(resolve, 5);
});
await Promise.resolve();
return await originalReadFile(...args);
} finally {
activePageReads -= 1;

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
@@ -88,8 +88,13 @@ describe("buildAssistantMessage", () => {
});
describe("createOllamaStreamFn thinking events", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
vi.useRealTimers();
});
function makeNdjsonBody(chunks: Array<Record<string, unknown>>): ReadableStream<Uint8Array> {

View File

@@ -1,6 +1,6 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { expectExplicitMusicGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
const {
@@ -74,12 +74,33 @@ function postRequest(): Record<string, unknown> {
return request as Record<string, unknown>;
}
function resetOpenRouterMusicMocks() {
assertOkOrThrowHttpErrorMock.mockResolvedValue(undefined);
postJsonRequestMock.mockReset();
resolveApiKeyForProviderMock.mockResolvedValue({
apiKey: "openrouter-key",
source: "env",
mode: "api-key",
});
resolveProviderHttpRequestConfigMock.mockImplementation((params: Record<string, unknown>) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
dispatcherPolicy: undefined,
}));
}
describe("openrouter music generation provider", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
resetOpenRouterMusicMocks();
});
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
postJsonRequestMock.mockReset();
resolveApiKeyForProviderMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
resetOpenRouterMusicMocks();
vi.restoreAllMocks();
vi.useRealTimers();
});
it("declares explicit mode capabilities", () => {

View File

@@ -243,6 +243,11 @@ describe("registerPolicyDoctorChecks", () => {
strictness: "requires-true",
selectors: ["channelIds"],
},
{
path: "dataHandling.memory.denySessionTranscriptIndexing",
strictness: "requires-true",
selectors: ["agentIds"],
},
]);
});
@@ -549,6 +554,10 @@ describe("registerPolicyDoctorChecks", () => {
"policy/sandbox-container-runtime-socket-mount",
"policy/sandbox-container-unconfined-profile",
"policy/sandbox-browser-cdp-source-range-missing",
"policy/data-handling-redaction-disabled",
"policy/data-handling-telemetry-content-capture",
"policy/data-handling-session-retention-not-enforced",
"policy/data-handling-session-transcript-memory-enabled",
"policy/secrets-unmanaged-provider",
"policy/secrets-denied-provider-source",
"policy/secrets-insecure-provider",
@@ -652,7 +661,6 @@ describe("registerPolicyDoctorChecks", () => {
["tools settings array", { tools: { settings: [] } }, "oc://policy.jsonc/tools/settings"],
["tools entries object", { tools: { entries: {} } }, "oc://policy.jsonc/tools/entries"],
["tools profiles array", { tools: { profiles: [] } }, "oc://policy.jsonc/tools/profiles"],
[
"tools profiles allow string",
{ tools: { profiles: { allow: "coding" } } },
@@ -985,6 +993,182 @@ describe("registerPolicyDoctorChecks", () => {
]);
});
it("rejects unsupported policy keys across policy namespaces", async () => {
const cases: readonly {
readonly label: string;
readonly policy: unknown;
readonly target: string;
}[] = [
{ label: "top-level", policy: { channel: {} }, target: "oc://policy.jsonc/channel" },
{
label: "tools top-level",
policy: { tools: { execPolicy: { allowHosts: ["sandbox"] } } },
target: "oc://policy.jsonc/tools/execPolicy",
},
{
label: "tools settings",
policy: { tools: { settings: {} } },
target: "oc://policy.jsonc/tools/settings",
},
{
label: "tools entries",
policy: { tools: { entries: [] } },
target: "oc://policy.jsonc/tools/entries",
},
{
label: "tools profile",
policy: { tools: { profiles: { deny: ["full"] } } },
target: "oc://policy.jsonc/tools/profiles/deny",
},
{
label: "tools exec",
policy: { tools: { exec: { allowShells: ["bash"] } } },
target: "oc://policy.jsonc/tools/exec/allowShells",
},
{
label: "tools fs",
policy: { tools: { fs: { allowOutsideWorkspace: true } } },
target: "oc://policy.jsonc/tools/fs/allowOutsideWorkspace",
},
{
label: "tools alsoAllow",
policy: { tools: { alsoAllow: { denied: ["exec"] } } },
target: "oc://policy.jsonc/tools/alsoAllow/denied",
},
{
label: "channels",
policy: { channels: { allowRules: [] } },
target: "oc://policy.jsonc/channels/allowRules",
},
{
label: "channel deny rule",
policy: { channels: { denyRules: [{ when: { provider: "telegram" }, action: "deny" }] } },
target: "oc://policy.jsonc/channels/denyRules/#0/action",
},
{
label: "channel deny selector",
policy: {
channels: { denyRules: [{ when: { provider: "telegram", channel: "stable" } }] },
},
target: "oc://policy.jsonc/channels/denyRules/#0/when/channel",
},
{
label: "ingress top-level",
policy: { ingress: { directMessages: {} } },
target: "oc://policy.jsonc/ingress/directMessages",
},
{
label: "ingress session",
policy: { ingress: { session: { requiredScope: "per-channel-peer" } } },
target: "oc://policy.jsonc/ingress/session/requiredScope",
},
{
label: "ingress channels",
policy: { ingress: { channels: { allowOpenGroups: false } } },
target: "oc://policy.jsonc/ingress/channels/allowOpenGroups",
},
{ label: "mcp", policy: { mcp: { clients: {} } }, target: "oc://policy.jsonc/mcp/clients" },
{
label: "mcp servers",
policy: { mcp: { servers: { require: ["docs"] } } },
target: "oc://policy.jsonc/mcp/servers/require",
},
{
label: "models",
policy: { models: { modelRefs: {} } },
target: "oc://policy.jsonc/models/modelRefs",
},
{
label: "models providers",
policy: { models: { providers: { require: ["openai"] } } },
target: "oc://policy.jsonc/models/providers/require",
},
{
label: "network",
policy: { network: { publicNetwork: {} } },
target: "oc://policy.jsonc/network/publicNetwork",
},
{
label: "network privateNetwork",
policy: { network: { privateNetwork: { deny: true } } },
target: "oc://policy.jsonc/network/privateNetwork/deny",
},
{
label: "gateway top-level",
policy: { gateway: { bind: { allowNonLoopback: false } } },
target: "oc://policy.jsonc/gateway/bind",
},
{
label: "gateway exposure",
policy: { gateway: { exposure: { allowPublicBind: false } } },
target: "oc://policy.jsonc/gateway/exposure/allowPublicBind",
},
{
label: "gateway auth",
policy: { gateway: { auth: { allowDisabled: false } } },
target: "oc://policy.jsonc/gateway/auth/allowDisabled",
},
{
label: "agents",
policy: { agents: { tools: {} } },
target: "oc://policy.jsonc/agents/tools",
},
{
label: "agents workspace",
policy: { agents: { workspace: { requireReadOnly: true } } },
target: "oc://policy.jsonc/agents/workspace/requireReadOnly",
},
{
label: "dataHandling",
policy: { dataHandling: { logs: { requireRedaction: true } } },
target: "oc://policy.jsonc/dataHandling/logs",
},
{
label: "dataHandling nested",
policy: { dataHandling: { telemetry: { allowCaptureContent: false } } },
target: "oc://policy.jsonc/dataHandling/telemetry/allowCaptureContent",
},
{
label: "secrets",
policy: { secrets: { requireVault: true } },
target: "oc://policy.jsonc/secrets/requireVault",
},
{
label: "auth",
policy: { auth: { providers: {} } },
target: "oc://policy.jsonc/auth/providers",
},
{
label: "auth profiles",
policy: { auth: { profiles: { requireProvider: true } } },
target: "oc://policy.jsonc/auth/profiles/requireProvider",
},
];
for (const testCase of cases) {
const configPath = join(workspaceDir, `${testCase.label.replaceAll(" ", "-")}.jsonc`);
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify(testCase.policy),
"utf-8",
);
clearHealthChecksForTest();
resetPolicyDoctorChecksForTest();
const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings, testCase.label).toEqual([
expect.objectContaining({
checkId: "policy/policy-jsonc-invalid",
severity: "error",
path: "policy.jsonc",
target: testCase.target,
}),
]);
}
});
it("reports a policy hash mismatch when expectedHash is configured", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
@@ -1126,6 +1310,7 @@ describe("registerPolicyDoctorChecks", () => {
includeIngress: false,
includeGatewayExposure: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
includeSandboxPosture: false,
includeSecrets: false,
@@ -1159,6 +1344,7 @@ describe("registerPolicyDoctorChecks", () => {
includeIngress: false,
includeGatewayExposure: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
includeSandboxPosture: false,
includeSecrets: false,
@@ -1204,6 +1390,7 @@ describe("registerPolicyDoctorChecks", () => {
includeIngress: false,
includeGatewayExposure: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
includeSandboxPosture: false,
includeSecrets: false,
@@ -1235,6 +1422,7 @@ describe("registerPolicyDoctorChecks", () => {
includeIngress: false,
includeGatewayExposure: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
includeSandboxPosture: false,
includeSecrets: false,
@@ -1243,6 +1431,7 @@ describe("registerPolicyDoctorChecks", () => {
expect(evidence).not.toHaveProperty("ingress");
expect(evidence).not.toHaveProperty("gatewayExposure");
expect(evidence).not.toHaveProperty("agentWorkspace");
expect(evidence).not.toHaveProperty("dataHandling");
expect(evidence).not.toHaveProperty("sandboxPosture");
expect(evidence).not.toHaveProperty("secrets");
expect(evidence).not.toHaveProperty("authProfiles");
@@ -7217,6 +7406,404 @@ describe("registerPolicyDoctorChecks", () => {
]);
});
it("reports data-handling conformance findings from config posture", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
logging: { redactSensitive: "off" },
diagnostics: { otel: { enabled: true, captureContent: { enabled: true, toolInputs: true } } },
session: { maintenance: { mode: "warn" } },
memory: { backend: "qmd", qmd: { sessions: { enabled: true } } },
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
dataHandling: {
sensitiveLogging: { requireRedaction: true },
telemetry: { denyContentCapture: true },
retention: { requireSessionMaintenance: true },
memory: { denySessionTranscriptIndexing: true },
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfg));
const evidence = collectPolicyEvidence(cfg as unknown as Record<string, unknown>);
expect(evidence.dataHandling).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "sensitiveLoggingRedaction",
source: "oc://openclaw.config/logging/redactSensitive",
value: false,
}),
expect.objectContaining({
kind: "telemetryContentCapture",
source: "oc://openclaw.config/diagnostics/otel/captureContent",
value: true,
}),
expect.objectContaining({
kind: "sessionRetentionMode",
source: "oc://openclaw.config/session/maintenance/mode",
value: "warn",
}),
expect.objectContaining({
kind: "memorySessionTranscriptIndexing",
source: "oc://openclaw.config/memory/qmd/sessions/enabled",
value: true,
}),
]),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/data-handling-redaction-disabled",
ocPath: "oc://openclaw.config/logging/redactSensitive",
requirement: "oc://policy.jsonc/dataHandling/sensitiveLogging/requireRedaction",
}),
expect.objectContaining({
checkId: "policy/data-handling-telemetry-content-capture",
ocPath: "oc://openclaw.config/diagnostics/otel/captureContent",
requirement: "oc://policy.jsonc/dataHandling/telemetry/denyContentCapture",
}),
expect.objectContaining({
checkId: "policy/data-handling-session-retention-not-enforced",
ocPath: "oc://openclaw.config/session/maintenance/mode",
requirement: "oc://policy.jsonc/dataHandling/retention/requireSessionMaintenance",
}),
expect.objectContaining({
checkId: "policy/data-handling-session-transcript-memory-enabled",
ocPath: "oc://openclaw.config/memory/qmd/sessions/enabled",
requirement: "oc://policy.jsonc/dataHandling/memory/denySessionTranscriptIndexing",
}),
]),
);
});
it("treats omitted session maintenance mode as enforce for retention conformance", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
session: {},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
dataHandling: {
retention: { requireSessionMaintenance: true },
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfg));
const evidence = collectPolicyEvidence(cfg as unknown as Record<string, unknown>);
expect(evidence.dataHandling).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "sessionRetentionMode",
source: "oc://openclaw.config/session/maintenance/mode",
value: "enforce",
explicit: false,
}),
]),
);
expect(result.findings).toEqual([]);
});
it("does not treat disabled telemetry capture subkeys as content capture", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
diagnostics: { otel: { captureContent: { toolInputs: true } } },
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ dataHandling: { telemetry: { denyContentCapture: true } } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfg));
expect(result.findings).toEqual([]);
});
it("does not report inert telemetry capture config", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
diagnostics: {
enabled: false,
otel: { enabled: true, captureContent: true },
},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ dataHandling: { telemetry: { denyContentCapture: true } } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfg));
expect(result.findings).toEqual([]);
});
it("reports OTEL log body content capture without trace export", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
diagnostics: {
otel: { enabled: true, traces: false, logs: true, captureContent: true },
},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ dataHandling: { telemetry: { denyContentCapture: true } } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfg));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/data-handling-telemetry-content-capture",
ocPath: "oc://openclaw.config/diagnostics/otel/captureContent",
}),
]);
});
it("does not treat trace-only content capture subkeys as log body capture", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
diagnostics: {
otel: {
enabled: true,
traces: false,
logs: true,
captureContent: { enabled: true, toolInputs: true },
},
},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ dataHandling: { telemetry: { denyContentCapture: true } } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfg));
expect(result.findings).toEqual([]);
});
it("supports agent-scoped session transcript memory conformance", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
agents: {
defaults: {
memorySearch: { experimental: { sessionMemory: true }, sources: ["memory", "sessions"] },
},
list: [
{ id: "sebby" },
{ id: "buddy", memorySearch: { experimental: { sessionMemory: false } } },
],
},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby"],
dataHandling: { memory: { denySessionTranscriptIndexing: true } },
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfg));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/data-handling-session-transcript-memory-enabled",
ocPath: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory",
requirement:
"oc://policy.jsonc/scopes/restricted/dataHandling/memory/denySessionTranscriptIndexing",
}),
]);
});
it("applies agent-scoped data-handling memory claims to inherited default posture", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
agents: {
defaults: {
memorySearch: { experimental: { sessionMemory: true }, sources: ["sessions"] },
},
},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["release"],
dataHandling: { memory: { denySessionTranscriptIndexing: true } },
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfg));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/data-handling-session-transcript-memory-enabled",
ocPath: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory",
requirement:
"oc://policy.jsonc/scopes/restricted/dataHandling/memory/denySessionTranscriptIndexing",
}),
]);
});
it("does not report inert memory transcript indexing config", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
memory: { qmd: { sessions: { enabled: true } } },
agents: {
defaults: {
memorySearch: {
enabled: false,
experimental: { sessionMemory: true },
sources: ["sessions"],
},
},
},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
dataHandling: { memory: { denySessionTranscriptIndexing: true } },
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfg));
expect(result.findings).toEqual([]);
});
it("reports malformed data-handling policy sections", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
dataHandling: {
sensitiveLogging: true,
memory: [],
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/policy-jsonc-invalid",
target: "oc://policy.jsonc/dataHandling/sensitiveLogging",
}),
expect.objectContaining({
checkId: "policy/policy-jsonc-invalid",
target: "oc://policy.jsonc/dataHandling/memory",
}),
]),
);
});
it("rejects scoped data-handling rules that cannot be agent-scoped", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby"],
dataHandling: { telemetry: { denyContentCapture: true } },
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/policy-jsonc-invalid",
target: "oc://policy.jsonc/scopes/restricted/dataHandling/telemetry",
}),
]);
});
it("rejects malformed scoped data-handling memory rules", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby"],
dataHandling: { memory: { denySessionTranscriptIndexing: "true" } },
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/policy-jsonc-invalid",
target:
"oc://policy.jsonc/scopes/restricted/dataHandling/memory/denySessionTranscriptIndexing",
}),
]);
});
it("reports malformed secrets policy values before applying secrets checks", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input";
import {
asBoolean as readBoolean,
@@ -48,6 +49,7 @@ export type PolicyEvidence = {
readonly ingress?: readonly PolicyIngressEvidence[];
readonly gatewayExposure?: readonly PolicyGatewayExposureEvidence[];
readonly agentWorkspace?: readonly PolicyAgentWorkspaceEvidence[];
readonly dataHandling?: readonly PolicyDataHandlingEvidence[];
readonly secrets?: readonly PolicySecretEvidence[];
readonly authProfiles?: readonly PolicyAuthProfileEvidence[];
};
@@ -206,6 +208,20 @@ export type PolicyAuthProfileEvidence = {
readonly mode?: string;
};
export type PolicyDataHandlingEvidence = {
readonly id: string;
readonly kind:
| "memorySessionTranscriptIndexing"
| "sensitiveLoggingRedaction"
| "sessionRetentionMode"
| "telemetryContentCapture";
readonly source: string;
readonly scope: "global" | "agent";
readonly agentId?: string;
readonly value?: boolean | string;
readonly explicit?: boolean;
};
type SecretRefEvidence = {
readonly source: "env" | "file" | "exec";
readonly provider: string;
@@ -280,6 +296,7 @@ export function collectPolicyEvidence(
readonly includeIngress?: boolean;
readonly includeGatewayExposure?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeDataHandling?: boolean;
readonly includeToolPosture?: boolean;
readonly includeSandboxPosture?: boolean;
readonly includeSecrets?: boolean;
@@ -293,6 +310,7 @@ export function collectPolicyEvidence(
readonly includeIngress?: boolean;
readonly includeGatewayExposure?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeDataHandling?: boolean;
readonly includeToolPosture?: boolean;
readonly includeSandboxPosture?: boolean;
readonly includeSecrets?: boolean;
@@ -306,6 +324,7 @@ export function collectPolicyEvidence(
readonly includeIngress?: boolean;
readonly includeGatewayExposure?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeDataHandling?: boolean;
readonly includeToolPosture?: boolean;
readonly includeSandboxPosture?: boolean;
readonly includeSecrets?: boolean;
@@ -325,6 +344,7 @@ export function collectPolicyEvidence(
...(options.includeAgentWorkspace === false
? {}
: { agentWorkspace: scanPolicyAgentWorkspace(cfg) }),
...(options.includeDataHandling === false ? {} : { dataHandling: scanPolicyDataHandling(cfg) }),
...(options.includeToolPosture === false ? {} : { toolPosture: scanPolicyToolPosture(cfg) }),
...(options.includeSandboxPosture === false
? {}
@@ -795,6 +815,202 @@ export function scanPolicyAuthProfiles(
});
}
export function scanPolicyDataHandling(
cfg: Record<string, unknown>,
): readonly PolicyDataHandlingEvidence[] {
const entries: PolicyDataHandlingEvidence[] = [];
const logging = isRecord(cfg.logging) ? cfg.logging : {};
entries.push({
id: "logging-redaction",
kind: "sensitiveLoggingRedaction",
source: "oc://openclaw.config/logging/redactSensitive",
scope: "global",
value: logging.redactSensitive !== "off",
explicit: logging.redactSensitive !== undefined,
});
const diagnostics = isRecord(cfg.diagnostics) ? cfg.diagnostics : {};
const otel = isRecord(diagnostics.otel) ? diagnostics.otel : {};
const otelEnabled = diagnostics.enabled !== false && otel.enabled === true;
const tracesEnabled = otelEnabled && otel.traces !== false;
const logsEnabled = otelEnabled && otel.logs === true;
const captureContent =
otelEnabled &&
telemetryContentCaptureEnabled(otel.captureContent, {
tracesEnabled,
logsEnabled,
});
entries.push({
id: "diagnostics-otel-content-capture",
kind: "telemetryContentCapture",
source: "oc://openclaw.config/diagnostics/otel/captureContent",
scope: "global",
value: captureContent,
explicit: otel.captureContent !== undefined,
});
const session = isRecord(cfg.session) ? cfg.session : {};
const maintenance = isRecord(session.maintenance) ? session.maintenance : {};
const retentionMode = typeof maintenance.mode === "string" ? maintenance.mode : "enforce";
entries.push({
id: "session-maintenance-mode",
kind: "sessionRetentionMode",
source: "oc://openclaw.config/session/maintenance/mode",
scope: "global",
value: retentionMode,
explicit: maintenance.mode !== undefined,
});
pushMemorySessionTranscriptIndexing(entries, cfg);
return entries.toSorted((a, b) => a.source.localeCompare(b.source));
}
function telemetryContentCaptureEnabled(
value: unknown,
signals: { readonly tracesEnabled: boolean; readonly logsEnabled: boolean },
): boolean {
if (value === true) {
return signals.tracesEnabled || signals.logsEnabled;
}
if (!isRecord(value)) {
return false;
}
if (!signals.tracesEnabled) {
return false;
}
if (value.enabled !== true) {
return false;
}
return (
value.inputMessages === true ||
value.outputMessages === true ||
value.toolInputs === true ||
value.toolOutputs === true ||
value.systemPrompt === true ||
value.toolDefinitions === true
);
}
function pushMemorySessionTranscriptIndexing(
entries: PolicyDataHandlingEvidence[],
cfg: Record<string, unknown>,
): void {
const memory = isRecord(cfg.memory) ? cfg.memory : {};
const qmd = isRecord(memory.qmd) ? memory.qmd : {};
const qmdSessions = isRecord(qmd.sessions) ? qmd.sessions : {};
if (qmdSessions.enabled !== undefined) {
entries.push({
id: "memory-qmd-session-transcripts",
kind: "memorySessionTranscriptIndexing",
source: "oc://openclaw.config/memory/qmd/sessions/enabled",
scope: "global",
value: memory.backend === "qmd" && readBoolean(qmdSessions.enabled) === true,
explicit: true,
});
}
const agents = isRecord(cfg.agents) ? cfg.agents : {};
const defaults = isRecord(agents.defaults) ? agents.defaults : {};
const defaultsMemorySearch = isRecord(defaults.memorySearch) ? defaults.memorySearch : {};
const defaultSessionMemory = memorySearchSessionTranscriptIndexing(defaultsMemorySearch);
if (defaultSessionMemory !== undefined) {
entries.push({
id: "agents-defaults-memory-session-transcripts",
kind: "memorySessionTranscriptIndexing",
source: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory",
scope: "global",
value: defaultSessionMemory,
explicit: true,
});
}
if (!Array.isArray(agents.list)) {
return;
}
agents.list.forEach((rawAgent, index) => {
if (!isRecord(rawAgent)) {
return;
}
const agentId =
readString(rawAgent.id) ??
readString(rawAgent.name) ??
readString(rawAgent.slug) ??
`agent-${index}`;
const memorySearch = isRecord(rawAgent.memorySearch) ? rawAgent.memorySearch : undefined;
const agentSessionMemory =
memorySearch === undefined
? defaultSessionMemory
: memorySearchSessionTranscriptIndexing(memorySearch, defaultsMemorySearch);
if (agentSessionMemory === undefined) {
return;
}
const explicit = memorySearchSessionTranscriptIndexingHasLocalConfig(memorySearch);
entries.push({
id: `${agentId}-memory-session-transcripts`,
kind: "memorySessionTranscriptIndexing",
source: explicit
? `oc://openclaw.config/agents/list/#${index}/memorySearch/experimental/sessionMemory`
: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory",
scope: "agent",
agentId: normalizeAgentId(agentId),
value: agentSessionMemory,
explicit,
});
});
}
function memorySearchSessionTranscriptIndexing(
memorySearch: unknown,
inheritedMemorySearch?: unknown,
): boolean | undefined {
if (!isRecord(memorySearch)) {
return undefined;
}
const experimental = isRecord(memorySearch.experimental) ? memorySearch.experimental : {};
const inherited = isRecord(inheritedMemorySearch) ? inheritedMemorySearch : {};
const inheritedExperimental = isRecord(inherited.experimental) ? inherited.experimental : {};
const enabled = readBoolean(memorySearch.enabled) ?? readBoolean(inherited.enabled) ?? true;
const sessionMemory =
readBoolean(experimental.sessionMemory) ?? readBoolean(inheritedExperimental.sessionMemory);
const sourcesIncludeSessions =
memorySearchSourcesIncludeSessions(memorySearch) ??
memorySearchSourcesIncludeSessions(inherited) ??
false;
if (
sessionMemory === undefined &&
memorySearchSourcesIncludeSessions(memorySearch) === undefined &&
readBoolean(memorySearch.enabled) === undefined
) {
return undefined;
}
if (!enabled) {
return false;
}
return sessionMemory === true && sourcesIncludeSessions;
}
function memorySearchSessionTranscriptIndexingHasLocalConfig(memorySearch: unknown): boolean {
if (!isRecord(memorySearch)) {
return false;
}
const experimental = isRecord(memorySearch.experimental) ? memorySearch.experimental : {};
return (
readBoolean(memorySearch.enabled) !== undefined ||
readBoolean(experimental.sessionMemory) !== undefined ||
memorySearchSourcesIncludeSessions(memorySearch) !== undefined
);
}
function memorySearchSourcesIncludeSessions(memorySearch: unknown): boolean | undefined {
if (!isRecord(memorySearch) || memorySearch.sources === undefined) {
return undefined;
}
if (!Array.isArray(memorySearch.sources)) {
return false;
}
return memorySearch.sources.includes("sessions");
}
function scanPolicySecretProviders(cfg: Record<string, unknown>): readonly PolicySecretEvidence[] {
const secrets = isRecord(cfg.secrets) ? cfg.secrets : {};
const providers = isRecord(secrets.providers) ? secrets.providers : {};

View File

@@ -1,7 +1,98 @@
import { describe, expect, it } from "vitest";
import { createQaBusState } from "./bus-state.js";
import { readQaScenarioById } from "./scenario-catalog.js";
import { runScenarioFlow } from "./scenario-flow-runner.js";
type QaFlowStep = {
name: string;
run: () => Promise<string | void>;
};
function formatTestTranscript(state: ReturnType<typeof createQaBusState>) {
return state
.getSnapshot()
.messages.map((message) => `${message.direction}:${message.conversation.id}:${message.text}`)
.join("\n");
}
async function runLoadedScenarioFlow(
scenarioId: string,
params: {
onWaitForOutboundMessage?: (params: {
waitCount: number;
state: ReturnType<typeof createQaBusState>;
}) => void;
} = {},
) {
const scenario = readQaScenarioById(scenarioId);
const flow = scenario.execution.flow;
if (!flow) {
throw new Error(`scenario has no flow: ${scenarioId}`);
}
const state = createQaBusState();
let waitCount = 0;
const api = {
env: {},
state,
scenario,
config: scenario.execution.config ?? {},
randomUUID: () => "00000000-0000-4000-8000-000000000000",
liveTurnTimeoutMs: (_env: unknown, timeoutMs: number) => timeoutMs,
waitForGatewayHealthy: async () => undefined,
waitForQaChannelReady: async () => undefined,
waitForNoOutbound: async () => undefined,
sleep: async () => undefined,
reset: async () => {
state.reset();
},
resetBus: async () => {
state.reset();
},
runAgentPrompt: async () => undefined,
formatTransportTranscript: formatTestTranscript,
waitForOutboundMessage: async (
stateLocal: ReturnType<typeof createQaBusState>,
predicate: (candidate: unknown) => boolean,
timeoutMs: number,
options?: { sinceIndex?: number },
) => {
waitCount += 1;
params.onWaitForOutboundMessage?.({ waitCount, state: stateLocal });
const match = stateLocal
.getSnapshot()
.messages.slice(options?.sinceIndex ?? 0)
.find((candidate) => predicate(candidate));
if (match) {
return match;
}
throw new Error(`timed out after ${timeoutMs}ms waiting for outbound marker`);
},
runScenario: async (_name: string, steps: QaFlowStep[]) => {
const stepResults = [];
for (const step of steps) {
const details = await step.run();
stepResults.push({
name: step.name,
status: "pass" as const,
...(details !== undefined ? { details } : {}),
});
}
return {
name: scenario.title,
status: "pass" as const,
steps: stepResults,
};
},
};
return await runScenarioFlow({
api,
scenarioTitle: scenario.title,
flow,
});
}
describe("scenario-flow-runner", () => {
it("supports qaImport inside flow expressions", async () => {
const result = await runScenarioFlow({
@@ -221,4 +312,78 @@ describe("scenario-flow-runner", () => {
expect(result.status).toBe("pass");
expect(result.steps[0]?.details).toBe("QA_CODEX_PLUGIN_TURN_OK");
});
it.each([
{
scenarioId: "channel-chat-baseline",
to: "channel:qa-room",
text: "generic shared-channel reply without the required marker",
},
{
scenarioId: "dm-chat-baseline",
to: "dm:alice",
text: "generic DM reply without the required marker",
},
])("rejects unmarked outbound replies for $scenarioId", async ({ scenarioId, to, text }) => {
await expect(
runLoadedScenarioFlow(scenarioId, {
onWaitForOutboundMessage: ({ state }) => {
state.addOutboundMessage({
accountId: "qa-channel",
to,
text,
});
},
}),
).rejects.toThrow("waiting for outbound marker");
});
it("rejects reconnect follow-up replies that replay the first marker", async () => {
await expect(
runLoadedScenarioFlow("qa-channel-reconnect-dedupe", {
onWaitForOutboundMessage: ({ waitCount, state }) => {
if (waitCount === 1) {
state.addOutboundMessage({
accountId: "qa-channel",
to: "channel:qa-room",
text: "RECONNECT-FIRST-OK",
});
return;
}
state.addOutboundMessage({
accountId: "qa-channel",
to: "channel:qa-room",
text: "RECONNECT-FIRST-OK",
});
},
}),
).rejects.toThrow("waiting for outbound marker");
});
it("rejects reconnect follow-up turns with extra unmarked outbound replies", async () => {
await expect(
runLoadedScenarioFlow("qa-channel-reconnect-dedupe", {
onWaitForOutboundMessage: ({ waitCount, state }) => {
if (waitCount === 1) {
state.addOutboundMessage({
accountId: "qa-channel",
to: "channel:qa-room",
text: "RECONNECT-FIRST-OK",
});
return;
}
state.addOutboundMessage({
accountId: "qa-channel",
to: "channel:qa-room",
text: "RECONNECT-SECOND-OK",
});
state.addOutboundMessage({
accountId: "qa-channel",
to: "channel:qa-room",
text: "unmarked duplicate delivery",
});
},
}),
).rejects.toThrow("exactly one marked post-restart reply");
});
});

View File

@@ -227,6 +227,7 @@ const telegramDepsForTest: TelegramBotDeps = {
describe("dispatchTelegramMessage draft streaming", () => {
type TelegramMessageContext = Parameters<typeof dispatchTelegramMessage>[0]["context"];
const trailingFinalStatusText = "Post-final plugin status";
beforeAll(async () => {
({ dispatchTelegramMessage, resetTelegramReplyFenceForTests } =
@@ -1605,6 +1606,30 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("sends trailing verbose status after streamed final answer without replacing the answer draft", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "Normal reply" });
await dispatcherOptions.deliver({ text: "Normal reply" }, { kind: "final" });
await dispatcherOptions.deliver({ text: trailingFinalStatusText }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenCalledTimes(3);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Normal reply");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Normal reply");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, trailingFinalStatusText);
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.forceNewMessage.mock.invocationCallOrder[0]).toBeLessThan(
answerDraftStream.update.mock.invocationCallOrder[2],
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("applies partial deltas while preserving the first-preview debounce", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -2075,6 +2100,33 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("sends trailing verbose status after a progress-mode final answer", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver({ text: "Branch is up to date" }, { kind: "final" });
await dispatcherOptions.deliver({ text: trailingFinalStatusText }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress" } },
});
expect(answerDraftStream.update).toHaveBeenCalledTimes(2);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Cracking\n\n`🛠️ Exec`");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, trailingFinalStatusText);
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
expect(answerDraftStream.forceNewMessage.mock.invocationCallOrder[1]).toBeLessThan(
answerDraftStream.update.mock.invocationCallOrder[1],
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
it("does not stream text-only tool results into progress drafts", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -2317,6 +2369,81 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.flush).toHaveBeenCalled();
});
it("composes streamed reasoning with tool progress in Telegram progress drafts", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onReplyStart?.();
await replyOptions?.onAssistantMessageStart?.();
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await replyOptions?.onReasoningStream?.({ text: "<think>Checking files</think>" });
return { queuedFinal: false };
});
await dispatchWithContext({
context: createReasoningStreamContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(createTelegramDraftStream).toHaveBeenCalledTimes(1);
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n`🛠️ Exec`\n• _Checking files_");
});
it("renders configured Telegram commentary progress from preamble item events", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onReplyStart?.();
await replyOptions?.onItemEvent?.({
kind: "preamble",
itemId: "preamble-1",
progressText: "Checking recent context",
});
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: {
streaming: {
mode: "progress",
progress: { label: "Shelling", commentary: true },
},
},
});
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n_Checking recent context_");
});
it("suppresses Telegram preamble progress when commentary is disabled", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onReplyStart?.();
await replyOptions?.onItemEvent?.({
kind: "preamble",
itemId: "preamble-1",
progressText: "Checking recent context",
});
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: {
streaming: {
mode: "progress",
progress: { label: "Shelling" },
},
},
});
expect(draftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Checking recent"));
});
it("keeps the progress draft label when tool progress lines are hidden", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
@@ -3446,6 +3573,150 @@ describe("dispatchTelegramMessage draft streaming", () => {
await sidePromise;
});
it("does not drop the first chunk of a long final after a generic lane rotation", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver(
{ text: "A".repeat(4000) + "B".repeat(4000) },
{ kind: "final" },
);
return { queuedFinal: true };
},
);
await dispatchWithContext({
context: createContext(),
textLimit: 4000,
});
expect(answerDraftStream.update).toHaveBeenCalledWith("A".repeat(4000));
});
it("does not suppress text-only blocks as delivered when answer draft is inactive", async () => {
setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "forced block" }, { kind: "block" });
await dispatcherOptions.deliver({ text: "final text" }, { kind: "final" });
return { queuedFinal: true };
});
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
telegramCfg: {
streaming: { mode: "partial", block: { enabled: true } },
} satisfies Parameters<typeof dispatchTelegramMessage>[0]["telegramCfg"],
});
const deliveredTexts = deliverReplies.mock.calls.flatMap((call) =>
((call[0] as { replies?: Array<{ text?: string }> }).replies ?? []).map(
(reply) => reply.text,
),
);
expect(deliveredTexts).toContain("forced block");
});
it("does not suppress text-only blocks after a tool-progress draft", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver({ text: "block after progress" }, { kind: "block" });
return { queuedFinal: true };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
telegramCfg: { streaming: { mode: "partial" } },
});
expect(mockCallArg(answerDraftStream.update)).toContain("Exec");
expect(answerDraftStream.update).toHaveBeenLastCalledWith("block after progress");
});
it("does not suppress button-bearing blocks after answer streaming starts", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
const buttons = [[{ text: "OK", callback_data: "ok" }]];
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "partial answer" });
await dispatcherOptions.deliver(
{ text: "choose now", channelData: { telegram: { buttons } } },
{ kind: "block" },
);
return { queuedFinal: true };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
telegramCfg: { streaming: { mode: "partial" } },
});
expect(answerDraftStream.update).toHaveBeenLastCalledWith("choose now");
expectRecordFields(mockCallArg(editMessageTelegram, 0, 3), { buttons });
});
it("finalizes a duplicate text-only block when no final follows", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "partial answer" });
await dispatcherOptions.deliver({ text: "partial answer" }, { kind: "block" });
return { queuedFinal: false };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
telegramCfg: { streaming: { mode: "partial" } },
});
expect(answerDraftStream.stop).toHaveBeenCalled();
expect(answerDraftStream.clear).not.toHaveBeenCalled();
expectRecordFields(mockCallArg(emitInternalMessageSentHook), {
content: "partial answer",
messageId: 2001,
});
expectRecordFields(mockCallArg(recordOutboundMessageForPromptContext), {
text: "partial answer",
messageId: 2001,
});
});
it("materializes a pending duplicate text-only block before finalizing it", async () => {
const { answerDraftStream } = setupDraftStreams();
answerDraftStream.stop.mockImplementation(async () => {
answerDraftStream.setMessageId(2001);
});
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "pending answer" });
await dispatcherOptions.deliver({ text: "pending answer" }, { kind: "block" });
return { queuedFinal: false };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
telegramCfg: { streaming: { mode: "partial" } },
});
expect(answerDraftStream.stop).toHaveBeenCalled();
expect(answerDraftStream.clear).not.toHaveBeenCalled();
expectRecordFields(mockCallArg(emitInternalMessageSentHook), {
content: "pending answer",
messageId: 2001,
});
});
it("keeps queued room events abortable after their source dispatch returns", async () => {
const historyKey = "telegram:group:-100123";
const groupHistories = new Map([[historyKey, []]]);

View File

@@ -19,19 +19,16 @@ import { CURRENT_MESSAGE_MARKER } from "openclaw/plugin-sdk/channel-mention-gati
import {
createChannelMessageReplyPipeline,
createOutboundPayloadPlan,
createPreviewMessageReceipt,
deriveDurableFinalDeliveryRequirements,
projectOutboundPayloadPlanForDelivery,
} from "openclaw/plugin-sdk/channel-outbound";
import {
buildChannelProgressDraftLineForEntry,
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
createChannelProgressDraftCompositor,
formatChannelProgressDraftLine,
formatChannelProgressDraftLineForEntry,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingPreviewNativeToolProgress,
resolveChannelStreamingPreviewNativeToolProgressAllowFrom,
@@ -408,6 +405,13 @@ function formatProgressAsMarkdownCode(text: string): string {
return `\`${sanitizeProgressMarkdownText(clipped)}\``;
}
function formatTelegramProgressLine(text: string): string {
const trimmed = text.trim();
return trimmed.startsWith("_") && trimmed.endsWith("_")
? trimmed
: formatProgressAsMarkdownCode(text);
}
function normalizeTelegramThreadId(value: unknown): number | undefined {
return parseStrictPositiveInteger(value);
}
@@ -873,7 +877,10 @@ export const dispatchTelegramMessage = async ({
!hasTelegramQuoteReply &&
!accountBlockStreamingEnabled &&
!forceBlockStreamingForReasoning;
const canStreamReasoningDraft = !isRoomEvent && streamReasoningDraft;
const streamReasoningInProgressDraft =
streamReasoningDraft && streamMode === "progress" && canStreamAnswerDraft;
const canStreamReasoningDraft =
!isRoomEvent && streamReasoningDraft && !streamReasoningInProgressDraft;
const draftReplyToMessageId =
replyToMode !== "off" && typeof msg.message_id === "number"
? (replyQuoteMessageId ?? msg.message_id)
@@ -893,6 +900,7 @@ export const dispatchTelegramMessage = async ({
renderText: renderStreamText,
onSupersededPreview: (superseded) => {
if (superseded.retain) {
lanes[laneName].activeChunkIndex += 1;
return;
}
void bot.api.deleteMessage(chatId, superseded.messageId).catch((err: unknown) => {
@@ -910,6 +918,7 @@ export const dispatchTelegramMessage = async ({
lastPartialText: "",
hasStreamedMessage: false,
finalized: false,
activeChunkIndex: 0,
};
};
const lanes: Record<LaneName, DraftLaneState> = {
@@ -936,8 +945,6 @@ export const dispatchTelegramMessage = async ({
log: logVerbose,
})
: undefined;
let streamToolProgressSuppressed = false;
let streamToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
let lastAnswerPartialText = "";
let activeAnswerDraftIsToolProgressOnly = false;
function resetAnswerToolProgressDraft() {
@@ -952,33 +959,25 @@ export const dispatchTelegramMessage = async ({
}
activeAnswerDraftIsToolProgressOnly = true;
}
const renderProgressDraft = async (options?: { flush?: boolean }): Promise<boolean> => {
if (!answerLane.stream || streamMode !== "progress") {
return false;
}
const streamText = formatChannelProgressDraftText({
entry: telegramCfg,
lines: streamToolProgressLines,
seed: progressSeed,
formatLine: formatProgressAsMarkdownCode,
});
if (!streamText || streamText === answerLane.lastPartialText) {
return false;
}
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
answerLane.stream.update(streamText);
if (options?.flush) {
await answerLane.stream.flush();
}
return true;
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: async () => {
await renderProgressDraft({ flush: true });
const progressDraft = createChannelProgressDraftCompositor({
entry: telegramCfg,
mode: streamMode,
active: Boolean(answerLane.stream),
seed: progressSeed,
formatLine: formatTelegramProgressLine,
update: async (streamText, options) => {
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
answerLane.stream?.update(streamText);
if (options?.flush) {
await answerLane.stream?.flush();
}
},
tryNativeUpdate: nativeToolProgressDraft
? async (streamText) => await nativeToolProgressDraft.update(streamText)
: undefined,
});
let finalAnswerDeliveryStarted = false;
let finalAnswerDelivered = false;
@@ -986,80 +985,37 @@ export const dispatchTelegramMessage = async ({
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string; startImmediately?: boolean },
) => {
if (!answerLane.stream) {
if (
!answerLane.stream ||
answerLane.finalized ||
finalAnswerDeliveryStarted ||
finalAnswerDelivered
) {
return false;
}
if (answerLane.finalized || finalAnswerDeliveryStarted || finalAnswerDelivered) {
return false;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return false;
}
const rawText = typeof line === "string" ? line : line?.text;
const normalized = sanitizeProgressMarkdownText(rawText?.replace(/\s+/g, " ").trim() ?? "");
if (streamToolProgressSuppressed) {
return false;
}
if (streamMode !== "progress" && !streamToolProgressEnabled) {
return false;
}
const shouldUpdateProgressLines =
streamToolProgressEnabled && !streamToolProgressSuppressed && Boolean(normalized);
if (!shouldUpdateProgressLines && streamMode !== "progress") {
return false;
}
const progressLine =
typeof line === "object" && line !== undefined ? { ...line, text: normalized } : normalized;
const nextLines = shouldUpdateProgressLines
? mergeChannelProgressDraftLine(streamToolProgressLines, progressLine, {
maxLines: resolveChannelProgressDraftMaxLines(telegramCfg),
})
: streamToolProgressLines;
if (shouldUpdateProgressLines && nextLines === streamToolProgressLines) {
return false;
}
if (nativeToolProgressDraft && shouldUpdateProgressLines) {
const streamText = formatChannelProgressDraftText({
entry: telegramCfg,
lines: nextLines,
seed: progressSeed,
});
if (streamText && (await nativeToolProgressDraft.update(streamText))) {
streamToolProgressLines = nextLines;
return true;
}
}
if (streamMode !== "progress") {
streamToolProgressLines = nextLines;
const streamText = formatChannelProgressDraftText({
entry: telegramCfg,
lines: streamToolProgressLines,
seed: progressSeed,
formatLine: formatProgressAsMarkdownCode,
});
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
answerLane.stream.update(streamText);
return true;
}
streamToolProgressLines = nextLines;
if (options?.startImmediately) {
await progressDraftGate.startNow();
if (progressDraftGate.hasStarted) {
await renderProgressDraft();
return true;
}
return progressDraftGate.hasStarted;
}
const alreadyStarted = progressDraftGate.hasStarted;
const progressActive = await progressDraftGate.noteWork();
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
await renderProgressDraft();
return true;
}
return false;
return await progressDraft.pushToolProgress(line, options);
};
const pushStreamReasoningProgress = async (payload: {
text?: string;
isReasoningSnapshot?: boolean;
}) => {
return await progressDraft.pushReasoningProgress(payload.text, {
snapshot: payload.isReasoningSnapshot === true,
});
};
const markProgressFinalStarted = () => {
finalAnswerDeliveryStarted = true;
progressDraft.markFinalReplyStarted();
};
const markProgressFinalDelivered = () => {
finalAnswerDelivered = true;
progressDraft.markFinalReplyDelivered();
};
const resetProgressDraftState = () => {
progressDraft.reset();
};
const suppressProgressDraftState = () => {
progressDraft.suppress();
};
let splitReasoningOnNextStream = false;
let draftLaneEventQueue = Promise.resolve();
@@ -1122,6 +1078,7 @@ export const dispatchTelegramMessage = async ({
}
lane.hasStreamedMessage = false;
lane.finalized = false;
lane.activeChunkIndex = 0;
if (lane === answerLane) {
resetAnswerToolProgressDraft();
}
@@ -1143,8 +1100,7 @@ export const dispatchTelegramMessage = async ({
await answerLane.stream?.clear();
answerLane.stream?.forceNewMessage();
resetDraftLaneState(answerLane);
streamToolProgressSuppressed = true;
streamToolProgressLines = [];
suppressProgressDraftState();
return true;
};
const prepareAnswerLaneForText = async () => {
@@ -1172,8 +1128,7 @@ export const dispatchTelegramMessage = async ({
return;
}
resetAnswerToolProgressDraft();
streamToolProgressSuppressed = true;
streamToolProgressLines = [];
suppressProgressDraftState();
}
lane.hasStreamedMessage = true;
lane.finalized = false;
@@ -1342,6 +1297,7 @@ export const dispatchTelegramMessage = async ({
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null;
let queuedFinal = false;
let skippedDuplicateAnswerBlockDraftDelivery = false;
let suppressSilentReplyFallback = false;
let hadErrorReplyFailureOrSkip = false;
let isFirstTurnInSession = false;
@@ -1540,6 +1496,43 @@ export const dispatchTelegramMessage = async ({
});
}
};
const finalizeSkippedDuplicateAnswerBlockDraft = async () => {
if (
!skippedDuplicateAnswerBlockDraftDelivery ||
queuedFinal ||
dispatchError ||
isDispatchSuperseded() ||
answerLane.finalized
) {
return;
}
const stream = answerLane.stream;
const content = answerLane.lastPartialText;
if (!stream || !content) {
return;
}
await stream.stop();
const messageId = stream.messageId();
if (typeof messageId !== "number") {
if (stream.sendMayHaveLanded?.()) {
answerLane.finalized = true;
deliveryState.markDelivered();
}
return;
}
answerLane.finalized = true;
deliveryState.markDelivered();
await emitPreviewFinalizedHook({
kind: "preview-finalized",
delivery: {
content,
promptContextContent: content,
messageId,
buttonsAttached: false,
receipt: createPreviewMessageReceipt({ id: messageId }),
},
});
};
const deliverLaneText = createLaneTextDeliverer({
lanes,
draftMaxChars,
@@ -1587,7 +1580,7 @@ export const dispatchTelegramMessage = async ({
return { kind: "skipped" };
}
answerLane.finalized = true;
finalAnswerDelivered = true;
markProgressFinalDelivered();
return { kind: "sent" };
};
const resolveTranscriptBackedFinalText = async (text: string): Promise<string> =>
@@ -1702,7 +1695,7 @@ export const dispatchTelegramMessage = async ({
const segments = split.segments;
const reply = resolveSendableOutboundReplyParts(effectivePayload);
if (info.kind === "final" && (reply.text.length > 0 || reply.hasMedia)) {
finalAnswerDeliveryStarted = true;
markProgressFinalStarted();
}
if (info.kind === "final") {
await enqueueDraftLaneEvent(async () => {});
@@ -1722,6 +1715,19 @@ export const dispatchTelegramMessage = async ({
buttons?: TelegramInlineButtons,
) => {
const finalText = await resolveTranscriptBackedFinalText(text);
const deliverPostFinalFollowUpText = async () => {
await prepareAnswerLaneForText();
return deliverLaneText({
laneName: "answer",
text: finalText,
payload: answerPayload,
infoKind: "final",
buttons,
});
};
if (finalAnswerDelivered) {
return deliverPostFinalFollowUpText();
}
if (streamMode === "progress") {
return deliverProgressModeFinalAnswer(answerPayload, finalText);
}
@@ -1734,7 +1740,7 @@ export const dispatchTelegramMessage = async ({
buttons,
});
if (result.kind !== "skipped") {
finalAnswerDelivered = true;
markProgressFinalDelivered();
}
return result;
};
@@ -1796,6 +1802,24 @@ export const dispatchTelegramMessage = async ({
}
await prepareAnswerLaneForToolProgress();
}
const skipTextOnlyBlock =
streamMode === "partial" &&
info.kind === "block" &&
segment.lane === "answer" &&
!reply.hasMedia &&
!hasExecApprovalPayload(effectivePayload) &&
telegramButtons === undefined &&
answerLane.hasStreamedMessage &&
!activeAnswerDraftIsToolProgressOnly &&
segment.update.text.trimEnd() === answerLane.lastPartialText.trimEnd();
if (skipTextOnlyBlock) {
skippedDuplicateAnswerBlockDraftDelivery = true;
blockDelivered = true;
continue;
}
const result =
segment.lane === "answer" && info.kind === "final"
? await deliverFinalAnswerText(
@@ -1849,7 +1873,7 @@ export const dispatchTelegramMessage = async ({
});
}
if (info.kind === "final" && delivered) {
finalAnswerDelivered = true;
markProgressFinalDelivered();
}
if (info.kind === "final") {
await flushBufferedFinalAnswer();
@@ -1875,7 +1899,7 @@ export const dispatchTelegramMessage = async ({
durable: info.kind === "final",
});
if (info.kind === "final" && delivered) {
finalAnswerDelivered = true;
markProgressFinalDelivered();
}
if (info.kind === "final") {
await flushBufferedFinalAnswer();
@@ -1957,13 +1981,20 @@ export const dispatchTelegramMessage = async ({
}
await ingestDraftLaneSegments(payload, true);
})
: undefined,
: streamReasoningInProgressDraft
? (payload) =>
enqueueDraftLaneEvent(async () => {
await pushStreamReasoningProgress(payload);
})
: undefined,
onAssistantMessageStart: answerLane.stream
? () =>
enqueueDraftLaneEvent(async () => {
reasoningStepState.resetForNextStep();
streamToolProgressSuppressed = false;
streamToolProgressLines = [];
finalAnswerDelivered = false;
if (streamMode !== "progress") {
resetProgressDraftState();
}
if (answerLane.finalized) {
await rotateLaneForNewMessage(answerLane);
}
@@ -1973,8 +2004,7 @@ export const dispatchTelegramMessage = async ({
? () =>
enqueueDraftLaneEvent(async () => {
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
streamToolProgressSuppressed = false;
streamToolProgressLines = [];
resetProgressDraftState();
})
: undefined,
suppressDefaultToolProgressMessages:
@@ -2002,6 +2032,12 @@ export const dispatchTelegramMessage = async ({
await progressPromise;
},
onItemEvent: async (payload) => {
if (payload.kind === "preamble") {
await progressDraft.pushCommentaryProgress(payload.progressText, {
itemId: payload.itemId,
});
return;
}
await pushStreamToolProgress(
buildChannelProgressDraftLineForEntry(telegramCfg, {
event: "item",
@@ -2106,9 +2142,10 @@ export const dispatchTelegramMessage = async ({
dispatchError = err;
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
} finally {
progressDraftGate.cancel();
progressDraft.cancel();
await draftLaneEventQueue;
nativeToolProgressDraft?.stop();
await finalizeSkippedDuplicateAnswerBlockDraft();
const lanesToCleanup: Array<{ laneName: LaneName; lane: DraftLaneState }> = [
{ laneName: "answer", lane: answerLane },
{ laneName: "reasoning", lane: reasoningLane },

View File

@@ -106,6 +106,22 @@ describe("telegram custom commands schema", () => {
});
});
it("accepts Telegram progress commentary config", () => {
expectTelegramConfigValid({
streaming: {
mode: "progress",
progress: { commentary: true },
},
accounts: {
ops: {
streaming: {
progress: { commentary: true },
},
},
},
});
});
it("rejects removed DM thread reply policy keys", () => {
expectTelegramConfigIssue({ dm: { threadReplies: "off" } }, "");
expectTelegramConfigIssue(

View File

@@ -109,6 +109,10 @@ export const telegramChannelConfigUiHints = {
label: "Telegram Progress Command Text",
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
"streaming.progress.commentary": {
label: "Telegram Progress Commentary",
help: "Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged.",
},
"retry.attempts": {
label: "Telegram Retry Attempts",
help: "Max retry attempts for outbound Telegram API calls (default: 3).",

View File

@@ -315,7 +315,7 @@ describe("createTelegramDraftStream", () => {
}
});
it("does not rebind to an old message when forceNewMessage races an in-flight send", async () => {
it("retains an old message when forceNewMessage races an in-flight send", async () => {
let resolveFirstSend: ((value: { message_id: number }) => void) | undefined;
const firstSend = new Promise<{ message_id: number }>((resolve) => {
resolveFirstSend = resolve;
@@ -326,16 +326,11 @@ describe("createTelegramDraftStream", () => {
deleteMessage: vi.fn().mockResolvedValue(true),
};
const onSupersededPreview = vi.fn();
const stream = createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
onSupersededPreview,
});
const stream = createDraftStream(api, { onSupersededPreview });
stream.update("Message A partial");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
// Rotate to message B before message A send resolves.
stream.forceNewMessage();
stream.update("Message B partial");
@@ -349,6 +344,7 @@ describe("createTelegramDraftStream", () => {
textSnapshot: "Message A partial",
parseMode: undefined,
visibleSinceMs: supersededPreview.visibleSinceMs,
retain: true,
});
expect(typeof supersededPreview.visibleSinceMs).toBe("number");
expect(Number.isFinite(supersededPreview.visibleSinceMs)).toBe(true);

View File

@@ -177,6 +177,7 @@ export function createTelegramDraftStream(params: {
textSnapshot: renderedText,
parseMode: renderedParseMode,
visibleSinceMs,
retain: true,
});
return true;
}

View File

@@ -22,6 +22,7 @@ export type DraftLaneState = {
lastPartialText: string;
hasStreamedMessage: boolean;
finalized: boolean;
activeChunkIndex: number;
};
type LanePreviewFinalizedDelivery = {
@@ -275,11 +276,19 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
text.length > params.draftMaxChars
? compactChunks(params.splitFinalTextForStream?.(text) ?? [])
: [text];
const [firstChunk, ...remainingChunks] = chunks;
if (!firstChunk || firstChunk.length > params.draftMaxChars) {
const clampActiveChunkIndex = () =>
Math.min(lane.activeChunkIndex, Math.max(0, chunks.length - 1));
const activeChunkIndex = clampActiveChunkIndex();
const activeChunk = chunks[activeChunkIndex];
const remainingChunks = chunks.slice(activeChunkIndex + 1);
if (!activeChunk || activeChunk.length > params.draftMaxChars) {
return undefined;
}
const finalText = text.trimEnd();
const activeFullText = chunks.slice(activeChunkIndex).join("");
const finalText = activeFullText.trimEnd();
const deliveredStreamTextBeforeUpdate = stream.lastDeliveredText?.();
const deliveredPrefixBeforeUpdate =
isFinal &&
@@ -288,7 +297,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
deliveredText: deliveredStreamTextBeforeUpdate,
finalText,
}) &&
deliveredStreamTextBeforeUpdate.length > firstChunk.trimEnd().length;
deliveredStreamTextBeforeUpdate.length > activeChunk.trimEnd().length;
const finalizeDeliveredPrefix = async (
deliveredStreamText: string,
messageId: number,
@@ -310,7 +320,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
}
}
}
const suffix = finalText.slice(deliveredStreamText.length);
const suffix = activeFullText.slice(deliveredStreamText.length);
if (suffix.trim().length > 0) {
for (const chunk of compactChunks(params.splitFinalTextForStream?.(suffix) ?? [])) {
if (chunk.trim().length === 0) {
@@ -327,17 +337,29 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
});
};
const candidateTexts = [stream.lastDeliveredText?.(), lane.lastPartialText];
if (isFinal && remainingChunks.length === 0 && isPotentialTruncatedFinal(activeFullText)) {
const resolvedFullCandidate = await params.resolveFinalTextCandidate?.({
finalText: text,
laneName,
});
if (resolvedFullCandidate) {
const resolvedChunks =
resolvedFullCandidate.length > params.draftMaxChars
? compactChunks(params.splitFinalTextForStream?.(resolvedFullCandidate) ?? [])
: [resolvedFullCandidate];
candidateTexts.push(resolvedChunks.slice(activeChunkIndex).join(""));
}
}
const retainedPreview =
isFinal && remainingChunks.length === 0 && isPotentialTruncatedFinal(text)
isFinal && remainingChunks.length === 0 && isPotentialTruncatedFinal(activeFullText)
? selectLongerFinalText({
finalText: text,
candidateTexts: [
await params.resolveFinalTextCandidate?.({ finalText: text, laneName }),
stream.lastDeliveredText?.(),
lane.lastPartialText,
],
finalText: activeFullText,
candidateTexts,
})
: undefined;
if (retainedPreview && (!buttons || retainedPreview.length <= params.draftMaxChars)) {
const previewText = retainedPreview;
lane.lastPartialText = previewText;
@@ -376,20 +398,28 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
}
lane.finalized = true;
params.markDelivered();
return result("preview-finalized", { content: previewText, messageId, buttonsAttached });
return result("preview-finalized", {
content: previewText,
promptContextContent: previewText,
messageId,
buttonsAttached,
});
}
if (!deliveredPrefixBeforeUpdate) {
lane.lastPartialText = firstChunk;
lane.lastPartialText = activeChunk;
lane.hasStreamedMessage = true;
lane.finalized = false;
stream.update(firstChunk);
stream.update(activeChunk);
}
if (isFinal) {
await params.stopDraftLane(lane);
} else {
await params.flushDraftLane(lane);
}
const activeChunkIndexAfterStop = isFinal ? clampActiveChunkIndex() : activeChunkIndex;
const activeChunkAfterStop = chunks[activeChunkIndexAfterStop] ?? activeChunk;
const remainingChunksAfterStop = chunks.slice(activeChunkIndexAfterStop + 1);
const messageId = stream.messageId();
if (typeof messageId !== "number") {
@@ -402,14 +432,19 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
}
const deliveredStreamTextAfterStop = stream.lastDeliveredText?.();
const activeChunkTextAfterStop = activeChunkAfterStop.trimEnd();
const retainedActiveChunkAfterStop =
activeChunkIndexAfterStop !== activeChunkIndex &&
deliveredStreamTextAfterStop === activeChunk.trimEnd();
if (
isFinal &&
deliveredStreamTextAfterStop !== undefined &&
deliveredStreamTextAfterStop !== firstChunk.trimEnd()
deliveredStreamTextAfterStop !== activeChunkTextAfterStop &&
!retainedActiveChunkAfterStop
) {
if (
isDeliveredPrefix({ deliveredText: deliveredStreamTextAfterStop, finalText }) &&
deliveredStreamTextAfterStop.length > firstChunk.trimEnd().length
deliveredStreamTextAfterStop.length > activeChunkTextAfterStop.length
) {
return await finalizeDeliveredPrefix(deliveredStreamTextAfterStop, messageId);
}
@@ -424,7 +459,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
let buttonsAttached = false;
if (buttons) {
try {
await params.editStreamMessage({ laneName, messageId, text: firstChunk, buttons });
await params.editStreamMessage({
laneName,
messageId,
text: activeChunkAfterStop,
buttons,
});
buttonsAttached = true;
} catch (err) {
params.log(`telegram: ${laneName} stream button edit failed: ${String(err)}`);
@@ -433,7 +473,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
if (isFinal) {
lane.finalized = true;
for (const chunk of remainingChunks) {
for (const chunk of remainingChunksAfterStop) {
if (chunk.trim().length === 0) {
continue;
}
@@ -441,7 +481,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
}
return result("preview-finalized", {
content: text,
promptContextContent: firstChunk,
promptContextContent: activeChunkAfterStop,
messageId,
buttonsAttached,
});

View File

@@ -31,12 +31,14 @@ function createHarness(params?: {
lastPartialText: "",
hasStreamedMessage: false,
finalized: false,
activeChunkIndex: 0,
},
reasoning: {
stream: reasoning,
lastPartialText: "",
hasStreamedMessage: false,
finalized: false,
activeChunkIndex: 0,
},
};
const sendPayload = vi.fn().mockResolvedValue(true);
@@ -762,6 +764,87 @@ describe("createLaneTextDeliverer", () => {
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("does not resend chunks retained while stopping a long streamed final", async () => {
const answer = createTestDraftStream({ messageId: 999 });
const harness = createHarness({
answerStream: answer,
draftMaxChars: 5,
splitFinalTextForStream: () => ["Hello", " world", " again"],
});
harness.lanes.answer.hasStreamedMessage = true;
answer.stop.mockImplementation(async () => {
harness.lanes.answer.activeChunkIndex = 1;
});
const result = await deliverFinalAnswer(harness, "Hello world again");
const delivery = expectPreviewFinalized(result);
expect(delivery.content).toBe("Hello world again");
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).toHaveBeenCalledWith({ text: " again" });
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("compares retained delivered prefixes against the full final text", async () => {
let deliveredText = "Hello";
const answer = createTestDraftStream({ messageId: 999 });
const harness = createHarness({
answerStream: answer,
draftMaxChars: 5,
splitFinalTextForStream: (text) =>
text === " again" ? [" again"] : ["Hello", " world", " again"],
});
answer.lastDeliveredText.mockImplementation(() => deliveredText);
answer.stop.mockImplementation(async () => {
harness.lanes.answer.activeChunkIndex = 1;
deliveredText = "Hello world";
});
harness.lanes.answer.hasStreamedMessage = true;
const result = await deliverFinalAnswer(harness, "Hello world again");
const delivery = expectPreviewFinalized(result);
expect(delivery.promptContextContent).toBe("Hello world");
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).toHaveBeenCalledWith({ text: " again" });
});
it("edits buttons onto the chunk active after stopping a retained long final", async () => {
const buttons = [[{ text: "OK", callback_data: "ok" }]];
const answer = createTestDraftStream({ messageId: 999 });
const harness = createHarness({
answerStream: answer,
draftMaxChars: 6,
splitFinalTextForStream: () => ["Hello", " world", " again"],
});
harness.lanes.answer.hasStreamedMessage = true;
answer.stop.mockImplementation(async () => {
harness.lanes.answer.activeChunkIndex = 1;
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Hello world again",
payload: { text: "Hello world again", channelData: { telegram: { buttons } } },
infoKind: "final",
buttons,
});
const delivery = expectPreviewFinalized(result);
expect(delivery.buttonsAttached).toBe(true);
expect(harness.editStreamMessage).toHaveBeenCalledWith({
laneName: "answer",
messageId: 999,
text: " world",
buttons,
});
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).toHaveBeenCalledWith({
text: " again",
channelData: { telegram: { buttons } },
});
});
it("keeps inline buttons on the current chunk of an already-streamed long final", async () => {
const buttons = [[{ text: "OK", callback_data: "ok" }]];
const fullAnswer = "Hello world again";

View File

@@ -760,6 +760,45 @@ describe("telegram message cache", () => {
expect(context.map((entry) => entry.node.body)).not.toContain(staleInstruction);
});
it("uses the current reset command as the session boundary", async () => {
const cache = createTelegramMessageCache();
const chat = { id: 7, type: "group", title: "Ops" } as const;
await cache.record({
accountId: "default",
chatId: 7,
msg: {
chat,
message_id: 100,
date: 1736380800,
text: "stale context",
from: { id: 100, is_bot: false, first_name: "Requester" },
} as Message,
});
await cache.record({
accountId: "default",
chatId: 7,
msg: {
chat,
message_id: 101,
date: 1736380860,
text: "/new",
from: { id: 101, is_bot: false, first_name: "Requester" },
} as Message,
});
const context = await buildTelegramConversationContext({
cache,
accountId: "default",
chatId: 7,
messageId: "101",
replyChainNodes: [],
recentLimit: 10,
replyTargetWindowSize: 1,
});
expect(context).toEqual([]);
});
it("does not select messages before the persisted session start when the reset command is absent", async () => {
const cache = createTelegramMessageCache();
const beforeSession = Date.parse("2026-05-10T12:40:00.000Z");

View File

@@ -54,6 +54,13 @@ export type TelegramMessageCache = {
before: number;
after: number;
}) => Promise<TelegramCachedMessageNode[]>;
latestMatchingAtOrBefore: (params: {
accountId: string;
chatId: string | number;
messageId?: string;
threadId?: number;
matches: (node: TelegramCachedMessageNode) => boolean;
}) => Promise<TelegramCachedMessageNode | null>;
};
type MessageWithExternalReply = Message & { external_reply?: Message };
@@ -712,6 +719,40 @@ export function createTelegramMessageCache(params?: {
targetIndex + Math.max(0, after) + 1,
);
},
latestMatchingAtOrBefore: async ({ accountId, chatId, messageId, threadId, matches }) => {
if (!messageId) {
return null;
}
const targetId = parseSafeMessageId(messageId);
if (targetId === undefined) {
return null;
}
await hydrateMessageCacheBucket(bucket, maxMessages, scopeKey);
const prefix = telegramMessageCacheKeyPrefix({ scopeKey, accountId, chatId });
const normalizedThreadId = normalizeTelegramCacheThreadId(threadId);
if (threadId != null && normalizedThreadId === undefined) {
return null;
}
const normalizedThread =
normalizedThreadId !== undefined ? String(normalizedThreadId) : undefined;
let latest: TelegramCachedMessageNode | null = null;
for (const [key, entry] of messages) {
if (!key.startsWith(prefix)) {
continue;
}
if (normalizedThread !== undefined && entry.threadId !== normalizedThread) {
continue;
}
const entryId = parseSafeMessageId(entry.messageId);
if (entryId === undefined || entryId > targetId || !matches(entry)) {
continue;
}
if (!latest || compareCachedMessageNodes(entry, latest) > 0) {
latest = entry;
}
}
return latest;
},
};
}
@@ -789,25 +830,15 @@ async function resolveSessionBoundaryNode(params: {
if (!params.messageId) {
return undefined;
}
const { messageId } = params;
const candidates = (
await params.cache.recentBefore({
return (
(await params.cache.latestMatchingAtOrBefore({
accountId: params.accountId,
chatId: params.chatId,
messageId,
messageId: params.messageId,
...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
limit: Number.MAX_SAFE_INTEGER,
})
).filter(isSessionBoundaryCommandNode);
const current = await params.cache.get({
accountId: params.accountId,
chatId: params.chatId,
messageId,
});
if (current && isSessionBoundaryCommandNode(current)) {
candidates.push(current);
}
return candidates.toSorted(compareCachedMessageNodes).at(-1);
matches: isSessionBoundaryCommandNode,
})) ?? undefined
);
}
export async function buildTelegramReplyChain(params: {

View File

@@ -19,7 +19,9 @@ import type { TelegramIngressWorkerMessage } from "./telegram-ingress-worker.js"
const runMock = vi.hoisted(() => vi.fn());
const createTelegramBotMock = vi.hoisted(() => vi.fn());
const isRecoverableTelegramNetworkErrorMock = vi.hoisted(() => vi.fn(() => true));
const computeBackoffMock = vi.hoisted(() => vi.fn(() => 0));
const computeBackoffMock = vi.hoisted(() =>
vi.fn((_policy: { initialMs: number }, _attempt: number) => 0),
);
const sleepWithAbortMock = vi.hoisted(() => vi.fn(async () => undefined));
const drainPendingDeliveriesMock = vi.hoisted(() => vi.fn(async (_opts: unknown) => undefined));
@@ -190,19 +192,37 @@ function installPollingStallWatchdogHarness(dateNowSequence: readonly number[] =
});
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
const watchdogs: Array<() => void> = [];
const watchdogWaiters: Array<{
count: number;
resolve: (fn: () => void) => void;
reject: (err: Error) => void;
timeout: ReturnType<typeof realSetTimeout>;
}> = [];
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn, delay) => {
if (delay === POLLING_TEST_WATCHDOG_INTERVAL_MS) {
watchdog = fn as () => void;
watchdogs.push(watchdog);
resolveWatchdog?.(watchdog);
for (let index = watchdogWaiters.length - 1; index >= 0; index -= 1) {
const waiter = watchdogWaiters[index];
if (watchdogs.length < waiter.count) {
continue;
}
realClearTimeout(waiter.timeout);
watchdogWaiters.splice(index, 1);
waiter.resolve(watchdogs[waiter.count - 1]);
}
}
return 1 as unknown as ReturnType<typeof setInterval>;
});
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((fn) => {
void Promise.resolve().then(() => (fn as () => void)());
return 1 as unknown as ReturnType<typeof setTimeout>;
const setTimeoutSpy = vi
.spyOn(globalThis, "setTimeout")
.mockImplementation((fn) => realSetTimeout(fn as () => void, 0));
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation((timeoutId) => {
realClearTimeout(timeoutId);
});
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => {});
const dateNowSpy = vi.spyOn(Date, "now");
for (const value of dateNowSequence) {
dateNowSpy.mockImplementationOnce(() => value);
@@ -230,6 +250,18 @@ function installPollingStallWatchdogHarness(dateNowSequence: readonly number[] =
);
});
},
async waitForWatchdogRegistration(count: number) {
const registered = watchdogs[count - 1];
if (registered) {
return registered;
}
return await new Promise<() => void>((resolve, reject) => {
const timeout = realSetTimeout(() => {
reject(new Error(`Timed out waiting for polling watchdog registration ${count}`));
}, 5_000);
watchdogWaiters.push({ count, resolve, reject, timeout });
});
},
setNow(now: number) {
dateNowSpy.mockReset();
dateNowSpy.mockImplementation(() => now);
@@ -662,9 +694,31 @@ describe("TelegramPollingSession", () => {
mockObjectArg(createTelegramBotMock, "createTelegramBot").minimumClientTimeoutSeconds,
).toBe(45);
expect(computeBackoffMock).toHaveBeenCalledTimes(1);
expect(computeBackoffMock).toHaveBeenCalledWith(
{
initialMs: 30_000,
maxMs: 600_000,
factor: 2,
jitter: 0.2,
},
1,
);
expect(sleepWithAbortMock).toHaveBeenCalledTimes(1);
});
it("resets restart backoff after a healthy polling cycle", () => {
const state = pollingSessionTesting.createTelegramRestartBackoffState();
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true });
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true });
pollingSessionTesting.resetTelegramRestartBackoffState(state);
pollingSessionTesting.resolveTelegramRestartDelayMs(state);
expect(computeBackoffMock.mock.calls.map((call) => call[1])).toEqual([1, 2, 1, 1]);
expect(
computeBackoffMock.mock.calls.map((call) => (call[0] as { initialMs: number }).initialMs),
).toEqual([30_000, 30_000, 120_000, 30_000]);
});
it("does not call getUpdates for offset confirmation (avoiding 409 conflicts)", async () => {
const abort = new AbortController();
const bot = makeBot();
@@ -961,6 +1015,63 @@ describe("TelegramPollingSession", () => {
await runPromise;
});
it("resets restart backoff after isolated ingress reports poll success", async () => {
const abort = new AbortController();
const init = vi.fn(async () => undefined);
const bot = {
api: {
deleteWebhook: vi.fn(async () => true),
config: { use: vi.fn() },
},
init,
handleUpdate: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
};
createTelegramBotMock.mockReturnValue(bot);
sleepWithAbortMock.mockImplementation(async () => {
if (sleepWithAbortMock.mock.calls.length >= 2) {
abort.abort();
}
});
let cycle = 0;
const createWorker = vi.fn(() => {
let onMessage: WorkerPollSuccessListener | undefined;
cycle += 1;
return {
onMessage: vi.fn((handler) => {
onMessage = handler;
return () => undefined;
}),
stop: vi.fn(async () => undefined),
task: vi.fn(async () => {
if (cycle === 2) {
onMessage?.({
type: "poll-success",
offset: null,
finishedAt: Date.now(),
count: 0,
});
}
}),
};
});
const session = createPollingSession({
abortSignal: abort.signal,
isolatedIngress: {
enabled: true,
createWorker,
drainIntervalMs: 10,
},
});
await session.runUntilAbort();
expect(createWorker).toHaveBeenCalledTimes(2);
expect(computeBackoffMock.mock.calls.map((call) => call[1])).toEqual([1, 1]);
});
it("restarts isolated ingress when worker liveness stalls", async () => {
const abort = new AbortController();
const log = vi.fn();
@@ -1026,6 +1137,98 @@ describe("TelegramPollingSession", () => {
expectLogIncludes(log, "Polling stall detected");
expectLogIncludes(log, "isolated polling ingress finished reason=polling stall detected");
expectLogExcludes(log, "Isolated polling ingress stop timed out");
} finally {
watchdogHarness.restore();
abort.abort();
}
});
it("applies stop-timeout cooldown to isolated ingress forced restarts", async () => {
const abort = new AbortController();
const log = vi.fn();
const bot = {
api: {
deleteWebhook: vi.fn(async () => true),
config: { use: vi.fn() },
},
init: vi.fn(async () => undefined),
handleUpdate: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
};
createTelegramBotMock.mockReturnValue(bot);
computeBackoffMock.mockImplementation((policy: { initialMs: number }, attempt: number) => {
if (policy.initialMs === 120_000) {
return attempt * 100_000;
}
return attempt * 1_000;
});
const finishStoppedWorkers: Array<() => void> = [];
let workerCycle = 0;
const createWorker = vi.fn(() => {
workerCycle += 1;
if (workerCycle <= 2) {
let finishTask: (() => void) | undefined;
const task = new Promise<void>((resolve) => {
finishTask = resolve;
});
let finishStop: (() => void) | undefined;
const stop = new Promise<void>((resolve) => {
finishStop = resolve;
});
finishStoppedWorkers.push(() => {
finishStop?.();
finishTask?.();
});
return {
onMessage: vi.fn(() => () => undefined),
stop: vi.fn(() => stop),
task: vi.fn(async () => {
await task;
}),
};
}
return {
onMessage: vi.fn(() => () => undefined),
stop: vi.fn(async () => undefined),
task: vi.fn(async () => {
abort.abort();
}),
};
});
const watchdogHarness = installPollingStallWatchdogHarness([0]);
const session = createPollingSession({
abortSignal: abort.signal,
log,
stallThresholdMs: 30_000,
isolatedIngress: {
enabled: true,
createWorker,
drainIntervalMs: 500,
},
});
try {
const runPromise = session.runUntilAbort();
const firstWatchdog = await watchdogHarness.waitForWatchdog();
watchdogHarness.setNow(31_000);
firstWatchdog?.();
await vi.waitFor(() => expectLogIncludes(log, "Isolated polling ingress stop timed out"));
finishStoppedWorkers.shift()?.();
await vi.waitFor(() => expect(createWorker).toHaveBeenCalledTimes(2));
const secondWatchdog = await watchdogHarness.waitForWatchdogRegistration(2);
watchdogHarness.setNow(62_000);
secondWatchdog?.();
await vi.waitFor(() => expectLogIncludes(log, "Stop timeout burst=2; applying cooldown."));
finishStoppedWorkers.shift()?.();
await runPromise;
const stopCooldownCalls = computeBackoffMock.mock.calls.filter(
([policy]) => (policy as { initialMs: number }).initialMs === 120_000,
);
expect(stopCooldownCalls.map((call) => call[1])).toEqual([1]);
} finally {
watchdogHarness.restore();
abort.abort();
@@ -2762,6 +2965,7 @@ describe("TelegramPollingSession", () => {
it("forces a restart when polling stalls without getUpdates activity", async () => {
const abort = new AbortController();
const botStop = vi.fn(async () => undefined);
const secondBotStop = vi.fn(async () => undefined);
const firstRunnerStop = vi.fn(async () => undefined);
const secondRunnerStop = vi.fn(async () => undefined);
const bot = {
@@ -2772,7 +2976,10 @@ describe("TelegramPollingSession", () => {
},
stop: botStop,
};
createTelegramBotMock.mockReturnValue(bot);
createTelegramBotMock.mockReturnValueOnce(bot).mockReturnValueOnce({
...bot,
stop: secondBotStop,
});
let firstTaskResolve: (() => void) | undefined;
const firstTask = new Promise<void>((resolve) => {
@@ -2826,14 +3033,46 @@ describe("TelegramPollingSession", () => {
expect(runMock).toHaveBeenCalledTimes(2);
expect(firstRunnerStop).toHaveBeenCalledTimes(1);
expect(botStop).toHaveBeenCalled();
expect(botStop).toHaveBeenCalledTimes(1);
expectLogIncludes(log, "Polling stall detected");
expectLogIncludes(log, "polling stall detected");
expectLogExcludes(log, "Polling runner stop timed out");
} finally {
watchdogHarness.restore();
}
});
it("cools down repeated stop-timeout restart bursts", () => {
computeBackoffMock.mockImplementation((policy: { initialMs: number }, attempt: number) => {
if (policy.initialMs === 120_000) {
return attempt * 100_000;
}
return attempt * 1_000;
});
const state = pollingSessionTesting.createTelegramRestartBackoffState();
expect(
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true }),
).toEqual({ delayMs: 1_000, stopTimeoutSuffix: "" });
expect(
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true }),
).toEqual({
delayMs: 100_000,
stopTimeoutSuffix: " Stop timeout burst=2; applying cooldown.",
});
expect(
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true }),
).toEqual({
delayMs: 200_000,
stopTimeoutSuffix: " Stop timeout burst=3; applying cooldown.",
});
const stopCooldownCalls = computeBackoffMock.mock.calls.filter(
([policy]) => (policy as { initialMs: number }).initialMs === 120_000,
);
expect(stopCooldownCalls.map((call) => call[1])).toEqual([1, 2]);
});
it("forces a restart when the runner task is pending but reports not running", async () => {
const abort = new AbortController();
const firstRunnerStop = vi.fn(async () => undefined);

View File

@@ -50,12 +50,65 @@ import {
} from "./telegram-reply-fence.js";
const TELEGRAM_POLL_RESTART_POLICY = {
initialMs: 2000,
maxMs: 30_000,
factor: 1.8,
jitter: 0.25,
initialMs: 30_000,
maxMs: 600_000,
factor: 2,
jitter: 0.2,
};
const TELEGRAM_POLL_STOP_TIMEOUT_COOLDOWN_POLICY = {
initialMs: 120_000,
maxMs: 600_000,
factor: 2,
jitter: 0.2,
};
const TELEGRAM_POLL_STOP_TIMEOUT_BURST_LIMIT = 2;
type TelegramRestartBackoffState = {
restartAttempts: number;
stopTimeoutBurst: number;
stopTimeoutCooldownAttempts: number;
};
function createTelegramRestartBackoffState(): TelegramRestartBackoffState {
return {
restartAttempts: 0,
stopTimeoutBurst: 0,
stopTimeoutCooldownAttempts: 0,
};
}
function resetTelegramRestartBackoffState(state: TelegramRestartBackoffState): void {
state.restartAttempts = 0;
state.stopTimeoutBurst = 0;
state.stopTimeoutCooldownAttempts = 0;
}
function resolveTelegramRestartDelayMs(
state: TelegramRestartBackoffState,
opts: { stopTimedOut?: boolean } = {},
): { delayMs: number; stopTimeoutSuffix: string } {
state.restartAttempts += 1;
let delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, state.restartAttempts);
let stopTimeoutSuffix = "";
if (opts.stopTimedOut) {
state.stopTimeoutBurst += 1;
if (state.stopTimeoutBurst >= TELEGRAM_POLL_STOP_TIMEOUT_BURST_LIMIT) {
state.stopTimeoutCooldownAttempts += 1;
const cooldownMs = computeBackoff(
TELEGRAM_POLL_STOP_TIMEOUT_COOLDOWN_POLICY,
state.stopTimeoutCooldownAttempts,
);
delayMs = Math.max(delayMs, cooldownMs);
stopTimeoutSuffix = ` Stop timeout burst=${state.stopTimeoutBurst}; applying cooldown.`;
}
} else {
state.stopTimeoutBurst = 0;
state.stopTimeoutCooldownAttempts = 0;
}
return { delayMs, stopTimeoutSuffix };
}
const DEFAULT_POLL_STALL_THRESHOLD_MS = 120_000;
const MIN_POLL_STALL_THRESHOLD_MS = 30_000;
const MAX_POLL_STALL_THRESHOLD_MS = 600_000;
@@ -239,7 +292,7 @@ function isSpooledUpdateHandlerKeyForSpool(handlerKey: string, spoolDir: string)
}
export class TelegramPollingSession {
#restartAttempts = 0;
#restartBackoffState = createTelegramRestartBackoffState();
#webhookCleared = false;
#forceRestarted = false;
#activeRunner: ReturnType<typeof run> | undefined;
@@ -321,11 +374,20 @@ export class TelegramPollingSession {
}
}
async #waitBeforeRestart(buildLine: (delay: string) => string): Promise<boolean> {
this.#restartAttempts += 1;
const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, this.#restartAttempts);
#noteHealthyPollingCycle() {
resetTelegramRestartBackoffState(this.#restartBackoffState);
}
async #waitBeforeRestart(
buildLine: (delay: string) => string,
opts: { stopTimedOut?: boolean } = {},
): Promise<boolean> {
const { delayMs, stopTimeoutSuffix } = resolveTelegramRestartDelayMs(
this.#restartBackoffState,
opts,
);
const delay = formatDurationPrecise(delayMs);
this.opts.log(buildLine(delay));
this.opts.log(`${buildLine(delay)}${stopTimeoutSuffix}`);
try {
await sleepWithAbort(delayMs, this.opts.abortSignal);
} catch (sleepErr) {
@@ -741,9 +803,7 @@ export class TelegramPollingSession {
const stopWorker = () => {
stopWorkerPromise ??= Promise.resolve(worker.stop())
.then(() => undefined)
.catch(() => {
// Worker may already be stopped by restart/abort paths.
});
.catch(() => undefined);
return stopWorkerPromise;
};
this.opts.log(`[telegram][diag] isolated polling ingress started spool=${spoolDir}`);
@@ -761,6 +821,7 @@ export class TelegramPollingSession {
let consecutiveDrainFailures = 0;
let restartRequested = false;
let stalledRestart = false;
let stopTimedOut = false;
let forceCycleTimer: ReturnType<typeof setTimeout> | undefined;
let forceCycleResolve: (() => void) | undefined;
const forceCyclePromise = new Promise<void>((resolve) => {
@@ -796,6 +857,7 @@ export class TelegramPollingSession {
if (message.type === "poll-success") {
liveness.noteGetUpdatesSuccessCount(message.count, message.finishedAt);
liveness.noteGetUpdatesFinished();
this.#noteHealthyPollingCycle();
if (!restartRequested && stalledBacklogKeys.size === 0) {
this.#status.notePollSuccess(message.finishedAt);
}
@@ -840,9 +902,33 @@ export class TelegramPollingSession {
const stopBot = () => {
return Promise.resolve(bot.stop())
.then(() => undefined)
.catch(() => {
// Bot may already be stopped by shutdown paths.
});
.catch(() => undefined);
};
const clearForceCycleTimer = () => {
if (!forceCycleTimer) {
return;
}
clearTimeout(forceCycleTimer);
forceCycleTimer = undefined;
};
const requestStopForRestart = () => {
if (restartRequested) {
return;
}
restartRequested = true;
void stopWorker();
if (!forceCycleTimer) {
forceCycleTimer = setTimeout(() => {
if (this.opts.abortSignal?.aborted) {
return;
}
this.opts.log(
`[telegram] Isolated polling ingress stop timed out after ${formatDurationPrecise(POLL_STOP_GRACE_MS)}; forcing restart cycle.`,
);
stopTimedOut = true;
forceCycleResolve?.();
}, POLL_STOP_GRACE_MS);
}
};
const drainOnce = async () => {
if (restartRequested || drainActive || this.opts.abortSignal?.aborted) {
@@ -872,8 +958,7 @@ export class TelegramPollingSession {
}
const timedOutRecovery = await this.#recoverTimedOutSpooledHandler(drain.blockedByLane);
if (timedOutRecovery?.restart) {
restartRequested = true;
void stopWorker();
requestStopForRestart();
} else if (timedOutRecovery) {
stalledBacklogKeys.add(timedOutRecovery.handlerKey);
}
@@ -903,26 +988,15 @@ export class TelegramPollingSession {
}
this.#transportState.markDirty();
stalledRestart = true;
restartRequested = true;
this.opts.log(`[telegram] ${stall.message}`);
this.#status.notePollingError(stall.message);
void stopWorker();
if (!forceCycleTimer) {
forceCycleTimer = setTimeout(() => {
if (this.opts.abortSignal?.aborted) {
return;
}
this.opts.log(
`[telegram] Isolated polling ingress stop timed out after ${formatDurationPrecise(POLL_STOP_GRACE_MS)}; forcing restart cycle.`,
);
forceCycleResolve?.();
}, POLL_STOP_GRACE_MS);
}
requestStopForRestart();
}, POLL_WATCHDOG_INTERVAL_MS);
watchdog.unref?.();
try {
try {
await Promise.race([worker.task(), forceCyclePromise]);
clearForceCycleTimer();
} catch (err) {
if (this.opts.abortSignal?.aborted) {
return "exit";
@@ -937,6 +1011,7 @@ export class TelegramPollingSession {
const message = formatErrorMessage(err);
this.opts.log(`[telegram][diag] isolated polling ingress failed: ${message}`);
this.#status.notePollingError(message);
clearForceCycleTimer();
const shouldRestart = await this.#waitBeforeRestart(
(delay) => `Telegram isolated polling ingress failed; restarting in ${delay}.`,
);
@@ -951,7 +1026,11 @@ export class TelegramPollingSession {
`[telegram][diag] isolated polling ingress finished reason=polling stall detected ${liveness.formatDiagnosticFields("error")}`,
);
}
return "continue";
const shouldRestart = await this.#waitBeforeRestart(
(delay) => `Telegram isolated polling ingress restart requested; restarting in ${delay}.`,
{ stopTimedOut },
);
return shouldRestart ? "continue" : "exit";
}
const errorText = pollState.error ? ` error=${pollState.error}` : "";
this.opts.log(
@@ -964,9 +1043,7 @@ export class TelegramPollingSession {
} finally {
clearInterval(watchdog);
clearInterval(drainTimer);
if (forceCycleTimer) {
clearTimeout(forceCycleTimer);
}
clearForceCycleTimer();
unsubscribe();
this.opts.abortSignal?.removeEventListener("abort", stopOnAbort);
await stopWorker();
@@ -981,6 +1058,7 @@ export class TelegramPollingSession {
async #runPollingCycle(bot: TelegramBot): Promise<"continue" | "exit"> {
const liveness = new TelegramPollingLivenessTracker({
onPollSuccess: (finishedAt) => {
this.#noteHealthyPollingCycle();
this.#status.notePollSuccess(finishedAt);
this.#drainPendingDeliveriesAfterReconnect();
},
@@ -1021,21 +1099,26 @@ export class TelegramPollingSession {
const forceCyclePromise = new Promise<void>((resolve) => {
forceCycleResolve = resolve;
});
const clearForceCycleTimer = () => {
if (!forceCycleTimer) {
return;
}
clearTimeout(forceCycleTimer);
forceCycleTimer = undefined;
};
const stopRunner = () => {
fetchAbortController?.abort();
stopPromise ??= Promise.resolve(runner.stop())
.then(() => undefined)
.catch(() => {
// Runner may already be stopped by abort/retry paths.
});
.catch(() => undefined);
return stopPromise;
};
let stopBotPromise: Promise<void> | undefined;
const stopBot = () => {
return Promise.resolve(bot.stop())
stopBotPromise ??= Promise.resolve(bot.stop())
.then(() => undefined)
.catch(() => {
// Bot may already be stopped by runner stop/abort paths.
});
.catch(() => undefined);
return stopBotPromise;
};
const stopOnAbort = () => {
if (this.opts.abortSignal?.aborted) {
@@ -1043,8 +1126,31 @@ export class TelegramPollingSession {
}
};
let restartRequested = false;
let stopTimedOut = false;
const requestStopForRestart = () => {
if (restartRequested) {
return;
}
restartRequested = true;
void stopRunner();
void stopBot();
if (!forceCycleTimer) {
forceCycleTimer = setTimeout(() => {
if (this.opts.abortSignal?.aborted) {
return;
}
this.opts.log(
`[telegram] Polling runner stop timed out after ${formatDurationPrecise(POLL_STOP_GRACE_MS)}; forcing restart cycle.`,
);
stopTimedOut = true;
forceCycleResolve?.();
}, POLL_STOP_GRACE_MS);
}
};
const watchdog = setInterval(() => {
if (this.opts.abortSignal?.aborted) {
if (this.opts.abortSignal?.aborted || restartRequested) {
return;
}
@@ -1055,25 +1161,14 @@ export class TelegramPollingSession {
this.#transportState.markDirty();
stalledRestart = true;
this.opts.log(`[telegram] ${stall.message}`);
void stopRunner();
void stopBot();
if (!forceCycleTimer) {
forceCycleTimer = setTimeout(() => {
if (this.opts.abortSignal?.aborted) {
return;
}
this.opts.log(
`[telegram] Polling runner stop timed out after ${formatDurationPrecise(POLL_STOP_GRACE_MS)}; forcing restart cycle.`,
);
forceCycleResolve?.();
}, POLL_STOP_GRACE_MS);
}
requestStopForRestart();
}
}, POLL_WATCHDOG_INTERVAL_MS);
this.opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
try {
await Promise.race([runner.task(), forceCyclePromise]);
clearForceCycleTimer();
if (this.opts.abortSignal?.aborted) {
return "exit";
}
@@ -1088,6 +1183,7 @@ export class TelegramPollingSession {
);
const shouldRestart = await this.#waitBeforeRestart(
(delay) => `Telegram polling runner stopped (${reason}); restarting in ${delay}.`,
{ stopTimedOut },
);
return shouldRestart ? "continue" : "exit";
} catch (err) {
@@ -1119,15 +1215,14 @@ export class TelegramPollingSession {
this.opts.log(
`[telegram][diag] polling cycle error reason=${reason} ${liveness.formatDiagnosticFields("lastGetUpdatesError")} err=${errMsg}${conflictHint}`,
);
clearForceCycleTimer();
const shouldRestart = await this.#waitBeforeRestart(
(delay) => `Telegram ${reason}: ${errMsg};${conflictHint} retrying in ${delay}.`,
);
return shouldRestart ? "continue" : "exit";
} finally {
clearInterval(watchdog);
if (forceCycleTimer) {
clearTimeout(forceCycleTimer);
}
clearForceCycleTimer();
this.opts.abortSignal?.removeEventListener("abort", abortFetch);
this.opts.abortSignal?.removeEventListener("abort", stopOnAbort);
await waitForGracefulStop(stopRunner);
@@ -1166,6 +1261,9 @@ export const testing = {
resetActiveSpooledUpdateHandlersForTests: (): void => {
activeSpooledUpdateHandlersByLane.clear();
},
createTelegramRestartBackoffState,
resetTelegramRestartBackoffState,
resolveTelegramRestartDelayMs,
resolveSpooledUpdateHandlerAbortGraceMs: (valueMs: unknown): number =>
resolvePositiveTimerTimeoutMs(valueMs, TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS),
};

View File

@@ -1080,6 +1080,32 @@ describe("sendMessageTelegram", () => {
});
});
it("preserves internal target writeback when gateway scopes are absent", async () => {
const sendMessage = vi.fn().mockResolvedValue({
message_id: 1,
chat: { id: "-100123" },
});
const getChat = vi.fn().mockResolvedValue({ id: -100123 });
const api = { sendMessage, getChat } as unknown as {
sendMessage: typeof sendMessage;
getChat: typeof getChat;
};
await sendMessageTelegram("https://t.me/mychannel", "hi", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
});
expect(getChat).toHaveBeenCalledWith("@mychannel");
expectPersistedTarget({
rawTarget: "https://t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
trustedInternalWriteback: true,
});
});
it("fails clearly when a legacy target cannot be resolved", async () => {
const getChat = vi.fn().mockRejectedValue(new Error("400: Bad Request: chat not found"));
const api = { getChat } as unknown as {

View File

@@ -399,6 +399,7 @@ async function resolveAndPersistChatId(params: {
resolvedChatId: chatId,
verbose: params.verbose,
gatewayClientScopes: params.gatewayClientScopes,
...(params.gatewayClientScopes === undefined ? { trustedInternalWriteback: true } : {}),
});
return chatId;
}

View File

@@ -95,6 +95,7 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
cfg: {} as OpenClawConfig,
rawTarget: "-100123",
resolvedChatId: "-100123",
gatewayClientScopes: ["operator.admin"],
});
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
@@ -118,6 +119,23 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
expect(saveCronStore).not.toHaveBeenCalled();
});
it("does not let internal writeback override non-admin gateway scopes", async () => {
await maybePersistResolvedTelegramTarget({
cfg: {
cron: { store: "/tmp/cron/jobs.json" },
} as OpenClawConfig,
rawTarget: "t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: ["operator.write"],
trustedInternalWriteback: true,
});
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(loadCronStore).not.toHaveBeenCalled();
expect(saveCronStore).not.toHaveBeenCalled();
});
it("skips config and cron writeback for gateway callers with an empty scope set", async () => {
await maybePersistResolvedTelegramTarget({
cfg: {
@@ -133,6 +151,53 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
expect(loadCronStore).not.toHaveBeenCalled();
expect(saveCronStore).not.toHaveBeenCalled();
});
it("skips config and cron writeback when gateway scopes are missing", async () => {
await maybePersistResolvedTelegramTarget({
cfg: {
cron: { store: "/tmp/cron/jobs.json" },
} as OpenClawConfig,
rawTarget: "t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
});
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(loadCronStore).not.toHaveBeenCalled();
expect(saveCronStore).not.toHaveBeenCalled();
});
it("writes back for gateway callers with operator.admin", async () => {
readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: {
config: {
channels: {
telegram: {
defaultTo: "t.me/mychannel",
},
},
},
},
writeOptions: {},
});
loadCronStore.mockResolvedValue({
version: 1,
jobs: [{ id: "a", delivery: { channel: "telegram", to: "t.me/mychannel" } }],
});
await maybePersistResolvedTelegramTarget({
cfg: {
cron: { store: "/tmp/cron/jobs.json" },
} as OpenClawConfig,
rawTarget: "t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: ["operator.admin"],
});
expect(writeConfigFile).toHaveBeenCalledTimes(1);
expect(saveCronStore).toHaveBeenCalledTimes(1);
});
}
it("writes back matching config and cron targets", async () => {
@@ -167,6 +232,8 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
} as OpenClawConfig,
rawTarget: "t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
trustedInternalWriteback: true,
});
expect(writeConfigFile).toHaveBeenCalledTimes(1);
@@ -202,6 +269,8 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
cfg: {} as OpenClawConfig,
rawTarget: "t.me/mychannel:topic:9",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
trustedInternalWriteback: true,
});
expect(writeConfigFile).toHaveBeenCalledTimes(1);
@@ -232,6 +301,8 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
cfg: {} as OpenClawConfig,
rawTarget: "@MyChannel",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
trustedInternalWriteback: true,
});
expect(writeConfigFile).toHaveBeenCalledTimes(1);

View File

@@ -147,6 +147,7 @@ export async function maybePersistResolvedTelegramTarget(params: {
resolvedChatId: string;
verbose?: boolean;
gatewayClientScopes?: readonly string[];
trustedInternalWriteback?: boolean;
}): Promise<void> {
const raw = params.rawTarget.trim();
if (!raw) {
@@ -160,10 +161,10 @@ export async function maybePersistResolvedTelegramTarget(params: {
return;
}
const { matchKey, resolvedTarget } = rewrite;
if (
Array.isArray(params.gatewayClientScopes) &&
!params.gatewayClientScopes.includes(TELEGRAM_ADMIN_SCOPE)
) {
const hasGatewayAdminScope = params.gatewayClientScopes?.includes(TELEGRAM_ADMIN_SCOPE) === true;
const trustedInternalWriteback =
params.gatewayClientScopes === undefined && params.trustedInternalWriteback === true;
if (!hasGatewayAdminScope && !trustedInternalWriteback) {
writebackLogger.warn(
`skipping Telegram target writeback for ${raw} because gateway caller is missing ${TELEGRAM_ADMIN_SCOPE}`,
);

View File

@@ -166,7 +166,7 @@ describe("web auto-reply last-route", () => {
SenderE164: "+1000",
SenderId: "+1000",
RawBody: "hello",
Body: expect.stringMatching(/^\[WhatsApp \+1000 .+\] \+1000: hello$/),
Body: expect.stringMatching(/^\[WhatsApp \+1000 .+\] \+1000: hello$/u),
BodyForAgent: "hello",
CommandBody: "hello",
Timestamp: now,

View File

@@ -1482,8 +1482,8 @@
"crabbox:stop": "node scripts/crabbox-wrapper.mjs stop",
"crabbox:warmup": "node scripts/crabbox-wrapper.mjs warmup",
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-unused",
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx knip@6.8.0 --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
"deadcode:knip": "pnpm dlx knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
"deadcode:knip": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",
"deadcode:report:ci:knip": "mkdir -p .artifacts/deadcode && pnpm deadcode:knip > .artifacts/deadcode/knip.txt 2>&1 || true",
"deadcode:report:ci:ts-prune": "mkdir -p .artifacts/deadcode && pnpm deadcode:ts-prune > .artifacts/deadcode/ts-prune.txt 2>&1 || true",
@@ -1591,7 +1591,6 @@
"mac:open": "open dist/OpenClaw.app",
"mac:package": "bash scripts/package-mac-app.sh",
"mac:restart": "bash scripts/restart-mac.sh",
"moltbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
"openclaw": "node scripts/run-node.mjs",
"openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
"perf:issue-78851": "node --import tsx scripts/perf/issue-78851-model-resolution.ts",

View File

@@ -502,6 +502,7 @@ type PendingStop = {
ws: WebSocket;
promise: Promise<void>;
resolve: () => void;
terminateTimer?: NodeJS.Timeout;
};
export class GatewayClient {
@@ -783,8 +784,7 @@ export class GatewayClient {
const ws = this.ws;
this.ws = null;
if (ws) {
const stopPromise = this.createPendingStop(ws);
ws.close();
const pendingStop = this.createPendingStop(ws);
const forceTerminateTimer = setTimeout(() => {
try {
ws.terminate();
@@ -792,30 +792,35 @@ export class GatewayClient {
this.resolvePendingStop(ws);
}, FORCE_STOP_TERMINATE_GRACE_MS);
forceTerminateTimer.unref?.();
pendingStop.terminateTimer = forceTerminateTimer;
ws.close();
this.flushPendingErrors(new Error("gateway client stopped"));
return stopPromise;
return pendingStop.promise;
}
this.flushPendingErrors(new Error("gateway client stopped"));
return null;
}
private createPendingStop(ws: WebSocket): Promise<void> {
private createPendingStop(ws: WebSocket): PendingStop {
if (this.pendingStop?.ws === ws) {
return this.pendingStop.promise;
return this.pendingStop;
}
let resolve!: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
this.pendingStop = { ws, promise, resolve };
return promise;
return this.pendingStop;
}
private resolvePendingStop(ws: WebSocket): void {
if (this.pendingStop?.ws !== ws) {
return;
}
const { resolve } = this.pendingStop;
const { resolve, terminateTimer } = this.pendingStop;
if (terminateTimer) {
clearTimeout(terminateTimer);
}
this.pendingStop = null;
resolve();
}

View File

@@ -45,6 +45,36 @@ describe("cron protocol validators", () => {
).toBe(true);
});
it("accepts command cron payloads", () => {
expect(
validateCronAddParams({
...minimalAddParams,
sessionTarget: "isolated",
payload: {
kind: "command",
argv: ["sh", "-lc", "echo ok"],
cwd: "/srv/example",
env: { FOO: "bar" },
input: "stdin",
timeoutSeconds: 30,
noOutputTimeoutSeconds: 5,
outputMaxBytes: 4096,
},
}),
).toBe(true);
expect(
validateCronUpdateParams({
id: "job-1",
patch: {
payload: {
kind: "command",
argv: ["sh", "-lc", "echo updated"],
},
},
}),
).toBe(true);
});
it("rejects add params when required scheduling fields are missing", () => {
const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams;
expect(validateCronAddParams(withoutWakeMode)).toBe(false);

View File

@@ -5,6 +5,7 @@ import {
formatValidationErrors,
validateChatAbortParams,
validateChatHistoryParams,
validateChatMetadataParams,
validateChatSendParams,
validateChatEvent,
validateCommandsListParams,
@@ -104,6 +105,13 @@ describe("lazy protocol validators", () => {
).toBe(true);
});
it("accepts selected-agent scope on chat metadata params", () => {
expect(validateChatMetadataParams({})).toBe(true);
expect(validateChatMetadataParams({ agentId: "work" })).toBe(true);
expect(validateChatMetadataParams({ agentId: "" })).toBe(false);
expect(validateChatMetadataParams({ agentId: "work", view: "configured" })).toBe(false);
});
it("can still compile every exported protocol validator", () => {
const failures: string[] = [];
const validators: Array<[string, ProtocolValidator]> = [];

View File

@@ -126,6 +126,8 @@ import {
type ChatEvent,
ChatEventSchema,
ChatHistoryParamsSchema,
type ChatMetadataParams,
ChatMetadataParamsSchema,
ChatMessageGetResultSchema,
ChatMessageGetParamsSchema,
type ChatInjectParams,
@@ -846,6 +848,7 @@ export const validateExecApprovalsNodeSetParams = lazyCompile<ExecApprovalsNodeS
);
export const validateLogsTailParams = lazyCompile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
export const validateChatMetadataParams = lazyCompile<ChatMetadataParams>(ChatMetadataParamsSchema);
export const validateChatMessageGetParams = lazyCompile(ChatMessageGetParamsSchema);
export const validateChatSendParams = lazyCompile(ChatSendParamsSchema);
export const validateChatAbortParams = lazyCompile<ChatAbortParams>(ChatAbortParamsSchema);
@@ -1115,6 +1118,7 @@ export {
ExecApprovalRequestParamsSchema,
ExecApprovalResolveParamsSchema,
ChatHistoryParamsSchema,
ChatMetadataParamsSchema,
ChatSendParamsSchema,
ChatInjectParamsSchema,
UpdateRunParamsSchema,
@@ -1223,6 +1227,7 @@ export type {
ArtifactsDownloadResult,
AgentsListParams,
AgentsListResult,
ChatMetadataParams,
CommandsListParams,
CommandsListResult,
CommandEntry,

View File

@@ -18,6 +18,22 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema; toolsAllow: TSch
);
}
function cronCommandPayloadSchema(params: { argv: TSchema }) {
return Type.Object(
{
kind: Type.Literal("command"),
argv: params.argv,
cwd: Type.Optional(Type.String({ minLength: 1 })),
env: Type.Optional(Type.Record(Type.String({ minLength: 1 }), Type.String())),
input: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
noOutputTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
outputMaxBytes: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
);
}
const CronSessionTargetSchema = Type.Union([
Type.Literal("main"),
Type.Literal("isolated"),
@@ -198,6 +214,9 @@ export const CronPayloadSchema = Type.Union([
message: NonEmptyString,
toolsAllow: Type.Array(Type.String()),
}),
cronCommandPayloadSchema({
argv: Type.Array(NonEmptyString, { minItems: 1 }),
}),
]);
export const CronPayloadPatchSchema = Type.Union([
@@ -212,6 +231,9 @@ export const CronPayloadPatchSchema = Type.Union([
message: Type.Optional(NonEmptyString),
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
}),
cronCommandPayloadSchema({
argv: Type.Optional(Type.Array(NonEmptyString, { minItems: 1 })),
}),
]);
export const CronFailureAlertSchema = Type.Object(

View File

@@ -34,6 +34,13 @@ export const ChatHistoryParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ChatMetadataParamsSchema = Type.Object(
{
agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const ChatMessageGetParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,

View File

@@ -191,6 +191,7 @@ import {
ChatEventSchema,
ChatFinalEventSchema,
ChatHistoryParamsSchema,
ChatMetadataParamsSchema,
ChatMessageGetParamsSchema,
ChatMessageGetResultSchema,
ChatInjectParamsSchema,
@@ -534,6 +535,7 @@ export const ProtocolSchemas = {
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
ChatHistoryParams: ChatHistoryParamsSchema,
ChatMetadataParams: ChatMetadataParamsSchema,
ChatMessageGetParams: ChatMessageGetParamsSchema,
ChatMessageGetResult: ChatMessageGetResultSchema,
ChatSendParams: ChatSendParamsSchema,

View File

@@ -160,6 +160,7 @@ export type AgentsListResult = SchemaType<"AgentsListResult">;
export type ModelChoice = SchemaType<"ModelChoice">;
export type ModelsListParams = SchemaType<"ModelsListParams">;
export type ModelsListResult = SchemaType<"ModelsListResult">;
export type ChatMetadataParams = SchemaType<"ChatMetadataParams">;
export type CommandEntry = SchemaType<"CommandEntry">;
export type CommandsListParams = SchemaType<"CommandsListParams">;
export type CommandsListResult = SchemaType<"CommandsListResult">;

View File

@@ -12,6 +12,7 @@ coverage:
objective: Verify the QA agent can respond correctly in a shared channel and respect mention-driven group semantics.
successCriteria:
- Agent replies in the shared channel transcript.
- Agent visible reply contains the scenario marker.
- Agent keeps the conversation scoped to the channel.
- Agent respects mention-driven group routing semantics.
docsRefs:
@@ -24,7 +25,8 @@ execution:
kind: flow
summary: Verify the QA agent can respond correctly in a shared channel and respect mention-driven group semantics.
config:
mentionPrompt: "@openclaw explain the QA lab"
expectedMarker: QA-CHANNEL-BASELINE-OK
mentionPrompt: "@openclaw qa channel baseline marker check. Reply exactly: QA-CHANNEL-BASELINE-OK"
```
```yaml qa-flow
@@ -78,7 +80,14 @@ steps:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === 'qa-room' && !candidate.threadId"
expr: "candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && candidate.conversation.kind === 'channel' && !candidate.threadId && String(candidate.text ?? '').includes(config.expectedMarker)"
- expr: liveTurnTimeoutMs(env, 180000)
- set: matchingOutbound
value:
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && candidate.conversation.kind === 'channel' && String(candidate.text ?? '').includes(config.expectedMarker))"
- assert:
expr: matchingOutbound.length === 1
message:
expr: "`expected exactly one channel baseline marker reply, saw ${matchingOutbound.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
detailsExpr: message.text
```

View File

@@ -12,6 +12,7 @@ coverage:
objective: Verify the QA agent can chat coherently in a DM, explain the QA setup, and stay in character.
successCriteria:
- Agent replies in DM without channel routing mistakes.
- Agent visible reply contains the scenario marker.
- Agent explains the QA lab and message bus correctly.
- Agent keeps the dev C-3PO personality.
docsRefs:
@@ -24,7 +25,8 @@ execution:
kind: flow
summary: Verify the QA agent can chat coherently in a DM, explain the QA setup, and stay in character.
config:
prompt: "Hello there, who are you?"
expectedMarker: QA-DM-BASELINE-OK
prompt: "DM baseline marker check. Include exact marker: `QA-DM-BASELINE-OK` and briefly identify the QA lab message bus."
```
```yaml qa-flow
@@ -47,7 +49,14 @@ steps:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === 'alice'"
expr: "candidate.direction === 'outbound' && candidate.conversation.id === 'alice' && candidate.conversation.kind === 'direct' && String(candidate.text ?? '').includes(config.expectedMarker)"
- expr: liveTurnTimeoutMs(env, 45000)
- set: matchingOutbound
value:
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'alice' && candidate.conversation.kind === 'direct' && String(candidate.text ?? '').includes(config.expectedMarker))"
- assert:
expr: matchingOutbound.length === 1
message:
expr: "`expected exactly one DM baseline marker reply, saw ${matchingOutbound.length}; transcript=${formatTransportTranscript(state, { conversationId: 'alice' })}`"
detailsExpr: outbound.text
```

View File

@@ -64,7 +64,7 @@ steps:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound'"
expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound' && String(candidate.text ?? '').includes(config.firstMarker)"
- expr: liveTurnTimeoutMs(env, 60000)
- set: beforeRestartCursor
value:
@@ -80,9 +80,9 @@ steps:
value:
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room')"
- assert:
expr: "firstMatchesBeforeFollowup.length === 1"
expr: "firstMatchesBeforeFollowup.length === 1 && String(firstMatchesBeforeFollowup[0]?.text ?? '').includes(config.firstMarker)"
message:
expr: "`readiness cycle replayed first reply ${firstMatchesBeforeFollowup.length} times; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
expr: "`readiness cycle should preserve exactly one marked first reply, saw ${firstMatchesBeforeFollowup.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
- call: runAgentPrompt
args:
- ref: env
@@ -99,7 +99,7 @@ steps:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound'"
expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound' && String(candidate.text ?? '').includes(config.secondMarker)"
- expr: liveTurnTimeoutMs(env, 60000)
- sinceIndex:
ref: beforeRestartCursor
@@ -108,13 +108,16 @@ steps:
expr: state.getSnapshot()
- set: firstMatches
value:
expr: "snapshot.messages.slice(0, beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room')"
expr: "snapshot.messages.slice(0, beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && String(candidate.text ?? '').includes(config.firstMarker))"
- set: secondMatches
value:
expr: "snapshot.messages.slice(beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && String(candidate.text ?? '').includes(config.secondMarker))"
- set: postRestartOutbounds
value:
expr: "snapshot.messages.slice(beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room')"
- assert:
expr: "firstMatches.length === 1 && secondMatches.length === 1"
expr: "firstMatches.length === 1 && secondMatches.length === 1 && postRestartOutbounds.length === 1 && !postRestartOutbounds.some((candidate) => String(candidate.text ?? '').includes(config.firstMarker))"
message:
expr: "`expected one pre-restart and one post-restart reply; first=${firstMatches.length} second=${secondMatches.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
expr: "`expected one marked pre-restart reply and exactly one marked post-restart reply without replaying the first marker; first=${firstMatches.length} second=${secondMatches.length} post=${postRestartOutbounds.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
detailsExpr: "`before=${firstOutbound.text}\\nafter=${secondOutbound.text}`"
```

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