Compare commits

..

227 Commits

Author SHA1 Message Date
Mariano Belinky
8d4b640499 chore: sync remote PR branch history without force-push 2026-02-16 13:50:58 +00:00
Mariano Belinky
f83476364d plugin-sdk/device-pair: keep mainline exports and behavior 2026-02-16 13:48:37 +00:00
Mariano Belinky
551efb9fc9 CLI: add qr setup-code flow with --remote support 2026-02-16 13:47:18 +00:00
Mariano Belinky
5ef811771a CLI: prefer gateway.remote auth for qr --remote 2026-02-16 13:47:18 +00:00
Mariano Belinky
fa90b3c92b CLI: restore qr --remote 2026-02-16 13:47:18 +00:00
pierreeurope
fec4be8dec fix(cron): prevent daily jobs from skipping days (48h jump) #17852 (#17903)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 1ffe6a45af
Co-authored-by: pierreeurope <248892285+pierreeurope@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:35:49 -05:00
brandonwise
095d522099 fix(security): create session transcript files with 0o600 permissions (#18066)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 962f497d24
Co-authored-by: brandonwise <21148772+brandonwise@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:33:40 -05:00
sebslight
6931f0fb50 refactor(telegram): avoid double-wrapping proxy fetch 2026-02-16 08:24:55 -05:00
sebslight
b4fa10ae67 refactor(infra): make fetch wrapping idempotent 2026-02-16 08:24:55 -05:00
sebslight
7b8cce0910 test(config): normalize merge-patch regression fixture formatting 2026-02-16 08:24:55 -05:00
sebslight
5b8bfd261b test(gateway): cover mixed-id config.patch rollback 2026-02-16 08:24:55 -05:00
sebslight
f4b2fd00bc fix(config): harden object-array merge-by-id fallback 2026-02-16 08:24:55 -05:00
Hongwei Ma
dddb1bc942 fix(telegram): fix streaming with extended thinking models overwriting previous messages/ also happens to Execution error (#17973)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 34b52eead8
Co-authored-by: Marvae <11957602+Marvae@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-16 18:54:34 +05:30
sebslight
553d17f8af refactor(agents): use silent token constant in prompts 2026-02-16 08:20:24 -05:00
Jackten
e3e8046a93 fix(infra): avoid detached finally unhandled rejection in fetch wrapper (#18014)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 4ec21c89cb
Co-authored-by: Jackten <2895479+Jackten@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:17:23 -05:00
不做了睡大觉
cb391f4bdc fix(config): prevent config.patch from destroying arrays when patch entries lack id (#18030)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a857df9e32
Co-authored-by: stakeswky <64798754+stakeswky@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:13:51 -05:00
sebslight
3a277e394e test(agents): add cooldown expiry helper regressions 2026-02-16 08:10:52 -05:00
sebslight
d224776ffb refactor(agents): extract cooldown probe decision helper 2026-02-16 08:10:52 -05:00
zerone0x
c2a0cf0c28 fix(tts): update tool description to prevent duplicate audio delivery (#18046)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 70c096abaa
Co-authored-by: zerone0x <39543393+zerone0x@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:09:02 -05:00
Mariano Belinky
6e8ed7af3a CLI: add qr setup-code flow with --remote support 2026-02-16 13:03:50 +00:00
Ítalo Souza
39bb1b3322 fix: auto-recover primary model after rate-limit cooldown expires (#17478) (#18045)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f7a7865727
Co-authored-by: PlayerGhost <28265945+PlayerGhost@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:03:35 -05:00
Mariano Belinky
5ccabe9e63 CLI: prefer gateway.remote auth for qr --remote 2026-02-16 12:56:50 +00:00
Mariano Belinky
aab3c4d2f0 CLI: restore qr --remote 2026-02-16 12:55:08 +00:00
yinghaosang
244ed9db39 fix(telegram): draft stream preview not threaded when replyToMode is on (#17880) (#17928)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: cfd4181a23
Co-authored-by: yinghaosang <261132136+yinghaosang@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-16 18:10:24 +05:30
Ayaan Zaidi
b2aa6e094d fix(telegram): prevent non-abort slash commands from racing chat replies (#17899)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 5c2f6f2c96
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-16 16:21:10 +05:30
Advait Paliwal
bc67af6ad8 cron: separate webhook POST delivery from announce (#17901)
* cron: split webhook delivery from announce mode

* cron: validate webhook delivery target

* cron: remove legacy webhook fallback config

* fix: finalize cron webhook delivery prep (#17901) (thanks @advaitpaliwal)

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-02-16 02:36:00 -08:00
Peter Steinberger
d841c9b26b test: remove duplicate replyToTag assertion in split-tag case 2026-02-16 10:02:59 +00:00
Peter Steinberger
597f956a4f test: remove duplicate existing-id all-mode planner case 2026-02-16 10:01:58 +00:00
Peter Steinberger
f043f2d8c9 test: trim duplicate first-mode hasReplied assertion variant 2026-02-16 10:00:57 +00:00
Peter Steinberger
a4e7f256db test: drop redundant off-mode hasReplied assertion 2026-02-16 09:59:59 +00:00
Peter Steinberger
893f56b87d test: remove redundant multi-variable template resolution case 2026-02-16 09:59:09 +00:00
Peter Steinberger
4da68afc73 test: remove duplicate off-mode existing-id planner case 2026-02-16 09:58:05 +00:00
Peter Steinberger
7cfd0aed5f test: remove duplicate non-date negative-case assertion 2026-02-16 09:56:46 +00:00
Peter Steinberger
d611db8049 test: remove duplicate provider-prefix assertion variant 2026-02-16 09:55:44 +00:00
Peter Steinberger
3eb9c2105c test: remove duplicate date-suffix assertion variant 2026-02-16 09:54:56 +00:00
Peter Steinberger
9f6462bd56 test: trim duplicate latest-suffix assertion variant 2026-02-16 09:54:05 +00:00
Peter Steinberger
2d03473072 test: trim duplicate provider-prefix assertion in short-model tests 2026-02-16 09:52:16 +00:00
Peter Steinberger
dbcdcc5d19 test: remove duplicate positive template-variable detection case 2026-02-16 09:51:09 +00:00
Peter Steinberger
c4297a8d60 test: remove redundant no-provider short-model case 2026-02-16 09:49:58 +00:00
Peter Steinberger
deef9f91bf test: remove duplicate multi-variable template check case 2026-02-16 09:48:51 +00:00
Peter Steinberger
523193a91f test: remove duplicate static template-variable false case 2026-02-16 09:47:45 +00:00
Peter Steinberger
cd04385f9f test: remove redundant provider-plus-date model-name case 2026-02-16 09:46:44 +00:00
Peter Steinberger
82fa526bb0 test: remove duplicate undefined template-variable guard case 2026-02-16 09:45:51 +00:00
Peter Steinberger
3fb4a7eb53 test: remove duplicate hook-wake heartbeat empty-file case 2026-02-16 09:44:16 +00:00
Peter Steinberger
7a6928712b test: remove redundant explicit telegram heartbeat target case 2026-02-16 09:43:01 +00:00
Peter Steinberger
9b351fcbd8 test: remove duplicate whatsapp group heartbeat target case 2026-02-16 09:41:50 +00:00
Peter Steinberger
d3ddf893c2 test: remove redundant store-rotation integration prune case 2026-02-16 09:39:48 +00:00
Peter Steinberger
a597bd26d4 test: remove duplicate direct-enabled whatsapp ack variant 2026-02-16 09:37:42 +00:00
Peter Steinberger
6fa150a890 test: trim redundant whatsapp mention-true ack reaction case 2026-02-16 09:36:02 +00:00
Peter Steinberger
93ad783c1b test: remove redundant slash channel-policy integration case 2026-02-16 09:34:35 +00:00
Peter Steinberger
acc6b62289 test: remove low-value private-channel lookup slash edge case 2026-02-16 09:32:38 +00:00
Peter Steinberger
fec1566f04 test: remove duplicate ack-reaction none-scope branch case 2026-02-16 09:30:33 +00:00
Peter Steinberger
ced5148afd test: remove redundant identity emoji response-prefix case 2026-02-16 09:29:41 +00:00
Peter Steinberger
c0973f24c6 test: remove low-value concurrency passthrough unit case 2026-02-16 09:28:20 +00:00
Peter Steinberger
6a392b8493 test: trim redundant ack-reaction removeAfterReply guard case 2026-02-16 09:27:12 +00:00
Peter Steinberger
f8ae538985 test: remove low-value slash arg-menu payload-shape case 2026-02-16 09:25:34 +00:00
Peter Steinberger
b63f9b7066 test: remove redundant configured memory-flush prompt case 2026-02-16 09:23:03 +00:00
Peter Steinberger
4e3429ae6e test: remove low-value typing-without-consumer variant 2026-02-16 09:22:17 +00:00
Peter Steinberger
78976d3f6f test: remove low-value dm-fallback slash access edge case 2026-02-16 09:21:39 +00:00
Peter Steinberger
e30900f93e test: remove low-value deprecated pruneDays e2e mapping case 2026-02-16 09:20:40 +00:00
Peter Steinberger
cef02df9d5 test: remove redundant explicit-deny slash policy case 2026-02-16 09:19:55 +00:00
Peter Steinberger
0f7ad51020 test: remove low-signal malformed slash button edge case 2026-02-16 09:18:36 +00:00
Peter Steinberger
4e16893c61 test: remove low-value memory-flush ro workspace case 2026-02-16 09:17:47 +00:00
Peter Steinberger
192dbc3ba9 test: drop duplicate role-ordering exception rewrite case 2026-02-16 09:16:11 +00:00
Peter Steinberger
d0b0ca9fcf test: remove low-value open-policy slash channel case 2026-02-16 09:15:18 +00:00
Peter Steinberger
22c53af604 test: remove redundant saveSessionStore cap e2e case 2026-02-16 09:13:56 +00:00
Peter Steinberger
54948a1d44 test: remove redundant maintenance config mapping e2e case 2026-02-16 09:13:05 +00:00
Peter Steinberger
22a1a56e7e test: remove low-value maintenance defaults e2e assertion 2026-02-16 09:11:17 +00:00
Peter Steinberger
15f8c57797 test: speed up subagent announce e2e and drop duplicate defer case 2026-02-16 09:10:11 +00:00
Peter Steinberger
404a8bc35f test: remove redundant pruning-plus-capping e2e case 2026-02-16 09:07:24 +00:00
Peter Steinberger
7a4c131d6b test: remove low-value mirrored-text media-filename unit case 2026-02-16 09:05:38 +00:00
Peter Steinberger
b156aafab9 test: remove low-value direct metadata-mapping unit case 2026-02-16 09:04:20 +00:00
Peter Steinberger
838d875fcb test: remove low-value custom-root agent-extraction path case 2026-02-16 09:03:07 +00:00
Peter Steinberger
7932387df2 test: remove low-value stale-prune no-updatedAt edge case 2026-02-16 09:02:08 +00:00
Peter Steinberger
4d2ba58da5 test: remove low-value legacy dm-direct fallback permutation 2026-02-16 09:00:54 +00:00
Peter Steinberger
7d26eae3ee test: remove low-value no-updatedAt cap-priority edge case 2026-02-16 09:00:02 +00:00
Peter Steinberger
5dc02aa55e test: remove low-value concurrent store-entry merge permutation 2026-02-16 08:58:43 +00:00
Peter Steinberger
c8704297b2 test: remove low-value relative traversal session-file guard case 2026-02-16 08:57:45 +00:00
Peter Steinberger
eb7b5c02c3 test: remove low-value cross-storepath lock parallelism case 2026-02-16 08:56:28 +00:00
Peter Steinberger
314f193030 fix(ci): run scope detection on blacksmith runners 2026-02-16 09:56:11 +01:00
Peter Steinberger
d5bc5ab7ba test: remove low-value resolveStorePath tilde-expansion unit case 2026-02-16 08:54:55 +00:00
Peter Steinberger
fecd623431 test: remove duplicate reset precedence permutation case 2026-02-16 08:53:51 +00:00
Peter Steinberger
1e4cf489e0 fix(ci): keep main runs alive while coalescing newer pushes 2026-02-16 09:53:36 +01:00
Peter Steinberger
5d8f43ae8e test: remove duplicate explicit-agent fallback path case 2026-02-16 08:52:55 +00:00
Peter Steinberger
896f9efcb7 test: remove low-value absolute-in-dir session-file happy path 2026-02-16 08:51:41 +00:00
Peter Steinberger
f448e4bf77 test: remove low-value lock queue cleanup bookkeeping case 2026-02-16 08:50:59 +00:00
Peter Steinberger
ada7a6289f fix(ci): dedupe docker release runs by ref 2026-02-16 09:50:37 +01:00
Peter Steinberger
731d72e119 test: remove redundant in-dir relative session-file acceptance case 2026-02-16 08:49:41 +00:00
Peter Steinberger
bf801f5159 test: remove low-value unknown-session mirror guard case 2026-02-16 08:48:23 +00:00
Peter Steinberger
929a96c2f8 test: remove low-signal mirrored-text trim unit case 2026-02-16 08:47:45 +00:00
Peter Steinberger
2983ef0243 fix(ci): use ref-based concurrency across workflows 2026-02-16 09:47:07 +01:00
Peter Steinberger
b5183c93d6 test: remove low-value lock-storePath guard wrapper test 2026-02-16 08:46:49 +00:00
Peter Steinberger
bd0e7d3d22 test: remove low-value positive session-id validation case 2026-02-16 08:45:30 +00:00
Peter Steinberger
19dfdfe5a8 test: remove low-value missing-session-key mirror guard case 2026-02-16 08:44:46 +00:00
Peter Steinberger
2d6b605cc3 test: remove low-value session-file options wrapper assertion 2026-02-16 08:44:01 +00:00
Peter Steinberger
025d4152d1 fix(ci): key concurrency by ref instead of sha 2026-02-16 09:42:58 +01:00
Peter Steinberger
f9419e26bb test: remove duplicate empty-text mirror integration case 2026-02-16 08:42:38 +00:00
Peter Steinberger
a4f86dc433 test: remove low-value session-file options agent-only case 2026-02-16 08:41:46 +00:00
Peter Steinberger
0c035c85ab test: remove redundant single-error lock queue recovery case 2026-02-16 08:40:34 +00:00
Peter Steinberger
aabc09bb9b test: remove duplicate lock-queue cleanup success case 2026-02-16 08:39:43 +00:00
Peter Steinberger
0d2e13fb73 test: remove redundant transcript-path wrapper case 2026-02-16 08:38:18 +00:00
Peter Steinberger
4f05d045b9 test: remove duplicate absolute outside-session-path guard case 2026-02-16 08:37:19 +00:00
Peter Steinberger
3daaa19426 fix(ci): use JDK 17 for Android SDK setup 2026-02-16 09:36:54 +01:00
Peter Steinberger
ec00efb38d test: remove duplicate reset-by-type direct selection case 2026-02-16 08:36:30 +00:00
Peter Steinberger
83a5f7ba8c test: remove duplicate passthrough storePath guard case 2026-02-16 08:35:14 +00:00
Peter Steinberger
6a759c9191 test: remove duplicate empty-storePath guard case 2026-02-16 08:34:22 +00:00
Peter Steinberger
f6b7736744 test: remove redundant absolute topic-suffix session-file case 2026-02-16 08:33:33 +00:00
Ayaan Zaidi
b6a9741ba4 refactor(telegram): simplify send/dispatch/target handling (#17819)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: fcb7aeeca3
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-16 14:00:34 +05:30
Peter Steinberger
1f607bec49 test: remove low-value no-rotation file-size case 2026-02-16 08:24:46 +00:00
Peter Steinberger
3dbb69da05 test: remove duplicate session file options fallback case 2026-02-16 08:23:52 +00:00
Peter Steinberger
49d383ba7c test: remove redundant default-root explicit fallback case 2026-02-16 08:23:09 +00:00
Peter Steinberger
e72e8ebe62 test: remove redundant default-root extracted fallback case 2026-02-16 08:22:23 +00:00
Peter Steinberger
374ad8c813 test: remove redundant all-stale pruning case 2026-02-16 08:20:51 +00:00
Peter Steinberger
6f4da72cb5 test: remove redundant cap-entry default-fallback case 2026-02-16 08:19:06 +00:00
Peter Steinberger
facf53cc3f test: remove redundant stale-prune default-fallback case 2026-02-16 08:17:51 +00:00
Peter Steinberger
eaec65656f test: remove redundant cap-entry under-limit case 2026-02-16 08:16:34 +00:00
Peter Steinberger
dfaca933c6 test: remove redundant rotate timestamp case 2026-02-16 08:15:20 +00:00
Peter Steinberger
aaf308d7ec test: remove redundant stale-prune under-limit case 2026-02-16 08:13:57 +00:00
Peter Steinberger
d115d48a72 test: remove redundant rotate missing-file no-op case 2026-02-16 08:12:38 +00:00
Peter Steinberger
d174c38737 test: remove redundant stale-prune boundary case 2026-02-16 08:11:40 +00:00
Peter Steinberger
005dbdd13e test: remove redundant stale-prune empty-store case 2026-02-16 08:09:56 +00:00
Peter Steinberger
c31e33cd18 test: remove redundant stale-prune return-count case 2026-02-16 08:08:59 +00:00
Peter Steinberger
f4fbfae97e test: remove redundant cap-entry empty-store case 2026-02-16 08:07:35 +00:00
Peter Steinberger
f349d40e62 test: remove redundant cap-entry return-count case 2026-02-16 08:06:41 +00:00
Peter Steinberger
d71779b46f test: remove redundant session-rotation exact-limit case 2026-02-16 08:05:26 +00:00
Peter Steinberger
df062fdb63 test: remove redundant cap-entry exact-limit case 2026-02-16 08:04:24 +00:00
Peter Steinberger
52ddaed795 test: remove redundant elide exact-limit case 2026-02-16 08:03:02 +00:00
Peter Steinberger
b7a20d8e8d test: remove redundant depth-1 subagent session case 2026-02-16 08:02:10 +00:00
Peter Steinberger
5cb228fdd0 test: remove redundant quick-reply truncation case 2026-02-16 08:00:47 +00:00
Peter Steinberger
8fd6d4d6dd test: remove redundant messageAction default-text case 2026-02-16 07:59:19 +00:00
Peter Steinberger
242e8f5c43 test: remove low-signal line account listing coverage 2026-02-16 07:58:00 +00:00
Peter Steinberger
4aab640fd1 test: remove redundant default-account normalization case 2026-02-16 07:56:41 +00:00
Peter Steinberger
35b6ccd62c test: remove redundant rich-menu action passthrough case 2026-02-16 07:55:39 +00:00
Peter Steinberger
7e1f542233 test: remove redundant uriAction passthrough case 2026-02-16 07:54:37 +00:00
Peter Steinberger
5927c53630 test: remove redundant postback displayText passthrough case 2026-02-16 07:53:10 +00:00
Peter Steinberger
8505577218 test: remove redundant line account-id env listing case 2026-02-16 07:52:08 +00:00
Peter Steinberger
b18b85dc77 test: remove redundant default rich-menu command smoke case 2026-02-16 07:51:04 +00:00
Peter Steinberger
f3eb003db9 test: remove redundant quick-reply creation smoke case 2026-02-16 07:50:02 +00:00
Peter Steinberger
0448693f8f test: remove redundant messageAction passthrough case 2026-02-16 07:49:15 +00:00
Peter Steinberger
e86647889c test: remove redundant datetimepicker passthrough case 2026-02-16 07:47:36 +00:00
Peter Steinberger
993a5e63a1 test: remove redundant yes-no label passthrough case 2026-02-16 07:46:48 +00:00
Peter Steinberger
c01e97f124 test: remove redundant list-card action passthrough case 2026-02-16 07:45:09 +00:00
Peter Steinberger
bf2d78505e test: remove redundant notification title passthrough case 2026-02-16 07:43:03 +00:00
Peter Steinberger
91337b4b6f test: remove redundant confirm alt-text passthrough case 2026-02-16 07:42:09 +00:00
Peter Steinberger
f74d56bd3b test: remove redundant image-card aspect ratio passthrough case 2026-02-16 07:40:45 +00:00
Peter Steinberger
56d0ad6942 test: remove redundant action-card hero passthrough case 2026-02-16 07:39:50 +00:00
Peter Steinberger
5997a4b0ef test: remove redundant media-player image passthrough case 2026-02-16 07:39:01 +00:00
Peter Steinberger
720aa3c1e6 test: drop redundant line info-card default footer case 2026-02-16 07:37:41 +00:00
Peter Steinberger
223e2a7127 test: remove redundant button thumbnail passthrough case 2026-02-16 07:36:45 +00:00
Peter Steinberger
31ab8ad46d test: remove overlapping short line grid layout case 2026-02-16 07:35:16 +00:00
Peter Steinberger
82a8fc0bc7 test: remove redundant yes-no default template case 2026-02-16 07:34:18 +00:00
Peter Steinberger
227e31d791 test: remove redundant line default menu config case 2026-02-16 07:32:30 +00:00
Peter Steinberger
357b1e8fee test: remove duplicate line account listing case 2026-02-16 07:30:37 +00:00
Peter Steinberger
4c46c23ca8 test: remove redundant default line account id case 2026-02-16 07:29:10 +00:00
Peter Steinberger
189b2e0588 test: remove redundant line default-menu bounds case 2026-02-16 07:28:02 +00:00
Peter Steinberger
a39c2263e5 test: prune overlapping line markdown conversion cases 2026-02-16 07:26:43 +00:00
Peter Steinberger
0490d0e173 test: drop redundant product carousel limit case 2026-02-16 07:25:16 +00:00
Peter Steinberger
64a0339d58 test: trim redundant line quick-reply account checks 2026-02-16 07:23:40 +00:00
Peter Steinberger
077130bdb8 test: remove overlapping line webhook/account cases 2026-02-16 07:22:30 +00:00
Peter Steinberger
12d6b3b0c9 test: prune redundant line action-type checks 2026-02-16 07:20:57 +00:00
Peter Steinberger
3028a1bd3e test: remove redundant line template type assertions 2026-02-16 07:19:41 +00:00
Peter Steinberger
57e055ddb5 test: remove line text quick-reply passthrough tests 2026-02-16 07:17:39 +00:00
Peter Steinberger
4fd008e918 test: remove redundant flex message wrapper test 2026-02-16 07:16:46 +00:00
Peter Steinberger
d39b8541f8 test: prune redundant markdown extractor plain-text negatives 2026-02-16 07:15:47 +00:00
Peter Steinberger
ac4183edd7 test: remove redundant line existence assertions 2026-02-16 07:14:54 +00:00
Peter Steinberger
838963d66c test: drop low-signal line media player footer assertion 2026-02-16 07:13:47 +00:00
Peter Steinberger
4852dd4503 test: remove duplicate line flex wrapper coverage 2026-02-16 07:12:52 +00:00
Peter Steinberger
4d1cb661fc test: remove redundant line link menu wrapper test 2026-02-16 07:11:16 +00:00
Peter Steinberger
3bd961f00a test: drop duplicate line quick-reply wrapper assertion 2026-02-16 07:10:19 +00:00
Peter Steinberger
583345fdfe test: collapse redundant markdown conversion micro-tests 2026-02-16 07:09:31 +00:00
Peter Steinberger
3d550ed4c3 test: remove low-signal line card existence tests 2026-02-16 07:08:32 +00:00
Peter Steinberger
c37cc5ffad test: trim redundant markdown strip and table layout checks 2026-02-16 07:07:07 +00:00
Peter Steinberger
b83ccfba13 test: remove redundant line flex baseline checks 2026-02-16 07:04:56 +00:00
Peter Steinberger
8ea890e8fb test: remove duplicate line quick-reply assertions 2026-02-16 07:03:51 +00:00
Peter Steinberger
ae6060d777 test: remove redundant line markdown conversion smoke checks 2026-02-16 07:02:37 +00:00
Peter Steinberger
ec708b6ab5 test: trim redundant line action helper smoke checks 2026-02-16 07:01:43 +00:00
Peter Steinberger
944a32cf02 test: remove redundant line flex smoke checks 2026-02-16 06:59:46 +00:00
Peter Steinberger
c4880675e1 test: prune redundant line template constructor checks 2026-02-16 06:58:33 +00:00
Peter Steinberger
8b6537d857 test: trim redundant line template shape checks 2026-02-16 06:57:15 +00:00
Peter Steinberger
12c3821acb test: prune low-signal line flex template checks 2026-02-16 06:55:49 +00:00
Peter Steinberger
a69c06e3cc test: remove duplicate daemon profile trim wrappers 2026-02-16 06:53:13 +00:00
Peter Steinberger
67aa7eefe5 test: remove redundant sticker thread id assertion 2026-02-16 06:51:50 +00:00
Peter Steinberger
425c715a05 test: remove duplicate sticker recipient normalization checks 2026-02-16 06:50:44 +00:00
Peter Steinberger
dcba3e5699 test: trim redundant telegram thread+reply combination checks 2026-02-16 06:49:17 +00:00
Peter Steinberger
27083e6f1a test: remove redundant telegram requireMention negative case 2026-02-16 06:47:45 +00:00
Peter Steinberger
18bb242316 test: remove duplicate line action creator coverage 2026-02-16 06:46:21 +00:00
the sun gif man
68ea063958 🤖 fix: preserve openai reasoning replay ids (#17792)
What:
- disable tool-call id sanitization for OpenAI/OpenAI Codex transcript policy
- gate id sanitization in image sanitizer to full mode only
- keep orphan reasoning downgrade scoped to OpenAI model-switch replay path
- update transcript policy, session-history, sanitizer, and reasoning replay tests
- document OpenAI model-switch orphan-reasoning cleanup behavior in transcript hygiene reference

Why:
- OpenAI Responses replay depends on canonical call_id|fc_id pairings for reasoning followers
- strict id rewriting in OpenAI path breaks follower matching and triggers rs_* orphan 400s
- limiting scope avoids behavior expansion while fixing the identified regression

Tests:
- pnpm vitest run src/agents/transcript-policy.test.ts src/agents/pi-embedded-runner.sanitize-session-history.test.ts src/agents/openai-responses.reasoning-replay.test.ts
- pnpm vitest run --config vitest.e2e.config.ts src/agents/transcript-policy.e2e.test.ts src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts
- pnpm lint
- pnpm format:check
- pnpm check:docs
- pnpm test (fails in current macOS bash 3.2 env at test/git-hooks-pre-commit.integration.test.ts: mapfile not found)
2026-02-15 22:45:01 -08:00
Peter Steinberger
eefda1314f test: drop duplicate telegram username allowFrom check 2026-02-16 06:44:38 +00:00
Peter Steinberger
a8a22920f1 test: remove duplicate telegram allowFrom cases 2026-02-16 06:43:24 +00:00
Peter Steinberger
a8084b24d6 test: trim additional low-signal flex template checks 2026-02-16 06:40:26 +00:00
Peter Steinberger
97d5ff3500 test: remove low-signal flex template option-only assertions 2026-02-16 06:38:41 +00:00
Peter Steinberger
abb7618b0f test: remove pass-through rich menu action mode checks 2026-02-16 06:37:38 +00:00
Peter Steinberger
1ec0f3b81d test: drop redundant daemon profile normalization wrappers 2026-02-16 06:36:15 +00:00
Peter Steinberger
6c3e7896c5 test: remove duplicate lowercase default profile daemon path cases 2026-02-16 06:34:05 +00:00
Peter Steinberger
2a5fa426f2 test: remove redundant schtasks command parsing cases 2026-02-16 06:32:59 +00:00
Peter Steinberger
29203884c2 test: consolidate gateway profile normalization coverage 2026-02-16 06:31:36 +00:00
Peter Steinberger
91e120870f test: remove duplicate uppercase default profile daemon cases 2026-02-16 06:29:20 +00:00
Peter Steinberger
6a9ead3813 test: remove duplicate profile-specific daemon constant cases 2026-02-16 06:28:15 +00:00
Peter Steinberger
cb998aa7f9 test: remove duplicate systemd exec-start split assertion 2026-02-16 06:27:19 +00:00
Peter Steinberger
ac02e45a88 test: drop redundant empty-profile extraction cases 2026-02-16 06:25:18 +00:00
Peter Steinberger
8f603ec03d test: remove duplicate default-profile casing checks 2026-02-16 06:23:34 +00:00
Peter Steinberger
84e0ee3c31 test: remove duplicate uppercase default profile case 2026-02-16 06:22:04 +00:00
Peter Steinberger
da2bdbef7e test: remove duplicate systemd exec-start split case 2026-02-16 06:21:13 +00:00
Peter Steinberger
1be7c4ba8e test: move session store pruning integration suite to e2e lane 2026-02-16 06:19:49 +00:00
Peter Steinberger
a82df1015b test: remove duplicate hr spacing assertion 2026-02-16 06:16:33 +00:00
Peter Steinberger
a0b459b8f9 test: remove duplicate undefined-profile default cases 2026-02-16 06:15:26 +00:00
Peter Steinberger
28118ca051 test: drop duplicate internal hook lifecycle case 2026-02-16 06:14:23 +00:00
Peter Steinberger
d374a64658 test: move skills-cli integration coverage to e2e lane 2026-02-16 06:13:46 +00:00
Peter Steinberger
0895bb6de6 test: move skills-install fallback suite to e2e lane 2026-02-16 06:11:01 +00:00
Peter Steinberger
189cba0100 test: remove duplicate sandbox access memory-flush case 2026-02-16 06:08:44 +00:00
Peter Steinberger
108ebc380f test: remove duplicate registry throw assertion 2026-02-16 06:06:24 +00:00
Peter Steinberger
93e62d8e3e test: remove duplicate slack dm authorization case 2026-02-16 06:04:58 +00:00
Peter Steinberger
ed28ad2822 test: remove duplicate configured canonical key case 2026-02-16 06:03:09 +00:00
Peter Steinberger
00bbddeef5 test: move git hook regression to e2e lane 2026-02-16 06:01:07 +00:00
Peter Steinberger
5ac59e6e02 test: remove duplicate availability-unavailable fallback case 2026-02-16 05:58:49 +00:00
Peter Steinberger
bfb5a44089 test: speed up plugin optional tools suite 2026-02-16 05:56:26 +00:00
Peter Steinberger
599195fb31 test: trim duplicate antigravity availability case 2026-02-16 05:53:54 +00:00
Peter Steinberger
705d83aec7 test: drop duplicate z-ai alias filter case 2026-02-16 05:41:58 +00:00
Peter Steinberger
c80017e704 test: trim duplicate z.ai provider alias case 2026-02-16 05:39:42 +00:00
Peter Steinberger
9e67f9d889 test: remove duplicate invalid slash-button case 2026-02-16 05:36:55 +00:00
Peter Steinberger
9383f85046 test: trim redundant web media prefix coverage 2026-02-16 05:34:08 +00:00
Peter Steinberger
5212d1c79e test: make sandbox symlink-escape assertion platform-aware 2026-02-16 06:26:08 +01:00
Peter Steinberger
7aa7b04fb0 test: rebalance isolated unit test lane 2026-02-16 05:22:00 +00:00
Peter Steinberger
b3d3f36360 test: speed up slack slash monitor tests 2026-02-16 05:20:22 +00:00
Peter Steinberger
2b6f8548c9 test: trim pre-commit hook integration setup 2026-02-16 05:15:55 +00:00
Peter Steinberger
9684ae4c6d test: tighten process timeout thresholds with stabilized emit guard 2026-02-16 05:09:47 +00:00
Peter Steinberger
39fa81dc96 chore: bump version to 2026.2.16 2026-02-16 06:08:47 +01:00
215 changed files with 5156 additions and 6457 deletions

View File

@@ -37,16 +37,6 @@ OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token
# ANTHROPIC_API_KEY=sk-ant-...
# GEMINI_API_KEY=...
# OPENROUTER_API_KEY=sk-or-...
# OPENCLAW_LIVE_OPENAI_KEY=sk-...
# OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-...
# OPENCLAW_LIVE_GEMINI_KEY=...
# OPENAI_API_KEY_1=...
# ANTHROPIC_API_KEY_1=...
# GEMINI_API_KEY_1=...
# GOOGLE_API_KEY=...
# OPENAI_API_KEYS=sk-1,sk-2
# ANTHROPIC_API_KEYS=sk-ant-1,sk-ant-2
# GEMINI_API_KEYS=key-1,key-2
# Optional additional providers
# ZAI_API_KEY=...

View File

@@ -6,14 +6,14 @@ on:
pull_request:
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
# Lint and format always run. Fail-safe: if detection fails, run everything.
docs-scope:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
docs_changed: ${{ steps.check.outputs.docs_changed }}
@@ -33,7 +33,7 @@ jobs:
changed-scope:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
run_node: ${{ steps.scope.outputs.run_node }}
run_macos: ${{ steps.scope.outputs.run_macos }}
@@ -672,7 +672,8 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
# setup-android's sdkmanager currently crashes on JDK 21 in CI.
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v3

View File

@@ -13,6 +13,10 @@ on:
- ".agents/**"
- "skills/**"
concurrency:
group: docker-release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

View File

@@ -7,8 +7,8 @@ on:
workflow_dispatch:
concurrency:
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
docs-scope:

View File

@@ -14,8 +14,8 @@ on:
- scripts/sandbox-common-setup.sh
concurrency:
group: sandbox-common-smoke-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
sandbox-common-smoke:

View File

@@ -6,8 +6,8 @@ on:
branches: [main]
concurrency:
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
no-tabs:

View File

@@ -213,7 +213,7 @@
- skip if package is missing on npm or version already matches.
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
- Post-check for each release:
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.15`
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.16`
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
## Changelog Release Notes

View File

@@ -2,7 +2,7 @@
Docs: https://docs.openclaw.ai
## 2026.2.15 (Unreleased)
## 2026.2.16 (Unreleased)
### Changes
@@ -12,11 +12,13 @@ Docs: https://docs.openclaw.ai
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal.
- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
### Fixes
- Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions.
- Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
@@ -26,7 +28,9 @@ Docs: https://docs.openclaw.ai
- Skills/Security: restrict `download` installer `targetDir` to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
- Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten.
- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez.
- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky.
- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou.
- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
@@ -44,19 +48,24 @@ Docs: https://docs.openclaw.ai
- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost.
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus.
- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus.
- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd.
- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme.
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x.
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
- Memory/FTS: in embedding-provider fallback mode, ensure file/session indexing still writes and refreshes FTS rows so first-run memory search works and stale removed entries are removed from FTS too. Thanks @irchelper.
- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope.
- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.

View File

@@ -21,8 +21,8 @@ android {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602150
versionName = "2026.2.15"
versionCode = 202602160
versionName = "2026.2.16"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.15</string>
<string>2026.2.16</string>
<key>CFBundleVersion</key>
<string>20260215</string>
<string>20260216</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.15</string>
<string>2026.2.16</string>
<key>CFBundleVersion</key>
<string>20260215</string>
<string>20260216</string>
</dict>
</plist>

View File

@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: OpenClaw
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.2.15"
CFBundleVersion: "20260215"
CFBundleShortVersionString: "2026.2.16"
CFBundleVersion: "20260216"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.2.15"
CFBundleVersion: "20260215"
CFBundleShortVersionString: "2026.2.16"
CFBundleVersion: "20260216"

View File

@@ -21,6 +21,7 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
case none
case announce
case webhook
var id: String {
self.rawValue

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.15</string>
<string>2026.2.16</string>
<key>CFBundleVersion</key>
<string>202602150</string>
<string>202602160</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -2087,7 +2087,6 @@ public struct CronJob: Codable, Sendable {
public let name: String
public let description: String?
public let enabled: Bool
public let notify: Bool?
public let deleteafterrun: Bool?
public let createdatms: Int
public let updatedatms: Int
@@ -2095,7 +2094,7 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: [String: AnyCodable]?
public let delivery: AnyCodable?
public let state: [String: AnyCodable]
public init(
@@ -2104,7 +2103,6 @@ public struct CronJob: Codable, Sendable {
name: String,
description: String?,
enabled: Bool,
notify: Bool?,
deleteafterrun: Bool?,
createdatms: Int,
updatedatms: Int,
@@ -2112,7 +2110,7 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
delivery: [String: AnyCodable]?,
delivery: AnyCodable?,
state: [String: AnyCodable]
) {
self.id = id
@@ -2120,7 +2118,6 @@ public struct CronJob: Codable, Sendable {
self.name = name
self.description = description
self.enabled = enabled
self.notify = notify
self.deleteafterrun = deleteafterrun
self.createdatms = createdatms
self.updatedatms = updatedatms
@@ -2137,7 +2134,6 @@ public struct CronJob: Codable, Sendable {
case name
case description
case enabled
case notify
case deleteafterrun = "deleteAfterRun"
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
@@ -2171,32 +2167,29 @@ public struct CronAddParams: Codable, Sendable {
public let agentid: AnyCodable?
public let description: String?
public let enabled: Bool?
public let notify: Bool?
public let deleteafterrun: Bool?
public let schedule: AnyCodable
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: [String: AnyCodable]?
public let delivery: AnyCodable?
public init(
name: String,
agentid: AnyCodable?,
description: String?,
enabled: Bool?,
notify: Bool?,
deleteafterrun: Bool?,
schedule: AnyCodable,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
delivery: [String: AnyCodable]?
delivery: AnyCodable?
) {
self.name = name
self.agentid = agentid
self.description = description
self.enabled = enabled
self.notify = notify
self.deleteafterrun = deleteafterrun
self.schedule = schedule
self.sessiontarget = sessiontarget
@@ -2209,7 +2202,6 @@ public struct CronAddParams: Codable, Sendable {
case agentid = "agentId"
case description
case enabled
case notify
case deleteafterrun = "deleteAfterRun"
case schedule
case sessiontarget = "sessionTarget"

View File

@@ -2087,7 +2087,6 @@ public struct CronJob: Codable, Sendable {
public let name: String
public let description: String?
public let enabled: Bool
public let notify: Bool?
public let deleteafterrun: Bool?
public let createdatms: Int
public let updatedatms: Int
@@ -2095,7 +2094,7 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: [String: AnyCodable]?
public let delivery: AnyCodable?
public let state: [String: AnyCodable]
public init(
@@ -2104,7 +2103,6 @@ public struct CronJob: Codable, Sendable {
name: String,
description: String?,
enabled: Bool,
notify: Bool?,
deleteafterrun: Bool?,
createdatms: Int,
updatedatms: Int,
@@ -2112,7 +2110,7 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
delivery: [String: AnyCodable]?,
delivery: AnyCodable?,
state: [String: AnyCodable]
) {
self.id = id
@@ -2120,7 +2118,6 @@ public struct CronJob: Codable, Sendable {
self.name = name
self.description = description
self.enabled = enabled
self.notify = notify
self.deleteafterrun = deleteafterrun
self.createdatms = createdatms
self.updatedatms = updatedatms
@@ -2137,7 +2134,6 @@ public struct CronJob: Codable, Sendable {
case name
case description
case enabled
case notify
case deleteafterrun = "deleteAfterRun"
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
@@ -2171,32 +2167,29 @@ public struct CronAddParams: Codable, Sendable {
public let agentid: AnyCodable?
public let description: String?
public let enabled: Bool?
public let notify: Bool?
public let deleteafterrun: Bool?
public let schedule: AnyCodable
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: [String: AnyCodable]?
public let delivery: AnyCodable?
public init(
name: String,
agentid: AnyCodable?,
description: String?,
enabled: Bool?,
notify: Bool?,
deleteafterrun: Bool?,
schedule: AnyCodable,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
delivery: [String: AnyCodable]?
delivery: AnyCodable?
) {
self.name = name
self.agentid = agentid
self.description = description
self.enabled = enabled
self.notify = notify
self.deleteafterrun = deleteafterrun
self.schedule = schedule
self.sessiontarget = sessiontarget
@@ -2209,7 +2202,6 @@ public struct CronAddParams: Codable, Sendable {
case agentid = "agentId"
case description
case enabled
case notify
case deleteafterrun = "deleteAfterRun"
case schedule
case sessiontarget = "sessionTarget"

View File

@@ -27,7 +27,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
- **Main session**: enqueue a system event, then run on the next heartbeat.
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none).
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
- Webhook posting is opt-in per job: set `notify: true` and configure `cron.webhook`.
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
## Quick start (actionable)
@@ -100,7 +101,7 @@ A cron job is a stored record with:
- a **schedule** (when it should run),
- a **payload** (what it should do),
- optional **delivery mode** (announce or none).
- optional **delivery mode** (`announce`, `webhook`, or `none`).
- optional **agent binding** (`agentId`): run the job under a specific agent; if
missing or unknown, the gateway falls back to the default agent.
@@ -141,8 +142,9 @@ Key behaviors:
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
- Each run starts a **fresh session id** (no prior conversation carry-over).
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
- `delivery.mode` (isolated-only) chooses what happens:
- `delivery.mode` chooses what happens:
- `announce`: deliver a summary to the target channel and post a brief summary to the main session.
- `webhook`: POST the finished event payload to `delivery.to`.
- `none`: internal only (no delivery, no main-session summary).
- `wakeMode` controls when the main-session summary posts:
- `now`: immediate heartbeat.
@@ -164,11 +166,11 @@ Common `agentTurn` fields:
- `model` / `thinking`: optional overrides (see below).
- `timeoutSeconds`: optional timeout override.
Delivery config (isolated jobs only):
Delivery config:
- `delivery.mode`: `none` | `announce`.
- `delivery.mode`: `none` | `announce` | `webhook`.
- `delivery.channel`: `last` or a specific channel.
- `delivery.to`: channel-specific target (phone/chat/channel id).
- `delivery.to`: channel-specific target (announce) or webhook URL (webhook mode).
- `delivery.bestEffort`: avoid failing the job if announce delivery fails.
Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to`
@@ -193,6 +195,18 @@ Behavior details:
- The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and
`next-heartbeat` waits for the next scheduled heartbeat.
#### Webhook delivery flow
When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to`.
Behavior details:
- The endpoint must be a valid HTTP(S) URL.
- No channel delivery is attempted in webhook mode.
- No main-session summary is posted in webhook mode.
- If `cron.webhookToken` is set, auth header is `Authorization: Bearer <cron.webhookToken>`.
- Deprecated fallback: stored legacy jobs with `notify: true` still post to `cron.webhook` (if configured), with a warning so you can migrate to `delivery.mode = "webhook"`.
### Model and thinking overrides
Isolated jobs (`agentTurn`) can override the model and thinking level:
@@ -214,11 +228,12 @@ Resolution priority:
Isolated jobs can deliver output to a channel via the top-level `delivery` config:
- `delivery.mode`: `announce` (deliver a summary) or `none`.
- `delivery.mode`: `announce` (channel delivery), `webhook` (HTTP POST), or `none`.
- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`.
- `delivery.to`: channel-specific recipient target.
Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`).
`announce` delivery is only valid for isolated jobs (`sessionTarget: "isolated"`).
`webhook` delivery is valid for both main and isolated jobs.
If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main sessions
“last route” (the last place the agent replied).
@@ -289,7 +304,7 @@ Notes:
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
- `everyMs` is milliseconds.
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
- Optional fields: `agentId`, `description`, `enabled`, `notify`, `deleteAfterRun` (defaults to true for `at`),
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
`delivery`.
- `wakeMode` defaults to `"now"` when omitted.
@@ -334,18 +349,20 @@ Notes:
enabled: true, // default true
store: "~/.openclaw/cron/jobs.json",
maxConcurrentRuns: 1, // default 1
webhook: "https://example.invalid/cron-finished", // optional finished-run webhook endpoint
webhookToken: "replace-with-dedicated-webhook-token", // optional, do not reuse gateway auth token
webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs
webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode
},
}
```
Webhook behavior:
- The Gateway posts finished run events to `cron.webhook` only when the job has `notify: true`.
- Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job.
- Webhook URLs must be valid `http://` or `https://` URLs.
- Payload is the cron finished event JSON.
- If `cron.webhookToken` is set, auth header is `Authorization: Bearer <cron.webhookToken>`.
- If `cron.webhookToken` is not set, no `Authorization` header is sent.
- Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present.
Disable cron entirely:

View File

@@ -17,20 +17,6 @@ For model selection rules, see [/concepts/models](/concepts/models).
- If you set `agents.defaults.models`, it becomes the allowlist.
- CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set <provider/model>`.
## API key rotation
- Supports generic provider rotation for selected providers.
- Configure multiple keys via:
- `OPENCLAW_LIVE_<PROVIDER>_KEY` (single live override, highest priority)
- `<PROVIDER>_API_KEYS` (comma or semicolon list)
- `<PROVIDER>_API_KEY` (primary key)
- `<PROVIDER>_API_KEY_*` (numbered list, e.g. `<PROVIDER>_API_KEY_1`)
- For Google providers, `GOOGLE_API_KEY` is also included as fallback.
- Key selection order preserves priority and deduplicates values.
- Requests are retried with the next key only on rate-limit responses (for example `429`, `rate_limit`, `quota`, `resource exhausted`).
- Non-rate-limit failures fail immediately; no key rotation is attempted.
- When all candidate keys fail, the final error is returned from the last attempt.
## Built-in providers (pi-ai catalog)
OpenClaw ships with the piai catalog. These providers require **no**
@@ -40,7 +26,6 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `openai`
- Auth: `OPENAI_API_KEY`
- Optional rotation: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, plus `OPENCLAW_LIVE_OPENAI_KEY` (single override)
- Example model: `openai/gpt-5.1-codex`
- CLI: `openclaw onboard --auth-choice openai-api-key`
@@ -54,7 +39,6 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `anthropic`
- Auth: `ANTHROPIC_API_KEY` or `claude setup-token`
- Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override)
- Example model: `anthropic/claude-opus-4-6`
- CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic`
@@ -94,7 +78,6 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `google`
- Auth: `GEMINI_API_KEY`
- Optional rotation: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` fallback, and `OPENCLAW_LIVE_GEMINI_KEY` (single override)
- Example model: `google/gemini-3-pro-preview`
- CLI: `openclaw onboard --auth-choice gemini-api-key`

View File

@@ -103,23 +103,6 @@ openclaw models status
openclaw doctor
```
## API key rotation behavior (gateway)
Some providers support retrying a request with alternative keys when an API call
hits a provider rate limit.
- Priority order:
- `OPENCLAW_LIVE_<PROVIDER>_KEY` (single override)
- `<PROVIDER>_API_KEYS`
- `<PROVIDER>_API_KEY`
- `<PROVIDER>_API_KEY_*`
- Google providers also include `GOOGLE_API_KEY` as an additional fallback.
- The same key list is deduplicated before use.
- OpenClaw retries with the next key only for rate-limit errors (for example
`429`, `rate_limit`, `quota`, `resource exhausted`).
- Non-rate-limit errors are not retried with alternate keys.
- If all keys fail, the final error from the last attempt is returned.
## Controlling which credential is used
### Per-session (chat command)

View File

@@ -2320,7 +2320,7 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
cron: {
enabled: true,
maxConcurrentRuns: 2,
webhook: "https://example.invalid/cron-finished", // optional, must be http:// or https://
webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs
webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth
sessionRetention: "24h", // duration string or false
},
@@ -2328,8 +2328,8 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
```
- `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`.
- `webhook`: finished-run webhook endpoint, only used when the job has `notify: true`.
- `webhookToken`: dedicated bearer token for webhook auth, if omitted no auth header is sent.
- `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
- `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`.
See [Cron Jobs](/automation/cron-jobs).

View File

@@ -91,7 +91,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Costs money / uses rate limits
- Prefer running narrowed subsets instead of “everything”
- Live runs will source `~/.profile` to pick up missing API keys
- API key rotation (provider-specific): set `*_API_KEYS` with comma/semicolon format or `*_API_KEY_1`, `*_API_KEY_2` (for example `OPENAI_API_KEYS`, `ANTHROPIC_API_KEYS`, `GEMINI_API_KEYS`) or per-live override via `OPENCLAW_LIVE_*_KEY`; tests retry on rate limit responses.
- Anthropic key rotation: set `OPENCLAW_LIVE_ANTHROPIC_KEYS="sk-...,sk-..."` (or `OPENCLAW_LIVE_ANTHROPIC_KEY=sk-...`) or multiple `ANTHROPIC_API_KEY*` vars; tests will retry on rate limits
## Which suite should I run?

View File

@@ -34,17 +34,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.15 \
APP_VERSION=2026.2.16 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.15.zip
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.16.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.15.dmg
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.16.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.15.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.15 \
APP_VERSION=2026.2.16 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.15.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.16.dSYM.zip
```
## Appcast entry
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.15.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.16.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
- Upload `OpenClaw-2026.2.15.zip` (and `OpenClaw-2026.2.15.dSYM.zip`) to the GitHub release for tag `v2026.2.15`.
- Upload `OpenClaw-2026.2.16.zip` (and `OpenClaw-2026.2.16.dSYM.zip`) to the GitHub release for tag `v2026.2.16`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.

View File

@@ -83,9 +83,10 @@ Cron jobs panel notes:
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
- Channel/target fields appear when announce is selected.
- New job form includes a **Notify webhook** toggle (`notify` on the job).
- Gateway webhook posting requires both `notify: true` on the job and `cron.webhook` in config.
- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL.
- For main-session jobs, webhook and none delivery modes are available.
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated.
## Chat behavior

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-antigravity-auth",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Google Antigravity OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"devDependencies": {

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Mattermost channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-core",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nostr",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Twitch channel plugin",
"type": "module",

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalo",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalouser",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Zalo Personal Account plugin via zca-cli",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",

View File

@@ -27,12 +27,8 @@ const unitIsolatedFilesRaw = [
"src/browser/server.agent-contract-form-layout-act-commands.test.ts",
"src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts",
"src/browser/server.auth-token-gates-http.test.ts",
"src/browser/server-context.remote-tab-ops.test.ts",
"src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts",
// Keep this high-variance heavy file off the unit-fast critical path.
"src/auto-reply/reply.block-streaming.test.ts",
// Integration test is process-heavy and can bottleneck unit-fast.
"test/git-hooks-pre-commit.integration.test.ts",
// Archive extraction/fixture-heavy suite; keep off unit-fast critical path.
"src/hooks/install.test.ts",
// Setup-heavy bot bootstrap suite.

View File

@@ -1,72 +0,0 @@
import { formatErrorMessage } from "../infra/errors.js";
import { collectProviderApiKeys, isApiKeyRateLimitError } from "./live-auth-keys.js";
type ApiKeyRetryParams = {
apiKey: string;
error: unknown;
attempt: number;
};
type ExecuteWithApiKeyRotationOptions<T> = {
provider: string;
apiKeys: string[];
execute: (apiKey: string) => Promise<T>;
shouldRetry?: (params: ApiKeyRetryParams & { message: string }) => boolean;
onRetry?: (params: ApiKeyRetryParams & { message: string }) => void;
};
function dedupeApiKeys(raw: string[]): string[] {
const seen = new Set<string>();
const keys: string[] = [];
for (const value of raw) {
const apiKey = value.trim();
if (!apiKey || seen.has(apiKey)) {
continue;
}
seen.add(apiKey);
keys.push(apiKey);
}
return keys;
}
export function collectProviderApiKeysForExecution(params: {
provider: string;
primaryApiKey?: string;
}): string[] {
const { primaryApiKey, provider } = params;
return dedupeApiKeys([primaryApiKey?.trim() ?? "", ...collectProviderApiKeys(provider)]);
}
export async function executeWithApiKeyRotation<T>(
params: ExecuteWithApiKeyRotationOptions<T>,
): Promise<T> {
const keys = dedupeApiKeys(params.apiKeys);
if (keys.length === 0) {
throw new Error(`No API keys configured for provider "${params.provider}".`);
}
let lastError: unknown;
for (let attempt = 0; attempt < keys.length; attempt += 1) {
const apiKey = keys[attempt];
try {
return await params.execute(apiKey);
} catch (error) {
lastError = error;
const message = formatErrorMessage(error);
const retryable = params.shouldRetry
? params.shouldRetry({ apiKey, error, attempt, message })
: isApiKeyRateLimitError(message);
if (!retryable || attempt + 1 >= keys.length) {
break;
}
params.onRetry?.({ apiKey, error, attempt, message });
}
}
if (lastError === undefined) {
throw new Error(`Failed to run API request for ${params.provider}.`);
}
throw lastError;
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { AuthProfileStore } from "./auth-profiles.js";
import { getSoonestCooldownExpiry } from "./auth-profiles.js";
function makeStore(usageStats?: AuthProfileStore["usageStats"]): AuthProfileStore {
return {
version: 1,
profiles: {},
usageStats,
};
}
describe("getSoonestCooldownExpiry", () => {
it("returns null when no cooldown timestamps exist", () => {
const store = makeStore();
expect(getSoonestCooldownExpiry(store, ["openai:p1"])).toBeNull();
});
it("returns earliest unusable time across profiles", () => {
const store = makeStore({
"openai:p1": {
cooldownUntil: 1_700_000_002_000,
disabledUntil: 1_700_000_004_000,
},
"openai:p2": {
cooldownUntil: 1_700_000_003_000,
},
"openai:p3": {
disabledUntil: 1_700_000_001_000,
},
});
expect(getSoonestCooldownExpiry(store, ["openai:p1", "openai:p2", "openai:p3"])).toBe(
1_700_000_001_000,
);
});
it("ignores unknown profiles and invalid cooldown values", () => {
const store = makeStore({
"openai:p1": {
cooldownUntil: -1,
},
"openai:p2": {
cooldownUntil: Infinity,
},
"openai:p3": {
disabledUntil: NaN,
},
"openai:p4": {
cooldownUntil: 1_700_000_005_000,
},
});
expect(
getSoonestCooldownExpiry(store, [
"missing",
"openai:p1",
"openai:p2",
"openai:p3",
"openai:p4",
]),
).toBe(1_700_000_005_000);
});
it("returns past timestamps when cooldown already expired", () => {
const store = makeStore({
"openai:p1": {
cooldownUntil: 1_700_000_000_000,
},
"openai:p2": {
disabledUntil: 1_700_000_010_000,
},
});
expect(getSoonestCooldownExpiry(store, ["openai:p1", "openai:p2"])).toBe(1_700_000_000_000);
});
});

View File

@@ -33,6 +33,7 @@ export type {
export {
calculateAuthProfileCooldownMs,
clearAuthProfileCooldown,
getSoonestCooldownExpiry,
isProfileInCooldown,
markAuthProfileCooldown,
markAuthProfileFailure,

View File

@@ -25,6 +25,32 @@ export function isProfileInCooldown(store: AuthProfileStore, profileId: string):
return unusableUntil ? Date.now() < unusableUntil : false;
}
/**
* Return the soonest `unusableUntil` timestamp (ms epoch) among the given
* profiles, or `null` when no profile has a recorded cooldown. Note: the
* returned timestamp may be in the past if the cooldown has already expired.
*/
export function getSoonestCooldownExpiry(
store: AuthProfileStore,
profileIds: string[],
): number | null {
let soonest: number | null = null;
for (const id of profileIds) {
const stats = store.usageStats?.[id];
if (!stats) {
continue;
}
const until = resolveProfileUnusableUntil(stats);
if (typeof until !== "number" || !Number.isFinite(until) || until <= 0) {
continue;
}
if (soonest === null || until < soonest) {
soonest = until;
}
}
return soonest;
}
/**
* Mark a profile as successfully used. Resets error count and updates lastUsed.
* Uses store lock to avoid overwriting concurrent usage updates.

View File

@@ -1,47 +1,4 @@
import { normalizeProviderId } from "./model-selection.js";
const KEY_SPLIT_RE = /[\s,;]+/g;
const GOOGLE_LIVE_SINGLE_KEY = "OPENCLAW_LIVE_GEMINI_KEY";
const PROVIDER_PREFIX_OVERRIDES: Record<string, string> = {
google: "GEMINI",
"google-vertex": "GEMINI",
};
type ProviderApiKeyConfig = {
liveSingle?: string;
listVar?: string;
primaryVar?: string;
prefixedVar?: string;
fallbackVars: string[];
};
const PROVIDER_API_KEY_CONFIG: Record<string, Omit<ProviderApiKeyConfig, "fallbackVars">> = {
anthropic: {
liveSingle: "OPENCLAW_LIVE_ANTHROPIC_KEY",
listVar: "OPENCLAW_LIVE_ANTHROPIC_KEYS",
primaryVar: "ANTHROPIC_API_KEY",
prefixedVar: "ANTHROPIC_API_KEY_",
},
google: {
liveSingle: GOOGLE_LIVE_SINGLE_KEY,
listVar: "GEMINI_API_KEYS",
primaryVar: "GEMINI_API_KEY",
prefixedVar: "GEMINI_API_KEY_",
},
"google-vertex": {
liveSingle: GOOGLE_LIVE_SINGLE_KEY,
listVar: "GEMINI_API_KEYS",
primaryVar: "GEMINI_API_KEY",
prefixedVar: "GEMINI_API_KEY_",
},
openai: {
liveSingle: "OPENCLAW_LIVE_OPENAI_KEY",
listVar: "OPENAI_API_KEYS",
primaryVar: "OPENAI_API_KEY",
prefixedVar: "OPENAI_API_KEY_",
},
};
function parseKeyList(raw?: string | null): string[] {
if (!raw) {
@@ -68,53 +25,17 @@ function collectEnvPrefixedKeys(prefix: string): string[] {
return keys;
}
function resolveProviderApiKeyConfig(provider: string): ProviderApiKeyConfig {
const normalized = normalizeProviderId(provider);
const custom = PROVIDER_API_KEY_CONFIG[normalized];
const base = PROVIDER_PREFIX_OVERRIDES[normalized] ?? normalized.toUpperCase().replace(/-/g, "_");
const liveSingle = custom?.liveSingle ?? `OPENCLAW_LIVE_${base}_KEY`;
const listVar = custom?.listVar ?? `${base}_API_KEYS`;
const primaryVar = custom?.primaryVar ?? `${base}_API_KEY`;
const prefixedVar = custom?.prefixedVar ?? `${base}_API_KEY_`;
if (normalized === "google" || normalized === "google-vertex") {
return {
liveSingle,
listVar,
primaryVar,
prefixedVar,
fallbackVars: ["GOOGLE_API_KEY"],
};
}
return {
liveSingle,
listVar,
primaryVar,
prefixedVar,
fallbackVars: [],
};
}
export function collectProviderApiKeys(provider: string): string[] {
const config = resolveProviderApiKeyConfig(provider);
const forcedSingle = config.liveSingle ? process.env[config.liveSingle]?.trim() : undefined;
export function collectAnthropicApiKeys(): string[] {
const forcedSingle = process.env.OPENCLAW_LIVE_ANTHROPIC_KEY?.trim();
if (forcedSingle) {
return [forcedSingle];
}
const fromList = parseKeyList(config.listVar ? process.env[config.listVar] : undefined);
const primary = config.primaryVar ? process.env[config.primaryVar]?.trim() : undefined;
const fromPrefixed = config.prefixedVar ? collectEnvPrefixedKeys(config.prefixedVar) : [];
const fallback = config.fallbackVars
.map((envVar) => process.env[envVar]?.trim())
.filter(Boolean) as string[];
const fromList = parseKeyList(process.env.OPENCLAW_LIVE_ANTHROPIC_KEYS);
const fromEnv = collectEnvPrefixedKeys("ANTHROPIC_API_KEY");
const primary = process.env.ANTHROPIC_API_KEY?.trim();
const seen = new Set<string>();
const add = (value?: string) => {
if (!value) {
return;
@@ -128,26 +49,17 @@ export function collectProviderApiKeys(provider: string): string[] {
for (const value of fromList) {
add(value);
}
add(primary);
for (const value of fromPrefixed) {
add(value);
if (primary) {
add(primary);
}
for (const value of fallback) {
for (const value of fromEnv) {
add(value);
}
return Array.from(seen);
}
export function collectAnthropicApiKeys(): string[] {
return collectProviderApiKeys("anthropic");
}
export function collectGeminiApiKeys(): string[] {
return collectProviderApiKeys("google");
}
export function isApiKeyRateLimitError(message: string): boolean {
export function isAnthropicRateLimitError(message: string): boolean {
const lower = message.toLowerCase();
if (lower.includes("rate_limit")) {
return true;
@@ -158,22 +70,9 @@ export function isApiKeyRateLimitError(message: string): boolean {
if (lower.includes("429")) {
return true;
}
if (lower.includes("quota exceeded") || lower.includes("quota_exceeded")) {
return true;
}
if (lower.includes("resource exhausted") || lower.includes("resource_exhausted")) {
return true;
}
if (lower.includes("too many requests")) {
return true;
}
return false;
}
export function isAnthropicRateLimitError(message: string): boolean {
return isApiKeyRateLimitError(message);
}
export function isAnthropicBillingError(message: string): boolean {
const lower = message.toLowerCase();
if (lower.includes("credit balance")) {
@@ -192,7 +91,7 @@ export function isAnthropicBillingError(message: string): boolean {
return true;
}
if (
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\spayment/i.test(
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment/i.test(
lower,
)
) {

View File

@@ -0,0 +1,343 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { AuthProfileStore } from "./auth-profiles.js";
// Mock auth-profiles module — must be before importing model-fallback
vi.mock("./auth-profiles.js", () => ({
ensureAuthProfileStore: vi.fn(),
getSoonestCooldownExpiry: vi.fn(),
isProfileInCooldown: vi.fn(),
resolveAuthProfileOrder: vi.fn(),
}));
import {
ensureAuthProfileStore,
getSoonestCooldownExpiry,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
import { _probeThrottleInternals, runWithModelFallback } from "./model-fallback.js";
const mockedEnsureAuthProfileStore = vi.mocked(ensureAuthProfileStore);
const mockedGetSoonestCooldownExpiry = vi.mocked(getSoonestCooldownExpiry);
const mockedIsProfileInCooldown = vi.mocked(isProfileInCooldown);
const mockedResolveAuthProfileOrder = vi.mocked(resolveAuthProfileOrder);
function makeCfg(overrides: Partial<OpenClawConfig> = {}): OpenClawConfig {
return {
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
...overrides,
} as OpenClawConfig;
}
describe("runWithModelFallback probe logic", () => {
let realDateNow: () => number;
const NOW = 1_700_000_000_000;
beforeEach(() => {
realDateNow = Date.now;
Date.now = vi.fn(() => NOW);
// Clear throttle state between tests
_probeThrottleInternals.lastProbeAttempt.clear();
// Default: ensureAuthProfileStore returns a fake store
const fakeStore: AuthProfileStore = {
version: 1,
profiles: {},
};
mockedEnsureAuthProfileStore.mockReturnValue(fakeStore);
// Default: resolveAuthProfileOrder returns profiles only for "openai" provider
mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => {
if (provider === "openai") {
return ["openai-profile-1"];
}
if (provider === "anthropic") {
return ["anthropic-profile-1"];
}
if (provider === "google") {
return ["google-profile-1"];
}
return [];
});
// Default: only openai profiles are in cooldown; fallback providers are available
mockedIsProfileInCooldown.mockImplementation((_store, profileId: string) => {
return profileId.startsWith("openai");
});
});
afterEach(() => {
Date.now = realDateNow;
vi.restoreAllMocks();
});
it("skips primary model when far from cooldown expiry (30 min remaining)", async () => {
const cfg = makeCfg();
// Cooldown expires in 30 min — well beyond the 2-min margin
const expiresIn30Min = NOW + 30 * 60 * 1000;
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
const run = vi.fn().mockResolvedValue("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
// Should skip primary and use fallback
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("anthropic", "claude-haiku-3-5");
expect(result.attempts[0]?.reason).toBe("rate_limit");
});
it("probes primary model when within 2-min margin of cooldown expiry", async () => {
const cfg = makeCfg();
// Cooldown expires in 1 minute — within 2-min probe margin
const expiresIn1Min = NOW + 60 * 1000;
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn1Min);
const run = vi.fn().mockResolvedValue("probed-ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
// Should probe primary and succeed
expect(result.result).toBe("probed-ok");
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
});
it("probes primary model when cooldown already expired", async () => {
const cfg = makeCfg();
// Cooldown expired 5 min ago
const expiredAlready = NOW - 5 * 60 * 1000;
mockedGetSoonestCooldownExpiry.mockReturnValue(expiredAlready);
const run = vi.fn().mockResolvedValue("recovered");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("recovered");
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
});
it("does NOT probe non-primary candidates during cooldown", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"],
},
},
},
} as Partial<OpenClawConfig>);
// Override: ALL providers in cooldown for this test
mockedIsProfileInCooldown.mockReturnValue(true);
// All profiles in cooldown, cooldown just about to expire
const almostExpired = NOW + 30 * 1000; // 30s remaining
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
// Primary probe fails with 429
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
.mockResolvedValue("should-not-reach");
try {
await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect.unreachable("should have thrown since all candidates exhausted");
} catch {
// Primary was probed (i === 0 + within margin), non-primary were skipped
expect(run).toHaveBeenCalledTimes(1); // only primary was actually called
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
}
});
it("throttles probe when called within 30s interval", async () => {
const cfg = makeCfg();
// Cooldown just about to expire (within probe margin)
const almostExpired = NOW + 30 * 1000;
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
// Simulate a recent probe 10s ago
_probeThrottleInternals.lastProbeAttempt.set("openai", NOW - 10_000);
const run = vi.fn().mockResolvedValue("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
// Should be throttled → skip primary, use fallback
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("anthropic", "claude-haiku-3-5");
expect(result.attempts[0]?.reason).toBe("rate_limit");
});
it("allows probe when 30s have passed since last probe", async () => {
const cfg = makeCfg();
const almostExpired = NOW + 30 * 1000;
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
// Last probe was 31s ago — should NOT be throttled
_probeThrottleInternals.lastProbeAttempt.set("openai", NOW - 31_000);
const run = vi.fn().mockResolvedValue("probed-ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("probed-ok");
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
});
it("handles non-finite soonest safely (treats as probe-worthy)", async () => {
const cfg = makeCfg();
// Return Infinity — should be treated as "probe" per the guard
mockedGetSoonestCooldownExpiry.mockReturnValue(Infinity);
const run = vi.fn().mockResolvedValue("ok-infinity");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok-infinity");
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
});
it("handles NaN soonest safely (treats as probe-worthy)", async () => {
const cfg = makeCfg();
mockedGetSoonestCooldownExpiry.mockReturnValue(NaN);
const run = vi.fn().mockResolvedValue("ok-nan");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok-nan");
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
});
it("handles null soonest safely (treats as probe-worthy)", async () => {
const cfg = makeCfg();
mockedGetSoonestCooldownExpiry.mockReturnValue(null);
const run = vi.fn().mockResolvedValue("ok-null");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok-null");
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
});
it("single candidate skips with rate_limit and exhausts candidates", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: [],
},
},
},
} as Partial<OpenClawConfig>);
const almostExpired = NOW + 30 * 1000;
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
const run = vi.fn().mockResolvedValue("unreachable");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
fallbacksOverride: [],
run,
}),
).rejects.toThrow("All models failed");
expect(run).not.toHaveBeenCalled();
});
it("scopes probe throttling by agentDir to avoid cross-agent suppression", async () => {
const cfg = makeCfg();
const almostExpired = NOW + 30 * 1000;
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
const run = vi.fn().mockResolvedValue("probed-ok");
await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
agentDir: "/tmp/agent-a",
run,
});
await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
agentDir: "/tmp/agent-b",
run,
});
expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini");
expect(run).toHaveBeenNthCalledWith(2, "openai", "gpt-4.1-mini");
});
});

View File

@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
import type { FailoverReason } from "./pi-embedded-helpers.js";
import {
ensureAuthProfileStore,
getSoonestCooldownExpiry,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
@@ -217,6 +218,50 @@ function resolveFallbackCandidates(params: {
return candidates;
}
const lastProbeAttempt = new Map<string, number>();
const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key
const PROBE_MARGIN_MS = 2 * 60 * 1000;
const PROBE_SCOPE_DELIMITER = "::";
function resolveProbeThrottleKey(provider: string, agentDir?: string): string {
const scope = String(agentDir ?? "").trim();
return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider;
}
function shouldProbePrimaryDuringCooldown(params: {
isPrimary: boolean;
hasFallbackCandidates: boolean;
now: number;
throttleKey: string;
authStore: ReturnType<typeof ensureAuthProfileStore>;
profileIds: string[];
}): boolean {
if (!params.isPrimary || !params.hasFallbackCandidates) {
return false;
}
const lastProbe = lastProbeAttempt.get(params.throttleKey) ?? 0;
if (params.now - lastProbe < MIN_PROBE_INTERVAL_MS) {
return false;
}
const soonest = getSoonestCooldownExpiry(params.authStore, params.profileIds);
if (soonest === null || !Number.isFinite(soonest)) {
return true;
}
// Probe when cooldown already expired or within the configured margin.
return params.now >= soonest - PROBE_MARGIN_MS;
}
/** @internal exposed for unit tests only */
export const _probeThrottleInternals = {
lastProbeAttempt,
MIN_PROBE_INTERVAL_MS,
PROBE_MARGIN_MS,
resolveProbeThrottleKey,
} as const;
export async function runWithModelFallback<T>(params: {
cfg: OpenClawConfig | undefined;
provider: string;
@@ -239,6 +284,8 @@ export async function runWithModelFallback<T>(params: {
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
const hasFallbackCandidates = candidates.length > 1;
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i];
if (authStore) {
@@ -250,14 +297,34 @@ export async function runWithModelFallback<T>(params: {
const isAnyProfileAvailable = profileIds.some((id) => !isProfileInCooldown(authStore, id));
if (profileIds.length > 0 && !isAnyProfileAvailable) {
// All profiles for this provider are in cooldown; skip without attempting
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
reason: "rate_limit",
// All profiles for this provider are in cooldown.
// For the primary model (i === 0), probe it if the soonest cooldown
// expiry is close or already past. This avoids staying on a fallback
// model long after the real rate-limit window clears.
const now = Date.now();
const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir);
const shouldProbe = shouldProbePrimaryDuringCooldown({
isPrimary: i === 0,
hasFallbackCandidates,
now,
throttleKey: probeThrottleKey,
authStore,
profileIds,
});
continue;
if (!shouldProbe) {
// Skip without attempting
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
reason: "rate_limit",
});
continue;
}
// Primary model probe: attempt it despite cooldown to detect recovery.
// If it fails, the error is caught below and we fall through to the
// next candidate as usual.
lastProbeAttempt.set(probeThrottleKey, now);
}
}
try {

View File

@@ -115,6 +115,15 @@ describe("openai-responses reasoning replay", () => {
expect(types).toContain("reasoning");
expect(types).toContain("function_call");
expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call"));
const functionCall = input.find(
(item) =>
item &&
typeof item === "object" &&
(item as Record<string, unknown>).type === "function_call",
) as Record<string, unknown> | undefined;
expect(functionCall?.call_id).toBe("call_123");
expect(functionCall?.id).toBe("fc_123");
});
it("still replays reasoning when paired with an assistant message", async () => {

View File

@@ -160,6 +160,35 @@ describe("sanitizeSessionMessagesImages", () => {
const toolResult = out[1] as { toolUseId?: string };
expect(toolResult.toolUseId).toBe("callabcitem123");
});
it("does not sanitize tool IDs in images-only mode", async () => {
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call_123|fc_456",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeMode: "images-only",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
});
const assistant = out[0] as unknown as { content?: Array<{ type?: string; id?: string }> };
const toolCall = assistant.content?.find((b) => b.type === "toolCall");
expect(toolCall?.id).toBe("call_123|fc_456");
const toolResult = out[1] as unknown as { toolCallId?: string };
expect(toolResult.toolCallId).toBe("call_123|fc_456");
});
it("filters whitespace-only assistant text blocks", async () => {
const input = [
{

View File

@@ -294,6 +294,27 @@ describe("downgradeOpenAIReasoningBlocks", () => {
// oxlint-disable-next-line typescript/no-explicit-any
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
});
it("is idempotent for orphaned reasoning cleanup", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinkingSignature: JSON.stringify({ id: "rs_orphan", type: "reasoning" }),
},
],
},
{ role: "user", content: "next" },
];
// oxlint-disable-next-line typescript/no-explicit-any
const once = downgradeOpenAIReasoningBlocks(input as any);
// oxlint-disable-next-line typescript/no-explicit-any
const twice = downgradeOpenAIReasoningBlocks(once as any);
expect(twice).toEqual(once);
});
});
describe("normalizeTextForComparison", () => {

View File

@@ -51,9 +51,10 @@ export async function sanitizeSessionMessagesImages(
const allowNonImageSanitization = sanitizeMode === "full";
// We sanitize historical session messages because Anthropic can reject a request
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
const sanitizedIds = options?.sanitizeToolCallIds
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
: messages;
const sanitizedIds =
allowNonImageSanitization && options?.sanitizeToolCallIds
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
: messages;
const out: AgentMessage[] = [];
for (const msg of sanitizedIds) {
if (!msg || typeof msg !== "object") {

View File

@@ -0,0 +1,50 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
makeInMemorySessionManager,
makeModelSnapshotEntry,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
describe("sanitizeSessionHistory openai tool id preservation", () => {
it("keeps canonical call_id|fc_id pairings for same-model openai replay", async () => {
const sessionEntries = [
makeModelSnapshotEntry({
provider: "openai",
modelApi: "openai-responses",
modelId: "gpt-5.2-codex",
}),
];
const sessionManager = makeInMemorySessionManager(sessionEntries);
const messages: AgentMessage[] = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call_123|fc_123",
toolName: "noop",
content: [{ type: "text", text: "ok" }],
isError: false,
} as unknown as AgentMessage,
];
const result = await sanitizeSessionHistory({
messages,
modelApi: "openai-responses",
provider: "openai",
modelId: "gpt-5.2-codex",
sessionManager,
sessionId: "test-session",
});
const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> };
const toolCall = assistant.content?.find((block) => block.type === "toolCall");
expect(toolCall?.id).toBe("call_123|fc_123");
const toolResult = result[1] as { toolCallId?: string };
expect(toolResult.toolCallId).toBe("call_123|fc_123");
});
});

View File

@@ -52,7 +52,7 @@ describe("sanitizeSessionHistory e2e smoke", () => {
);
});
it("applies strict tool-call sanitization for openai-responses", async () => {
it("keeps images-only sanitize policy without tool-call id rewriting for openai-responses", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
await sanitizeSessionHistory({
@@ -68,8 +68,7 @@ describe("sanitizeSessionHistory e2e smoke", () => {
"session:history",
expect.objectContaining({
sanitizeMode: "images-only",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
sanitizeToolCallIds: false,
}),
);
});

View File

@@ -98,7 +98,7 @@ describe("sanitizeSessionHistory", () => {
);
});
it("sanitizes tool call ids for openai-responses while keeping images-only mode", async () => {
it("does not sanitize tool call ids for openai-responses", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
await sanitizeSessionHistory({
@@ -112,11 +112,7 @@ describe("sanitizeSessionHistory", () => {
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({
sanitizeMode: "images-only",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
}),
expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }),
);
});
@@ -243,7 +239,7 @@ describe("sanitizeSessionHistory", () => {
expect(result).toEqual(messages);
});
it("downgrades openai reasoning only when the model changes", async () => {
it("downgrades openai reasoning when the model changes", async () => {
const sessionEntries = [
makeModelSnapshotEntry({
provider: "anthropic",

View File

@@ -471,6 +471,7 @@ export async function runEmbeddedPiAgent(
blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking,
onReasoningStream: params.onReasoningStream,
onReasoningEnd: params.onReasoningEnd,
onToolResult: params.onToolResult,
onAgentEvent: params.onAgentEvent,
extraSystemPrompt: params.extraSystemPrompt,

View File

@@ -737,6 +737,7 @@ export async function runEmbeddedAttempt(
shouldEmitToolOutput: params.shouldEmitToolOutput,
onToolResult: params.onToolResult,
onReasoningStream: params.onReasoningStream,
onReasoningEnd: params.onReasoningEnd,
onBlockReply: params.onBlockReply,
onBlockReplyFlush: params.onBlockReplyFlush,
blockReplyBreak: params.blockReplyBreak,

View File

@@ -95,6 +95,7 @@ export type RunEmbeddedPiAgentParams = {
blockReplyBreak?: "text_end" | "message_end";
blockReplyChunking?: BlockReplyChunking;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onReasoningEnd?: () => void | Promise<void>;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
lane?: string;

View File

@@ -184,6 +184,29 @@ describe("buildEmbeddedRunPayloads", () => {
expect(payloads[0]?.text).toContain("code 1");
});
it("does not add tool error fallback when assistant text exists after tool calls", () => {
const payloads = buildPayloads({
assistantTexts: ["Checked the page and recovered with final answer."],
lastAssistant: makeAssistant({
stopReason: "toolUse",
errorMessage: undefined,
content: [
{
type: "toolCall",
id: "toolu_01",
name: "browser",
arguments: { action: "search", query: "openclaw docs" },
},
],
}),
lastToolError: { toolName: "browser", error: "connection timeout" },
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.isError).toBeUndefined();
expect(payloads[0]?.text).toContain("recovered");
});
it("suppresses recoverable tool errors containing 'required' for non-mutating tools", () => {
const payloads = buildPayloads({
lastToolError: { toolName: "browser", error: "url required" },

View File

@@ -218,6 +218,7 @@ export function buildEmbeddedRunPayloads(params: {
: []
).filter((text) => !shouldSuppressRawErrorText(text));
let hasUserFacingAssistantReply = false;
for (const text of answerTexts) {
const {
text: cleanedText,
@@ -238,22 +239,13 @@ export function buildEmbeddedRunPayloads(params: {
replyToTag,
replyToCurrent,
});
hasUserFacingAssistantReply = true;
}
if (params.lastToolError) {
const lastAssistantHasToolCalls =
Array.isArray(params.lastAssistant?.content) &&
params.lastAssistant?.content.some((block) =>
block && typeof block === "object"
? (block as { type?: unknown }).type === "toolCall"
: false,
);
const lastAssistantWasToolUse = params.lastAssistant?.stopReason === "toolUse";
const hasUserFacingReply =
replyItems.length > 0 && !lastAssistantHasToolCalls && !lastAssistantWasToolUse;
const shouldShowToolError = shouldShowToolErrorWarning({
lastToolError: params.lastToolError,
hasUserFacingReply,
hasUserFacingReply: hasUserFacingAssistantReply,
suppressToolErrors: Boolean(params.config?.messages?.suppressToolErrors),
});

View File

@@ -140,7 +140,12 @@ export function handleMessageUpdate(
})
.trim();
if (next) {
const wasThinking = ctx.state.partialBlockState.thinking;
const visibleDelta = chunk ? ctx.stripBlockTags(chunk, ctx.state.partialBlockState) : "";
// Detect when thinking block ends (</think> tag processed)
if (wasThinking && !ctx.state.partialBlockState.thinking) {
void ctx.params.onReasoningEnd?.();
}
const parsedDelta = visibleDelta ? ctx.consumePartialReplyDirectives(visibleDelta) : null;
const parsedFull = parseReplyDirectives(stripTrailingDirective(next));
const cleanedText = parsedFull.text;

View File

@@ -17,6 +17,8 @@ export type SubscribeEmbeddedPiSessionParams = {
shouldEmitToolOutput?: () => boolean;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
/** Called when a thinking/reasoning block ends (</think> tag processed). */
onReasoningEnd?: () => void | Promise<void>;
onBlockReply?: (payload: {
text?: string;
mediaUrls?: string[];

View File

@@ -78,7 +78,15 @@ describe("validateBindMounts", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
const link = join(dir, "etc-link");
symlinkSync("/etc", link);
expect(() => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`])).toThrow(/blocked path/);
const run = () => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`]);
if (process.platform === "win32") {
// Windows source paths (e.g. C:\...) are intentionally rejected as non-POSIX.
expect(run).toThrow(/non-absolute source path/);
return;
}
expect(run).toThrow(/blocked path/);
});
it("rejects non-absolute source paths (relative or named volumes)", () => {

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
const agentSpy = vi.fn(async () => ({ runId: "run-main", status: "ok" }));
const sessionsDeleteSpy = vi.fn();
@@ -22,6 +23,17 @@ let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfi
},
};
function loadSessionStoreFixture(): Record<string, Record<string, unknown>> {
return new Proxy(sessionStore, {
get(target, key: string | symbol) {
if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) {
return { inputTokens: 1, outputTokens: 1, totalTokens: 2 };
}
return target[key as keyof typeof target];
},
});
}
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async (req: unknown) => {
const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } };
@@ -47,7 +59,7 @@ vi.mock("./tools/agent-step.js", () => ({
}));
vi.mock("../config/sessions.js", () => ({
loadSessionStore: vi.fn(() => sessionStore),
loadSessionStore: vi.fn(() => loadSessionStoreFixture()),
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions.json",
resolveMainSessionKey: () => "agent:main:main",
@@ -93,6 +105,9 @@ describe("subagent announce formatting", () => {
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-123",
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
},
};
await runSubagentAnnounceFlow({
@@ -208,7 +223,7 @@ describe("subagent announce formatting", () => {
expect(msg).toContain("[sessionId: child-session-usage]");
expect(msg).toContain("A completed subagent task is ready for user delivery.");
expect(msg).toContain(
"Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.",
`Reply ONLY: ${SILENT_REPLY_TOKEN} if this exact result was already delivered to the user in this same turn.`,
);
expect(msg).toContain("step-0");
expect(msg).toContain("step-139");
@@ -580,6 +595,9 @@ describe("subagent announce formatting", () => {
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-1",
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
},
};
@@ -742,34 +760,6 @@ describe("subagent announce formatting", () => {
expect(agentSpy).not.toHaveBeenCalled();
});
it("does not delete child session when announce is deferred for an active run", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.waitForEmbeddedPiRunEnd.mockResolvedValue(false);
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-active",
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-child-active-delete",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "context-stress-test",
timeoutMs: 1000,
cleanup: "delete",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(false);
expect(sessionsDeleteSpy).not.toHaveBeenCalled();
});
it("normalizes requesterOrigin for direct announce delivery", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);

View File

@@ -1,4 +1,5 @@
import { resolveQueueSettings } from "../auto-reply/reply/queue.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
@@ -364,9 +365,9 @@ function buildAnnounceReplyInstruction(params: {
return `There are still ${params.remainingActiveSubagentRuns} active subagent ${activeRunsLabel} for this session. If they are part of the same workflow, wait for the remaining results before sending a user update. If they are unrelated, respond normally using only the result above.`;
}
if (params.requesterIsSubagent) {
return "Convert this completion into a concise internal orchestration update for your parent agent in your own words. Keep this internal context private (don't mention system/log/stats/session details or announce type). If this result is duplicate or no update is needed, reply ONLY: NO_REPLY.";
return `Convert this completion into a concise internal orchestration update for your parent agent in your own words. Keep this internal context private (don't mention system/log/stats/session details or announce type). If this result is duplicate or no update is needed, reply ONLY: ${SILENT_REPLY_TOKEN}.`;
}
return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the system message verbatim. Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.`;
return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the system message verbatim. Reply ONLY: ${SILENT_REPLY_TOKEN} if this exact result was already delivered to the user in this same turn.`;
}
export async function runSubagentAnnounceFlow(params: {

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { buildSubagentSystemPrompt } from "./subagent-announce.js";
import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js";
@@ -367,7 +368,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("message: Send messages and channel actions");
expect(prompt).toContain("### message tool");
expect(prompt).toContain("respond with ONLY: NO_REPLY");
expect(prompt).toContain(`respond with ONLY: ${SILENT_REPLY_TOKEN}`);
});
it("includes runtime provider capabilities when present", () => {

View File

@@ -112,7 +112,7 @@ function buildMessagingSection(params: {
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Sub-agent orchestration → use subagents(action=list|steer|kill)",
"- `[System Message] ...` blocks are internal context and are not user-visible by default.",
"- If a `[System Message]` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to NO_REPLY).",
`- If a \`[System Message]\` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to ${SILENT_REPLY_TOKEN}).`,
"- Never use exec/curl for provider messaging; OpenClaw handles all routing internally.",
params.availableTools.has("message")
? [

View File

@@ -443,4 +443,61 @@ describe("cron tool", () => {
};
expect(call?.params?.delivery).toEqual({ mode: "none" });
});
it("does not infer announce delivery when mode is webhook", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await tool.execute("call-webhook-explicit", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({
mode: "webhook",
to: "https://example.invalid/cron-finished",
});
});
it("fails fast when webhook mode is missing delivery.to", async () => {
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await expect(
tool.execute("call-webhook-missing", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "webhook" },
},
}),
).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL');
expect(callGatewayMock).toHaveBeenCalledTimes(0);
});
it("fails fast when webhook mode uses a non-http URL", async () => {
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await expect(
tool.execute("call-webhook-invalid", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" },
},
}),
).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL');
expect(callGatewayMock).toHaveBeenCalledTimes(0);
});
});

View File

@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { CronDelivery, CronMessageChannel } from "../../cron/types.js";
import { loadConfig } from "../../config/config.js";
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { extractTextFromChatContent } from "../../shared/chat-content.js";
import { isRecord, truncateUtf16Safe } from "../../utils.js";
@@ -217,10 +218,9 @@ JOB SCHEMA (for add action):
"name": "string (optional)",
"schedule": { ... }, // Required: when to run
"payload": { ... }, // Required: what to execute
"delivery": { ... }, // Optional: announce summary (isolated only)
"delivery": { ... }, // Optional: announce summary or webhook POST
"sessionTarget": "main" | "isolated", // Required
"enabled": true | false, // Optional, default true
"notify": true | false // Optional webhook opt-in; set true for user-facing reminders
"enabled": true | false // Optional, default true
}
SCHEDULE TYPES (schedule.kind):
@@ -239,15 +239,17 @@ PAYLOAD TYPES (payload.kind):
- "agentTurn": Runs agent with message (isolated sessions only)
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
DELIVERY (isolated-only, top-level):
{ "mode": "none|announce", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
DELIVERY (top-level):
{ "mode": "none|announce|webhook", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
- Default for isolated agentTurn jobs (when delivery omitted): "announce"
- If the task needs to send to a specific chat/recipient, set delivery.channel/to here; do not call messaging tools inside the run.
- announce: send to chat channel (optional channel/to target)
- webhook: send finished-run event as HTTP POST to delivery.to (URL required)
- If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
- For reminders users should be notified about, set notify=true.
- For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL.
Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event.
WAKE MODES (for wake action):
@@ -294,7 +296,6 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
"payload",
"delivery",
"enabled",
"notify",
"description",
"deleteAfterRun",
"agentId",
@@ -352,11 +353,25 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
const delivery = isRecord(deliveryValue) ? deliveryValue : undefined;
const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : "";
const mode = modeRaw.trim().toLowerCase();
if (mode === "webhook") {
const webhookUrl = normalizeHttpWebhookUrl(delivery?.to);
if (!webhookUrl) {
throw new Error(
'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL',
);
}
if (delivery) {
delivery.to = webhookUrl;
}
}
const hasTarget =
(typeof delivery?.channel === "string" && delivery.channel.trim()) ||
(typeof delivery?.to === "string" && delivery.to.trim());
const shouldInfer =
(deliveryValue == null || delivery) && mode !== "none" && !hasTarget;
(deliveryValue == null || delivery) &&
(mode === "" || mode === "announce") &&
!hasTarget;
if (shouldInfer) {
const inferred = inferDeliveryFromSessionKey(opts.agentSessionKey);
if (inferred) {

View File

@@ -81,14 +81,12 @@ export function createMemorySearchTool(options: {
status.backend === "qmd"
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
: decorated;
const searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode;
return jsonResult({
results,
provider: status.provider,
model: status.model,
fallback: status.fallback,
citations: citationsMode,
mode: searchMode,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

View File

@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../auto-reply/tokens.js", () => ({
SILENT_REPLY_TOKEN: "QUIET_TOKEN",
}));
const { createTtsTool } = await import("./tts-tool.js");
describe("createTtsTool", () => {
it("uses SILENT_REPLY_TOKEN in guidance text", () => {
const tool = createTtsTool();
expect(tool.description).toContain("QUIET_TOKEN");
expect(tool.description).not.toContain("NO_REPLY");
});
});

View File

@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import type { AnyAgentTool } from "./common.js";
import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import { loadConfig } from "../../config/config.js";
import { textToSpeech } from "../../tts/tts.js";
import { readStringParam } from "./common.js";
@@ -20,8 +21,7 @@ export function createTtsTool(opts?: {
return {
label: "TTS",
name: "tts",
description:
"Convert text to speech and return a MEDIA: path. Use when the user requests audio or TTS is enabled. Copy the MEDIA line exactly.",
description: `Convert text to speech. Audio is delivered automatically from the tool result — reply with ${SILENT_REPLY_TOKEN} after a successful call to avoid duplicate messages.`,
parameters: TtsToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;

View File

@@ -2,15 +2,15 @@ import { describe, expect, it } from "vitest";
import { resolveTranscriptPolicy } from "./transcript-policy.js";
describe("resolveTranscriptPolicy e2e smoke", () => {
it("uses strict tool-call sanitization for OpenAI models", () => {
it("uses images-only sanitization without tool-call id rewriting for OpenAI models", () => {
const policy = resolveTranscriptPolicy({
provider: "openai",
modelId: "gpt-4o",
modelApi: "openai",
});
expect(policy.sanitizeMode).toBe("images-only");
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.toolCallIdMode).toBe("strict");
expect(policy.sanitizeToolCallIds).toBe(false);
expect(policy.toolCallIdMode).toBeUndefined();
});
it("uses strict9 tool-call sanitization for Mistral-family models", () => {

View File

@@ -30,13 +30,13 @@ describe("resolveTranscriptPolicy", () => {
expect(policy.toolCallIdMode).toBe("strict9");
});
it("enables sanitizeToolCallIds for OpenAI provider", () => {
it("disables sanitizeToolCallIds for OpenAI provider", () => {
const policy = resolveTranscriptPolicy({
provider: "openai",
modelId: "gpt-4o",
modelApi: "openai",
});
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.toolCallIdMode).toBe("strict");
expect(policy.sanitizeToolCallIds).toBe(false);
expect(policy.toolCallIdMode).toBeUndefined();
});
});

View File

@@ -95,7 +95,7 @@ export function resolveTranscriptPolicy(params: {
const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini;
const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic || isOpenAi;
const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic;
const toolCallIdMode: ToolCallIdMode | undefined = isMistral
? "strict9"
: sanitizeToolCallIds
@@ -109,7 +109,7 @@ export function resolveTranscriptPolicy(params: {
return {
sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only",
sanitizeToolCallIds,
sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds,
toolCallIdMode,
repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing,
preserveSignatures: isAntigravityClaudeModel,

View File

@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../../config/config.js";
import {
getAbortMemory,
getAbortMemorySizeForTest,
isAbortRequestText,
isAbortTrigger,
resetAbortMemoryForTest,
setAbortMemory,
@@ -75,6 +76,17 @@ describe("abort detection", () => {
expect(isAbortTrigger("/stop")).toBe(false);
});
it("isAbortRequestText aligns abort command semantics", () => {
expect(isAbortRequestText("/stop")).toBe(true);
expect(isAbortRequestText("stop")).toBe(true);
expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true);
expect(isAbortRequestText("/status")).toBe(false);
expect(isAbortRequestText("stop please")).toBe(false);
expect(isAbortRequestText("/abort")).toBe(false);
expect(isAbortRequestText("/abort now")).toBe(false);
});
it("removes abort memory entry when flag is reset", () => {
setAbortMemory("session-1", true);
expect(getAbortMemory("session-1")).toBe(true);

View File

@@ -19,7 +19,7 @@ import {
import { logVerbose } from "../../globals.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import { normalizeCommandBody } from "../commands-registry.js";
import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { clearSessionQueues } from "./queue.js";
@@ -35,6 +35,17 @@ export function isAbortTrigger(text?: string): boolean {
return ABORT_TRIGGERS.has(normalized);
}
export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean {
if (!text) {
return false;
}
const normalized = normalizeCommandBody(text, options).trim();
if (!normalized) {
return false;
}
return normalized.toLowerCase() === "/stop" || isAbortTrigger(normalized);
}
export function getAbortMemory(key: string): boolean | undefined {
const normalized = key.trim();
if (!normalized) {
@@ -202,8 +213,7 @@ export async function tryFastAbortFromMessage(params: {
const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "");
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
const normalized = normalizeCommandBody(stripped);
const abortRequested = normalized === "/stop" || isAbortTrigger(stripped);
const abortRequested = isAbortRequestText(stripped);
if (!abortRequested) {
return { handled: false, aborted: false };
}

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