Compare commits

..

566 Commits

Author SHA1 Message Date
Alex Knight
7f74f595eb feat(plugin-sdk): expose tool execution context 2026-06-22 22:28:36 +10:00
Vincent Koc
66b94ba577 fix(process): clamp execfile timeouts 2026-06-22 01:10:52 +02:00
Vincent Koc
77b6ca9a9b fix(sdk): tighten surface report budgets 2026-06-22 01:04:53 +02:00
Vincent Koc
1425bb3a03 fix(process): clamp command timeouts 2026-06-22 01:00:41 +02:00
Peter Steinberger
11484f8a14 fix(ollama): support GLM-5.2 cloud discovery 2026-06-21 19:00:15 -04:00
mikasa
ec7a548062 fix #95378: https://github.com/openclaw/openclaw/issues/95378 (#95390)
* fix(telegram): use session transcript for direct context

* fix(telegram): account for proof and SDK checks

* fix(telegram): address review findings

* fix(telegram): tighten session transcript context

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-06-22 04:29:44 +05:30
Vincent Koc
8ecdb97b63 fix(channels): bound capabilities probes 2026-06-22 00:53:26 +02:00
Vincent Koc
a39e548ede fix(mcp): cap channel bridge request limits 2026-06-22 00:32:32 +02:00
Vincent Koc
328a44695f chore(deadcode): remove unused agent-core prompt formatter 2026-06-22 06:23:07 +08:00
Vincent Koc
c21dcfc7c2 fix(tui): clamp local shutdown grace timeout 2026-06-22 00:22:08 +02:00
Vincent Koc
2bdcc8314d fix(sdk): preserve zero run timeout watchdog 2026-06-22 00:13:51 +02:00
Vincent Koc
464adfe5e5 chore(deadcode): remove unused agent-core harness APIs 2026-06-22 06:08:32 +08:00
Vincent Koc
b66b4504f8 fix(gateway): cap plugin session message reads 2026-06-22 00:06:04 +02:00
Vincent Koc
f6d432e545 fix(gateway): expire default node pending work 2026-06-21 23:52:44 +02:00
Vincent Koc
fd13192adc fix(qa-lab): version UI assets from repo root 2026-06-21 23:43:48 +02:00
Vincent Koc
1c63da09d8 fix(qa-lab): cap agent wait timeout payloads 2026-06-21 23:34:38 +02:00
Vincent Koc
735505442c chore(deadcode): dedupe live assistant text extraction 2026-06-22 05:32:29 +08:00
Vincent Koc
108d6d7eca fix(gateway): reject malformed restart request params 2026-06-21 23:28:53 +02:00
Vincent Koc
984c8f6ea0 chore(deadcode): remove stale channel presence helper 2026-06-22 05:21:39 +08:00
Shakker
cd9060e06a test: restore host cleanup state env 2026-06-21 22:20:58 +01:00
Vincent Koc
5b96eb0172 fix(e2e): reject flag Parallels smoke values 2026-06-21 23:19:32 +02:00
Vincent Koc
b95b725c83 fix(e2e): reject help flag Crabbox proof values 2026-06-21 23:13:12 +02:00
Shakker
4b881509eb fix: restore media cache state env 2026-06-21 22:12:46 +01:00
Vincent Koc
a03032a272 chore(plugin-sdk): refresh API baseline hash 2026-06-21 23:09:27 +02:00
Shakker
c94ebdbebd test: restore heartbeat state env 2026-06-21 22:07:04 +01:00
Alex Knight
7b46167607 fix(channels): preserve migrated account policies 2026-06-22 07:05:45 +10:00
Vincent Koc
0e53358945 fix(e2e): reject flag Telegram credential values 2026-06-21 23:04:51 +02:00
Vincent Koc
6b45e9af7a fix(scripts): validate iOS node CLI values before help 2026-06-21 23:00:30 +02:00
Vincent Koc
2ef61eb782 chore(deadcode): drop unused exact session lookup 2026-06-22 04:58:21 +08:00
Vincent Koc
6823f56d8e fix(scripts): reject flag device-pair Telegram values 2026-06-21 22:56:54 +02:00
Shakker
3e1d3c5feb fix: isolate provider runtime env cache 2026-06-21 21:54:05 +01:00
Vincent Koc
dfbc9ab246 fix(scripts): reject short flag TUI PTY values 2026-06-21 22:52:21 +02:00
Vincent Koc
179eb15554 chore(deadcode): remove unused state path readers 2026-06-22 04:50:54 +08:00
Shakker
c3b1e926e8 test: route gateway call env resets 2026-06-21 21:46:55 +01:00
Vincent Koc
89768d456b fix(scripts): reject short flag Discord smoke values 2026-06-21 22:45:03 +02:00
Vincent Koc
609d7a14b1 chore(deadcode): remove test-only helper APIs 2026-06-22 04:41:04 +08:00
Vincent Koc
3bae0d6b82 fix(qa): reject short flag gateway smoke values 2026-06-21 22:40:40 +02:00
Vincent Koc
75a997dd7c fix(scripts): reject short flag shrinkwrap refs 2026-06-21 22:40:07 +02:00
Shakker
ab8dc3af52 fix: reuse doctor env snapshot helper 2026-06-21 21:37:56 +01:00
Vincent Koc
bebc5d847d fix(scripts): reject short flag CI timing limits 2026-06-21 22:34:29 +02:00
Vincent Koc
a0f28bd3f5 fix(scripts): reject short flag version values 2026-06-21 22:33:59 +02:00
Shakker
c638617897 test: share agent state-dir env guard 2026-06-21 21:31:10 +01:00
Vincent Koc
b77d6149e1 fix(scripts): reject short flag extension memory values 2026-06-21 22:28:14 +02:00
Vincent Koc
36db108fc1 fix(scripts): reject short flag boundary values 2026-06-21 22:26:44 +02:00
heichaowo
e00c1eebc4 fix(telegram): skip duplicate mirror replies (#95069)
Fix Telegram session transcript duplicate mirror writes after the primary assistant reply already exists.

Thanks @heichaowo!
2026-06-22 01:55:08 +05:30
NIO
32d22d04cc fix(telegram): honor outbound reaction directives (#94977) 2026-06-22 01:54:55 +05:30
Vincent Koc
648ef73bde fix(qa): reject short flag UX evidence paths 2026-06-21 22:20:56 +02:00
Vincent Koc
beebb35de4 fix(scripts): reject short flag values in helper CLIs 2026-06-21 22:19:54 +02:00
Vincent Koc
55959148ca chore(deadcode): remove unused infra wrappers 2026-06-22 04:18:02 +08:00
Vincent Koc
4684bbba97 fix(scripts): reject short flag values in benchmark CLIs 2026-06-21 22:11:19 +02:00
Vincent Koc
409adfbe10 chore(deadcode): drop stale helper APIs 2026-06-22 04:10:02 +08:00
Vincent Koc
2609b97222 fix(scripts): reject short flag values in bench parsers 2026-06-21 22:06:30 +02:00
Vincent Koc
03bc600e67 fix(scripts): reject short flag startup memory paths 2026-06-21 22:06:04 +02:00
Vincent Koc
8102d5ebc3 fix(scripts): reject short flag performance summary paths 2026-06-21 22:02:05 +02:00
Vincent Koc
6399eb8191 fix(scripts): reject short flag vitest profile dirs 2026-06-21 21:53:58 +02:00
Vincent Koc
1a5839fbd8 fix(scripts): reject short flag audit severity values 2026-06-21 21:51:02 +02:00
Vincent Koc
d17045db6f chore(deadcode): drop unused helper exports 2026-06-22 03:49:17 +08:00
Vincent Koc
2a8db1fc23 fix(scripts): reject short flag ownership report values 2026-06-21 21:45:36 +02:00
Vincent Koc
82f43f0a62 fix(scripts): reject short flag values in release docs parsers 2026-06-21 21:39:41 +02:00
Vincent Koc
9adf3d92bd chore(deadcode): remove unused helper paths 2026-06-22 03:39:19 +08:00
Vincent Koc
e21164933a fix(scripts): reject short flag report values 2026-06-21 21:36:26 +02:00
Vincent Koc
1b17517969 fix(scripts): reject short flag closeout values 2026-06-21 21:32:17 +02:00
Vincent Koc
86fea26797 fix(plugin-sdk): stabilize surface report after builds 2026-06-21 21:28:47 +02:00
Vincent Koc
2e7c3ace9c chore(plugin-sdk): refresh API baseline hashes 2026-06-21 21:28:47 +02:00
Vincent Koc
e34204a1e0 fix(scripts): reject short flag package roots 2026-06-21 21:27:57 +02:00
Vincent Koc
6daf9307e0 fix(scripts): reject short flag docker package values 2026-06-21 21:24:30 +02:00
Vincent Koc
ebb670b208 fix(scripts): reject short flag gauntlet values 2026-06-21 21:18:48 +02:00
Vincent Koc
52672c7af1 fix(scripts): reject short flag gateway cpu values 2026-06-21 21:15:21 +02:00
Vincent Koc
690efd2a16 chore(deadcode): inline constant helper stubs 2026-06-22 03:14:04 +08:00
Vincent Koc
102ab759e7 fix(scripts): reject option-shaped package candidate values 2026-06-21 21:11:25 +02:00
Vincent Koc
7da955fae4 fix(config): type slack secret refs 2026-06-21 20:58:33 +02:00
Vincent Koc
06a0148072 chore(deadcode): remove inert browser relay hook 2026-06-22 02:50:55 +08:00
Vincent Koc
edd1d3319c chore(deadcode): dedupe repeated literal lists 2026-06-22 02:35:03 +08:00
Vincent Koc
0ea39a2276 chore(deadcode): remove inert memory provider bootstrap 2026-06-22 02:30:56 +08:00
Sarah Fortune
6fa944e80f [codex] Add Slack relay mode for incoming messages (#94707) 2026-06-21 11:28:33 -07:00
Vincent Koc
4c453c931f fix(test): reject dependency report flag values 2026-06-21 20:23:23 +02:00
Vincent Koc
13b0976c70 fix(release): reject dependency evidence flag values 2026-06-21 20:19:49 +02:00
Vincent Koc
43e00c06c3 fix(docs): reject sync publish flag values 2026-06-21 20:13:50 +02:00
Vincent Koc
03ce3d41b1 fix(ci): reject hosted gate flag values 2026-06-21 20:09:53 +02:00
Vincent Koc
bda05dbc2f fix(release): reject validation flag values 2026-06-21 20:05:22 +02:00
Vincent Koc
7069d95720 fix(test): reject diff ref flag values 2026-06-21 19:59:55 +02:00
Vincent Koc
8086cffd17 fix(test): reject group report flag values 2026-06-21 19:56:25 +02:00
Vincent Koc
d91aee7220 fix(ci): reject flag refs in changed scope 2026-06-21 19:54:57 +02:00
Vincent Koc
c8ab37f6fe chore(deadcode): drop inert legacy workspace doctor check 2026-06-22 01:47:27 +08:00
Vincent Koc
a823cb2b30 fix(test): bound rpc process tree sampling 2026-06-21 19:41:51 +02:00
Vincent Koc
5230ec66ae chore(plugin-sdk): refresh API surface baselines 2026-06-21 19:32:49 +02:00
Vincent Koc
eea777c9fc chore(deadcode): trim stale facade re-exports 2026-06-22 01:16:08 +08:00
Vincent Koc
0b28a72be1 fix(test): reject kova help value bypasses 2026-06-21 19:07:02 +02:00
Vincent Koc
adcba85264 fix(test): reject cpuprofile limit help tokens 2026-06-21 18:56:26 +02:00
Vincent Koc
5b79fa13e2 chore(deadcode): trim doctor alias wrappers 2026-06-22 00:54:26 +08:00
Vincent Koc
124ea48549 fix(test): reject docker timing flag limits 2026-06-21 18:49:24 +02:00
Vincent Koc
12756fc4c8 fix(test): reject env report flag paths 2026-06-21 18:46:05 +02:00
Vincent Koc
5bf459e23b fix(test): reject attestation platform flags 2026-06-21 18:43:05 +02:00
Vincent Koc
d64a27feeb chore(deadcode): drop node daemon runtime alias 2026-06-22 00:41:56 +08:00
Vincent Koc
6c42f73619 fix(test): reject i18n report flag values 2026-06-21 18:37:35 +02:00
Vincent Koc
d460f00eb9 fix(test): reject metadata ref flags 2026-06-21 18:33:10 +02:00
Vincent Koc
b83dce7b33 fix(test): reject rpc rtt flag values 2026-06-21 18:28:28 +02:00
Vincent Koc
d3c907193f fix(test): route qa otel smoke parser 2026-06-21 18:26:26 +02:00
Vincent Koc
b47c930e7e chore(deadcode): trim runtime plugin selection wrappers 2026-06-22 00:24:26 +08:00
Vincent Koc
0befd3c8f2 fix(test): route android release wrappers 2026-06-21 18:19:07 +02:00
Vincent Koc
c2ee9b0be8 fix(gateway): preserve owner MCP tools for agent RPC 2026-06-21 18:14:38 +02:00
Vincent Koc
9d27583190 fix(test): route release signing args 2026-06-21 18:10:35 +02:00
Vincent Koc
04c8c50cc4 fix(test): route testbox env hydration 2026-06-21 18:04:47 +02:00
Vincent Koc
5abf4ce2e2 chore(deadcode): trim reply runtime dead helpers 2026-06-22 00:03:31 +08:00
Vincent Koc
07d5cdec99 fix(test): route ios release wrappers 2026-06-21 17:59:21 +02:00
Vincent Koc
f5f23e739e fix(test): route proxy CA installer 2026-06-21 17:51:44 +02:00
Vincent Koc
0c183283e5 fix(test): route release preflight script 2026-06-21 17:46:59 +02:00
Vincent Koc
514b3365b5 fix(deadcode): move restart sentinels to sqlite 2026-06-21 23:39:38 +08:00
Vincent Koc
2804c24dc6 fix(test): route plugin dependency helpers 2026-06-21 17:32:43 +02:00
Vincent Koc
94c7b5a874 fix(test): route release ref resolver 2026-06-21 17:27:29 +02:00
Vincent Koc
93ec8b8c5c fix(test): route install helper scripts 2026-06-21 17:14:12 +02:00
Vincent Koc
9d83eeaccf fix(test): route release wrapper scripts 2026-06-21 17:08:40 +02:00
Vincent Koc
33eb6ab9de fix(test): route release approval script 2026-06-21 16:59:51 +02:00
Vincent Koc
757ab933f4 fix(test): route release script owners 2026-06-21 16:53:49 +02:00
Vincent Koc
63fdc57b3a fix(test): route mac helper script owners 2026-06-21 16:39:41 +02:00
Vincent Koc
a39a3b74de fix(deadcode): move restart handoffs to sqlite 2026-06-21 22:36:42 +08:00
Vincent Koc
77a859f4ae fix(ci): route mac packaging scripts to macos checks 2026-06-21 16:32:57 +02:00
Peter Lee
84cf64770f fix(whatsapp): wire missing Baileys retry/cache hooks for group message reliability (#94338)
Merged via squash.

Prepared head SHA: ee6de071f7
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-06-21 11:32:49 -03:00
Vincent Koc
0ad48dad2c fix(deadcode): move restart intents to sqlite 2026-06-21 22:29:05 +08:00
Marcus Castro
b50a5aebba fix(whatsapp): preserve native quote replies (#95483)
* fix(whatsapp): preserve native quote replies

* feat(plugin-sdk): add quote-reply live transport standard

* test(qa-lab): add WhatsApp quote-reply live scenarios
2026-06-21 11:26:58 -03:00
Vincent Koc
b84665222c fix(ci): regenerate Swift protocol model 2026-06-21 22:19:18 +08:00
snowzlmbot
6441e56465 fix(telegram): materialize streaming progress placeholders (#95183)
* fix(telegram): materialize streaming progress placeholders

* fix(telegram): cancel delayed progress drafts before final

* fix(telegram): satisfy progress placeholder lint

* fix(telegram): cancel delayed draft preview on clear

* refactor(telegram): simplify delayed preview flush

---------

Co-authored-by: snowzlmbot <snowzlmbot@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-06-21 19:48:41 +05:30
Vincent Koc
6daabd23f8 fix(deadcode): move config health state to sqlite 2026-06-21 22:07:43 +08:00
Vincent Koc
2f33999898 fix(agents): retain bounded preflight history 2026-06-21 21:58:57 +08:00
Vincent Koc
a60947fb3e fix(agents): keep live tool-result prompts cache-stable 2026-06-21 21:54:35 +08:00
Peter Lee
ac5d219be3 fix(telegram): stop clearing registered webhook on channel restart (#94506)
Prepared head SHA: f721e14a92

Co-authored-by: xialonglee <li.xialong@xydigit.com>
2026-06-21 19:22:49 +05:30
Vincent Koc
ae41b00922 fix(deadcode): move plugin binding approvals to sqlite 2026-06-21 21:52:20 +08:00
Vincent Koc
b28e68e0ce fix(macos): validate DMG layout values 2026-06-21 15:48:52 +02:00
Josh Avant
5d1e649aea fix: route mobile exec approvals to reviewer device (#95175)
* fix: route mobile exec approvals to reviewer device

* fix: surface iOS approval events in foreground

* fix: forward codex approval reviewer device

* test: harden approval reviewer device contract

* test: cover reviewer approval fallback resolvers
2026-06-21 08:47:52 -05:00
Ayaan Zaidi
d6d17709e8 fix(telegram): clear progress draft before tool output (#93002) (thanks @zhangguiping-xydt) 2026-06-21 19:14:57 +05:30
Ayaan Zaidi
6fd6bddb92 style(telegram): trim progress draft dispatch comment 2026-06-21 19:14:57 +05:30
张贵萍0668001030
f4dee99574 fix(telegram): clear progress draft before tool artifacts 2026-06-21 19:14:57 +05:30
张贵萍0668001030
db33402af0 fix(telegram): clear progress draft before verbose tool output 2026-06-21 19:14:57 +05:30
Vincent Koc
088cab5ee4 fix(macos): prefer repo pnpm for packaging 2026-06-21 15:39:17 +02:00
Vincent Koc
bd74a62118 fix(install): use repo pnpm for git installs 2026-06-21 15:34:56 +02:00
Vincent Koc
9f888d95e0 fix(deadcode): move current conversation bindings to sqlite 2026-06-21 21:30:06 +08:00
Vincent Koc
11a2e03bd4 fix(install): detect package manager launcher names 2026-06-21 15:18:57 +02:00
Vincent Koc
5693fcda78 fix(ci): validate artifact package source sha 2026-06-21 15:08:18 +02:00
Vincent Koc
eb00d499d1 fix(deadcode): move update check state to sqlite 2026-06-21 20:57:41 +08:00
Vincent Koc
8a7906c716 fix(qa): reject fractional live token usage 2026-06-21 14:49:29 +02:00
Vincent Koc
c85113e30e fix(qa): escape tool coverage markdown cells 2026-06-21 14:46:38 +02:00
Vincent Koc
bdf81a825f fix(deadcode): move voicewake settings to sqlite 2026-06-21 20:45:17 +08:00
Vincent Koc
d9dfcd6c8a fix(qa): reject impossible confidence counts 2026-06-21 14:38:42 +02:00
Alex Knight
2cafbd0774 fix(plugins): reconcile managed npm root overrides with managed peer pins 2026-06-21 22:33:18 +10:00
Vincent Koc
54b2836eab test(scripts): stabilize gauntlet termination timing 2026-06-21 14:27:10 +02:00
Vincent Koc
5b6f4b2919 fix(test): reject gauntlet flag values 2026-06-21 14:21:16 +02:00
Vincent Koc
c037a34ba7 fix(security): ignore Docker rerun artifact commands 2026-06-21 14:14:57 +02:00
Vincent Koc
12c34fc3a9 fix(security): bound trusted package URL prefixes 2026-06-21 13:51:24 +02:00
Vincent Koc
e366349730 test(deadcode): reuse gateway restart intent writer 2026-06-21 19:49:47 +08:00
Vincent Koc
89a73d08c8 test(deadcode): dedupe wizard prompter helpers 2026-06-21 19:49:46 +08:00
Vincent Koc
e6f41a4df0 fix(test): reject loose env report limits 2026-06-21 13:37:55 +02:00
Vincent Koc
b7fef7fca6 fix(mac): clean failed dSYM merges 2026-06-21 13:32:47 +02:00
Vincent Koc
d1cbe29f3d fix(qa): require sampled Kova metric counts 2026-06-21 13:24:30 +02:00
Alex Knight
9dbc21d283 fix(telegram): materialize rich message line breaks as <br>
Bot API 10.1 rich messages parse structured HTML, so bare newlines
collapse as insignificant whitespace and flatten multi-paragraph replies
and bullet runs into a single line. Materialize logical line breaks as
<br> in prepareTelegramRichHtml -- the shared rich send/edit/draft
chokepoint covering both the Markdown and explicit-HTML text modes --
while keeping newlines literal inside code/pre/math and where they only
separate structural tags (block elements plus table/figure/details
container children, so pretty-printed rich HTML keeps valid markup).

Refs #95409
2026-06-21 21:22:33 +10:00
Vincent Koc
5e86c7eef4 fix(qa): reject non-object mock Anthropic JSON 2026-06-21 13:13:37 +02:00
Vincent Koc
6f3af56952 fix(qa): reject non-object mock OpenAI JSON 2026-06-21 13:10:50 +02:00
Vincent Koc
03ee22666b chore(deadcode): drop retired memory wiki vault metadata 2026-06-21 19:08:39 +08:00
Vincent Koc
0d351b9875 fix(ci): filter ClawSweeper comment dispatches before token minting (#95308)
Merged via squash.

Prepared head SHA: b5389b59e4
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-21 19:05:08 +08:00
Vincent Koc
f69ba12a37 fix(qa): reject malformed mock OpenAI JSON 2026-06-21 12:58:20 +02:00
Vincent Koc
b796890b97 test(sdk): resolve Windows package taskkill path 2026-06-21 12:52:41 +02:00
Vincent Koc
b5fc9514c0 fix(qa): reject coerced evidence artifact indexes 2026-06-21 12:50:37 +02:00
Vincent Koc
2a140e6e6a fix(daemon): resolve Windows scheduled-task tools 2026-06-21 12:45:49 +02:00
Vincent Koc
6e5f4d685e chore(deadcode): remove stale readability compare script 2026-06-21 18:43:56 +08:00
Vincent Koc
600bace853 fix(qa): validate QA bus poll numbers 2026-06-21 12:41:22 +02:00
Vincent Koc
b8a5dac1a2 fix(qa-lab): resolve Windows PowerShell path 2026-06-21 12:40:57 +02:00
Vincent Koc
15c880aeff fix(infra): share trusted Windows process argv lookup 2026-06-21 12:31:27 +02:00
Vincent Koc
3a53eb5d77 test(deadcode): remove stale heartbeat transcript prune test 2026-06-21 18:25:28 +08:00
Vincent Koc
66e5cfdd86 fix(qa): reject QA Lab host port collisions 2026-06-21 12:23:52 +02:00
Vincent Koc
c4facb2bb3 fix(infra): resolve Windows port inspection tools 2026-06-21 12:22:18 +02:00
Vincent Koc
a70b34a3cb fix(qa-lab): resolve Windows node lookup tool 2026-06-21 12:14:39 +02:00
Vincent Koc
59bf85c586 chore(deadcode): remove stale cron usage helper 2026-06-21 18:12:36 +08:00
Vincent Koc
e486a1d1cf test(scripts): route Codex install assertions 2026-06-21 12:11:01 +02:00
Vincent Koc
72b9bc7303 fix(core): resolve Windows PATH locator tools 2026-06-21 12:09:33 +02:00
Vincent Koc
d3b44442f6 fix(infra): resolve Windows binary lookup tool 2026-06-21 12:02:02 +02:00
Vincent Koc
6ddbcbd460 chore(deadcode): remove stale proof scripts 2026-06-21 18:00:25 +08:00
Vincent Koc
7bd4aab21f test(scripts): route fixture common helper 2026-06-21 11:55:25 +02:00
Vincent Koc
a4c8b17b9e test(scripts): route Parallels lib helpers 2026-06-21 11:52:46 +02:00
Vincent Koc
d87f8325d0 test(agents): fix tools-manager mock typing 2026-06-21 11:49:00 +02:00
Vincent Koc
a5fde9119c fix(agents): resolve Windows extraction tools 2026-06-21 11:49:00 +02:00
Vincent Koc
e6c899dfa5 chore(deadcode): dedupe internal event formatting 2026-06-21 17:47:29 +08:00
Vincent Koc
425f512897 test(scripts): route ClawHub fixture server 2026-06-21 11:42:29 +02:00
Vincent Koc
735d70d9db test(extensions): pin Windows taskkill test roots 2026-06-21 11:37:59 +02:00
Vincent Koc
15300291ed fix(qa-matrix): resolve Windows taskkill path 2026-06-21 11:37:59 +02:00
Vincent Koc
eb7789c8cb test(scripts): route auth profile store assertions 2026-06-21 11:32:59 +02:00
Vincent Koc
f19052b3f3 test(scripts): route doctor install wrapper helper 2026-06-21 11:30:45 +02:00
Vincent Koc
61d1fd1f72 fix(qa-lab): resolve Windows taskkill path 2026-06-21 11:26:30 +02:00
Vincent Koc
1435fc123f test(scripts): route onboard config helpers 2026-06-21 11:23:08 +02:00
Vincent Koc
6c4028e073 fix(scripts): resolve Windows cmd shim launcher 2026-06-21 11:15:14 +02:00
Vincent Koc
9242137ca7 test(scripts): route config reload metadata helper 2026-06-21 11:13:13 +02:00
Vincent Koc
35d7cb0bff chore(deadcode): remove stale qa-matrix wrappers 2026-06-21 17:10:35 +08:00
Vincent Koc
c000e4811d fix(scripts): resolve Windows tools in kitchen sink walk 2026-06-21 11:06:56 +02:00
Vincent Koc
d25549f142 test(scripts): route plugin fixture commands 2026-06-21 11:04:38 +02:00
Vincent Koc
a192b2ea52 fix(windows): resolve taskkill in core spawns 2026-06-21 10:57:41 +02:00
Vincent Koc
7975ec0b11 test(scripts): route incremental line reader 2026-06-21 10:54:09 +02:00
Vincent Koc
e9b694ef9c fix(windows): resolve process inspection tools 2026-06-21 10:47:42 +02:00
Vincent Koc
3b332fd0a4 chore(deadcode): remove stale copilot doctor probes 2026-06-21 16:42:27 +08:00
Vincent Koc
7dd01d15c5 fix(windows): resolve cmd handoff path 2026-06-21 10:41:25 +02:00
Vincent Koc
675c56692a test(scripts): route mock OpenAI fixture helper 2026-06-21 10:31:00 +02:00
Vincent Koc
3f597619c8 test(scripts): route fixture config helper 2026-06-21 10:27:08 +02:00
Vincent Koc
91531ba35c fix(release): resolve taskkill in cross-os checks 2026-06-21 10:24:19 +02:00
Vincent Koc
206bbb01b0 fix(docs): resolve taskkill in i18n codex helper 2026-06-21 10:19:05 +02:00
Vincent Koc
c9758bf2a0 test(scripts): cover e2e text file utilities 2026-06-21 10:16:09 +02:00
Vincent Koc
282eb74128 fix(i18n): resolve taskkill in control ui runner 2026-06-21 10:14:00 +02:00
Vincent Koc
9940110b88 fix(telegram): resolve taskkill in crabbox proof 2026-06-21 10:10:27 +02:00
Vincent Koc
73b35cc3ca fix(bench): resolve taskkill in gateway child cleanup 2026-06-21 10:06:48 +02:00
Vincent Koc
ac0537e363 fix(dev): resolve taskkill in tui pty watcher 2026-06-21 10:02:31 +02:00
Vincent Koc
0321c04663 chore(deadcode): remove stale web artifact helpers 2026-06-21 16:00:38 +08:00
Vincent Koc
78b717a54c fix(plugins): resolve taskkill in bundled runtime smoke 2026-06-21 09:56:30 +02:00
Vincent Koc
7b00fd6c45 test(scripts): route plugin update registry helper 2026-06-21 09:54:59 +02:00
Vincent Koc
1f1155597b fix(telegram): resolve taskkill in credential helper 2026-06-21 09:52:23 +02:00
Vincent Koc
e9e42d5db4 fix(secret-provider): resolve taskkill in e2e cleanup 2026-06-21 09:49:07 +02:00
Vincent Koc
bc2f4ce923 test(scripts): route npm telegram runner 2026-06-21 09:47:52 +02:00
Vincent Koc
7dbae1b2cd fix(scripts): resolve taskkill in boundary artifacts 2026-06-21 09:44:42 +02:00
Vincent Koc
63f2c56222 test(scripts): route e2e helper owners 2026-06-21 09:43:00 +02:00
Vincent Koc
675cae58d7 fix(build): resolve taskkill in tsdown wrapper 2026-06-21 09:40:53 +02:00
Vincent Koc
c372f6ef0b fix(plugins): keep extension tests on public boundaries 2026-06-21 15:39:51 +08:00
Vincent Koc
1d4b712f9a fix(scripts): resolve taskkill in startup metadata 2026-06-21 09:37:35 +02:00
Vincent Koc
e016f0b496 fix(scripts): resolve taskkill in test group report 2026-06-21 09:34:05 +02:00
Vincent Koc
f8d2c4b25a chore(deadcode): remove stale migrate argv wrapper 2026-06-21 15:32:56 +08:00
Vincent Koc
b15f745a60 fix(package): resolve taskkill from system32 in candidate runner 2026-06-21 09:28:31 +02:00
Vincent Koc
8c9c8aad2e fix(qalab): resolve taskkill from system32 in cleanup probes 2026-06-21 09:24:16 +02:00
Vincent Koc
0a7b009647 fix(rpc): resolve taskkill from system32 in script probes 2026-06-21 09:18:06 +02:00
Vincent Koc
fc8542b377 chore(deadcode): remove stale internal exports 2026-06-21 15:14:36 +08:00
Vincent Koc
37a4b565ea test(scripts): mirror declaration route owners 2026-06-21 09:07:10 +02:00
Vincent Koc
6b0210a5fd fix(scripts): resolve taskkill from system32 2026-06-21 09:03:40 +02:00
Vincent Koc
c22e300084 fix(scripts): taskkill managed children via system32 on windows 2026-06-21 08:57:25 +02:00
Vincent Koc
5134dd0c54 test(scripts): route package runner declarations 2026-06-21 08:55:18 +02:00
Vincent Koc
830691b201 fix(memory-host-sdk): taskkill qmd process trees on windows 2026-06-21 08:51:36 +02:00
Vincent Koc
ab39bab52a chore(deadcode): dedupe session lineage patching 2026-06-21 14:48:50 +08:00
Vincent Koc
3f4d1cfcce test(scripts): run macOS app tests for app changes 2026-06-21 08:43:59 +02:00
Vincent Koc
06574920dd fix(sdk): taskkill package e2e trees on windows 2026-06-21 08:33:36 +02:00
Vincent Koc
d46b64df66 test(scripts): gate runtime sidecar baseline changes 2026-06-21 08:28:55 +02:00
Vincent Koc
b06e2f9149 fix(qa-matrix): taskkill scenario cli trees on windows 2026-06-21 08:25:25 +02:00
Vincent Koc
b574da57cf chore(deadcode): share levenshtein distance helper 2026-06-21 14:23:43 +08:00
Dallin Romney
bfe0caefd1 test: route broad flow tests out of unit-fast (#95499) 2026-06-20 23:22:39 -07:00
Vincent Koc
0004cfd59e test(scripts): route prompt snapshot helper changes 2026-06-21 08:16:47 +02:00
Vincent Koc
604aa30189 fix(qa): taskkill lifecycle probe trees on windows 2026-06-21 08:12:29 +02:00
Dallin Romney
5dd30c3995 test: fold HTTP API script proof into QA Lab (#94700)
* test: fold HTTP API script proof into QA Lab

* test: remove folded HTTP API script tests

* test: relax QA native scenario catalog inventory

* test: trim folded QA Lab script cruft

* test: align folded QA coverage ids

* test: keep native QA evidence out of parity tiers

* test: update mirrored QA routing expectation

* test: preserve chat tools profile build guard

* test: avoid overclaiming gateway tool API coverage

* test: pin folded QA coverage ids
2026-06-20 23:10:35 -07:00
Vincent Koc
5b22409389 fix(qa-lab): taskkill gateway children on windows 2026-06-21 08:07:00 +02:00
Vincent Koc
b970d57175 fix(qa-lab): taskkill timed-out cli trees on windows 2026-06-21 08:01:55 +02:00
Dallin Romney
9ab8e466d2 test(qa): make release scorecard categories explicit (#95406) 2026-06-20 23:01:23 -07:00
Vincent Koc
c43822077a fix(qa-lab): force taskkill scenario trees on windows 2026-06-21 07:56:03 +02:00
Vincent Koc
8797564254 chore(deadcode): share deferred test helper 2026-06-21 13:53:24 +08:00
Vincent Koc
273eb88874 fix(runtime-smoke): force taskkill bundled trees on windows 2026-06-21 07:50:42 +02:00
Vincent Koc
140a2fa520 fix(crabbox): force taskkill telegram proof trees on windows 2026-06-21 07:46:57 +02:00
Vincent Koc
c8b48c78d0 fix(qa): force taskkill telegram credential trees on windows 2026-06-21 07:44:10 +02:00
Vincent Koc
29033e67af fix(bench): force taskkill gateway bench trees on windows 2026-06-21 07:41:36 +02:00
Vincent Koc
e9a47fe554 test(scripts): route setup pnpm action helper 2026-06-21 07:40:06 +02:00
Vincent Koc
2f213a1606 test(scripts): route github yaml pinning guards 2026-06-21 07:40:06 +02:00
Vincent Koc
992ddf6310 test(scripts): cover workflow docker routes 2026-06-21 07:40:06 +02:00
Vincent Koc
7b259bd2a4 test(scripts): route github action metadata 2026-06-21 07:40:06 +02:00
Vincent Koc
e91ca8df86 test(scripts): route docs spellcheck config 2026-06-21 07:40:06 +02:00
Vincent Koc
af3e509ab8 fix(scripts): remove private auth monitor defaults 2026-06-21 07:40:06 +02:00
Vincent Koc
dec76bb5eb test(scripts): route pr wrapper scripts 2026-06-21 07:40:05 +02:00
Vincent Koc
862ef1cec1 test(scripts): route doctor switch shims 2026-06-21 07:40:05 +02:00
Vincent Koc
486c9e6ba3 test(scripts): route podman template metadata 2026-06-21 07:40:05 +02:00
Vincent Koc
b2d78abe94 test(scripts): route extensionless helper scripts 2026-06-21 07:40:05 +02:00
Vincent Koc
2f38b5aa2e test(scripts): route dockerfile metadata changes 2026-06-21 07:40:05 +02:00
Vincent Koc
4d17a52924 fix(scripts): force taskkill boundary node steps on windows 2026-06-21 07:36:02 +02:00
Vincent Koc
4b2298e8cb fix(package): force taskkill candidate runner trees on windows 2026-06-21 07:33:04 +02:00
Vincent Koc
f640ca11f9 fix(scripts): force taskkill run-with-env trees on windows 2026-06-21 07:30:11 +02:00
Vincent Koc
2029f87f29 fix(scripts): force taskkill managed trees on windows 2026-06-21 07:26:46 +02:00
Vincent Koc
ca1aa33eba fix(rpc): force taskkill rtt gateway trees on windows 2026-06-21 07:23:54 +02:00
Vincent Koc
5b212162d3 fix(rpc): force taskkill kitchen sink trees on windows 2026-06-21 07:21:04 +02:00
Vincent Koc
3df5207389 fix(testing): force taskkill group report trees on windows 2026-06-21 07:16:31 +02:00
Vincent Koc
78f30a010c fix(build): kill startup metadata trees on windows 2026-06-21 07:13:29 +02:00
Vincent Koc
83785a6e79 fix(build): kill tsdown trees on windows 2026-06-21 07:09:21 +02:00
Vincent Koc
195890f815 fix(dev): kill tui pty watch trees on windows 2026-06-21 07:06:48 +02:00
Vincent Koc
0f8df48a91 fix(release): forward-port 2026.6.9 closeout fixes 2026-06-21 13:05:11 +08:00
Vincent Koc
7b28b73e78 fix(e2e): preserve secret proof windows tree cleanup 2026-06-21 06:59:18 +02:00
Vincent Koc
3650766f26 fix(rpc): kill kitchen sink trees on windows 2026-06-21 06:53:14 +02:00
Vincent Koc
eb03d0ee2b chore(deadcode): share session cost totals 2026-06-21 12:52:23 +08:00
Vincent Koc
38c8b0c196 fix(crabbox): kill telegram proof trees on windows 2026-06-21 06:47:25 +02:00
Vincent Koc
0030a192c8 fix(qa): kill telegram credential trees on windows 2026-06-21 06:42:35 +02:00
Vincent Koc
34806b39cd fix(package): kill candidate resolver trees on windows 2026-06-21 06:37:51 +02:00
Vincent Koc
b0f21f8af7 fix(scripts): kill boundary prep trees on windows 2026-06-21 06:34:10 +02:00
Vincent Koc
5af318b95d fix(testing): kill group report trees on windows 2026-06-21 06:29:14 +02:00
Vincent Koc
8d2e6d7686 fix(scripts): kill run-with-env trees on windows 2026-06-21 06:24:47 +02:00
Vincent Koc
b039e949b6 chore(release): close out 2026.6.9 2026-06-21 12:24:15 +08:00
Vincent Koc
2ca5b7c93e fix(mac): retry DMG detach 2026-06-21 12:24:15 +08:00
Vincent Koc
51d1789cea fix(release): terminate command descendants on signal 2026-06-21 12:24:15 +08:00
Vincent Koc
89e73240a1 test(release): align full validation workflow contract 2026-06-21 12:24:15 +08:00
Vincent Koc
f3ee317f71 fix(ci): deduplicate release Telegram validation 2026-06-21 12:24:15 +08:00
Vincent Koc
ecb82f1be9 fix(plugins): restore StepFun ClawHub release 2026-06-21 12:24:14 +08:00
Vincent Koc
f1a48dac18 test(release): stabilize validation contracts 2026-06-21 12:24:14 +08:00
Vincent Koc
cba9c02095 chore(deadcode): share plugin snapshot fingerprint 2026-06-21 12:23:31 +08:00
Peter Steinberger
715dc718fc fix(opencode-go): align Kimi input with runtime 2026-06-21 00:22:52 -04:00
Vincent Koc
85f71f4c8f fix(scripts): kill managed child trees on windows 2026-06-21 06:19:23 +02:00
Peter Steinberger
66f84a9bf1 fix(opencode-go): add current Go models
Co-authored-by: samson1357924 <samson1357924@gmail.com>
2026-06-21 00:06:55 -04:00
Zak
50c2cc6a45 fix(zai): expose GLM-5.2 reasoning levels [AI-assisted] (#94136)
Merged via squash.

Prepared head SHA: 432214158a
Co-authored-by: BorClaw <268442329+BorClaw@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:57:49 -04:00
Chunyue Wang
e3ccf8743f fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models (#94067)
Merged via squash.

Prepared head SHA: 079347b8b8
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:56:53 -04:00
Vincent Koc
d4c2fa7aed chore(deadcode): drop unused sdk specifier helper 2026-06-21 11:50:51 +08:00
Nik
6c1041339d fix(agents): classify Zhipu GLM overload as overloaded for failover (#93241)
Merged via squash.

Prepared head SHA: db79e94c89
Co-authored-by: 0xghost42 <151941421+0xghost42@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:49:39 -04:00
huangjianxiong
db54a3268b fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models (#94461)
Merged via squash.

Prepared head SHA: 445a418187
Co-authored-by: Pandah97 <80405497+Pandah97@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:49:08 -04:00
Vincent Koc
9750d887f5 chore(deadcode): dedupe plugin lookup table types 2026-06-21 11:08:34 +08:00
Vincent Koc
fce586538a chore(deadcode): drop unused i18n config type 2026-06-21 10:45:56 +08:00
Vincent Koc
97b97a9999 chore(deadcode): drop unused private exports 2026-06-21 10:21:58 +08:00
Vincent Koc
cbbb466852 test(scripts): route docs i18n module changes 2026-06-21 04:08:30 +02:00
Vincent Koc
c2de9d0822 test(scripts): route docs i18n and k8s metadata 2026-06-21 04:02:14 +02:00
Vincent Koc
e46aaead2c chore(deadcode): drop duplicate script declarations 2026-06-21 09:57:10 +08:00
Vincent Koc
c191d7978b test(scripts): route script fixture metadata 2026-06-21 03:48:20 +02:00
Vincent Koc
1b5e1e2d53 test(scripts): route scripts lib metadata changes 2026-06-21 03:41:44 +02:00
Vincent Koc
ab41a311cf chore(deadcode): drop duplicate pnpm runner declaration 2026-06-21 09:40:02 +08:00
Vincent Koc
9e70d251b0 test(scripts): focus plugin sdk entry metadata routing 2026-06-21 03:32:25 +02:00
Vincent Koc
025f8fb087 test(scripts): focus plugin sdk metadata routing 2026-06-21 03:23:54 +02:00
Vincent Koc
e012f2cd3c chore(deadcode): share telegram send runtime loader 2026-06-21 09:12:41 +08:00
ZengWen-DT
73c988a9c8 fix(sessions): reset stale per-channel origin fields on channel switch (#95328)
Merged via squash.

Prepared head SHA: 3a946cb078
Co-authored-by: ZengWen-DT <290981215+ZengWen-DT@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-20 18:09:37 -07:00
Vincent Koc
8b4eedf1bc test(scripts): focus extension package routing 2026-06-21 02:54:40 +02:00
Vincent Koc
b43eedbb18 chore(deadcode): drop duplicate provider default refs 2026-06-21 08:49:41 +08:00
Vincent Koc
ebbf506a77 test(scripts): focus deprecated config routing 2026-06-21 02:47:58 +02:00
Vincent Koc
e0c1add79a test(scripts): focus config guard routing 2026-06-21 02:39:11 +02:00
Vincent Koc
eae1e8f3f8 test(scripts): focus guard inventory routing 2026-06-21 02:26:34 +02:00
Vincent Koc
ba34052a0e chore(deadcode): share memory vector blob encoding 2026-06-21 08:23:16 +08:00
Vincent Koc
ce78ac3efb test(scripts): focus extension classifier routing 2026-06-21 02:10:59 +02:00
Vincent Koc
6c2c43d63f chore(deadcode): reuse gateway json responder 2026-06-21 08:06:38 +08:00
Vincent Koc
0168ff0c2e test(scripts): focus ts topology routing 2026-06-21 01:57:14 +02:00
Vincent Koc
22bdda2555 chore(deadcode): share nostr state account ids 2026-06-21 07:50:19 +08:00
Vincent Koc
2755112353 test(scripts): focus docker plan routing 2026-06-21 01:48:16 +02:00
Vincent Koc
57c7fa22bb test(scripts): focus workspace bootstrap routing 2026-06-21 01:36:00 +02:00
Vincent Koc
269e44e164 test(scripts): focus extension vitest routing 2026-06-21 01:28:05 +02:00
Vincent Koc
2e75d925ad chore(deadcode): share telegram state account ids 2026-06-21 07:22:52 +08:00
Vincent Koc
0e763c1499 test(scripts): focus changed extension routing 2026-06-21 01:21:24 +02:00
Vincent Koc
6ed9fb8ec2 test(scripts): focus direct run routing 2026-06-21 01:15:59 +02:00
Vincent Koc
a826d6a4a4 test(scripts): focus bundled build entry routing 2026-06-21 01:06:10 +02:00
Vincent Koc
9d519c1481 test(scripts): focus mobile version routing 2026-06-21 01:06:10 +02:00
Vincent Koc
b09b35c13c chore(deadcode): share ui helper predicates 2026-06-21 07:03:53 +08:00
Vincent Koc
15f2a56590 fix(harness): preserve empty prompt ranges 2026-06-21 07:00:11 +08:00
Vincent Koc
e66c36df37 test(copilot): fix harness test typings 2026-06-21 07:00:11 +08:00
Vincent Koc
42c504b8b1 fix(plugin-sdk): lazy-load harness agent-end effects 2026-06-21 07:00:11 +08:00
Vincent Koc
9ac3759ffc chore(plugin-sdk): refresh harness API budgets 2026-06-21 07:00:11 +08:00
Vincent Koc
d7f747af3b fix(harness): satisfy lifecycle lint gates 2026-06-21 07:00:11 +08:00
Vincent Koc
9cb3b4ea2b test(codex): prove bounded hook suffix preservation 2026-06-21 07:00:11 +08:00
Vincent Koc
f257116c92 test(codex): cover projected hook suffix bounds 2026-06-21 07:00:11 +08:00
Vincent Koc
a88ce96ee1 fix(copilot): ignore subagent idle during cleanup 2026-06-21 07:00:11 +08:00
Vincent Koc
448b3fa0be fix(copilot): retain timed-out sessions until idle 2026-06-21 07:00:11 +08:00
Vincent Koc
88ad407be2 fix(codex): retain bounded hook prompt context 2026-06-21 07:00:11 +08:00
Vincent Koc
13e77cc055 fix(copilot): normalize terminal prompt errors 2026-06-21 07:00:11 +08:00
Vincent Koc
b78718f42a fix(codex): bound delivery hint prompts 2026-06-21 07:00:11 +08:00
Vincent Koc
54dddda68d fix(copilot): preserve replacement session reuse 2026-06-21 07:00:11 +08:00
Vincent Koc
6084442ab6 fix(harness): retain empty prompt ranges 2026-06-21 07:00:11 +08:00
Vincent Koc
692c7e78f4 fix(harness): bound empty hook prompts 2026-06-21 07:00:11 +08:00
Vincent Koc
3f166b1f64 fix(codex): bound hook-expanded prompts 2026-06-21 07:00:11 +08:00
Vincent Koc
8e8905560b fix(codex): align protected prompt ranges 2026-06-21 07:00:11 +08:00
Vincent Koc
3968fea383 fix(harness): protect reset and prompt bounds 2026-06-21 07:00:11 +08:00
Vincent Koc
43e8c29fbf fix(codex): bound inbound projected context 2026-06-21 07:00:11 +08:00
Vincent Koc
7e80bb8abf fix(codex): retain projected context after hook expansion 2026-06-21 07:00:11 +08:00
Vincent Koc
9826619e22 fix(harness): pass config to agent-end side effects 2026-06-21 07:00:11 +08:00
Vincent Koc
8f3672beaa fix(copilot): settle timed-out compaction waits 2026-06-21 07:00:11 +08:00
Vincent Koc
0d25928fa4 fix(copilot): scope pending cleanup to session owner 2026-06-21 07:00:11 +08:00
Vincent Koc
807641548b fix(copilot): retain replacement session during reset 2026-06-21 07:00:11 +08:00
Vincent Koc
f87f30d429 fix(harness): preserve prompt input range 2026-06-21 07:00:11 +08:00
Vincent Koc
22c5ced69f fix(codex): preserve projected context after hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
faa4f5a23b fix(copilot): ignore stale compaction cleanup 2026-06-21 07:00:11 +08:00
Vincent Koc
fc0b28cb73 fix(copilot): serialize compaction cleanup 2026-06-21 07:00:11 +08:00
Vincent Koc
8265acaacb docs(copilot): use portable model example 2026-06-21 07:00:11 +08:00
Vincent Koc
d20e96a650 fix(copilot): isolate root compaction events 2026-06-21 07:00:11 +08:00
Vincent Koc
8ff1d3e67b fix(harness): surface returned tool errors 2026-06-21 07:00:11 +08:00
Vincent Koc
49a9032705 fix(copilot): preserve timeout compaction state 2026-06-21 07:00:11 +08:00
Vincent Koc
aab1dd88e0 fix(copilot): defer background compaction hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
a81a505c72 fix(copilot): defer background compaction cleanup 2026-06-21 07:00:11 +08:00
Vincent Koc
7d219bd6e7 fix(copilot): bound background compaction waits 2026-06-21 07:00:11 +08:00
Vincent Koc
ece7d0945c fix(copilot): cancel deferred compaction on abort 2026-06-21 07:00:11 +08:00
Vincent Koc
979238dbb3 fix(copilot): retain completed compaction sessions 2026-06-21 07:00:11 +08:00
Vincent Koc
ab165d119c fix(copilot): retain timed-out compaction hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
6f3df79f17 fix(copilot): await background compaction hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
7c2fb845db fix(harness): cover manual lifecycle hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
00f67b845b fix(harness): preserve effective hook inputs 2026-06-21 07:00:11 +08:00
Vincent Koc
6ef9207201 refactor(harness): complete lifecycle parity 2026-06-21 07:00:11 +08:00
Vincent Koc
3283540c78 chore(copilot): preserve hook bridge file modes 2026-06-21 07:00:11 +08:00
Vincent Koc
03b022b88e refactor(copilot): unify harness lifecycle hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
45f9358877 chore(deadcode): share chat token formatter 2026-06-21 06:35:53 +08:00
Vincent Koc
29ec5b331c chore(deadcode): share session model default check 2026-06-21 06:33:10 +08:00
Vincent Koc
fa08942396 test(scripts): focus plugin manifest routing 2026-06-21 00:31:29 +02:00
Vincent Koc
2c9192a9a8 test(scripts): focus plugin runtime build routing 2026-06-21 00:27:26 +02:00
Vincent Koc
955f3ed094 test(scripts): focus build metadata routing 2026-06-21 00:22:35 +02:00
Vincent Koc
7217477553 chore(deadcode): reuse session terminal status helper 2026-06-21 06:16:30 +08:00
Vincent Koc
97b0b559ad test(scripts): focus static asset routing 2026-06-21 00:04:49 +02:00
Vincent Koc
1b299f4dbf chore(deadcode): share runner tool-call type guard 2026-06-21 05:59:00 +08:00
Vincent Koc
7064d198a3 test(scripts): focus tsgo guard routing 2026-06-20 23:57:23 +02:00
Vincent Koc
f18ff7551e test(scripts): focus bundled metadata routing 2026-06-20 23:53:40 +02:00
Vincent Koc
58d295840e test(scripts): focus build stamp routing 2026-06-20 23:49:30 +02:00
Vincent Koc
896b3c612d test(scripts): focus extension boundary routing 2026-06-20 23:45:20 +02:00
Vincent Koc
2f240a4a4c test(scripts): focus sdk boundary routing 2026-06-20 23:41:37 +02:00
Vincent Koc
172412d756 chore(deadcode): remove stale exported type aliases 2026-06-21 05:38:16 +08:00
Vincent Koc
58578b3250 test(scripts): focus mcp temp state routing 2026-06-20 23:32:56 +02:00
Vincent Koc
ce9769faae test(scripts): focus install package routing 2026-06-20 23:28:48 +02:00
Vincent Koc
e8920f6f6b test(scripts): focus parallels shell routing 2026-06-20 23:23:04 +02:00
Vincent Koc
9be53b4aa2 test(scripts): focus github helper routing 2026-06-20 23:15:15 +02:00
Vincent Koc
3ec2a46907 test(scripts): focus release helper routing 2026-06-20 23:11:34 +02:00
Vincent Koc
15a2d74320 test(scripts): focus installer routing changes 2026-06-20 23:05:21 +02:00
Shakker
77f07a11e7 fix: share operator approval env snapshots 2026-06-20 22:02:27 +01:00
Josh Lehman
7a0d36f3d0 refactor: add SDK transcript identity target API (#95030) 2026-06-20 14:01:07 -07:00
Vincent Koc
0a707afb9a chore(deadcode): inline exec approval wait helper 2026-06-21 04:58:14 +08:00
Shakker
bdeda6553b test: finish gateway token env routing 2026-06-20 21:50:55 +01:00
Shakker
3499b277e3 fix: route gateway env setup through helpers 2026-06-20 21:50:55 +01:00
Vincent Koc
8c8857c3ef fix(qa): keep telegram credential tests sparse safe 2026-06-20 22:45:25 +02:00
Vincent Koc
d75613e794 chore(deadcode): reuse tool result details reader 2026-06-21 04:42:48 +08:00
Shakker
beb8897f49 test: keep Claude seed HOME fallback covered 2026-06-20 21:36:15 +01:00
Shakker
add5f76a1e fix: isolate Claude history HOME setup 2026-06-20 21:34:58 +01:00
Vincent Koc
9a9f4dbefe test(rpc): map rtt measurement script changes 2026-06-20 22:32:17 +02:00
Vincent Koc
5beaaf343c test(qa): map qa e2e script changes 2026-06-20 22:29:33 +02:00
Vincent Koc
1db811282c fix(release): validate plugin manifest runner args 2026-06-20 22:23:30 +02:00
Vincent Koc
aa23d9f34e chore(deadcode): inline approval abort classification 2026-06-21 04:22:12 +08:00
Vincent Koc
2962c95010 fix(release): validate plugin runtime build args 2026-06-20 22:19:50 +02:00
Vincent Koc
80d3b132a5 fix(release): validate package dist check args 2026-06-20 22:16:26 +02:00
Shakker
1a5d84d3fe test: reuse discovery env snapshot 2026-06-20 21:09:10 +01:00
Vincent Koc
71a75b9b28 fix(release): validate package tarball check args 2026-06-20 22:08:25 +02:00
Vincent Koc
b1f562570a fix(release): validate openclaw npm verifier args 2026-06-20 22:03:38 +02:00
Vincent Koc
bdcc691745 chore(deadcode): inline message provider tool filtering 2026-06-21 04:00:09 +08:00
Shakker
4461e257e3 fix: restore env warning flags with helper 2026-06-20 20:58:13 +01:00
Vincent Koc
76014cfe95 fix(release): validate plugin npm verifier args 2026-06-20 21:57:13 +02:00
Vincent Koc
498ff1fb5a fix(release): validate plugin clawhub publish args 2026-06-20 21:53:59 +02:00
Shakker
ae81aa018d test: reuse update method env wrapper 2026-06-20 20:52:09 +01:00
Vincent Koc
1706bfda2c fix(release): validate plugin npm publish args 2026-06-20 21:51:32 +02:00
Vincent Koc
a1201e99fc fix(release): validate npm publish wrapper args 2026-06-20 21:48:01 +02:00
Shakker
90d2f161c9 fix: scope config open path env 2026-06-20 20:46:29 +01:00
Vincent Koc
bff7134a69 fix(mac): validate notarization wrapper args 2026-06-20 21:44:09 +02:00
Vincent Koc
e59d0b540e fix(mac): reject invalid codesign args 2026-06-20 21:41:34 +02:00
Shakker
aa5fcf70f7 test: share gateway credential env guard 2026-06-20 20:40:57 +01:00
Vincent Koc
63ac2e2ce0 fix(mac): reject build-and-run wrapper args 2026-06-20 21:36:42 +02:00
Shakker
803064c6e0 fix: localize session transcript env 2026-06-20 20:35:32 +01:00
Vincent Koc
577e5a4692 fix(mac): reject unknown restart options 2026-06-20 21:33:48 +02:00
Vincent Koc
a49f3f9362 fix(qa): parse qa e2e wrapper flags 2026-06-20 21:29:18 +02:00
Vincent Koc
7b9ddbda99 chore(deadcode): inline inbound prompt prefix 2026-06-21 03:27:50 +08:00
Shakker
0f83051353 test: share release journey env wrapper 2026-06-20 20:22:18 +01:00
Vincent Koc
4341cf24cc fix(crabbox): detect node-wrapped changed gates 2026-06-20 21:19:03 +02:00
Shakker
6a3f990140 fix: isolate plugin index loader env 2026-06-20 20:13:24 +01:00
scotthuang
81abc2b21b fix: preserve cron delivery awareness for target sessions (#93580)
Merged via squash.

Prepared head SHA: 460562ceff
Co-authored-by: scotthuang <1670837+scotthuang@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-20 12:13:10 -07:00
Shakker
09fcafffbc test: scope package root fallback env 2026-06-20 20:11:46 +01:00
Vincent Koc
2a93d7b9c5 chore(deadcode): inline runtime context builders 2026-06-21 03:09:43 +08:00
Shakker
0eaefc9050 fix: share npm verifier env guard 2026-06-20 20:02:45 +01:00
Shakker
52e01676be test: reuse memory fd env helper 2026-06-20 19:58:05 +01:00
Shakker
df68b81006 fix: isolate bundled probe env 2026-06-20 19:57:16 +01:00
Vincent Koc
a5417b5c6c chore(deadcode): inline bootstrap routing helpers 2026-06-21 02:55:16 +08:00
Shakker
da2c7e2d2b test: reuse startup bench env helper 2026-06-20 19:45:59 +01:00
Shakker
3a14f247ad fix: scope bundled skills env 2026-06-20 19:44:37 +01:00
Vincent Koc
5c36001fcb chore(deadcode): inline tool-search allowlist helpers 2026-06-21 02:40:32 +08:00
Shakker
05bed72a8d test: restore plugin trust env 2026-06-20 19:34:22 +01:00
Vincent Koc
c2433d41a7 fix(ci): reject release metadata option typos 2026-06-20 20:32:50 +02:00
Shakker
d368fd620c fix: restore clawhub home env 2026-06-20 19:31:26 +01:00
Vincent Koc
7dc7deaa13 fix(ci): reject mistyped changed gate options 2026-06-20 20:28:15 +02:00
Vincent Koc
a2ff59fdb2 chore(deadcode): inline same-model retry backoff 2026-06-21 02:24:56 +08:00
Vincent Koc
b12223a79f fix(qa): reject empty qa lab port flags 2026-06-20 20:17:52 +02:00
Vincent Koc
f519ceab9c fix(ci): allow gtimeout for docker pull retry 2026-06-20 20:12:30 +02:00
Vincent Koc
1f1b1aee6b chore(deadcode): remove duplicate Gemini schema helper 2026-06-21 02:09:19 +08:00
Vincent Koc
62b2e9ef14 fix(scripts): honor gtimeout in host setup wrappers 2026-06-20 20:07:50 +02:00
Vincent Koc
0f67474251 fix(docker): keep upgrade survivor auto-auth summary safe 2026-06-20 20:02:14 +02:00
Gio Della-Libera
e56fd1dc04 Keep core doctor health in contribution order (#86627)
Merged via squash.

Prepared head SHA: e0955797c1
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-20 10:59:31 -07:00
Vincent Koc
b3968f69c9 fix(package): accept uppercase artifact digests 2026-06-20 19:52:59 +02:00
Vincent Koc
b0df6dc10e fix(package): scope trusted URL auth to original origin 2026-06-20 19:50:09 +02:00
Vincent Koc
141fb2b119 fix(crabbox): bootstrap macOS stdin shell scripts 2026-06-20 19:44:40 +02:00
Vincent Koc
64b6488f6c fix(crabbox): bootstrap env-option macOS stdin scripts 2026-06-20 19:39:05 +02:00
Vincent Koc
e1fc4683bb chore(deadcode): remove unused cron run log reader 2026-06-21 01:32:51 +08:00
Vincent Koc
85ab952956 fix(release): reject zero correction tags 2026-06-20 19:30:26 +02:00
Vincent Koc
abd5fb4494 fix(release): guard appcast cleanup before notes path 2026-06-20 19:28:42 +02:00
Vincent Koc
aea050b43e fix(mac): clean failed notary zip staging 2026-06-20 19:25:38 +02:00
Vincent Koc
85f552bf37 fix(qa): clean failed Parallels package locks 2026-06-20 19:20:40 +02:00
Vincent Koc
dafd98dd98 chore(deadcode): drop unused llm provider helpers 2026-06-21 01:17:06 +08:00
Vincent Koc
3632c62f85 fix(qa): isolate OTEL smoke exporter env 2026-06-20 19:14:06 +02:00
Vincent Koc
ad5d2cbc1b fix(mac): clean dSYM staging on zip failure 2026-06-20 19:07:04 +02:00
Vincent Koc
7cda58c109 fix(package): keep artifact duplicate diagnostics relative 2026-06-20 19:02:54 +02:00
Vincent Koc
5c0b99ae2b chore(deadcode): remove unused task flow retry path 2026-06-21 01:00:42 +08:00
Vincent Koc
979925c194 fix(openwebui): redact failed chat diagnostics 2026-06-20 18:58:30 +02:00
Vincent Koc
2f9f45f734 fix(telegram): include session probe artifacts 2026-06-20 18:51:20 +02:00
Vincent Koc
32cbaecd09 fix(telegram): stage full proof artifacts safely 2026-06-20 18:47:12 +02:00
Vincent Koc
1989726eb6 chore(deadcode): remove unused cron failure target wrapper 2026-06-21 00:40:26 +08:00
Vincent Koc
2454acc287 fix(crabbox): bound macos bun bootstrap fetches 2026-06-20 18:38:00 +02:00
Vincent Koc
fce5db415b fix(crabbox): bound macos node bootstrap downloads 2026-06-20 18:33:48 +02:00
Vincent Koc
2166652eb3 fix(parallels): bound update tarball probe 2026-06-20 18:28:13 +02:00
Vincent Koc
7a9c269541 chore(deadcode): drop unused cron summary guard 2026-06-21 00:27:23 +08:00
Vincent Koc
aa893b9228 fix(parallels): bound linux smoke downloads 2026-06-20 18:25:57 +02:00
Vincent Koc
98a7741468 fix(parallels): bound windows smoke downloads 2026-06-20 18:24:13 +02:00
Vincent Koc
3df4341e5a fix(parallels): bound macos smoke downloads 2026-06-20 18:20:55 +02:00
Vincent Koc
ecac665bf3 fix(parallels): pace background launch probes 2026-06-20 18:14:08 +02:00
Vincent Koc
021fd5de2b chore(deadcode): remove unused channel sender validator 2026-06-21 00:11:51 +08:00
Vincent Koc
60159b9f00 fix(parallels): keep fresh malformed package locks 2026-06-20 18:10:32 +02:00
Vincent Koc
165440117e fix(canvas): ignore stale pnpm execpath 2026-06-20 18:05:23 +02:00
Vincent Koc
fddfcbe10e fix(canvas): use corepack for a2ui pnpm fallback 2026-06-20 18:02:17 +02:00
Vincent Koc
7c850bdf38 fix(test): kill SDK package command trees 2026-06-20 17:54:16 +02:00
Vincent Koc
2bc20f2ec5 fix(test): use pnpm runner for SDK package build 2026-06-20 17:51:21 +02:00
Vincent Koc
ed500dda25 fix(qa): use corepack for lab docker build fallback 2026-06-20 17:45:09 +02:00
Vincent Koc
bc754b3160 fix(ci): restore Vitest watchdog cleanup 2026-06-20 23:42:22 +08:00
Vincent Koc
b972956173 test(ci): use public Feishu temp-dir helper 2026-06-20 23:42:22 +08:00
Vincent Koc
29444b26f2 chore(deadcode): dedupe plugin JSON logger 2026-06-20 23:37:00 +08:00
Vincent Koc
7fc5a72433 fix(qa): cap chunked credential lease payloads 2026-06-20 17:34:38 +02:00
Vincent Koc
a590f7f690 fix(qa): require boundary entry shim outputs 2026-06-20 17:25:11 +02:00
Vincent Koc
2252674168 fix(qa): reject matrix output symlink escapes 2026-06-20 17:15:45 +02:00
Vincent Koc
60612ff492 chore(deadcode): inline auto-reply display wrappers 2026-06-20 23:14:23 +08:00
Vincent Koc
c5623e72f3 fix(qa): quote generated compose paths 2026-06-20 17:08:40 +02:00
Vincent Koc
947c21ee5a refactor(qa): reuse qa shell quote helper 2026-06-20 17:05:10 +02:00
Vincent Koc
99f58ae6d6 fix(qa): quote qa docker stop command 2026-06-20 16:59:14 +02:00
Vincent Koc
3f0e740f83 chore(deadcode): inline session visibility wrappers 2026-06-20 22:56:40 +08:00
Vincent Koc
106961b513 fix(e2e): resolve mounted macOS desktop homes 2026-06-20 16:51:20 +02:00
Vincent Koc
d0001f96f0 fix(e2e): ignore bundled plugin list diagnostics 2026-06-20 16:44:11 +02:00
Vincent Koc
527bd807b9 fix(e2e): ignore runtime smoke rpc log records 2026-06-20 16:40:14 +02:00
Vincent Koc
7546231762 fix(run-node): type signal process injection 2026-06-20 22:37:26 +08:00
Vincent Koc
a977dc843d chore(deadcode): delete unused route wrappers 2026-06-20 22:37:26 +08:00
Vincent Koc
6ad7f66af2 fix(e2e): ignore inline kitchen sink json diagnostics 2026-06-20 16:34:52 +02:00
Vincent Koc
1b4fb6291d fix(e2e): parse secret proof json records 2026-06-20 16:31:09 +02:00
Vincent Koc
ee69465fe9 fix(e2e): ignore embedded diagnostic reply json 2026-06-20 16:26:00 +02:00
Vincent Koc
7b329ade32 fix(e2e): reject malformed package lock pids 2026-06-20 16:21:27 +02:00
Vincent Koc
44422b2151 fix(e2e): isolate Windows background control markers 2026-06-20 16:17:04 +02:00
Vincent Koc
48b338a5a9 fix(e2e): report signaled host server startups 2026-06-20 16:14:16 +02:00
Vincent Koc
d4f68475fd fix(e2e): preserve spaced macOS desktop homes 2026-06-20 16:11:03 +02:00
Vincent Koc
d81ae7a441 chore(deadcode): inline unused CLI helpers 2026-06-20 22:09:32 +08:00
Vincent Koc
99d8549de6 fix(crabbox): always mark shell changed gates as remote 2026-06-20 16:04:05 +02:00
Vincent Koc
7a077ffead fix(run-node): bind process signal cleanup 2026-06-20 15:55:16 +02:00
Vincent Koc
b980d678a4 fix(run-node): clean child groups on forwarded signals 2026-06-20 15:55:16 +02:00
Vincent Koc
e02e3d6971 chore(deadcode): remove unused CLI helper exports 2026-06-20 21:51:36 +08:00
Vincent Koc
6fa05685ea fix(check): clean managed child groups after forwarded signals 2026-06-20 15:46:14 +02:00
Vincent Koc
6585cb3b44 fix(watch): clean child groups on watcher shutdown 2026-06-20 15:43:04 +02:00
Vincent Koc
730c7269ef fix(test): clean Vitest runner child groups on signal 2026-06-20 15:35:33 +02:00
Vincent Koc
d72f7edf2d chore(deadcode): move gateway live probe helper out of prod path 2026-06-20 21:21:19 +08:00
Vincent Koc
24b6e6ba96 fix(test-live): force cleanup shard child groups on parent signal 2026-06-20 15:19:22 +02:00
Vincent Koc
c33f8c20ef fix(test-live): force cleanup Vitest child groups on parent signal 2026-06-20 15:16:15 +02:00
Vincent Koc
1c0c072bc2 fix(boundary): force cleanup tsc child trees on parent signal 2026-06-20 15:10:53 +02:00
Vincent Koc
aaf335af04 fix(deadcode): clean Knip child trees on parent signal 2026-06-20 15:07:19 +02:00
Vincent Koc
ad049ef083 fix(build): clean tsdown child trees on parent signal 2026-06-20 15:03:37 +02:00
Vincent Koc
6dc121eb6a chore(deadcode): move gateway auth helper out of prod path 2026-06-20 21:01:29 +08:00
Vincent Koc
0742a2f37a fix(test-report): clean parent-signaled child trees 2026-06-20 14:59:00 +02:00
Vincent Koc
e2c567538d fix(boundary): clean active check child trees 2026-06-20 14:52:00 +02:00
Vincent Koc
5c8fa5da5c chore(deadcode): move plugin test mocks out of prod paths 2026-06-20 20:41:02 +08:00
Vincent Koc
9953b85e6d fix(install-smoke): clean Bun timeout child trees 2026-06-20 14:39:39 +02:00
Vincent Koc
048014d1ab fix(memory): clean extension profiler child trees 2026-06-20 14:30:28 +02:00
Vincent Koc
0cd6975352 fix(prompt-probe): clean direct prompt child trees 2026-06-20 14:20:09 +02:00
Vincent Koc
5384b91866 fix(prompt-probe): clean gateway child trees 2026-06-20 14:09:17 +02:00
Vincent Koc
19ec9d8979 chore(deadcode): remove msteams memory test stores 2026-06-20 20:03:23 +08:00
Vincent Koc
e65619dd0c fix(crabbox): clean wrapper child trees on parent signal 2026-06-20 13:52:34 +02:00
Vincent Koc
2f0f085826 chore(deadcode): remove bedrock test injection hooks 2026-06-20 19:44:10 +08:00
Vincent Koc
0cd8db97f9 fix(bench): kill gateway child trees on windows 2026-06-20 13:30:33 +02:00
Vincent Koc
087d999fce fix(secret-providers): clean PTY configure timeout trees 2026-06-20 13:29:56 +02:00
Vincent Koc
4514b5a387 fix(runtime-smoke): kill bundled child trees on windows 2026-06-20 13:24:20 +02:00
Vincent Koc
6b82d4ecb7 chore(deadcode): remove telegram topic cache test helpers 2026-06-20 19:22:42 +08:00
Vincent Koc
f719f0cf77 fix(rpc): kill measurement gateway trees on windows 2026-06-20 13:18:02 +02:00
Vincent Koc
8ee638236a fix(secret-providers): clean command trees on parent signal 2026-06-20 13:17:14 +02:00
Vincent Koc
36934fd9f5 fix(kitchen-sink): clean command groups on parent signal 2026-06-20 13:12:00 +02:00
Vincent Koc
84895e9276 fix(docker): clean active shell groups on parent signal 2026-06-20 13:04:14 +02:00
Vincent Koc
a6e41a0cc1 fix(qa-lab): kill script timeout trees on windows 2026-06-20 12:58:52 +02:00
Vincent Koc
1ede829fbf fix(qa-lab): leave vitest timeout cleanup to wrapper 2026-06-20 12:53:39 +02:00
Vincent Koc
b93b07ee1b test(qa-lab): use temp harness in scenario runner tests 2026-06-20 12:53:39 +02:00
Vincent Koc
405e5072fd fix(qa-lab): bound test file scenario commands 2026-06-20 12:53:39 +02:00
Vincent Koc
b79dfc739c fix(gauntlet): clean measured groups on parent signal 2026-06-20 12:49:02 +02:00
Vincent Koc
ff4808f94d chore(deadcode): remove stale feishu download helpers 2026-06-20 18:47:54 +08:00
Vincent Koc
602bc0baa9 fix(bench): clean timed-out sample process groups 2026-06-20 12:31:47 +02:00
Vincent Koc
a1d278b174 fix(crabbox): preserve telegram proof kill grace 2026-06-20 12:25:03 +02:00
Vincent Koc
0fd5dae36f test(ci): allow control ui runner startup 2026-06-20 18:22:56 +08:00
Vincent Koc
984e058624 fix(e2e): reap signaled PTY command trees 2026-06-20 12:20:16 +02:00
Vincent Koc
a6e4afe0fa fix(parallels): preserve npm update stream kill grace 2026-06-20 12:15:30 +02:00
Vincent Koc
66c62d52ad chore(deadcode): remove stale msteams mention helpers 2026-06-20 18:14:41 +08:00
Vincent Koc
9e3ef487eb test(ci): cover stable closeout retries 2026-06-20 18:13:27 +08:00
Vincent Koc
739636fc33 fix(parallels): reap signaled host command groups 2026-06-20 12:08:29 +02:00
Vincent Koc
ccc1415f6d fix(ui): clean up wrapper signal descendants 2026-06-20 12:08:07 +02:00
Vincent Koc
b1608b4a4e test(ci): refresh temp-dir helper routing 2026-06-20 18:05:14 +08:00
Vincent Koc
703dfbf453 chore(deadcode): remove stale auto-reply helpers 2026-06-20 17:58:59 +08:00
Vincent Koc
7cd58cca2a fix(qa-lab): keep lifecycle probe timeout trees tracked 2026-06-20 11:58:14 +02:00
Vincent Koc
2d603c90dc fix(i18n): reap control ui process groups on signal 2026-06-20 11:54:43 +02:00
Vincent Koc
4296ecb78c fix(qa-matrix): clean up killed CLI process groups 2026-06-20 11:50:38 +02:00
Vincent Koc
fe1d981a47 fix(ci): ignore ClawSweeper self-comments 2026-06-20 17:42:47 +08:00
Vincent Koc
5cf8ba973d fix(ci): cancel superseded main workflows 2026-06-20 17:42:47 +08:00
Vincent Koc
cb394309fe fix(qa-matrix): keep timed CLI process groups tracked 2026-06-20 11:41:46 +02:00
Vincent Koc
dd29a6de52 fix(scripts): reap startup metadata help descendants 2026-06-20 11:40:00 +02:00
Vincent Koc
93a0b5d353 fix(ci): handle missing closeout assets after backoff 2026-06-20 17:39:30 +08:00
Vincent Koc
4f8fd48ea7 fix(ci): cool down main workflow fanout 2026-06-20 17:37:04 +08:00
Vincent Koc
7679872ddf chore(deadcode): drop memory shadow trial scoring shims 2026-06-20 17:33:16 +08:00
Vincent Koc
cd7385c5c6 fix(rpc): preserve gateway signal cleanup grace 2026-06-20 11:22:36 +02:00
Vincent Koc
88cf142c98 fix(qa-lab): preserve model catalog abort grace 2026-06-20 11:18:43 +02:00
1063 changed files with 39778 additions and 13081 deletions

View File

@@ -670,11 +670,7 @@ function resolveAssociatedPullRequests(commitHashes, targetTimestamp) {
pending.push({ commitHash, cursor: connection.pageInfo.endCursor });
}
}
for (
let index = 0;
index < commitHashes.length;
index += commitAssociationQueryBatchSize
) {
for (let index = 0; index < commitHashes.length; index += commitAssociationQueryBatchSize) {
const chunk = commitHashes.slice(index, index + commitAssociationQueryBatchSize);
const fields = chunk
.map(

View File

@@ -18,15 +18,16 @@ permissions:
contents: read
concurrency:
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
group: ${{ github.event_name == 'push' && format('clawsweeper-dispatch-{0}-{1}', github.repository, github.ref) || format('clawsweeper-dispatch-{0}-{1}', github.repository, github.event.issue.number || github.event.pull_request.number || github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' || github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
jobs:
dispatch:
runs-on: ubuntu-latest
if: >-
${{
github.event_name == 'issue_comment' ||
(github.event_name != 'issue_comment' ||
(github.actor != 'clawsweeper[bot]' && github.actor != 'openclaw-clawsweeper[bot]')) &&
!(
endsWith(github.actor, '[bot]') &&
(github.event.action == 'labeled' || github.event.action == 'unlabeled')
@@ -41,6 +42,34 @@ jobs:
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
run: sleep 20
- name: Debounce main push dispatch
if: ${{ github.event_name == 'push' }}
run: sleep 45
- name: Install GitHub API backoff helper
run: |
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
gh_api_with_retry() {
local attempt output status lower_output
for attempt in 1 2 3 4 5; do
if output="$(gh api "$@" 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
lower_output="${output,,}"
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
printf '%s\n' "$output" >&2
return "$status"
fi
echo "::warning::GitHub API throttled ClawSweeper dispatch on attempt ${attempt}; retrying after backoff." >&2
sleep $((attempt * attempt * 5))
done
printf '%s\n' "$output" >&2
return "$status"
}
BASH
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
@@ -52,9 +81,27 @@ jobs:
repositories: clawsweeper
permission-contents: write
- name: Pre-filter ClawSweeper comment
id: comment_filter
if: ${{ github.event_name == 'issue_comment' }}
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then
echo "is_command=true" >> "$GITHUB_OUTPUT"
else
echo "is_command=false" >> "$GITHUB_OUTPUT"
fi
- name: Create target comment token
id: target_token
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true' &&
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
}}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
@@ -77,6 +124,7 @@ jobs:
echo "::notice::Skipping GitHub activity dispatch because no ClawSweeper app token is configured."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
activity="$(jq -c \
--arg target_repo "$TARGET_REPO" \
--arg event_name "$SOURCE_EVENT" \
@@ -143,7 +191,7 @@ jobs:
' "$GITHUB_EVENT_PATH")"
payload="$(jq -nc --argjson activity "$activity" \
'{event_type:"github_activity",client_payload:{activity:$activity}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched GitHub activity to ClawSweeper."
@@ -165,6 +213,7 @@ jobs:
echo "::notice::Skipping ClawSweeper dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
@@ -173,7 +222,7 @@ jobs:
--arg source_action "$SOURCE_ACTION" \
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper review."
@@ -182,7 +231,11 @@ jobs:
fi
- name: Acknowledge and dispatch ClawSweeper comment
if: ${{ github.event_name == 'issue_comment' }}
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true'
}}
env:
DISPATCH_TOKEN: ${{ steps.token.outputs.token }}
TARGET_TOKEN: ${{ steps.target_token.outputs.token }}
@@ -198,15 +251,12 @@ jobs:
echo "::notice::Skipping ClawSweeper comment dispatch because no ClawSweeper app token is configured."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
printf '%s\n' "$COMMENT_BODY" > "$body_file"
if ! grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
echo "No ClawSweeper command found in comment."
exit 0
fi
if [ -n "$TARGET_TOKEN" ]; then
err="$(mktemp)"
if GH_TOKEN="$TARGET_TOKEN" gh api -X POST \
if GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry -X POST \
-H "Accept: application/vnd.github+json" \
"repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \
-f content="eyes" 2>"$err" >/dev/null; then
@@ -233,7 +283,7 @@ jobs:
"Command router queued. I will update this comment with the next step.")"
status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')"
status_err="$(mktemp)"
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry \
"repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \
--method POST \
--input - <<< "$status_payload" 2>"$status_err")"; then
@@ -254,7 +304,7 @@ jobs:
--arg source_event "issue_comment" \
--arg source_action "$SOURCE_ACTION" \
'{event_type:"clawsweeper_comment",client_payload:({target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')"
if GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \
if GH_TOKEN="$DISPATCH_TOKEN" gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper comment router."
@@ -276,6 +326,7 @@ jobs:
echo "::notice::Skipping ClawSweeper commit dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
case "$CREATE_CHECKS" in
true|TRUE|1|yes|YES|on|ON) create_checks=true ;;
*) create_checks=false ;;
@@ -287,7 +338,7 @@ jobs:
--arg ref "$SOURCE_REF" \
--argjson create_checks "$create_checks" \
'{event_type:"clawsweeper_commit_review",client_payload:{target_repo:$target_repo,before_sha:$before_sha,after_sha:$after_sha,ref:$ref,enabled:true,create_checks:$create_checks}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper commit review."

View File

@@ -33,7 +33,7 @@ on:
concurrency:
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -23,8 +23,8 @@ permissions:
contents: write
concurrency:
group: control-ui-locale-refresh
cancel-in-progress: false
group: control-ui-locale-refresh-${{ github.event_name == 'push' && github.ref || github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'release' && format('release-{0}', github.event.release.tag_name) || format('{0}-{1}', github.event_name, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
plan:

View File

@@ -13,6 +13,10 @@ on:
permissions:
contents: read
concurrency:
group: docs-sync-publish-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
sync-publish-repo:
runs-on: ubuntu-latest

View File

@@ -23,8 +23,8 @@ permissions:
contents: write
concurrency:
group: openclaw-stable-main-closeout
cancel-in-progress: false
group: openclaw-stable-main-closeout-${{ github.event_name == 'workflow_dispatch' && (inputs.tag || github.run_id) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
resolve:
@@ -43,6 +43,30 @@ jobs:
should_closeout: ${{ steps.inputs.outputs.should_closeout }}
tag: ${{ steps.inputs.outputs.tag }}
steps:
- name: Install GitHub API backoff helper
run: |
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
gh_with_retry() {
local attempt output status lower_output
for attempt in 1 2 3 4 5; do
if output="$(gh "$@" 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
lower_output="${output,,}"
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
printf '%s\n' "$output" >&2
return "$status"
fi
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
sleep $((attempt * attempt * 5))
done
printf '%s\n' "$output" >&2
return "$status"
}
BASH
- name: Checkout pushed main
if: ${{ github.event_name == 'push' }}
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -62,9 +86,13 @@ jobs:
TRIGGER_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "push" ]]; then
sleep 45
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
if [[ "$EVENT_NAME" == "push" ]]; then
main_ref="$TRIGGER_SHA"
tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
tag="$(gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
--json tagName,isPrerelease,publishedAt \
--jq '[.[] | select(.isPrerelease | not) | select(.tagName | test("^v[0-9]{4}\\.[0-9]+\\.[0-9]+(-[0-9]+)?$"))] | sort_by(.publishedAt) | last | .tagName // empty')"
if [[ -z "$tag" ]]; then
@@ -91,7 +119,7 @@ jobs:
tag_package_content="$RUNNER_TEMP/tag-package-content.b64"
tag_package_read=false
for attempt in 1 2 3; do
if gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
if gh_with_retry api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
--jq '.content' > "$tag_package_content"; then
tag_package_read=true
break
@@ -126,7 +154,7 @@ jobs:
closeout_checksum_asset="${closeout_asset}.sha256"
closeout_dir="$RUNNER_TEMP/release-closeout-evidence"
mkdir -p "$closeout_dir"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
gh_with_retry release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$closeout_asset" --pattern "$closeout_checksum_asset" --dir "$closeout_dir" || true
closeout_json_path="$closeout_dir/$closeout_asset"
closeout_checksum_path="$closeout_dir/$closeout_checksum_asset"
@@ -182,8 +210,11 @@ jobs:
fi
evidence_dir="$RUNNER_TEMP/release-postpublish-evidence"
mkdir -p "$evidence_dir"
if ! gh release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir"; then
gh_with_retry release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir" || true
evidence_path="$evidence_dir/$evidence_asset"
evidence_checksum_path="$evidence_dir/$evidence_checksum_asset"
if [[ ! -f "$evidence_path" || ! -f "$evidence_checksum_path" ]]; then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "Stable closeout skipped: $evidence_source_tag predates immutable postpublish evidence." >&2
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
@@ -192,7 +223,6 @@ jobs:
echo "Stable closeout is required for $tag, but immutable postpublish evidence from $evidence_source_tag is missing." >&2
exit 1
fi
evidence_path="$evidence_dir/$evidence_asset"
if ! (
cd "$evidence_dir"
sha256sum --strict --status -c "$evidence_checksum_asset"
@@ -272,6 +302,30 @@ jobs:
exit 1
fi
- name: Install GitHub API backoff helper
run: |
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
gh_with_retry() {
local attempt output status lower_output
for attempt in 1 2 3 4 5; do
if output="$(gh "$@" 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
lower_output="${output,,}"
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
printf '%s\n' "$output" >&2
return "$status"
fi
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
sleep $((attempt * attempt * 5))
done
printf '%s\n' "$output" >&2
return "$status"
}
BASH
- name: Verify release workflow evidence
env:
GH_TOKEN: ${{ github.token }}
@@ -279,7 +333,8 @@ jobs:
RELEASE_PUBLISH_RUN_ID: ${{ needs.resolve.outputs.release_publish_run_id }}
run: |
set -euo pipefail
gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
. "$RUNNER_TEMP/github-api-backoff.sh"
gh_with_retry run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion \
> "$RUNNER_TEMP/full-release-validation-run.json"
node --input-type=module - "$RUNNER_TEMP/full-release-validation-run.json" <<'NODE'
@@ -296,7 +351,7 @@ jobs:
}
}
NODE
gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion \
> "$RUNNER_TEMP/release-publish-run.json"
node --input-type=module - "$RUNNER_TEMP/release-publish-run.json" <<'NODE'
@@ -317,7 +372,7 @@ jobs:
manifest_dir="$RUNNER_TEMP/full-release-validation-manifest"
rm -rf "$manifest_dir"
mkdir -p "$manifest_dir"
gh run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
gh_with_retry run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--name "full-release-validation-${FULL_RELEASE_VALIDATION_RUN_ID}" \
--dir "$manifest_dir"
tag_sha="$(git -C "$GITHUB_WORKSPACE/release-tag" rev-parse HEAD)"
@@ -346,7 +401,8 @@ jobs:
run: |
set -euo pipefail
mkdir -p "$CLOSEOUT_DIR"
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
. "$RUNNER_TEMP/github-api-backoff.sh"
gh_with_retry release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets \
> "$CLOSEOUT_DIR/github-release.json"
node scripts/verify-stable-main-closeout.mjs \
@@ -372,21 +428,23 @@ jobs:
CLOSEOUT_DIR: ${{ runner.temp }}/openclaw-stable-main-closeout
run: |
set -euo pipefail
. "$RUNNER_TEMP/github-api-backoff.sh"
release_version="${RELEASE_TAG#v}"
attach_or_verify() {
local source_path="$1"
local asset_name="$2"
local existing_dir="$CLOSEOUT_DIR/existing-${asset_name}"
mkdir -p "$existing_dir"
if gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset_name" --dir "$existing_dir"; then
gh_with_retry release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset_name" --dir "$existing_dir" || true
if [[ -f "$existing_dir/$asset_name" ]]; then
cmp --silent "$source_path" "$existing_dir/$asset_name" || {
echo "Existing release asset $asset_name differs from closeout evidence." >&2
exit 1
}
return
fi
gh release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
gh_with_retry release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
}
attach_or_verify \
"$CLOSEOUT_DIR/stable-main-closeout.json" \

View File

@@ -38,8 +38,8 @@ on:
type: string
concurrency:
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
cancel-in-progress: false
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -19,7 +19,7 @@ permissions:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai
### Highlights
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangqueping, and @jairrab.
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93002, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangguiping-xydt, @zhangqueping, and @jairrab.
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is available from npm and ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
@@ -25,7 +25,7 @@ Docs: https://docs.openclaw.ai
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve pending subagent delivery, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469, #94349, #92383, #94257) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @vincentkoc, @sallyom, @oiGaDio, @Hidetsugu55, and @Nas01010101.
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, progress draft cleanup before visible tool output, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93002, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @zhangguiping-xydt, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
@@ -33,7 +33,7 @@ Docs: https://docs.openclaw.ai
### Complete contribution record
This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
@@ -57,6 +57,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs.
- **PR #88792** fix(state): harden sqlite path caching. Thanks @vincentkoc.
- **PR #93022** fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.
- **PR #93020** fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.
- **PR #93002** fix(telegram): clear progress drafts before visible tool output. Thanks @zhangguiping-xydt.
- **PR #89160** fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.
- **PR #93009** fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.
- **PR #92991** fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.

View File

@@ -23,6 +23,10 @@ private struct WatchChatPreview {
var statusText: String?
}
private struct ExecApprovalGatewayEventPayload: Decodable {
var id: String
}
/// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -895,26 +899,49 @@ final class NodeAppModel {
for await evt in stream {
if Task.isCancelled { return }
guard let payload = evt.payload else { continue }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
default:
continue
}
await self.handleOperatorGatewayServerEvent(evt)
}
}
}
private func handleOperatorGatewayServerEvent(_ evt: EventFrame) async {
guard let payload = evt.payload else { return }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
case ExecApprovalNotificationBridge.requestedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.presentExecApprovalNotificationPrompt(
ExecApprovalNotificationPrompt(approvalId: approvalId))
case ExecApprovalNotificationBridge.resolvedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
default:
return
}
}
private nonisolated static func execApprovalEventID(from payload: AnyCodable) -> String? {
guard let decoded = try? GatewayPayloadDecoding.decode(
payload,
as: ExecApprovalGatewayEventPayload.self)
else {
return nil
}
let approvalId = decoded.id.trimmingCharacters(in: .whitespacesAndNewlines)
return approvalId.isEmpty ? nil : approvalId
}
private func applyTalkModeSync(enabled: Bool, phase: String?) {
_ = phase
guard self.talkMode.isEnabled != enabled else { return }
@@ -5139,6 +5166,14 @@ extension NodeAppModel {
isBackgrounded: isBackgrounded)
}
nonisolated static func _test_execApprovalEventID(from payload: AnyCodable) -> String? {
self.execApprovalEventID(from: payload)
}
func _test_handleOperatorGatewayServerEvent(_ event: EventFrame) async {
await self.handleOperatorGatewayServerEvent(event)
}
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String]

View File

@@ -1160,6 +1160,35 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
isBackgrounded: false))
}
@Test func execApprovalEventIDDecodesGatewayPayload() {
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1")
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil)
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil)
}
@Test @MainActor func operatorGatewayResolvedEventClearsPendingApprovalPrompt() async throws {
let appModel = NodeAppModel()
try appModel._test_presentExecApprovalPrompt(
#require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-event-resolved",
commandText: "echo clear",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.resolvedKind,
payload: AnyCodable(["id": "approval-event-resolved"]),
seq: nil,
stateversion: nil))
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
}
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],

View File

@@ -6592,6 +6592,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let turnsourceto: AnyCodable?
public let turnsourceaccountid: AnyCodable?
public let turnsourcethreadid: AnyCodable?
public let approvalreviewerdeviceids: [String]?
public let requiredeliveryroute: Bool?
public let suppressdelivery: Bool?
public let timeoutms: Int?
@@ -6618,6 +6619,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
turnsourceto: AnyCodable?,
turnsourceaccountid: AnyCodable?,
turnsourcethreadid: AnyCodable?,
approvalreviewerdeviceids: [String]?,
requiredeliveryroute: Bool? = nil,
suppressdelivery: Bool? = nil,
timeoutms: Int?,
@@ -6643,6 +6645,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
self.turnsourcethreadid = turnsourcethreadid
self.approvalreviewerdeviceids = approvalreviewerdeviceids
self.requiredeliveryroute = requiredeliveryroute
self.suppressdelivery = suppressdelivery
self.timeoutms = timeoutms
@@ -6670,6 +6673,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
case turnsourcethreadid = "turnSourceThreadId"
case approvalreviewerdeviceids = "approvalReviewerDeviceIds"
case requiredeliveryroute = "requireDeliveryRoute"
case suppressdelivery = "suppressDelivery"
case timeoutms = "timeoutMs"

View File

@@ -128,14 +128,9 @@ const config = {
"**/*.test-utils.ts",
"test/helpers/live-image-probe.ts",
"src/secrets/credential-matrix.ts",
"src/gateway/live-tool-probe-utils.ts",
"src/gateway/server.auth.shared.ts",
"src/shared/text/assistant-visible-text.ts",
bundledPluginFile("telegram", "src/bot/reply-threading.ts"),
bundledPluginFile("telegram", "src/draft-chunking.ts"),
bundledPluginFile("msteams", "src/conversation-store-memory.ts"),
bundledPluginFile("msteams", "src/polls-store-memory.ts"),
bundledPluginFile("voice-call", "src/providers/index.ts"),
],
ignore: ["packages/*/dist/**"],
workspaces: {

View File

@@ -1,2 +1,2 @@
d5a8dc906d615f081799783ffda46b48dac43c3de9ddcb7c4b95311031fbe80e plugin-sdk-api-baseline.json
47d93e8b79e5d5fd0ef0a607a831a5b205c94e759a48401ce4a34da98e42b93d plugin-sdk-api-baseline.jsonl
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl

View File

@@ -1,11 +1,11 @@
---
summary: "Slack setup and runtime behavior (Socket Mode + HTTP Request URLs)"
summary: "Slack setup and runtime behavior (Socket Mode, HTTP Request URLs, and relay mode)"
read_when:
- Setting up Slack or debugging Slack socket/HTTP mode
- Setting up Slack or debugging Slack socket, HTTP, or relay mode
title: "Slack"
---
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported.
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported. Relay mode is intended for managed deployments where a trusted router owns Slack ingress.
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
@@ -41,6 +41,37 @@ Both transports are production-ready and reach feature parity for messaging, sla
**Pick HTTP Request URLs** when running multiple Gateway replicas behind a load balancer, when outbound WSS is blocked but inbound HTTPS is allowed, or when you already terminate Slack webhooks at a reverse proxy.
</Note>
### Relay mode
Relay mode separates Slack ingress from the OpenClaw gateway. A trusted router owns the
single Slack Socket Mode connection, chooses a destination gateway, and forwards a typed
event over an authenticated websocket. The gateway continues to use its bot token for
outbound Slack Web API calls.
```json5
{
channels: {
slack: {
mode: "relay",
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
relay: {
url: "wss://router.example.com/gateway/ws",
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
gatewayId: "team-gateway",
},
},
},
}
```
The relay URL must use `wss://` unless it targets localhost. Treat the bearer token and
router route table as part of the Slack authorization boundary: routed events enter the
normal Slack message handler as authorized activations. A router-provided `slack_identity`
in the websocket `hello` frame can set the default outbound username and icon; an explicit
identity supplied by the caller still wins. The relay connection reconnects with the same
bounded backoff timing used by Socket Mode and clears the router-provided identity whenever
it disconnects.
## Install
Install Slack before configuring the channel:
@@ -863,7 +894,8 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
- `botToken` + `appToken` are required for Socket Mode.
- HTTP mode requires `botToken` + `signingSecret`.
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
- Relay mode requires `botToken` plus `relay.url`, `relay.authToken`, and `relay.gatewayId`; it does not use an app token or signing secret.
- `botToken`, `appToken`, `signingSecret`, `relay.authToken`, and `userToken` accept plaintext
strings or SecretRef objects.
- Config tokens override env fallback.
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.

View File

@@ -336,7 +336,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Requirement:
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
- short initial answer previews are debounced, then materialized after a bounded delay if the run is still active
- `progress` keeps one editable status draft for tool progress, shows the stable status label when answer activity arrives before tool progress, clears it at completion, and sends the final answer as a normal message
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft

View File

@@ -172,10 +172,12 @@ A finding includes:
| `ocPath` | Precise `oc://` address when a check can point to one. |
| `fixHint` | Suggested operator action or repair summary. |
This release registers the modernized core doctor checks on the structured
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
contract for bundled follow-up consumers, but plugin-backed checks only run
after their owning package registers them in the active command path.
Modernized core doctor checks stay attached to the ordered doctor contribution
that owns their human `doctor` / `doctor --fix` behavior. The shared structured
health registry is the extension point: bundled and plugin-backed checks run
after core doctor checks once their owning package registers them in the active
command path. The `openclaw/plugin-sdk/health` subpath exposes the same
contract for those extension consumers.
## Check Selection

View File

@@ -230,8 +230,8 @@ canonical subscription `github-copilot` provider and is **never** selected by
The harness claims its provider, runtime, CLI session key, and auth profile
prefix in `extensions/copilot/doctor-contract-api.ts`, which
`openclaw doctor` auto-loads. For configuration, auth, transcript mirroring,
compaction, the doctor probe surface, and the broader PI vs Codex vs Copilot
SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
compaction, the declarative doctor contract, and the broader PI vs Codex vs
Copilot SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
## Compatibility contract

View File

@@ -302,13 +302,13 @@ Live transport runners should import the shared scenario ids, baseline
coverage helpers, and scenario-selection helper from
`openclaw/plugin-sdk/qa-live-transport-scenarios`.
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
| Matrix | x | x | x | x | x | x | x | x | x | | |
| Telegram | x | x | x | | | | | | | x | |
| Discord | x | x | x | | | | | | | | x |
| Slack | x | x | x | x | x | x | x | x | | | |
| WhatsApp | x | x | | x | x | x | | | x | x | |
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Quote reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | ----------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
| Matrix | x | x | x | x | x | | x | x | x | x | | |
| Telegram | x | x | x | | | | | | | | x | |
| Discord | x | x | x | | | | | | | | | x |
| Slack | x | x | x | x | x | | x | x | x | | | |
| WhatsApp | x | x | | x | x | x | x | | | x | x | |
This keeps `qa-channel` as the broad product-behavior suite while Matrix,
Telegram, and other live transports share one explicit transport-contract checklist.
@@ -731,8 +731,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
`whatsapp-whoami-command`, `whatsapp-context-command`,
`whatsapp-native-new-command`.
- Reply and final-output behavior: `whatsapp-tool-only-usage-footer`,
`whatsapp-reply-to-message`, `whatsapp-reply-context-isolation`,
`whatsapp-reply-delivery-shape`, `whatsapp-stream-final-message-accounting`.
`whatsapp-reply-to-message`, `whatsapp-group-reply-to-message`,
`whatsapp-reply-context-isolation`, `whatsapp-reply-delivery-shape`,
`whatsapp-stream-final-message-accounting`.
- Inbound media and structured messages: `whatsapp-inbound-image-caption`,
`whatsapp-audio-preflight`, `whatsapp-inbound-structured-messages`,
`whatsapp-group-audio-gating`. These send real WhatsApp image, audio,
@@ -749,9 +750,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
`whatsapp-approval-plugin-native`.
- Status reactions: `whatsapp-status-reactions`.
The catalog currently contains 35 scenarios. The `live-frontier` default lane is
kept small at 8 scenarios for fast smoke coverage. The `mock-openai` default
lane runs 29 deterministic scenarios through the real WhatsApp transport while
The catalog currently contains 36 scenarios. The `live-frontier` default lane is
kept small at 10 scenarios for fast smoke coverage. The `mock-openai` default
lane runs 31 deterministic scenarios through the real WhatsApp transport while
mocking only model output. Approval scenarios and a few heavier/blocking checks
remain explicit by scenario id.

View File

@@ -160,9 +160,10 @@ Legacy key migration:
Telegram:
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
- Short initial previews are still debounced for push-notification UX, but Telegram now materializes them after a bounded delay so active runs do not stay visually silent.
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
- `block` mode rotates the preview into a new message at `streaming.preview.chunk.maxChars` (default 800, capped at Telegram's 4096 edit limit); other modes grow one preview up to 4096 characters.
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
- `progress` mode keeps tool progress in an editable status draft, materializes the status label when answer streaming is active but no tool line is available yet, clears that draft at completion, and sends the final answer through normal delivery.
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
- `/reasoning stream` can write reasoning to a transient preview that is deleted after final delivery.

View File

@@ -249,9 +249,10 @@ Shared defaults for bounded runtime context surfaces.
- `toolResultMaxChars`: advanced live tool-result ceiling used for persisted
results and overflow recovery. Leave unset for the model-context auto cap:
`16000` chars below 100K tokens, `32000` chars at 100K+ tokens, and `64000`
chars at 200K+ tokens. The effective cap is still limited to about 30% of the
model context window. `openclaw doctor --deep` prints the effective cap, and
doctor warns only when an explicit override is stale or has no effect.
chars at 200K+ tokens. Explicit values up to `1000000` are accepted for
long-context models, but the effective cap is still limited to about 30% of
the model context window. `openclaw doctor --deep` prints the effective cap,
and doctor warns only when an explicit override is stale or has no effect.
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
refresh injection.

View File

@@ -160,8 +160,6 @@ must be paired with `--lint`; regular doctor and repair runs reject them.
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
- Extra workspace dir detection (`~/openclaw`).
</Accordion>
<Accordion title="Gateway, services, and supervisors">
- Sandbox image repair when sandboxing is enabled.
@@ -469,14 +467,14 @@ That stages grounded durable candidates into the short-term dreaming store while
<Accordion title="10. systemd linger (Linux)">
If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout.
</Accordion>
<Accordion title="11. Workspace status (skills, plugins, and legacy dirs)">
<Accordion title="11. Workspace status (skills, plugins, and TaskFlows)">
Doctor prints a summary of the workspace state for the default agent:
- **Skills status**: counts eligible, missing-requirements, and allowlist-blocked skills.
- **Legacy workspace dirs**: warns when `~/openclaw` or other legacy workspace directories exist alongside the current workspace.
- **Plugin status**: counts enabled/disabled/errored plugins; lists plugin IDs for any errors; reports bundle plugin capabilities.
- **Plugin compatibility warnings**: flags plugins that have compatibility issues with the current runtime.
- **Plugin diagnostics**: surfaces any load-time warnings or errors emitted by the plugin registry.
- **TaskFlow recovery**: surfaces suspicious managed TaskFlows that need manual inspection or cancellation.
</Accordion>
<Accordion title="11b. Bootstrap file size">

View File

@@ -15,15 +15,18 @@ OpenClaw treats **wake words as a single global list** owned by the **Gateway**.
## Storage (Gateway host)
Wake words are stored on the gateway machine at:
Wake words and routing rules are stored in the gateway state database:
- `~/.openclaw/settings/voicewake.json`
- `~/.openclaw/state/openclaw.sqlite`
Shape:
The active tables are:
```json
{ "triggers": ["openclaw", "claude", "computer"], "updatedAtMs": 1730000000000 }
```
- `voicewake_triggers`
- `voicewake_routing_config`
- `voicewake_routing_routes`
Legacy `settings/voicewake.json` and `settings/voicewake-routing.json` files are
doctor migration inputs only; runtime reads and writes the SQLite tables.
## Protocol

View File

@@ -145,6 +145,11 @@ local proof.
Use `definePluginEntry` for non-channel plugins. Channel plugins use
`defineChannelPluginEntry`.
Tool handlers may accept an optional fifth execution-context argument when
they need runtime-owned facts for the current call. The context includes the
active `runId`, effective `sessionKey`, ephemeral `sessionId`, owning
`agentId`, and ambient `deliveryContext` when those values are available.
</Step>
<Step title="Test the runtime">

View File

@@ -33,15 +33,12 @@ For the broader model/provider/runtime split, start with
- A GitHub Copilot subscription that can drive the Copilot CLI (or a
`gitHubToken` env / auth-profile entry for headless / cron runs).
- A writable `copilotHome` directory. The harness defaults to
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation. The
platform default (`%APPDATA%\copilot` on Windows, `$XDG_CONFIG_HOME/copilot`
or `~/.config/copilot` elsewhere) is used as the doctor probe fallback when
no explicit home is set.
`<agentDir>/copilot` when OpenClaw provides an agent directory, otherwise
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation.
`openclaw doctor` runs the plugin
[doctor contract](#doctor-and-probes) for the extension; failures there are
the canonical way to confirm the environment is ready before opting an agent
in.
[doctor contract](#doctor) for declarative session-state ownership and future
compatibility migrations. It does not run Copilot CLI environment probes.
## Plugin install
@@ -79,9 +76,9 @@ Pin one model (or one provider) to the harness:
{
agents: {
defaults: {
model: "github-copilot/gpt-5.5",
model: "github-copilot/auto",
models: {
"github-copilot/gpt-5.5": {
"github-copilot/auto": {
agentRuntime: { id: "copilot" },
},
},
@@ -95,6 +92,10 @@ when only that model should be routed through the harness; set
`agentRuntime.id` on a provider when every model under that provider should
use it.
`github-copilot/auto` is the portable starting point. Named Copilot models are
account- and organization-policy-dependent, so only pin one after confirming
that the authenticated Copilot CLI exposes it.
## Supported providers
The harness advertises support for the canonical `github-copilot` provider
@@ -149,10 +150,6 @@ the same directory), or `~/.openclaw/agents/<agentId>/copilot` otherwise.
Override with `copilotHome: <path>` on the attempt input when you need a
custom location (for example, a shared mount for migration).
`probeCopilotAuthShape` (see [Doctor and probes](#doctor-and-probes)) is the
pure shape check that validates which of the modes above will be used.
It does not perform a live SDK handshake.
## Configuration surface
The harness reads its config from per-attempt input
@@ -169,8 +166,9 @@ The harness reads its config from per-attempt input
- `infiniteSessionConfig` — optional override for the SDK
`infiniteSessions` block driven by `harness.compact`. Defaults are safe to
leave as-is.
- `hooksConfig` — optional bridge config exposing OpenClaw
before/after-message-write hooks to the SDK loop.
- `hooksConfig` — optional native Copilot SDK `SessionHooks` compatibility
config for tool/MCP, user-prompt, session, and error callbacks.
It is separate from OpenClaw's portable lifecycle hooks.
- `permissionPolicy` — optional override for the SDK's
`onPermissionRequest` handler used for built-in SDK tool kinds
(`shell`, `write`, `read`, `url`, `mcp`, `memory`, `hook`). Defaults
@@ -181,6 +179,14 @@ The harness reads its config from per-attempt input
wrapped `execute()`. See [Permissions and ask_user](#permissions-and-ask_user).
- `enableSessionTelemetry` — optional SDK session telemetry flag.
OpenClaw plugin hooks do not need Copilot-specific attempt configuration. The
harness runs `before_prompt_build` (and the legacy `before_agent_start`
compatibility hook), `llm_input`, `llm_output`, and `agent_end` through the
standard harness helpers. Successful SDK compactions also run
`before_compaction` and `after_compaction`. Bridged OpenClaw tools continue to
run `before_tool_call` and report `after_tool_call`; `hooksConfig` remains for
native SDK-only callbacks that have no portable equivalent.
Nothing in the rest of OpenClaw needs to know about these fields. Other
plugins, channels, and core code only see the standard
`AgentHarnessAttemptParams` / `AgentHarnessAttemptResult` shape.
@@ -226,7 +232,7 @@ asserted in
[`extensions/copilot/harness.test.ts`](https://github.com/openclaw/openclaw/blob/main/extensions/copilot/harness.test.ts)
under `describe("runSideQuestion")`.
## Doctor and probes
## Doctor
`extensions/copilot/doctor-contract-api.ts` is auto-loaded by
`src/plugins/doctor-contract-registry.ts`. It contributes:
@@ -238,18 +244,6 @@ under `describe("runSideQuestion")`.
runtime `copilot`; CLI session key `copilot`; auth profile
prefix `github-copilot:`.
`extensions/copilot/src/doctor-probes.ts` exports three imperative probes
that hosts (including `openclaw doctor`) can call to verify the environment:
| Probe | What it checks | Reasons it can fail |
| -------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `probeCopilotCliVersion` | `copilot --version` exits 0 with a non-empty version string | `non-zero-exit`, `empty-version`, `spawn-failed`, `spawn-error`, `probe-timeout` |
| `probeCopilotHomeWritable` | `mkdir -p copilotHome` + write + rm a marker file | `copilothome-not-writable` (with the underlying fs error in `details.rawError`) |
| `probeCopilotAuthShape` | At least one of `useLoggedInUser`, `gitHubToken`, or `profileId`+`profileVersion` | `no-auth-source` |
Each probe accepts a DI seam (`spawnFn`, `fsApi`) so tests do not spawn the
real Copilot CLI or touch the host fs.
## Limitations
- The harness only claims the canonical `github-copilot` provider at MVP.

View File

@@ -185,6 +185,17 @@ field; OpenClaw does not infer it from assistant prose. The helper intentionally
leaves prompt errors, in-flight turns, and intentional silent replies such as
`NO_REPLY` unclassified.
### Agent-end side effects
Native harnesses must call `runAgentEndSideEffects(...)` from
`openclaw/plugin-sdk/agent-harness-runtime` after they finalize an attempt. It
dispatches the portable `agent_end` hook and OpenClaw's research capture without
delaying interactive replies. Use `awaitAgentEndSideEffects(...)` for local,
non-interactive runs where the attempt must not resolve until those side effects
finish. Both helpers accept the same `{ event, ctx }` payload as
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
attempt result.
### Native Codex harness mode
The bundled `codex` harness is the native Codex mode for embedded OpenClaw

View File

@@ -238,9 +238,11 @@ releases.
`api.runtime.config.writeConfigFile(...)` directly. Prefer config that was
already passed into the active call path. Long-lived handlers that need the
current process snapshot can use `api.runtime.config.current()`. Long-lived
agent tools should use the tool context's `ctx.getRuntimeConfig()` inside
`execute` so a tool created before a config write still sees the refreshed
runtime config.
factory-created agent tools should use the tool factory context's
`ctx.getRuntimeConfig()` inside `execute` so a tool created before a config
write still sees the refreshed runtime config. For per-call run, session, or
delivery facts, use the tool execution context rather than closing over the
factory context.
Config writes must go through the transactional helpers and choose an
after-write policy:

View File

@@ -166,7 +166,9 @@ two-party event loops that do not go through the shared inbound reply runner.
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
</Accordion>
<Accordion title="api.runtime.agent.defaults">

View File

@@ -247,7 +247,8 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), bounded recent user/assistant transcript text reads by session identity, legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |

View File

@@ -151,6 +151,14 @@ Factories are still for fixed tool names. Use `definePluginEntry` directly when
the plugin computes tool names dynamically or combines tools with hooks,
services, providers, commands, or other runtime surfaces.
Factory context is construction-time state. Use it to decide whether the tool
exists for the run or to bind stable helpers. Per-call state belongs in the
execution context: static tool-plugin `execute` handlers receive it as fields on
their third `context` argument, and factory-created `AgentTool.execute`
handlers receive it as the optional fifth argument. The execution context
includes `runId`, effective `sessionKey`, `sessionId`, `agentId`, and
`deliveryContext` when OpenClaw knows those values.
## Return values
`defineToolPlugin` wraps plain return values into the OpenClaw tool-result

View File

@@ -28,8 +28,10 @@ The provider includes:
| ------------------------------- | --------------------- |
| `opencode-go/glm-5` | GLM-5 |
| `opencode-go/glm-5.1` | GLM-5.1 |
| `opencode-go/glm-5.2` | GLM-5.2 |
| `opencode-go/kimi-k2.5` | Kimi K2.5 |
| `opencode-go/kimi-k2.6` | Kimi K2.6 (3x limits) |
| `opencode-go/kimi-k2.7-code` | Kimi K2.7 Code |
| `opencode-go/deepseek-v4-pro` | DeepSeek V4 Pro |
| `opencode-go/deepseek-v4-flash` | DeepSeek V4 Flash |
| `opencode-go/mimo-v2-omni` | MiMo V2 Omni |
@@ -39,6 +41,8 @@ The provider includes:
| `opencode-go/qwen3.5-plus` | Qwen3.5 Plus |
| `opencode-go/qwen3.6-plus` | Qwen3.6 Plus |
GLM-5.2 uses a 1M-token context window and supports up to 131K output tokens.
## Getting started
<Tabs>

View File

@@ -126,6 +126,11 @@ The manifest-backed catalog currently includes:
GLM models are available as `zai/<model>` (example: `zai/glm-5`).
</Tip>
<Tip>
GLM-5.2 supports `off`, `low`, `high`, and `max` thinking levels. OpenClaw maps
`low` and `high` to Z.AI high reasoning effort, and `max` to max effort.
</Tip>
<Note>
Coding Plan setup defaults to `zai/glm-5.2`; general API setup keeps
`zai/glm-5.1`. Endpoint auto-detection falls back to `glm-5.1` or `glm-4.7`

View File

@@ -72,10 +72,12 @@ Scope intent:
- `channels.telegram.accounts.*.webhookSecret`
- `channels.slack.botToken`
- `channels.slack.appToken`
- `channels.slack.relay.authToken`
- `channels.slack.userToken`
- `channels.slack.signingSecret`
- `channels.slack.accounts.*.botToken`
- `channels.slack.accounts.*.appToken`
- `channels.slack.accounts.*.relay.authToken`
- `channels.slack.accounts.*.userToken`
- `channels.slack.accounts.*.signingSecret`
- `channels.sms.authToken`

View File

@@ -295,6 +295,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.relay.authToken",
"configFile": "openclaw.json",
"path": "channels.slack.accounts.*.relay.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.signingSecret",
"configFile": "openclaw.json",
@@ -323,6 +330,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.relay.authToken",
"configFile": "openclaw.json",
"path": "channels.slack.relay.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.signingSecret",
"configFile": "openclaw.json",

View File

@@ -54,7 +54,7 @@ for bounded runtime excerpts and injected runtime-owned blocks. They are
separate from bootstrap limits, startup-context limits, and skills prompt
limits.
`toolResultMaxChars` is an advanced ceiling. When it is unset, OpenClaw chooses
`toolResultMaxChars` is an advanced ceiling (up to `1000000` characters). When it is unset, OpenClaw chooses
the live tool-result cap from the effective model context window: `16000` chars
below 100K tokens, `32000` chars at 100K+ tokens, and `64000` chars at 200K+
tokens, still bounded by the runtime context-share guard.

View File

@@ -34,7 +34,7 @@ title: "Thinking levels"
- Stale configured OpenRouter Hunter Alpha refs skip proxy reasoning injection because that retired route could return final answer text through reasoning fields.
- Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family.
- MiniMax M2.x (`minimax/MiniMax-M2*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from M2.x's non-native Anthropic stream format. MiniMax-M3 (and M3.x) is exempt: M3 emits proper Anthropic thinking blocks and returns empty content when thinking is disabled, so OpenClaw keeps M3 on the provider's omitted/adaptive thinking path.
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
- Z.AI (`zai/*`) is binary (`on`/`off`) for most GLM models. GLM-5.2 is the exception: it exposes `/think off|low|high|max`, maps `low` and `high` to Z.AI `reasoning_effort: "high"`, and maps `max` to `reasoning_effort: "max"`.
- Moonshot Kimi K2.7 Code (`moonshot/kimi-k2.7-code`) always thinks. Its profile exposes only `on`, and OpenClaw omits the outbound `thinking` field as required by Moonshot. Other `moonshot/*` models map `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
## Resolution order

View File

@@ -6,8 +6,6 @@ type SharedIniFileLoader = {
loadSharedConfigFiles(init?: { ignoreCache?: boolean }): Promise<unknown>;
};
let sharedIniFileLoaderForTest: SharedIniFileLoader | null | undefined;
function hasStaticAwsCredentialEnv(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY);
}
@@ -21,12 +19,6 @@ export function shouldRefreshAwsSharedConfigCacheForBedrock(env: NodeJS.ProcessE
}
async function loadSharedIniFileLoader(): Promise<SharedIniFileLoader> {
if (sharedIniFileLoaderForTest !== undefined) {
if (!sharedIniFileLoaderForTest) {
throw new Error("AWS shared INI file loader unavailable");
}
return sharedIniFileLoaderForTest;
}
return (await import("@smithy/shared-ini-file-loader")) as SharedIniFileLoader;
}
@@ -40,10 +32,3 @@ export async function refreshAwsSharedConfigCacheForBedrock(
const loader = await loadSharedIniFileLoader();
await loader.loadSharedConfigFiles({ ignoreCache: true });
}
/** Override the shared INI loader for Bedrock credential-refresh tests. */
export function setAwsSharedIniFileLoaderForTest(
loader: SharedIniFileLoader | null | undefined,
): void {
sharedIniFileLoaderForTest = loader;
}

View File

@@ -9,14 +9,9 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setAwsSharedIniFileLoaderForTest } from "./aws-credential-refresh.js";
import { supportsBedrockPromptCaching } from "./bedrock-options.js";
import { resetBedrockDiscoveryCacheForTest } from "./discovery.js";
import amazonBedrockPlugin from "./index.js";
import {
resetBedrockAppProfileCacheEligibilityForTest,
setBedrockAppProfileControlPlaneForTest,
} from "./register.sync.runtime.js";
type BedrockClientResult =
| {
@@ -96,6 +91,10 @@ vi.mock("@aws-sdk/client-bedrock", () => {
};
});
vi.mock("@smithy/shared-ini-file-loader", () => ({
loadSharedConfigFiles: refreshSharedConfigCache,
}));
type RegisteredProviderPlugin = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
@@ -149,6 +148,8 @@ const ANTHROPIC_MODEL_DESCRIPTOR = {
const APP_INFERENCE_PROFILE_ARN =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-claude-profile";
const OPUS_APP_INFERENCE_PROFILE_ARN =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opus-temperature-profile";
const APP_INFERENCE_PROFILE_DESCRIPTOR = {
api: "openai-completions",
provider: "amazon-bedrock",
@@ -267,26 +268,12 @@ describe("amazon-bedrock provider plugin", () => {
inferenceProfileGetResults.length = 0;
bedrockClientConfigs.length = 0;
refreshSharedConfigCache.mockClear();
setAwsSharedIniFileLoaderForTest({ loadSharedConfigFiles: refreshSharedConfigCache });
sendBedrockCommand.mockClear();
resetBedrockDiscoveryCacheForTest();
resetBedrockAppProfileCacheEligibilityForTest();
setBedrockAppProfileControlPlaneForTest((region) => ({
async getInferenceProfile(input) {
class GetInferenceProfileCommand {
constructor(readonly inputLocal: Record<string, unknown> = {}) {}
}
bedrockClientConfigs.push(region ? { region } : {});
return await sendBedrockCommand(new GetInferenceProfileCommand(input));
},
}));
});
afterEach(() => {
setBedrockAppProfileControlPlaneForTest(undefined);
setAwsSharedIniFileLoaderForTest(undefined);
resetBedrockDiscoveryCacheForTest();
resetBedrockAppProfileCacheEligibilityForTest();
});
afterAll(() => {
@@ -1501,8 +1488,8 @@ describe("amazon-bedrock provider plugin", () => {
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
OPUS_APP_INFERENCE_PROFILE_ARN,
makeAppInferenceProfileDescriptor(OPUS_APP_INFERENCE_PROFILE_ARN),
{ temperature: 0.3, maxTokens: 10, cacheRetention: "short" },
payload,
);

View File

@@ -254,27 +254,7 @@ type BedrockControlPlane = {
}) => Promise<BedrockGetInferenceProfileResponse>;
};
type BedrockControlPlaneFactory = (region: string | undefined) => BedrockControlPlane;
let bedrockControlPlaneOverride: BedrockControlPlaneFactory | undefined;
/** Reset app-profile prompt-cache eligibility state for tests. */
export function resetBedrockAppProfileCacheEligibilityForTest(): void {
appProfileTraitsCache.clear();
}
/** Override Bedrock app-profile control-plane checks for tests. */
export function setBedrockAppProfileControlPlaneForTest(
controlPlane: BedrockControlPlaneFactory | undefined,
): void {
bedrockControlPlaneOverride = controlPlane;
resetBedrockAppProfileCacheEligibilityForTest();
}
async function createBedrockControlPlane(region: string | undefined): Promise<BedrockControlPlane> {
if (bedrockControlPlaneOverride) {
return bedrockControlPlaneOverride(region);
}
await refreshAwsSharedConfigCacheForBedrock();
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
const client = new BedrockClient(region ? { region } : {});

View File

@@ -10,6 +10,12 @@ import {
resolveClaudeCliExecutionArgs,
} from "./cli-shared.js";
function expectDefaultDisallowedTools(args: readonly string[] | undefined) {
const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1;
expect(disallowedIndex).toBeGreaterThanOrEqual(0);
expect(args?.[disallowedIndex + 1]).toBe("ScheduleWakeup,CronCreate");
}
describe("normalizeClaudePermissionArgs", () => {
it("leaves args alone when they omit permission flags", () => {
expect(
@@ -356,8 +362,10 @@ describe("normalizeClaudeBackendConfig", () => {
expect(backend.config.input).toBe("stdin");
expect(backend.config.args).toContain("--setting-sources");
expect(backend.config.args).toContain("user");
expectDefaultDisallowedTools(backend.config.args);
expect(backend.config.resumeArgs).toContain("--setting-sources");
expect(backend.config.resumeArgs).toContain("user");
expectDefaultDisallowedTools(backend.config.resumeArgs);
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
expect(backend.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");

View File

@@ -9,6 +9,23 @@ const { registerManagedProxyBrowserCdpBypassMock } = vi.hoisted(() => ({
),
}));
function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
if (!resolve || !reject) {
throw new Error("Expected deferred callbacks to be initialized");
}
return { promise, resolve, reject };
}
vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({
registerManagedProxyBrowserCdpBypass: registerManagedProxyBrowserCdpBypassMock,
}));
@@ -29,19 +46,6 @@ beforeEach(() => {
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
});
function createDeferred<T = void>() {
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
if (!resolve || !reject) {
throw new Error("Expected deferred callbacks to be initialized");
}
return { promise, resolve, reject };
}
async function withIsolatedNoProxyEnv(fn: () => Promise<void>) {
const origNoProxy = process.env.NO_PROXY;
const origNoProxyLower = process.env.no_proxy;

View File

@@ -1,12 +1,11 @@
/**
* Browser plugin runtime lifecycle helpers for startup relay setup and shutdown
* cleanup.
* Browser plugin runtime lifecycle helpers for startup and shutdown cleanup.
*/
import type { Server } from "node:http";
import { getPwAiModule } from "./pw-ai-module.js";
import { isPwAiLoaded } from "./pw-ai-state.js";
import type { BrowserServerState } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
import { stopKnownBrowserProfiles } from "./server-lifecycle.js";
import { startTrackedBrowserTabCleanupTimer } from "./session-tab-cleanup.js";
import { registerBrowserUnhandledRejectionHandler } from "./unhandled-rejections.js";
@@ -27,10 +26,6 @@ export async function createBrowserRuntimeState(params: {
onWarn: params.onWarn,
});
await ensureExtensionRelayForProfiles({
resolved: params.resolved,
onWarn: params.onWarn,
});
state.stopUnhandledRejectionHandler = registerBrowserUnhandledRejectionHandler();
return state;

View File

@@ -19,7 +19,6 @@ const { getUnhandledRejectionHandlers, registerUnhandledRejectionHandlerMock, re
});
const {
ensureExtensionRelayForProfilesMock,
getPwAiModuleMock,
isPwAiLoadedMock,
startTrackedBrowserTabCleanupTimerMock,
@@ -28,7 +27,6 @@ const {
} = vi.hoisted(() => {
const trackedTabCleanupMockLocal = vi.fn();
return {
ensureExtensionRelayForProfilesMock: vi.fn(async () => {}),
getPwAiModuleMock: vi.fn(),
isPwAiLoadedMock: vi.fn(() => false),
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMockLocal),
@@ -42,7 +40,6 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
}));
vi.mock("./server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: ensureExtensionRelayForProfilesMock,
stopKnownBrowserProfiles: stopKnownBrowserProfilesMock,
}));
@@ -64,7 +61,6 @@ const { isPlaywrightDialogRaceUnhandledRejection } = await import("./unhandled-r
beforeEach(() => {
resetHandlers();
registerUnhandledRejectionHandlerMock.mockClear();
ensureExtensionRelayForProfilesMock.mockClear();
getPwAiModuleMock.mockClear();
isPwAiLoadedMock.mockReset().mockReturnValue(false);
startTrackedBrowserTabCleanupTimerMock.mockClear();

View File

@@ -19,8 +19,7 @@ vi.mock("./server-context.js", () => ({
listKnownProfileNames: listKnownProfileNamesMock,
}));
const { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } =
await import("./server-lifecycle.js");
const { stopKnownBrowserProfiles } = await import("./server-lifecycle.js");
beforeEach(() => {
createBrowserRouteContextMock.mockClear();
@@ -28,17 +27,6 @@ beforeEach(() => {
stopOpenClawChromeMock.mockClear();
});
describe("ensureExtensionRelayForProfiles", () => {
it("is a no-op after removing the Chrome extension relay path", async () => {
await expect(
ensureExtensionRelayForProfiles({
resolved: { profiles: {} } as never,
onWarn: vi.fn(),
}),
).resolves.toBeUndefined();
});
});
describe("stopKnownBrowserProfiles", () => {
it("stops all known profiles and ignores per-profile failures", async () => {
listKnownProfileNamesMock.mockReturnValue(["openclaw", "user"]);

View File

@@ -1,24 +1,13 @@
/**
* Browser server lifecycle helpers for relay setup and profile shutdown.
* Browser server lifecycle helpers for profile shutdown.
*/
import { stopOpenClawChrome } from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
type BrowserServerState,
createBrowserRouteContext,
listKnownProfileNames,
} from "./server-context.js";
/** Ensures extension relay compatibility hooks for configured profiles. */
export async function ensureExtensionRelayForProfiles(_params: {
resolved: ResolvedBrowserConfig;
onWarn: (message: string) => void;
}) {
// Intentional no-op: the Chrome extension relay path has been removed.
// runtime-lifecycle still calls this helper, so keep the stub until the next
// breaking cleanup rather than changing the call graph in a patch release.
}
/** Stops every known Browser profile during runtime shutdown. */
export async function stopKnownBrowserProfiles(params: {
getState: () => BrowserServerState | null;

View File

@@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({
}),
resolveBrowserControlAuth: vi.fn(() => ({})),
shouldAutoGenerateBrowserAuth: vi.fn(() => true),
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
}));
vi.mock("../config/config.js", async () => {
@@ -69,7 +68,6 @@ vi.mock("./server-context.js", () => ({
}));
vi.mock("./server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
stopKnownBrowserProfiles: vi.fn(async () => {}),
}));
@@ -85,7 +83,6 @@ describe("browser control auth bootstrap failures", () => {
mocks.ensureBrowserControlAuth.mockClear();
mocks.resolveBrowserControlAuth.mockClear();
mocks.shouldAutoGenerateBrowserAuth.mockClear();
mocks.ensureExtensionRelayForProfiles.mockClear();
});
afterEach(async () => {
@@ -98,7 +95,6 @@ describe("browser control auth bootstrap failures", () => {
expect(started).toBeNull();
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
it("fails closed when auth bootstrap resolves empty auth in production-like mode", async () => {
@@ -111,7 +107,6 @@ describe("browser control auth bootstrap failures", () => {
expect(started).toBeNull();
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
it("fails closed when password mode has no resolved password", async () => {
@@ -123,7 +118,6 @@ describe("browser control auth bootstrap failures", () => {
const started = await startBrowserControlServerFromConfig();
expect(started).toBeNull();
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
it("fails closed when password mode drops an inactive token but has no password", async () => {
@@ -136,6 +130,5 @@ describe("browser control auth bootstrap failures", () => {
const started = await startBrowserControlServerFromConfig();
expect(started).toBeNull();
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
});

View File

@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
ensureBrowserControlAuth: vi.fn(async () => ({ auth: {} })),
resolveBrowserControlAuth: vi.fn(() => ({})),
shouldAutoGenerateBrowserAuth: vi.fn(() => false),
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
stopKnownBrowserProfiles: vi.fn(async () => {}),
isChromeReachable: vi.fn(async () => false),
isChromeCdpReady: vi.fn(async () => false),
@@ -32,7 +31,6 @@ vi.mock("../browser/control-auth.js", () => ({
}));
vi.mock("../browser/server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles,
}));

View File

@@ -1,5 +1,7 @@
export interface PnpmRunnerParams {
comSpec?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
nodeArgs?: string[];
nodeExecPath?: string;
npmExecPath?: string;

View File

@@ -2,6 +2,7 @@
* Cross-platform pnpm command resolver used by Canvas build scripts.
*/
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
import path from "node:path";
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
const PNPM_EXECUTABLE_RE = /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/;
@@ -48,13 +49,56 @@ function isExecutableFile(value) {
}
}
function isFile(value) {
try {
return statSync(value).isFile();
} catch {
return false;
}
}
function resolvePathEnvKey(env) {
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
}
function findExecutableOnPath(command, envPath, platform, env, cwd) {
if (typeof envPath !== "string" || envPath.length === 0) {
return undefined;
}
const extensions =
platform === "win32"
? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ??
".COM;.EXE;.BAT;.CMD")
.split(";")
.filter(Boolean)
.map((extension) => extension.toLowerCase())
: [""];
const pathImpl = platform === "win32" ? path.win32 : path;
const pathDelimiter = platform === "win32" ? ";" : path.delimiter;
for (const directory of envPath.split(pathDelimiter)) {
if (!directory) {
continue;
}
const resolvedDirectory = pathImpl.isAbsolute(directory)
? directory
: pathImpl.resolve(cwd, directory);
for (const extension of extensions) {
const candidate = pathImpl.join(resolvedDirectory, `${command}${extension}`);
if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) {
return candidate;
}
}
}
return undefined;
}
function isNodeRunnablePnpmExecPath(value) {
if (!isPnpmExecPath(value)) {
return false;
}
const { extension } = inspectExecutablePath(value);
if (NODE_RUNNABLE_EXTENSIONS.has(extension)) {
return true;
return isFile(value);
}
if (extension.length > 0) {
return false;
@@ -129,6 +173,22 @@ export function resolvePnpmRunner(params = {}) {
const pnpmArgs = params.pnpmArgs ?? [];
const platform = params.platform ?? process.platform;
const env = params.env ?? process.env;
const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"];
const cwd = params.cwd ?? process.cwd();
const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd);
if (pnpmPath) {
return platform === "win32"
? windowsCmdSpec(pnpmPath, pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
: { args: pnpmArgs, command: pnpmPath, shell: false };
}
const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd);
if (corepackPath) {
const args = ["pnpm", ...pnpmArgs];
return platform === "win32"
? windowsCmdSpec(corepackPath, args, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
: { args, command: corepackPath, shell: false };
}
if (platform === "win32") {
return windowsCmdSpec("pnpm.cmd", pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe");
}

View File

@@ -17,6 +17,7 @@ describe("canvas pnpm runner", () => {
try {
expect(
resolvePnpmRunner({
env: { PATH: "" },
npmExecPath,
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
@@ -40,6 +41,7 @@ describe("canvas pnpm runner", () => {
try {
expect(
resolvePnpmRunner({
env: { PATH: "" },
npmExecPath,
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
@@ -53,4 +55,79 @@ describe("canvas pnpm runner", () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("uses Corepack when pnpm is not directly available on PATH", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-corepack-"));
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: "",
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["pnpm", "exec", "rolldown", "-c"],
command: corepackPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("ignores a missing pnpm JS npm_execpath before checking PATH", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-missing-"));
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: path.join(tempDir, "missing-pnpm.mjs"),
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["pnpm", "exec", "rolldown", "-c"],
command: corepackPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("prefers a direct pnpm executable over Corepack", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-path-"));
const pnpmPath = path.join(tempDir, "pnpm");
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(pnpmPath, "#!/bin/sh\nexit 0\n");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(pnpmPath, 0o755);
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: "",
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["exec", "rolldown", "-c"],
command: pnpmPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -2,10 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import { loginChutes } from "./oauth.js";
function boundedErrorResponse(
body: string,
status = 500,
): {
function boundedErrorResponse(body: string, status = 500): {
response: Response;
cancel: ReturnType<typeof vi.fn>;
releaseLock: ReturnType<typeof vi.fn>;

View File

@@ -586,6 +586,51 @@ export function prependCodexOpenClawPromptContext(
return [context?.trim(), deliverySection, promptSection].filter(Boolean).join("\n\n");
}
/**
* Maps the surviving user-request portion of an input range after delivery
* metadata has been relocated before the request.
*/
export function resolveCodexDeliveryHintPreservedInputRange(params: {
prompt: string;
promptInputRange: { start: number; end: number } | undefined;
decoratedPrompt: string;
}): { start: number; end: number } | undefined {
const { prompt, promptInputRange, decoratedPrompt } = params;
const { deliveryHint, prompt: promptWithoutDeliveryHint } = splitLeadingCodexDeliveryHint(prompt);
if (
!deliveryHint ||
!promptInputRange ||
promptInputRange.start < 0 ||
promptInputRange.end < promptInputRange.start ||
promptInputRange.end > prompt.length ||
!decoratedPrompt.endsWith(promptWithoutDeliveryHint)
) {
return undefined;
}
const promptWithoutDeliveryHintStart = prompt.length - promptWithoutDeliveryHint.length;
const inputStart = Math.max(promptInputRange.start, promptWithoutDeliveryHintStart);
const inputEnd = Math.max(
inputStart,
Math.min(
promptInputRange.end,
promptWithoutDeliveryHint.length + promptWithoutDeliveryHintStart,
),
);
const decoratedPromptSuffixStart = decoratedPrompt.length - promptWithoutDeliveryHint.length;
const requestHeader = "Current user request:\n";
const requestHeaderStart = decoratedPromptSuffixStart - requestHeader.length;
// Delivery metadata moves outside the request, so retain the remaining input
// span rather than treating the original, now non-contiguous range as valid.
return {
start:
inputStart === promptWithoutDeliveryHintStart &&
decoratedPrompt.slice(requestHeaderStart, decoratedPromptSuffixStart) === requestHeader
? requestHeaderStart
: decoratedPromptSuffixStart + inputStart - promptWithoutDeliveryHintStart,
end: decoratedPromptSuffixStart + inputEnd - promptWithoutDeliveryHintStart,
};
}
function splitLeadingCodexDeliveryHint(prompt: string): {
deliveryHint?: string;
prompt: string;

View File

@@ -249,10 +249,64 @@ describe("projectContextEngineAssemblyForCodex", () => {
// The user's actual request is the priority tail and must survive truncation.
expect(fitted).toContain("Current user request:");
expect(fitted.endsWith("q".repeat(40))).toBe(true);
// The dropped older context is reported, not silently lost.
// Current context still survives even when an earlier projection is dropped.
expect(fitted).toContain("older context");
// The dropped older content is reported, not silently lost.
expect(fitted).toContain("[truncated ");
});
it("keeps the current request and fitting hook context after projecting history", () => {
const before = "OpenClaw assembled context for this turn:\n<conversation_context>\n";
const context = `recent context ${"c".repeat(800)}`;
const request = "\n</conversation_context>\n\nCurrent user request:\nkeep this request";
const hookAppend = "\n\nhook context survives";
const promptText = `${before}${context}${request}${hookAppend}`;
const maxChars = 420;
const fitted = fitCodexProjectedContextForTurnStart({
promptText,
contextRange: { start: before.length, end: before.length + context.length },
requestRange: {
start: before.length + context.length,
end: before.length + context.length + request.length,
},
maxChars,
});
expect(fitted.length).toBeLessThanOrEqual(maxChars);
expect(fitted).toContain("[truncated ");
expect(fitted).toContain("Current user request:\nkeep this request");
expect(fitted).toContain("hook context survives");
});
it("keeps the original input when a hook appends context without a projection", () => {
const prompt = "current prompt survives";
const hookAppend = `\n\nhook context ${"h".repeat(800)}`;
const maxChars = 420;
const fitted = fitCodexProjectedContextForTurnStart({
promptText: `${prompt}${hookAppend}`,
preservedRange: { start: 0, end: prompt.length },
maxChars,
});
expect(fitted.length).toBeLessThanOrEqual(maxChars);
expect(fitted).toContain(prompt);
expect(fitted).not.toContain("hook context");
});
it("bounds hook output for an empty original input", () => {
const maxChars = 420;
const fitted = fitCodexProjectedContextForTurnStart({
promptText: `hook context ${"h".repeat(800)} hook tail`,
preservedRange: { start: 0, end: 0 },
maxChars,
});
expect(fitted.length).toBeLessThanOrEqual(maxChars);
expect(fitted).toContain("hook tail");
});
it("bounds output for a large request under the default Codex turn limit", () => {
const maxChars = CODEX_TURN_START_TEXT_INPUT_MAX_CHARS;
// A large assembled header prefix already over the cap forces the

View File

@@ -121,6 +121,8 @@ export function resolveCodexContextEngineProjectionReserveTokens(params: {
export function fitCodexProjectedContextForTurnStart(params: {
promptText: string;
contextRange?: CodexProjectedContextRange;
requestRange?: CodexProjectedContextRange;
preservedRange?: CodexProjectedContextRange;
maxChars?: number;
}): string {
const maxChars =
@@ -132,23 +134,63 @@ export function fitCodexProjectedContextForTurnStart(params: {
}
const range = normalizeProjectedContextRange(params.contextRange, params.promptText.length);
if (!range) {
return params.promptText;
const preservedRange = normalizeProjectedContextRange(
params.preservedRange,
params.promptText.length,
);
if (!preservedRange) {
return params.promptText;
}
const preservedText = params.promptText.slice(preservedRange.start, preservedRange.end);
if (!preservedText) {
return truncateOlderContext(params.promptText, maxChars);
}
if (preservedText.length >= maxChars) {
return truncateOlderContext(preservedText, maxChars);
}
const beforeRange = params.promptText.slice(0, preservedRange.start);
return `${truncateOlderContext(beforeRange, maxChars - preservedText.length)}${preservedText}`;
}
const beforeContext = params.promptText.slice(0, range.start);
const context = params.promptText.slice(range.start, range.end);
const afterContext = params.promptText.slice(range.end);
const requestRange = normalizeProjectedContextRange(
params.requestRange,
params.promptText.length,
);
if (
requestRange &&
requestRange.start >= range.end &&
requestRange.end < params.promptText.length
) {
const request = params.promptText.slice(requestRange.start, requestRange.end);
if (request.length >= maxChars) {
return truncateOlderContext(request, maxChars);
}
const appendedContext = params.promptText.slice(requestRange.end);
// Hook-appended context is newer than the projected history. Retain it
// before trimming the projection, while the full current request remains
// the hard boundary that must survive a bounded turn/start input.
const fittedAppendedContext = truncateOlderContext(appendedContext, maxChars - request.length);
const contextBudget = maxChars - request.length - fittedAppendedContext.length;
const fittedContext = truncateOlderContext(context, contextBudget);
const beforeContextBudget =
maxChars - fittedContext.length - request.length - fittedAppendedContext.length;
return `${truncateOlderContext(beforeContext, beforeContextBudget)}${fittedContext}${request}${fittedAppendedContext}`;
}
const contextBudget = maxChars - beforeContext.length - afterContext.length;
if (contextBudget > 0) {
const fittedContext = truncateOlderContext(context, contextBudget);
return `${beforeContext}${fittedContext}${afterContext}`;
}
// The header plus the trailing user request already fill the limit, so the
// older context drops entirely and the remaining text must still be bounded;
// otherwise Codex app-server rejects the turn for exceeding
// MAX_USER_INPUT_TEXT_CHARS. truncateOlderContext keeps the tail, preserving
// the user's actual request over the older header text.
return truncateOlderContext(`${beforeContext}${afterContext}`, maxChars);
// Hook-added prefixes can make the non-context text exceed the limit. Keep
// the current context tail before the user's request; dropping it would make
// a duplicated earlier projection crowd out the newest assembled context.
const afterContextText = truncateOlderContext(afterContext, maxChars);
const contextBudgetAfterRequest = maxChars - afterContextText.length;
const fittedContext = truncateOlderContext(context, contextBudgetAfterRequest);
return `${fittedContext}${afterContextText}`;
}
function normalizeProjectedContextRange(

View File

@@ -961,6 +961,26 @@ describe("Codex app-server dynamic tool build", () => {
});
});
it("passes the approval reviewer device into Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.approvalReviewerDeviceId = "device-ios-reviewer";
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
factoryOptions.push(options);
return [];
});
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
approvalReviewerDeviceId: "device-ios-reviewer",
});
});
it("forwards tool outcome ordering into Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -19,10 +19,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
import {
readCodexPluginConfig,
type CodexPluginConfig,
} from "./config.js";
import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js";
import {
filterCodexDynamicTools,
isForcedPrivateQaCodexRuntime,
@@ -260,6 +257,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
...sessionKeys,
sessionId: params.sessionId,
runId: params.runId,
approvalReviewerDeviceId: params.approvalReviewerDeviceId,
agentDir,
cwd: input.effectiveCwd ?? input.effectiveWorkspace,
workspaceDir: input.effectiveWorkspace,
@@ -593,9 +591,10 @@ export function resolveCodexAppServerExecutionCwd(params: {
nativeToolSurfaceEnabled: boolean;
remoteWorkspaceRoot?: string;
}): string {
const cwd = params.environment && params.nativeToolSurfaceEnabled
? params.environment.cwd
: params.effectiveCwd;
const cwd =
params.environment && params.nativeToolSurfaceEnabled
? params.environment.cwd
: params.effectiveCwd;
return mapCodexAppServerRemoteWorkspacePath({
value: cwd,
localWorkspaceRoot: params.localWorkspaceRoot,

View File

@@ -9,9 +9,16 @@ import {
type HarnessContextEngine as ContextEngine,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { CODEX_TURN_START_TEXT_INPUT_MAX_CHARS } from "./context-engine-projection.js";
import type { CodexServerNotification } from "./protocol.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
import {
@@ -71,9 +78,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -349,6 +354,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
afterEach(async () => {
resetCodexAppServerClientFactoryForTest();
resetGlobalHookRunner();
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
});
@@ -493,6 +499,134 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
});
it("bounds active context-engine projections when prompt hooks append context", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_prompt_build",
handler: async (event) => ({
appendContext: `${(event as { prompt: string }).prompt}\n\nhook append marker`,
prependContext: "hook prefix context",
}),
},
]),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const contextEngine = createContextEngine({
assemble: vi.fn(async () => ({
messages: [
...Array.from({ length: 9 }, (_, index) =>
assistantMessage(`older context ${index} ${"x".repeat(120_000)}`, index),
),
assistantMessage("recent anchor", 10),
],
estimatedTokens: 300_000,
})),
});
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 300_000;
params.prompt = "current prompt survives";
params.currentInboundContext = { text: "current inbound context survives" };
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const inputText = getRequestInputText(harness);
expect(inputText.length).toBe(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
expect(inputText).toContain("recent anchor");
expect(inputText).toContain("current inbound context survives");
expect(inputText).toContain("current prompt survives");
expect(inputText).toContain("hook append marker");
await harness.completeTurn();
await run;
});
it("bounds hook-appended prompts without an active context engine", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_prompt_build",
handler: async () => ({ appendContext: `hook context ${"h".repeat(1_100_000)}` }),
},
]),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = "current prompt survives";
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const inputText = getRequestInputText(harness);
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
expect(inputText).toContain("current prompt survives");
expect(inputText).not.toContain("hook context");
await harness.completeTurn();
await run;
});
it("bounds hook-appended prompts after delivery metadata is relocated", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_prompt_build",
handler: async () => ({ appendContext: `hook context ${"h".repeat(1_100_000)}` }),
},
]),
);
const sessionFile = path.join(tempDir, "session-delivery-hint.jsonl");
const workspaceDir = path.join(tempDir, "workspace-delivery-hint");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = `${MESSAGE_TOOL_DELIVERY_HINTS[0]}\n\ncurrent prompt survives`;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const inputText = getRequestInputText(harness);
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
expect(inputText).toContain("Current user request:\ncurrent prompt survives");
expect(inputText).not.toContain("hook context");
await harness.completeTurn();
await run;
});
it("bounds hook-appended output for an empty prompt without an active context engine", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_prompt_build",
handler: async () => ({
appendContext: `hook context ${"h".repeat(1_100_000)} hook tail`,
}),
},
]),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = "";
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const inputText = getRequestInputText(harness);
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
expect(inputText).toContain("hook tail");
await harness.completeTurn();
await run;
});
it("uses configured compaction reserve when sizing Codex context-engine projections", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -2119,6 +2119,7 @@ describe("runCodexAppServerAttempt", () => {
prependSystemContext: "pre system",
appendSystemContext: "post system",
prependContext: "queued context",
appendContext: "tail context",
}));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
@@ -2158,7 +2159,7 @@ describe("runCodexAppServerAttempt", () => {
| { input?: Array<{ text?: string; text_elements?: unknown[]; type?: string }> }
| undefined;
expect(turnStartParams?.input).toEqual([
{ type: "text", text: "queued context\n\nhello", text_elements: [] },
{ type: "text", text: "queued context\n\nhello\n\ntail context", text_elements: [] },
]);
});

View File

@@ -22,8 +22,8 @@ import {
resolveSandboxContext,
resolveSessionAgentIds,
resolveUserPath,
awaitAgentHarnessAgentEndHook,
runAgentHarnessAgentEndHook,
awaitAgentEndSideEffects,
runAgentEndSideEffects,
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
runHarnessContextEngineMaintenance,
@@ -66,6 +66,7 @@ import {
readContextEngineThreadBootstrapProjection,
readMirroredSessionHistoryMessages,
renderCodexSkillsCollaborationInstructions,
resolveCodexDeliveryHintPreservedInputRange,
resolveContextEngineBootstrapProjectionDecision,
} from "./attempt-context.js";
import {
@@ -368,7 +369,7 @@ function formatUnsupportedCodexDynamicToolOutput(type: unknown): string {
return `[Unsupported Codex dynamic tool output: ${label}${suffix}]`;
}
type CodexAgentEndHookParams = Parameters<typeof runAgentHarnessAgentEndHook>[0];
type CodexAgentEndHookParams = Parameters<typeof runAgentEndSideEffects>[0];
function shouldAwaitCodexAgentEndHook(params: EmbeddedRunAttemptParams): boolean {
return !params.messageChannel && !params.messageProvider;
@@ -378,11 +379,15 @@ async function runCodexAgentEndHook(
params: EmbeddedRunAttemptParams,
hookParams: CodexAgentEndHookParams,
): Promise<void> {
const sideEffectParams = {
...hookParams,
ctx: { ...hookParams.ctx, config: params.config },
};
if (shouldAwaitCodexAgentEndHook(params)) {
await awaitAgentHarnessAgentEndHook(hookParams);
await awaitAgentEndSideEffects(sideEffectParams);
return;
}
runAgentHarnessAgentEndHook(hookParams);
runAgentEndSideEffects(sideEffectParams);
}
export async function runCodexAppServerAttempt(
@@ -1020,33 +1025,119 @@ export async function runCodexAppServerAttempt(
developerInstructions,
messages: codexModelInputHistoryMessages,
ctx: hookContext,
...("beforeAgentStartResult" in params
? { beforeAgentStartResult: params.beforeAgentStartResult }
: {}),
});
const resolveShiftedPromptContextRange = (
const resolveShiftedPromptInputRange = (
prompt: string,
promptInputRange: { start: number; end: number } | undefined,
turnPromptText: string,
): CodexProjectedContextRange | undefined => {
if (!promptContextRange || !prompt.endsWith(promptText) || !turnPromptText.endsWith(prompt)) {
if (
!promptInputRange ||
promptInputRange.start < 0 ||
promptInputRange.end < promptInputRange.start ||
promptInputRange.end > prompt.length ||
!turnPromptText.endsWith(prompt)
) {
return undefined;
}
const promptTextOffset = prompt.length - promptText.length;
const turnPromptOffset = turnPromptText.length - prompt.length + promptTextOffset;
const turnPromptOffset = turnPromptText.length - prompt.length;
return {
start: turnPromptOffset + promptInputRange.start,
end: turnPromptOffset + promptInputRange.end,
};
};
const resolveShiftedPromptContextRange = (
prompt: string,
promptInputRange: { start: number; end: number } | undefined,
turnPromptText: string,
):
| {
contextRange: CodexProjectedContextRange;
requestRange: CodexProjectedContextRange;
}
| undefined => {
// promptInputRange ends before hook appendContext. Measure from the
// immutable projected prompt instead of the hook-expanded prompt so that
// the suffix remains available for bounded fitting as newer context.
const promptTextInputOffset = promptInputRange
? promptInputRange.end - promptText.length
: undefined;
if (
!promptContextRange ||
!promptInputRange ||
promptTextInputOffset === undefined ||
promptInputRange.start < 0 ||
promptInputRange.end < promptInputRange.start ||
promptInputRange.end > prompt.length ||
promptTextInputOffset < promptInputRange.start ||
prompt.slice(promptTextInputOffset, promptInputRange.end) !== promptText ||
!turnPromptText.endsWith(prompt)
) {
return undefined;
}
// A hook can append the full projected prompt as newer transient context.
// Fit that suffix so truncation retains its latest context rather than the
// earlier input span. The exact input range still covers prepend-only hooks.
const promptTextOffset = prompt.endsWith(promptText)
? prompt.length - promptText.length
: promptTextInputOffset;
if (promptTextOffset < 0) {
return undefined;
}
const turnPromptOffset = turnPromptText.length - prompt.length + promptTextOffset;
const contextRange = {
start: turnPromptOffset + promptContextRange.start,
end: turnPromptOffset + promptContextRange.end,
};
return {
contextRange,
requestRange: {
start: contextRange.end,
end: turnPromptOffset + promptText.length,
},
};
};
let promptBuild = await buildPromptFromCurrentInputs();
const decorateCodexTurnPromptText = (prompt: string) => {
const turnPromptText = prependCodexOpenClawPromptContext(prompt, openClawPromptContext, {
preservePromptWithoutContext:
params.bootstrapContextMode === "lightweight" && params.bootstrapContextRunKind === "cron",
});
const decorateCodexTurnPromptText = (promptBuildResult: {
prompt: string;
promptInputRange?: { start: number; end: number };
}) => {
const turnPromptText = prependCodexOpenClawPromptContext(
promptBuildResult.prompt,
openClawPromptContext,
{
preservePromptWithoutContext:
params.bootstrapContextMode === "lightweight" &&
params.bootstrapContextRunKind === "cron",
},
);
const projectedRanges = resolveShiftedPromptContextRange(
promptBuildResult.prompt,
promptBuildResult.promptInputRange,
turnPromptText,
);
const preservedRange =
resolveShiftedPromptInputRange(
promptBuildResult.prompt,
promptBuildResult.promptInputRange,
turnPromptText,
) ??
resolveCodexDeliveryHintPreservedInputRange({
prompt: promptBuildResult.prompt,
promptInputRange: promptBuildResult.promptInputRange,
decoratedPrompt: turnPromptText,
});
return fitCodexProjectedContextForTurnStart({
promptText: turnPromptText,
contextRange: resolveShiftedPromptContextRange(prompt, turnPromptText),
contextRange: projectedRanges?.contextRange,
requestRange: projectedRanges?.requestRange,
preservedRange,
});
};
let codexTurnPromptText = decorateCodexTurnPromptText(promptBuild.prompt);
let codexTurnPromptText = decorateCodexTurnPromptText(promptBuild);
const buildCodexTurnCollaborationDeveloperInstructions = () =>
buildTurnCollaborationMode(params, {
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
@@ -1062,7 +1153,7 @@ export async function runCodexAppServerAttempt(
);
const rebuildCodexPromptBuildFromCurrentProjection = async () => {
promptBuild = await buildPromptFromCurrentInputs();
codexTurnPromptText = decorateCodexTurnPromptText(promptBuild.prompt);
codexTurnPromptText = decorateCodexTurnPromptText(promptBuild);
};
const rebuildCodexTurnPromptTextFromCurrentProjection = async () => {
const nextPromptBuild = await buildPromptFromCurrentInputs();
@@ -1071,8 +1162,9 @@ export async function runCodexAppServerAttempt(
promptBuild = {
...promptBuild,
prompt: nextPromptBuild.prompt,
promptInputRange: nextPromptBuild.promptInputRange,
};
codexTurnPromptText = decorateCodexTurnPromptText(nextPromptBuild.prompt);
codexTurnPromptText = decorateCodexTurnPromptText(nextPromptBuild);
};
const selectNewerVisibleHistoryAfterBinding = (binding: CodexAppServerThreadBinding) => {
const bindingUpdatedAt = Date.parse(binding.updatedAt);

View File

@@ -16,7 +16,7 @@ on a model or provider entry; `auto` never picks it. PI remains the default
embedded runtime.
See [GitHub Copilot agent runtime](../../docs/plugins/copilot.md) for
configuration, doctor probes, transcript mirroring, compaction, side
configuration, the doctor contract, transcript mirroring, compaction, side
questions, replay, and the supported-surface contract.
See [qa/copilot-capabilities.md](../../qa/copilot-capabilities.md)
for the SDK capability inventory the harness is pinned to.

View File

@@ -11,14 +11,6 @@
* fields exist for copilot yet; the array is empty by design
* and normalizeCompatibilityConfig is a structural no-op so
* future retirements have a stable in-tree home.
*
* The deeper runtime probes (copilot CLI version, copilot auth,
* copilotHome writability) live in {@link ./src/doctor-probes.ts}
* because they have side effects (subprocess spawn, fs touch) and
* need to be invoked imperatively, not declaratively, from the
* doctor command. They are exported separately so callers can opt
* in. Auto-discovery of doctor-contract-api.ts at the plugin root
* keeps this file purely declarative.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";

View File

@@ -1,5 +1,10 @@
// Copilot tests cover harness plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CopilotClientPool } from "./harness.js";
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
@@ -38,13 +43,13 @@ const TEST_SESSION_CONFIG = {
workingDirectory: "/workspace",
};
function makePoolMock(): CopilotClientPool {
function makePoolMock() {
return {
acquire: vi.fn(),
release: vi.fn(),
dispose: vi.fn().mockResolvedValue([]),
size: vi.fn().mockReturnValue(0),
};
} satisfies CopilotClientPool;
}
function makeSessionStoreMock() {
@@ -95,6 +100,10 @@ describe("createCopilotAgentHarness", () => {
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
});
afterEach(() => {
resetGlobalHookRunner();
});
it("returns the copilot id and default label", () => {
const harness = createCopilotAgentHarness();
@@ -519,6 +528,166 @@ describe("createCopilotAgentHarness", () => {
expect(deleteSession).not.toHaveBeenCalled();
});
it("aborts deferred compaction cleanup before disposal", async () => {
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
const abort = vi.fn(() => cleanup.resolve("aborted"));
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-pending-cleanup",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
abort,
cleanup: cleanup.promise,
sdkSessionId: "sdk-sess-pending-cleanup",
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness();
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-pending-cleanup" });
await harness.dispose?.();
expect(abort).toHaveBeenCalledTimes(1);
});
it("aborts deferred compaction cleanup when the OpenClaw session resets", async () => {
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
const abort = vi.fn(() => cleanup.resolve("aborted"));
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-reset-cleanup",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
abort,
cleanup: cleanup.promise,
sdkSessionId: "sdk-sess-reset-cleanup",
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness();
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-reset-cleanup" });
await harness.reset?.({ sessionId: "oc-reset-cleanup" });
expect(abort).toHaveBeenCalledTimes(1);
});
it("does not delete a replacement session while reset awaits deferred cleanup", async () => {
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
const abort = vi.fn();
const oldDeleteSession = vi.fn().mockResolvedValue(undefined);
const replacementDeleteSession = vi.fn().mockResolvedValue(undefined);
const sessionStore = makeSessionStoreMock();
let attempt = 0;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
attempt += 1;
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-before-reset",
pooledClient: { key: {} as any, client: { deleteSession: oldDeleteSession } as any },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
abort,
cleanup: cleanup.promise,
sdkSessionId: "sdk-sess-before-reset",
});
} else {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-replacement",
pooledClient: {
key: {} as any,
client: { deleteSession: replacementDeleteSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
}
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-reset-race" });
const reset = harness.reset?.({ sessionId: "oc-reset-race" });
await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce());
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-reset-race" });
cleanup.resolve("aborted");
await reset;
expect(oldDeleteSession).toHaveBeenCalledWith("sdk-sess-before-reset");
expect(replacementDeleteSession).not.toHaveBeenCalled();
expect(sessionStore.entries.get("oc-reset-race")?.sdkSessionId).toBe("sdk-sess-replacement");
});
it("does not reuse a reset target while deferred cleanup is pending", async () => {
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
const abort = vi.fn();
const replacementDeleteSession = vi.fn().mockResolvedValue(undefined);
const duringResetDeleteSession = vi.fn().mockResolvedValue(undefined);
const sessionStore = makeSessionStoreMock();
let attempt = 0;
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
attempt += 1;
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-before-reset",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
abort,
cleanup: cleanup.promise,
sdkSessionId: "sdk-sess-before-reset",
});
} else if (attempt === 2) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-replacement",
pooledClient: {
key: {} as any,
client: { deleteSession: replacementDeleteSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
} else if (attempt === 3 && !params.initialReplayState?.sdkSessionId) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-during-reset",
pooledClient: {
key: {} as any,
client: { deleteSession: duringResetDeleteSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
}
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
const params = { ...ATTEMPT_PARAMS, sessionId: "oc-reset-reuse" };
await harness.runAttempt(params);
await harness.runAttempt(params);
const reset = harness.reset?.({ sessionId: "oc-reset-reuse" });
await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce());
await harness.runAttempt(params);
cleanup.resolve("aborted");
await reset;
expect(
mocks.runCopilotAttempt.mock.calls[2]?.[0]?.initialReplayState?.sdkSessionId,
).toBeUndefined();
expect(replacementDeleteSession).toHaveBeenCalledWith("sdk-sess-replacement");
expect(duringResetDeleteSession).not.toHaveBeenCalled();
expect(sessionStore.entries.get("oc-reset-reuse")?.sdkSessionId).toBe("sdk-sess-during-reset");
});
describe("session reuse across turns (dogfood finding #4)", () => {
// These tests pin the harness's session-reuse contract: subsequent
// `runAttempt` calls within the same OpenClaw session should pass
@@ -566,6 +735,177 @@ describe("createCopilotAgentHarness", () => {
expect(secondCallParams.initialReplayState?.replayInvalid).toBeUndefined();
});
it("blocks reuse while timed-out compaction is pending, then resumes after completion", async () => {
const pool = makePoolMock();
const sessionStore = makeSessionStoreMock();
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
let attempt = 0;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
attempt += 1;
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-compacting",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
abort: () => undefined,
cleanup: cleanup.promise,
sdkSessionId: "sdk-sess-compacting",
});
}
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store });
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
cleanup.resolve("completed");
await flushAsyncWork();
await harness.runAttempt(makeAttemptParams({ runId: "t3" }));
const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(thirdCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-compacting");
expect(sessionStore.store.delete).not.toHaveBeenCalledWith("oc-sess-reuse");
});
it("reuses a replacement session while an older cleanup is pending", async () => {
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
let attempt = 0;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
attempt += 1;
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-old",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
abort: () => undefined,
cleanup: cleanup.promise,
sdkSessionId: "sdk-sess-old",
});
} else if (attempt === 2) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-replacement",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
}
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
await harness.runAttempt(makeAttemptParams({ runId: "t3" }));
const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(thirdCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-replacement");
cleanup.resolve("completed");
await flushAsyncWork();
});
it("invalidates the retained SDK binding when deferred compaction is cancelled", async () => {
const pool = makePoolMock();
const sessionStore = makeSessionStoreMock();
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
let attempt = 0;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
attempt += 1;
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-cancelled",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
abort: () => undefined,
cleanup: cleanup.promise,
sdkSessionId: "sdk-sess-cancelled",
});
}
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store });
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
cleanup.resolve("aborted");
await flushAsyncWork();
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-reuse");
});
it("ignores deferred cleanup from a session replaced by an overlapping attempt", async () => {
const firstAttemptFinished = createDeferred<void>();
const staleCleanup = createDeferred<"aborted" | "completed" | "deadline">();
let firstAttemptDeps:
| {
onDeferredCompaction?: (info: {
abort: () => void;
cleanup: Promise<"aborted" | "completed" | "deadline">;
sdkSessionId: string;
}) => void;
}
| undefined;
let attempt = 0;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
attempt += 1;
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-stale",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
firstAttemptDeps = deps;
await firstAttemptFinished.promise;
} else if (attempt === 2) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-current",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
}
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const firstAttempt = harness.runAttempt(makeAttemptParams({ runId: "t1" }));
await flushAsyncWork();
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
firstAttemptDeps?.onDeferredCompaction?.({
abort: () => undefined,
cleanup: staleCleanup.promise,
sdkSessionId: "sdk-sess-stale",
});
firstAttemptFinished.resolve();
await firstAttempt;
await harness.runAttempt(makeAttemptParams({ runId: "t3" }));
const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(thirdCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-current");
staleCleanup.resolve("completed");
await flushAsyncWork();
});
it("does not seed sdkSessionId on the first turn (nothing tracked yet)", async () => {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
@@ -1148,6 +1488,7 @@ describe("createCopilotAgentHarness", () => {
copilotHome: "/copilot-home",
auth: { useLoggedInUser: true },
sessionId: "oc-sess-compact",
sessionFile: "/session.json",
...overrides,
};
}
@@ -1178,7 +1519,92 @@ describe("createCopilotAgentHarness", () => {
});
});
it("does not resume a session while deferred background compaction is pending", async () => {
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-background",
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
abort: () => undefined,
cleanup: cleanup.promise,
sdkSessionId: "sdk-sess-background",
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams());
const result = await harness.compact?.(makeCompactParams());
expect(pool.acquire.mock.calls).toHaveLength(0);
expect(result).toEqual({
ok: false,
compacted: false,
reason: "background-compaction-pending",
failure: { reason: "background-compaction-pending" },
});
cleanup.resolve("completed");
await flushAsyncWork();
});
it("clears the reset block when storing a replacement session fails", async () => {
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
const sessionStore = makeSessionStoreMock();
sessionStore.store.register.mockImplementation(() => {
throw new Error("sqlite register failed");
});
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
const sdkSessionId =
mocks.runCopilotAttempt.mock.calls.length === 1
? "sdk-sess-background"
: "sdk-sess-replacement";
deps.onSessionEstablished?.({
sdkSessionId,
pooledClient: { key: {} as any, client: {} as any },
sessionConfig: TEST_SESSION_CONFIG,
});
if (sdkSessionId === "sdk-sess-background") {
deps.onDeferredCompaction?.({
abort: () => undefined,
cleanup: cleanup.promise,
sdkSessionId,
});
}
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
const params = makeCompactParams({ sessionId: "oc-sess-store-failure" });
await harness.runAttempt(params);
await harness.runAttempt(params);
await harness.runAttempt(params);
expect(mocks.runCopilotAttempt.mock.calls[1]?.[0]).not.toMatchObject({
initialReplayState: expect.objectContaining({ sdkSessionId: "sdk-sess-background" }),
});
expect(mocks.runCopilotAttempt.mock.calls[2]?.[0]).toMatchObject({
initialReplayState: expect.objectContaining({ sdkSessionId: "sdk-sess-replacement" }),
});
cleanup.resolve("completed");
await flushAsyncWork();
});
it("calls the SDK history compaction RPC without requiring a workspace sidecar", async () => {
const beforeCompaction = vi.fn();
const afterCompaction = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_compaction", handler: beforeCompaction },
{ hookName: "after_compaction", handler: afterCompaction },
]),
);
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 123,
@@ -1241,6 +1667,19 @@ describe("createCopilotAgentHarness", () => {
expect(compact).toHaveBeenCalledWith({ customInstructions: "Keep decisions." });
expect(disconnect).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(1);
expect(beforeCompaction).toHaveBeenCalledWith(
{ messageCount: -1, sessionFile: "/session.json" },
expect.objectContaining({
modelId: "gpt-4.1",
modelProviderId: "github-copilot",
sessionId: "oc-sess-compact-1",
sessionKey: "agent:main:main",
}),
);
expect(afterCompaction).toHaveBeenCalledWith(
{ compactedCount: 4, messageCount: -1, sessionFile: "/session.json" },
expect.objectContaining({ sessionId: "oc-sess-compact-1" }),
);
expect(result).toEqual({
ok: true,
compacted: true,

View File

@@ -1,8 +1,11 @@
// Copilot plugin module implements harness behavior.
import type { CopilotClient } from "@github/copilot-sdk";
import {
buildAgentHookContextChannelFields,
compactWithSafetyTimeout,
resolveCompactionTimeoutMs,
runAgentHarnessAfterCompactionHook,
runAgentHarnessBeforeCompactionHook,
type AgentHarness,
type AgentHarnessAttemptParams,
type AgentHarnessAttemptResult,
@@ -91,6 +94,11 @@ type LegacyCopilotSessionBinding = {
};
type CopilotAttemptSessionBinding = Pick<CopilotSessionBinding, "compatKey" | "sdkSessionId">;
type DeferredCompactionCleanupOutcome = "aborted" | "completed" | "deadline";
type DeferredCompactionCleanup = {
abort: () => void;
sdkSessionId: string;
};
type CopilotSessionBindingStore = Pick<
PluginStateSyncKeyedStore<CopilotSessionBinding>,
@@ -399,6 +407,20 @@ function computeSessionCompactKey(params: CopilotSessionCompatParams): string {
return computeSessionKey(params, { includeApi: false, includeAuth: false });
}
function buildCopilotCompactionHookContext(params: AgentHarnessCompactParams) {
return {
...(params.runId ? { runId: params.runId } : {}),
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
modelProviderId: params.provider,
modelId: params.model,
trigger: params.trigger,
...buildAgentHookContextChannelFields(params),
};
}
export function createCopilotAgentHarness(
options?: CreateCopilotAgentHarnessOptions,
): AgentHarness {
@@ -407,6 +429,10 @@ export function createCopilotAgentHarness(
let disposed = false;
let disposePromise: Promise<void> | undefined;
const inFlight = new Set<Promise<unknown>>();
const deferredCompactionCleanups = new Map<
string,
Map<Promise<DeferredCompactionCleanupOutcome>, DeferredCompactionCleanup>
>();
// Maps OpenClaw session id (from AgentHarnessAttemptParams.sessionId) to
// the SDK session id + client that owns it. Populated by
// runCopilotAttempt via the onSessionEstablished callback so that
@@ -428,6 +454,63 @@ export function createCopilotAgentHarness(
return poolPromise;
}
function trackDeferredCompactionCleanup(params: {
abort: () => void;
cleanup: Promise<DeferredCompactionCleanupOutcome>;
sessionId: string;
sdkSessionId: string;
}): void {
const cleanups =
deferredCompactionCleanups.get(params.sessionId) ??
new Map<Promise<DeferredCompactionCleanupOutcome>, DeferredCompactionCleanup>();
cleanups.set(params.cleanup, { abort: params.abort, sdkSessionId: params.sdkSessionId });
deferredCompactionCleanups.set(params.sessionId, cleanups);
void params.cleanup.then(
() => removeDeferredCompactionCleanup(params.sessionId, params.cleanup),
() => removeDeferredCompactionCleanup(params.sessionId, params.cleanup),
);
}
function removeDeferredCompactionCleanup(
sessionId: string,
cleanup: Promise<DeferredCompactionCleanupOutcome>,
): void {
const cleanups = deferredCompactionCleanups.get(sessionId);
if (!cleanups) {
return;
}
cleanups.delete(cleanup);
if (cleanups.size === 0) {
deferredCompactionCleanups.delete(sessionId);
}
}
function hasPendingDeferredCompactionCleanup(sessionId: string): boolean {
const cleanups = deferredCompactionCleanups.get(sessionId);
if (!cleanups) {
return false;
}
const currentSdkSessionId =
trackedSessions.get(sessionId)?.sdkSessionId ??
lookupStoredBinding(options?.sessionStore, sessionId)?.sdkSessionId;
return (
currentSdkSessionId !== undefined &&
[...cleanups.values()].some((cleanup) => cleanup.sdkSessionId === currentSdkSessionId)
);
}
async function abortDeferredCompactionCleanups(sessionId: string): Promise<void> {
const cleanups = deferredCompactionCleanups.get(sessionId);
if (!cleanups) {
return;
}
const pending = [...cleanups.entries()];
for (const [, cleanup] of pending) {
cleanup.abort();
}
await Promise.allSettled(pending.map(([cleanup]) => cleanup));
}
return {
id: options?.id ?? "copilot",
label: options?.label ?? "GitHub Copilot agent runtime",
@@ -488,9 +571,15 @@ export function createCopilotAgentHarness(
// surfaces as a prompt error.
const currentCompatKey = computeSessionCompatKey(params);
const currentCompactKey = computeSessionCompactKey(params);
const tracked = openclawSessionId ? trackedSessions.get(openclawSessionId) : undefined;
const compactionCleanupPending =
openclawSessionId !== undefined && hasPendingDeferredCompactionCleanup(openclawSessionId);
const replayBlocked =
openclawSessionId !== undefined &&
(compactionCleanupPending || resetBlockedStoredSessions.has(openclawSessionId));
const tracked =
openclawSessionId && !replayBlocked ? trackedSessions.get(openclawSessionId) : undefined;
const stored = openclawSessionId
? resetBlockedStoredSessions.has(openclawSessionId)
? replayBlocked
? undefined
: lookupStoredBinding(options?.sessionStore, openclawSessionId)
: undefined;
@@ -532,7 +621,7 @@ export function createCopilotAgentHarness(
sessionConfig,
...sessionAuthFields(poolAcquire.auth),
});
const persisted = registerStoredBinding(options?.sessionStore, openclawSessionId, {
registerStoredBinding(options?.sessionStore, openclawSessionId, {
schemaVersion: 2,
sdkSessionId,
compatKey: currentCompatKey,
@@ -540,9 +629,60 @@ export function createCopilotAgentHarness(
...sessionAuthFields(poolAcquire.auth),
updatedAt: Date.now(),
});
if (persisted) {
resetBlockedStoredSessions.delete(openclawSessionId);
resetBlockedStoredSessions.delete(openclawSessionId);
}
: undefined,
onDeferredCompaction: openclawSessionId
? ({
abort,
cleanup,
sdkSessionId,
}: {
abort: () => void;
cleanup: Promise<DeferredCompactionCleanupOutcome>;
sdkSessionId: string;
}) => {
const trackedBinding = trackedSessions.get(openclawSessionId);
const storedBinding = lookupStoredBinding(options?.sessionStore, openclawSessionId);
const ownsTrackedSession = trackedBinding?.sdkSessionId === sdkSessionId;
const ownsStoredSession = storedBinding?.sdkSessionId === sdkSessionId;
if (!ownsTrackedSession && !ownsStoredSession) {
return;
}
trackDeferredCompactionCleanup({
abort,
cleanup,
sessionId: openclawSessionId,
sdkSessionId,
});
// The attempt retains this SDK session until its background
// compaction resolves. Preserve its binding for a successful
// completion, but do not let a new turn resume it yet.
resetBlockedStoredSessions.add(openclawSessionId);
void cleanup.then((outcome) => {
const currentTracked = trackedSessions.get(openclawSessionId);
const currentStored = lookupStoredBinding(
options?.sessionStore,
openclawSessionId,
);
const stillOwnsTrackedSession = currentTracked?.sdkSessionId === sdkSessionId;
const stillOwnsStoredSession = currentStored?.sdkSessionId === sdkSessionId;
if (outcome === "completed") {
if (stillOwnsTrackedSession || stillOwnsStoredSession) {
resetBlockedStoredSessions.delete(openclawSessionId);
}
return;
}
if (stillOwnsTrackedSession) {
trackedSessions.delete(openclawSessionId);
}
if (stillOwnsStoredSession) {
deleteStoredBinding(options?.sessionStore, openclawSessionId);
}
if (stillOwnsTrackedSession || stillOwnsStoredSession) {
resetBlockedStoredSessions.add(openclawSessionId);
}
});
}
: undefined,
});
@@ -560,17 +700,30 @@ export function createCopilotAgentHarness(
if (!openclawSessionId) {
return;
}
// Deferred cleanup yields while another attempt can establish a fresh
// session. Capture the reset target first so reset never deletes that
// replacement session or its durable binding.
const tracked = trackedSessions.get(openclawSessionId);
if (deleteStoredBinding(options?.sessionStore, openclawSessionId)) {
resetBlockedStoredSessions.delete(openclawSessionId);
const stored = lookupStoredBinding(options?.sessionStore, openclawSessionId);
resetBlockedStoredSessions.add(openclawSessionId);
await abortDeferredCompactionCleanups(openclawSessionId);
const currentStored = lookupStoredBinding(options?.sessionStore, openclawSessionId);
const stillOwnsStoredSession =
stored !== undefined && currentStored?.sdkSessionId === stored.sdkSessionId;
if (stillOwnsStoredSession) {
if (deleteStoredBinding(options?.sessionStore, openclawSessionId)) {
resetBlockedStoredSessions.delete(openclawSessionId);
}
} else {
resetBlockedStoredSessions.add(openclawSessionId);
resetBlockedStoredSessions.delete(openclawSessionId);
}
if (!tracked) {
// Session was created by a different harness, or already reset.
return;
}
trackedSessions.delete(openclawSessionId);
if (trackedSessions.get(openclawSessionId)?.sdkSessionId === tracked.sdkSessionId) {
trackedSessions.delete(openclawSessionId);
}
try {
await tracked.client.deleteSession(tracked.sdkSessionId);
} catch {
@@ -596,6 +749,14 @@ export function createCopilotAgentHarness(
reason: "missing-required-params",
};
}
if (hasPendingDeferredCompactionCleanup(openclawSessionId)) {
return {
ok: false,
compacted: false,
reason: "background-compaction-pending",
failure: { reason: "background-compaction-pending" },
};
}
const tracked = trackedSessions.get(openclawSessionId);
const currentCompactKey = computeSessionCompactKey(params);
const { resolvePoolAcquire } = await import("./src/attempt.js");
@@ -623,11 +784,18 @@ export function createCopilotAgentHarness(
let handle: PooledClient | undefined;
let pool: CopilotClientPool | undefined;
let activeSdkSession: CopilotHistoryCompactSession | undefined;
const hookContext = buildCopilotCompactionHookContext(params);
try {
throwIfAborted(params.abortSignal);
pool = await getPool();
handle = await pool.acquire(poolAcquire.key, poolAcquire.options);
const client = handle.client;
// Manual compaction resumes a distinct SDK session, bypassing the attempt event bridge.
// Run the portable lifecycle hook here so both compaction paths stay observable.
await runAgentHarnessBeforeCompactionHook({
sessionFile: params.sessionFile,
ctx: hookContext,
});
compactResult = await compactWithSafetyTimeout(
(abortSignal) =>
compactTrackedSdkSession({
@@ -693,6 +861,13 @@ export function createCopilotAgentHarness(
};
}
const compacted = compactResult.tokensRemoved > 0 || compactResult.messagesRemoved > 0;
if (compacted) {
await runAgentHarnessAfterCompactionHook({
sessionFile: params.sessionFile,
compactedCount: compactResult.messagesRemoved,
ctx: hookContext,
});
}
return {
ok: true,
compacted,
@@ -709,6 +884,12 @@ export function createCopilotAgentHarness(
if (inFlight.size > 0) {
await Promise.allSettled(inFlight);
}
// Deferred compaction callbacks retain pooled clients after an attempt.
// Cancel them before pool disposal so they cannot outlive this harness.
const cleanupSessionIds = [...deferredCompactionCleanups.keys()];
for (const sessionId of cleanupSessionIds) {
await abortDeferredCompactionCleanups(sessionId);
}
trackedSessions.clear();
resetBlockedStoredSessions.clear();
if (createdPool) {

View File

@@ -8,9 +8,15 @@ import type {
AgentHarnessAttemptResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runCopilotAttempt } from "./attempt.js";
import type { CopilotClientPool } from "./runtime.js";
import type { CopilotToolBridgeInput } from "./tool-bridge.js";
const TINY_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADUlEQVR4nGP4////KwAJ5gPoxLp9owAAAABJRU5ErkJggg==";
@@ -64,6 +70,11 @@ type FakeSession = {
id: string;
off: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
rpc: {
history: {
cancelBackgroundCompaction: ReturnType<typeof vi.fn<() => Promise<{ cancelled: boolean }>>>;
};
};
sendAndWait: ReturnType<typeof vi.fn<SendAndWaitFn>>;
sessionId: string;
};
@@ -98,6 +109,12 @@ function flushAsync() {
return tick().then(tick).then(tick);
}
function waitForEventLoopTurn(): Promise<void> {
return new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
function getPromptErrorCode(result: AgentHarnessAttemptResult): string | undefined {
return (result.promptError as { code?: string } | undefined)?.code;
}
@@ -153,13 +170,20 @@ function createFakeSession(cfg: Record<string, unknown>, id: string): FakeSessio
handlers.push(handler);
listeners.set(eventType, handlers);
}),
rpc: {
history: {
cancelBackgroundCompaction: vi.fn<() => Promise<{ cancelled: boolean }>>(async () => ({
cancelled: true,
})),
},
},
sendAndWait: vi.fn<SendAndWaitFn>(async () => makeAssistantMessageEvent()),
sessionId: id,
};
}
function makeFakePool(sdk: FakeSdk) {
const pool: CopilotClientPool = {
const pool = {
acquire: vi.fn(async (key, _options) => ({
client: sdk.client as unknown as CopilotClient,
key,
@@ -167,7 +191,7 @@ function makeFakePool(sdk: FakeSdk) {
dispose: vi.fn(async () => []),
release: vi.fn(async () => undefined),
size: vi.fn(() => 0),
};
} satisfies CopilotClientPool;
return pool;
}
@@ -200,6 +224,7 @@ function makeFakeSdk(
return {
client: {
createSession,
deleteSession: vi.fn(async () => undefined),
resumeSession,
stop: vi.fn(async () => []),
},
@@ -249,7 +274,9 @@ function makeParams(
}
afterEach(() => {
resetGlobalHookRunner();
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("runCopilotAttempt", () => {
@@ -274,6 +301,406 @@ describe("runCopilotAttempt", () => {
expect(getSdkSessionId(result)).toBe("sess-1");
});
it("runs generic prompt and lifecycle hooks through the standard harness helpers", async () => {
const beforePromptBuild = vi.fn(() => ({
prependContext: "Use the current repository state.",
appendContext: "Finish with the current test status.",
appendSystemContext: "Keep the final response concise.",
}));
const afterToolCall = vi.fn();
const llmInput = vi.fn();
const llmOutput = vi.fn();
const agentEnd = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_prompt_build", handler: beforePromptBuild },
{ hookName: "after_tool_call", handler: afterToolCall },
{ hookName: "llm_input", handler: llmInput },
{ hookName: "llm_output", handler: llmOutput },
{ hookName: "agent_end", handler: agentEnd },
]),
);
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done"));
},
});
const createToolBridge = vi.fn(async (input: CopilotToolBridgeInput) => {
await input.onToolCompleted?.({
args: { path: "README.md" },
result: { content: [{ text: "read result", type: "text" }] },
startedAt: Date.now(),
toolCallId: "tool-call-1",
toolName: "read",
});
return { sdkTools: [], sourceTools: [] };
});
await runCopilotAttempt(makeParams(), {
createToolBridge,
pool: makeFakePool(sdk),
});
await waitForEventLoopTurn();
expect(beforePromptBuild).toHaveBeenCalledWith(
expect.objectContaining({ prompt: "hello" }),
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
);
const cfg = sdk.createSession.mock.calls[0]?.[0] as {
systemMessage?: { content?: string };
};
expect(cfg.systemMessage?.content).toContain("Keep the final response concise.");
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as { prompt?: string };
expect(messageOptions.prompt).toBe(
"Use the current repository state.\n\nhello\n\nFinish with the current test status.",
);
expect(llmInput).toHaveBeenCalledWith(
expect.objectContaining({
historyMessages: [],
model: "gpt-4o",
prompt:
"Use the current repository state.\n\nhello\n\nFinish with the current test status.",
provider: "github-copilot",
runId: "run-1",
}),
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
);
expect(llmOutput).toHaveBeenCalledWith(
expect.objectContaining({
assistantTexts: ["done"],
model: "gpt-4o",
provider: "github-copilot",
}),
expect.objectContaining({ runId: "run-1" }),
);
expect(agentEnd).toHaveBeenCalledWith(
expect.objectContaining({ success: true }),
expect.objectContaining({ sessionId: "session-1" }),
);
expect(afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
params: { path: "README.md" },
toolCallId: "tool-call-1",
toolName: "read",
}),
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
);
});
it("keeps generic compaction hooks attached through asynchronous SDK completion", async () => {
const beforeCompaction = vi.fn();
const afterCompaction = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_compaction", handler: beforeCompaction },
{ hookName: "after_compaction", handler: afterCompaction },
]),
);
let activeSession: FakeSession | undefined;
const sdk = makeFakeSdk({
onCreateSession: (session) => {
activeSession = session;
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
return makeAssistantMessageEvent("done");
});
},
});
const attempt = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
await vi.waitFor(() => {
expect(activeSession?.sendAndWait).toHaveBeenCalled();
});
if (!activeSession) {
throw new Error("expected Copilot session");
}
expect(activeSession.disconnect).not.toHaveBeenCalled();
activeSession.emit("session.compaction_complete", { messagesRemoved: 4, success: true });
await attempt;
expect(beforeCompaction).toHaveBeenCalledWith(
expect.objectContaining({
messageCount: -1,
sessionFile: "session.json",
}),
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
);
expect(afterCompaction).toHaveBeenCalledWith(
expect.objectContaining({
compactedCount: 4,
messageCount: -1,
sessionFile: "session.json",
}),
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
);
expect(beforeCompaction.mock.calls[0]?.[0]).not.toHaveProperty("messages");
});
it("does not await background compaction hooks before returning a turn", async () => {
const releaseBeforeCompaction = createDeferred<void>();
const beforeCompaction = vi.fn(async () => releaseBeforeCompaction.promise);
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_compaction", handler: beforeCompaction }]),
);
let activeSession: FakeSession | undefined;
const sdk = makeFakeSdk({
onCreateSession: (session) => {
activeSession = session;
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
return makeAssistantMessageEvent("done");
});
},
});
const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
expect(result.timedOut).toBe(false);
await vi.waitFor(() => {
expect(beforeCompaction).toHaveBeenCalledTimes(1);
});
expect(activeSession?.disconnect).not.toHaveBeenCalled();
releaseBeforeCompaction.resolve();
activeSession?.emit("session.compaction_complete", { success: true });
activeSession?.emit("session.idle", {});
await vi.waitFor(() => {
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
});
});
it("returns a successful turn while background compaction remains observed", async () => {
vi.useFakeTimers();
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
return makeAssistantMessageEvent("done");
});
},
});
const pool = makeFakePool(sdk);
const attempt = runCopilotAttempt(makeParams(), { pool });
const result = await attempt;
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeUndefined();
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
expect(sdk.client.deleteSession).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(180_000);
expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
expect(pool.release.mock.calls).toHaveLength(1);
});
it("cancels retained compaction when the caller aborts after a turn result", async () => {
const controller = new AbortController();
const onDeferredCompaction = vi.fn();
let activeSession: FakeSession | undefined;
const sdk = makeFakeSdk({
onCreateSession: (session) => {
activeSession = session;
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
setTimeout(() => controller.abort(), 0);
return makeAssistantMessageEvent("done");
});
},
});
const attempt = runCopilotAttempt(makeParams({ abortSignal: controller.signal }), {
onDeferredCompaction,
pool: makeFakePool(sdk),
});
const result = await attempt;
expect(result.aborted).toBe(false);
expect(activeSession?.abort).not.toHaveBeenCalled();
await vi.waitFor(() => {
expect(activeSession?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
});
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
expect(onDeferredCompaction).toHaveBeenCalledWith(
expect.objectContaining({
sdkSessionId: "sess-1",
}),
);
});
it("awaits deferred compaction cancellation before tearing down the SDK session", async () => {
const controller = new AbortController();
const cancellation = createDeferred<{ cancelled: boolean }>();
let activeSession: FakeSession | undefined;
const sdk = makeFakeSdk({
onCreateSession: (session) => {
activeSession = session;
session.rpc.history.cancelBackgroundCompaction.mockImplementationOnce(
() => cancellation.promise,
);
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
return undefined;
});
},
});
const result = await runCopilotAttempt(makeParams({ abortSignal: controller.signal }), {
pool: makeFakePool(sdk),
});
expect(result.timedOutDuringCompaction).toBe(true);
controller.abort();
await vi.waitFor(() => {
expect(activeSession?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
});
expect(activeSession?.disconnect).not.toHaveBeenCalled();
cancellation.resolve({ cancelled: true });
await vi.waitFor(() => {
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
});
});
it("reports the native prompt hook's effective input through llm_input", async () => {
const llmInput = vi.fn();
const onUserPromptSubmitted = vi.fn().mockResolvedValue({
additionalContext: "Use the approved repository.",
modifiedPrompt: "Review the authentication change.",
});
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "llm_input", handler: llmInput }]),
);
const sdk = makeFakeSdk({
onCreateSession: (session, cfg) => {
session.sendAndWait.mockImplementationOnce(async () => {
const hooks = cfg.hooks as {
onUserPromptSubmitted?: (
input: { prompt: string },
invocation: { sessionId: string },
) => Promise<unknown>;
};
await hooks.onUserPromptSubmitted?.(
{ prompt: "hello" },
{ sessionId: session.sessionId },
);
return makeAssistantMessageEvent("done");
});
},
});
await runCopilotAttempt(makeParams({ hooksConfig: { onUserPromptSubmitted } } as never), {
pool: makeFakePool(sdk),
});
await waitForEventLoopTurn();
expect(onUserPromptSubmitted).toHaveBeenCalledWith(
expect.objectContaining({ prompt: "hello" }),
{ sessionId: "sess-1" },
);
expect(llmInput).toHaveBeenCalledTimes(1);
expect(llmInput).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Review the authentication change.\n\nUse the approved repository.",
}),
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
);
});
it("reuses the precomputed legacy before_agent_start result", async () => {
const beforeAgentStart = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_agent_start", handler: beforeAgentStart }]),
);
const sdk = makeFakeSdk();
await runCopilotAttempt(
makeParams({
beforeAgentStartResult: { prependContext: "Use the cached result." },
} as never),
{ pool: makeFakePool(sdk) },
);
expect(beforeAgentStart).not.toHaveBeenCalled();
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as { prompt?: string };
expect(messageOptions.prompt).toBe("Use the cached result.\n\nhello");
});
it("preserves native Copilot SDK hooks alongside generic lifecycle hooks", async () => {
const sdk = makeFakeSdk();
const onPreToolUse = vi.fn();
await runCopilotAttempt(
makeParams({
hooksConfig: { onPreToolUse },
} as never),
{ pool: makeFakePool(sdk) },
);
const cfg = sdk.createSession.mock.calls[0]?.[0] as {
hooks?: { onPreToolUse?: unknown };
};
expect(cfg.hooks?.onPreToolUse).toEqual(expect.any(Function));
});
it("does not emit llm_output when cancellation happens before the SDK turn starts", async () => {
const llmOutput = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "llm_output", handler: llmOutput }]),
);
const controller = new AbortController();
const sdk = makeFakeSdk();
const result = await runCopilotAttempt(
makeParams({ abortSignal: controller.signal } as never),
{
onSessionEstablished: () => controller.abort(),
pool: makeFakePool(sdk),
},
);
await waitForEventLoopTurn();
expect(result.aborted).toBe(true);
expect(sdk.sessions[0]?.sendAndWait).not.toHaveBeenCalled();
expect(llmOutput).not.toHaveBeenCalled();
});
it("waits for agent_end hooks before resolving one-shot attempts", async () => {
let releaseAgentEnd: () => void = () => undefined;
const agentEndSettled = new Promise<void>((resolve) => {
releaseAgentEnd = resolve;
});
const agentEnd = vi.fn(() => agentEndSettled);
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
);
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done"));
},
});
let settled = false;
const run = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }).then((result) => {
settled = true;
return result;
});
await waitForEventLoopTurn();
expect(agentEnd).toHaveBeenCalledTimes(1);
expect(settled).toBe(false);
releaseAgentEnd();
await expect(run).resolves.toMatchObject({ promptError: undefined });
expect(settled).toBe(true);
});
it("forwards prompt images as SDK blob attachments", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
@@ -747,6 +1174,10 @@ describe("runCopilotAttempt", () => {
it("abort path (signal already aborted)", async () => {
const controller = new AbortController();
controller.abort();
const agentEnd = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
);
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
@@ -758,6 +1189,10 @@ describe("runCopilotAttempt", () => {
expect(result.externalAbort).toBe(true);
expect(sdk.createSession).toHaveBeenCalledTimes(0);
expect(pool["acquire"]).toHaveBeenCalledTimes(0);
expect(agentEnd).toHaveBeenCalledWith(
expect.objectContaining({ success: false }),
expect.objectContaining({ sessionId: "session-1" }),
);
});
it("abort path (signal fires after settled)", async () => {
@@ -920,6 +1355,10 @@ describe("runCopilotAttempt", () => {
});
it("tool bridge failures become prompt errors", async () => {
const agentEnd = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
);
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => {
@@ -935,9 +1374,20 @@ describe("runCopilotAttempt", () => {
expect(sdk.createSession).toHaveBeenCalledTimes(0);
expect(pool["acquire"]).toHaveBeenCalledTimes(0);
expect(pool["release"]).toHaveBeenCalledTimes(0);
expect(agentEnd).toHaveBeenCalledWith(
expect.objectContaining({
error: "[copilot-attempt] tool-bridge construction failed: bridge failed",
success: false,
}),
expect.objectContaining({ sessionId: "session-1" }),
);
});
it("unsupported providers skip injected tool bridge wiring", async () => {
const agentEnd = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
);
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
@@ -952,6 +1402,30 @@ describe("runCopilotAttempt", () => {
expect(getPromptErrorCode(result)).toBe("model_not_supported");
expect(createToolBridge).toHaveBeenCalledTimes(0);
expect(sdk.createSession).toHaveBeenCalledTimes(0);
expect(agentEnd).toHaveBeenCalledWith(
expect.objectContaining({ success: false }),
expect.objectContaining({ modelId: "claude", modelProviderId: "anthropic" }),
);
});
it("reports pool-release failures through agent_end before rejecting", async () => {
const agentEnd = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
);
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
pool.release.mockRejectedValueOnce(new Error("release failed"));
await expect(runCopilotAttempt(makeParams(), { pool })).rejects.toThrow("release failed");
expect(agentEnd).toHaveBeenCalledWith(
expect.objectContaining({
error: "release failed",
success: false,
}),
expect.objectContaining({ sessionId: "session-1" }),
);
});
it("default permission policy rejects fail-closed", async () => {
@@ -1147,6 +1621,53 @@ describe("runCopilotAttempt", () => {
expect("systemMessage" in cfg).toBe(false);
});
it("keeps raw model probes outside generic prompt hooks", async () => {
const beforePromptBuild = vi.fn(() => ({
appendContext: "must not reach raw model probes",
prependSystemContext: "must not reach raw model probes",
}));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
);
const sdk = makeFakeSdk();
await runCopilotAttempt(
makeParams({
modelRun: true,
} as never),
{ pool: makeFakePool(sdk) },
);
expect(beforePromptBuild).not.toHaveBeenCalled();
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
prompt?: string;
};
expect(messageOptions.prompt).toBe("hello");
});
it("keeps promptMode none runs outside generic prompt hooks", async () => {
const beforePromptBuild = vi.fn(() => ({
appendContext: "must not reach raw model probes",
}));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
);
const sdk = makeFakeSdk();
await runCopilotAttempt(
makeParams({
promptMode: "none",
} as never),
{ pool: makeFakePool(sdk) },
);
expect(beforePromptBuild).not.toHaveBeenCalled();
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
prompt?: string;
};
expect(messageOptions.prompt).toBe("hello");
});
it("appends extraSystemPrompt after rendered bootstrap instructions", async () => {
const rendered = "# Project Context\n## /ws/SOUL.md\n\nSoul voice goes here.";
workspaceBootstrapMock.resolveCopilotWorkspaceBootstrapContext.mockResolvedValueOnce({
@@ -1267,6 +1788,10 @@ describe("runCopilotAttempt", () => {
});
it("timeout", async () => {
const agentEnd = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
);
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockResolvedValueOnce(undefined);
@@ -1280,6 +1805,188 @@ describe("runCopilotAttempt", () => {
expect(result.aborted).toBe(false);
expect(getSdkSessionId(result)).toBe("sess-1");
expect(sdk.sessions[0]?.abort).toHaveBeenCalledTimes(0);
expect(agentEnd).toHaveBeenCalledWith(
expect.objectContaining({
error: "Copilot SDK turn timed out.",
success: false,
}),
expect.anything(),
);
sdk.sessions[0]?.emit("session.idle", {});
await vi.waitFor(() => {
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
});
});
it("marks a timeout during active SDK compaction", async () => {
const afterCompaction = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
);
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
return undefined;
});
},
});
const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
expect(result.timedOut).toBe(true);
expect(result.timedOutDuringCompaction).toBe(true);
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
sdk.sessions[0]?.emit("session.compaction_complete", { messagesRemoved: 3, success: true });
sdk.sessions[0]?.emit("session.idle", {});
await vi.waitFor(() => {
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
});
expect(sdk.client.deleteSession).not.toHaveBeenCalled();
expect(afterCompaction).toHaveBeenCalledWith(
expect.objectContaining({ compactedCount: 3, sessionFile: "session.json" }),
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
);
});
it("retains a timed-out session until later compaction reaches session.idle", async () => {
const afterCompaction = vi.fn();
const onDeferredCompaction = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
);
let activeSession: FakeSession | undefined;
const sdk = makeFakeSdk({
onCreateSession: (session) => {
activeSession = session;
session.sendAndWait.mockRejectedValueOnce(
new Error("Timeout after 60000ms waiting for session.idle"),
);
},
});
const result = await runCopilotAttempt(makeParams(), {
onDeferredCompaction,
pool: makeFakePool(sdk),
});
expect(result.timedOut).toBe(true);
expect(result.timedOutDuringCompaction).toBe(false);
expect(onDeferredCompaction).toHaveBeenCalledWith(
expect.objectContaining({ sdkSessionId: "sess-1" }),
);
expect(activeSession?.disconnect).not.toHaveBeenCalled();
activeSession?.emit("session.compaction_start", {});
activeSession?.emit("session.compaction_complete", { messagesRemoved: 3, success: true });
await vi.waitFor(() => {
expect(afterCompaction).toHaveBeenCalledTimes(1);
});
expect(activeSession?.disconnect).not.toHaveBeenCalled();
activeSession?.emit("session.idle", {});
await vi.waitFor(() => {
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
});
});
it("does not mark a timeout after SDK compaction has completed as active compaction", async () => {
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
session.emit("session.compaction_complete", { success: true });
session.emit("session.idle", {});
return undefined;
});
},
});
const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
expect(result.timedOut).toBe(true);
expect(result.timedOutDuringCompaction).toBe(false);
});
it("bounds deferred cleanup when SDK compaction never completes", async () => {
vi.useFakeTimers();
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
return undefined;
});
},
});
const pool = makeFakePool(sdk);
const result = await runCopilotAttempt(makeParams(), { pool });
expect(result.timedOutDuringCompaction).toBe(true);
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(180_000);
expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
expect(pool.release.mock.calls).toHaveLength(1);
});
it("cancels deferred cleanup when the timed-out caller aborts", async () => {
const controller = new AbortController();
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
return undefined;
});
},
});
const result = await runCopilotAttempt(makeParams({ abortSignal: controller.signal }), {
pool: makeFakePool(sdk),
});
expect(result.timedOutDuringCompaction).toBe(true);
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
controller.abort();
await vi.waitFor(() => {
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
});
expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
});
it("keeps the compaction timeout classification after deferred completion", async () => {
const mirror = createDeferred<void>();
dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear();
dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockImplementationOnce(() => mirror.promise);
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockImplementationOnce(async () => {
session.emit("session.compaction_start", {});
return undefined;
});
},
});
const attempt = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
await vi.waitFor(() => {
expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).toHaveBeenCalledTimes(1);
});
sdk.sessions[0]?.emit("session.compaction_complete", { success: true });
sdk.sessions[0]?.emit("session.idle", {});
mirror.resolve();
const result = await attempt;
expect(result.timedOut).toBe(true);
expect(result.timedOutDuringCompaction).toBe(true);
});
it("G1: SDK timeout rejection (Error 'Timeout after Nms waiting for session.idle') sets timedOut, leaves promptError undefined, and does NOT abort the session", async () => {
@@ -1317,6 +2024,10 @@ describe("runCopilotAttempt", () => {
// replay-shim incorrectly treated the attempt as side-effect-safe.
expect(result.replayMetadata?.hadPotentialSideEffects).toBe(true);
expect(result.replayMetadata?.replaySafe).toBe(false);
sdk.sessions[0]?.emit("session.idle", {});
await vi.waitFor(() => {
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
});
});
it("G1: SDK timeout flushes the in-flight delta chain before snapshot so assistant text is preserved", async () => {
@@ -1358,6 +2069,10 @@ describe("runCopilotAttempt", () => {
expect(result.timedOut).toBe(true);
expect(onAssistantDelta).toHaveBeenCalledTimes(1);
expect(result.assistantTexts?.join("")).toContain("partial-");
session.emit("session.idle", {});
await vi.waitFor(() => {
expect(session.disconnect).toHaveBeenCalledTimes(1);
});
});
it("model translation: unsupported provider", async () => {
@@ -2246,6 +2961,10 @@ describe("runCopilotAttempt", () => {
it("fails closed when sandbox is enabled with a cwd override", async () => {
const sandbox = makeSandboxStub({ workspaceAccess: "rw" });
const agentEnd = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
);
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
@@ -2266,9 +2985,17 @@ describe("runCopilotAttempt", () => {
expect(getPromptErrorCode(result)).toBe("sandbox_cwd_override_unsupported");
expect(createToolBridge).not.toHaveBeenCalled();
expect(sdk.createSession).not.toHaveBeenCalled();
expect(agentEnd).toHaveBeenCalledWith(
expect.objectContaining({ success: false }),
expect.objectContaining({ sessionId: "session-1" }),
);
});
it("fails closed when sandbox resolution fails", async () => {
const agentEnd = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
);
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done"));
@@ -2292,6 +3019,10 @@ describe("runCopilotAttempt", () => {
);
expect(createToolBridge).not.toHaveBeenCalled();
expect(sdk.createSession).not.toHaveBeenCalled();
expect(agentEnd).toHaveBeenCalledWith(
expect.objectContaining({ success: false }),
expect.objectContaining({ sessionId: "session-1" }),
);
});
it("fails closed when creating the sandbox copy workspace fails", async () => {

View File

@@ -8,12 +8,22 @@ import type {
SandboxContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
buildAgentHookContextChannelFields,
detectAndLoadAgentHarnessPromptImages,
resolveAgentHarnessBeforePromptBuildResult,
resolveAttemptFsWorkspaceOnly,
resolveAttemptSpawnWorkspaceDir,
resolveCompactionTimeoutMs,
resolveSandboxContext as defaultResolveSandboxContext,
resolveSessionAgentIds,
resolveUserPath,
runAgentHarnessAfterToolCallHook,
runAgentHarnessAfterCompactionHook,
runAgentHarnessBeforeCompactionHook,
awaitAgentEndSideEffects,
runAgentEndSideEffects,
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveCopilotAuth } from "./auth-bridge.js";
import {
@@ -48,9 +58,11 @@ import { createCopilotToolBridge } from "./tool-bridge.js";
import { resolveCopilotWorkspaceBootstrapContext } from "./workspace-bootstrap.js";
const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
const BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS = 5_000;
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
type CopilotAgentEndHookParams = Parameters<typeof runAgentEndSideEffects>[0];
export type CopilotSessionConfig = Pick<
SessionConfig,
| "availableTools"
@@ -128,6 +140,173 @@ export interface CopilotAttemptDeps {
pooledClient: PooledClient;
sessionConfig: CopilotSessionConfig;
}) => void;
/**
* Called before an attempt retains its live SDK session to observe background
* compaction. The harness must prevent that session ID from being resumed
* until cleanup completes.
*/
onDeferredCompaction?: (info: {
abort: () => void;
cleanup: Promise<"aborted" | "completed" | "deadline">;
sdkSessionId: string;
}) => void;
}
async function runCopilotAgentEndHook(
params: AttemptParamsLike,
hookParams: CopilotAgentEndHookParams,
): Promise<void> {
if (!params.messageChannel && !params.messageProvider) {
await awaitAgentEndSideEffects(hookParams);
return;
}
runAgentEndSideEffects(hookParams);
}
async function finalizeCopilotAttempt(
params: AttemptParamsLike,
result: AgentHarnessAttemptResult,
ctx: CopilotAgentEndHookParams["ctx"],
attemptStartedAt: number,
now: () => number,
): Promise<AgentHarnessAttemptResult> {
await runCopilotAgentEndHook(params, {
event: {
messages: result.messagesSnapshot,
success: !result.aborted && !result.promptError && !result.timedOut,
...(result.promptError
? { error: toError(result.promptError).message }
: result.timedOut
? { error: "Copilot SDK turn timed out." }
: {}),
durationMs: now() - attemptStartedAt,
},
ctx,
});
return result;
}
async function awaitDeferredCleanupCompletionOrAbort(params: {
abortSignal: AbortSignal | undefined;
awaitSessionIdle: boolean;
bridge: ReturnType<typeof attachEventBridge>;
}): Promise<"aborted" | "completed"> {
const awaitCompletion = async () => {
if (params.awaitSessionIdle) {
await params.bridge.awaitSessionIdle();
}
await params.bridge.awaitCompactionCompletion();
};
if (!params.abortSignal) {
await awaitCompletion();
return "completed";
}
if (params.abortSignal.aborted) {
return "aborted";
}
let resolveAbort: () => void = () => undefined;
const aborted = new Promise<"aborted">((resolve) => {
resolveAbort = () => resolve("aborted");
});
params.abortSignal.addEventListener("abort", resolveAbort, { once: true });
try {
return await Promise.race([awaitCompletion().then(() => "completed" as const), aborted]);
} finally {
params.abortSignal.removeEventListener("abort", resolveAbort);
}
}
function deferBackgroundCompactionCleanup(params: {
abortSignal: AbortSignal | undefined;
awaitSessionIdle: boolean;
bridge: ReturnType<typeof attachEventBridge>;
handle: PooledClient;
pool: CopilotClientPool;
sdkSessionId?: string;
session: SessionLike;
timeoutMs: number;
}): Promise<"aborted" | "completed" | "deadline"> {
// The SDK can compact after its turn result or a timeout. Keep the bridge
// attached so after_compaction uses the originating run context.
return (async () => {
let outcome: "aborted" | "completed" | "deadline" = "deadline";
try {
outcome = await awaitDeferredCleanupBeforeDeadline({
abortSignal: params.abortSignal,
awaitSessionIdle: params.awaitSessionIdle,
bridge: params.bridge,
timeoutMs: params.timeoutMs,
});
} catch {
// Event callbacks are best-effort; cleanup still releases the retained session.
} finally {
if (outcome !== "completed") {
await cancelBackgroundCompactionBeforeTeardown(params.session);
params.bridge.settleCompactionWait();
}
params.bridge.detach();
try {
await params.session.disconnect();
} catch {
// The attempt has already returned its timeout result.
}
if (outcome !== "completed" && params.sdkSessionId) {
try {
await params.handle.client.deleteSession(params.sdkSessionId);
} catch {
// The timeout path intentionally discards this SDK session either way.
}
}
try {
await params.pool.release(params.handle);
} catch {
// The pool will dispose this client later if its release cannot complete.
}
}
return outcome;
})();
}
async function cancelBackgroundCompactionBeforeTeardown(session: SessionLike): Promise<void> {
const cancelBackgroundCompaction = session.rpc?.history?.cancelBackgroundCompaction;
if (!cancelBackgroundCompaction) {
return;
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS);
});
try {
await Promise.race([
Promise.resolve()
.then(() => cancelBackgroundCompaction())
.catch(() => undefined),
deadline,
]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
async function awaitDeferredCleanupBeforeDeadline(params: {
abortSignal: AbortSignal | undefined;
awaitSessionIdle: boolean;
bridge: ReturnType<typeof attachEventBridge>;
timeoutMs: number;
}): Promise<"aborted" | "completed" | "deadline"> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<"deadline">((resolve) => {
timeoutId = setTimeout(() => resolve("deadline"), params.timeoutMs);
});
try {
return await Promise.race([awaitDeferredCleanupCompletionOrAbort(params), deadline]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
export async function runCopilotAttempt(
@@ -135,34 +314,79 @@ export async function runCopilotAttempt(
deps: CopilotAttemptDeps,
): Promise<AgentHarnessAttemptResult> {
const now = deps.now ?? Date.now;
const attemptStartedAt = now();
const input = params as AttemptParamsLike;
const createToolBridge = deps.createToolBridge ?? createCopilotToolBridge;
const messages = getMessagesSnapshotInput(input);
const modelRef = resolveModelRef(input);
const resolvedWorkspaceForSandbox =
readResolvedAttemptPath(input.workspaceDir) ?? readResolvedAttemptPath(input.cwd);
const sandboxSessionKey =
readString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ??
readString((input as { sessionKey?: unknown }).sessionKey) ??
readString(input.sessionId);
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
config: input.config,
agentId: readString(params.agentId),
});
const hookContextWindowFields = {
...(input.contextWindowInfo?.tokens
? { contextTokenBudget: input.contextWindowInfo.tokens }
: input.contextTokenBudget
? { contextTokenBudget: input.contextTokenBudget }
: {}),
...(input.contextWindowInfo?.source
? { contextWindowSource: input.contextWindowInfo.source }
: {}),
...(input.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: input.contextWindowInfo.referenceTokens }
: {}),
};
const hookContext = {
runId: input.runId,
jobId: input.jobId,
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
sessionId: input.sessionId,
workspaceDir: resolvedWorkspaceForSandbox,
modelProviderId: modelRef.provider,
modelId: modelRef.id,
trigger: input.trigger,
...(input.config ? { config: input.config } : {}),
...hookContextWindowFields,
...buildAgentHookContextChannelFields(input),
};
const finishAttempt = (result: AgentHarnessAttemptResult) =>
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
if (params.abortSignal?.aborted) {
return createResult(input, {
aborted: true,
externalAbort: true,
messagesSnapshot: messages,
now,
promptError: undefined,
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
});
return finishAttempt(
createResult(input, {
aborted: true,
externalAbort: true,
messagesSnapshot: messages,
now,
promptError: undefined,
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
const modelRef = resolveModelRef(input);
if (!SUPPORTED_PROVIDERS.has(modelRef.provider)) {
return createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
"model_not_supported",
`[copilot-attempt] provider ${modelRef.provider} is not supported at MVP (subscription Copilot models only; BYOK arrives via byok-mapping-skeleton)`,
),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
});
return finishAttempt(
createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
"model_not_supported",
`[copilot-attempt] provider ${modelRef.provider} is not supported at MVP (subscription Copilot models only; BYOK arrives via byok-mapping-skeleton)`,
),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
let abortRequested = false;
@@ -170,6 +394,7 @@ export async function runCopilotAttempt(
let externalAbort = false;
let settled = false;
let sentTurnStarted = false;
let timedOutDuringCompaction = false;
let timedOut = false;
let promptError: Error | undefined;
let sdkSessionId: string | undefined;
@@ -208,12 +433,6 @@ export async function runCopilotAttempt(
// spawned subagents should inherit. When sandbox is disabled (the default),
// `resolveSandboxContext` returns `null` and behavior is unchanged from the
// pre-fix path.
const resolvedWorkspaceForSandbox =
readResolvedAttemptPath(input.workspaceDir) ?? readResolvedAttemptPath(input.cwd);
const sandboxSessionKey =
readString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ??
readString((input as { sessionKey?: unknown }).sessionKey) ??
readString(input.sessionId);
const resolveSandbox = deps.resolveSandboxContextOverride ?? defaultResolveSandboxContext;
let sandbox: SandboxContext | null = null;
let effectiveWorkspaceDir = resolvedWorkspaceForSandbox;
@@ -245,52 +464,54 @@ export async function runCopilotAttempt(
settled = true;
params.abortSignal?.removeEventListener("abort", onAbort);
if (abortRequested || params.abortSignal?.aborted) {
return createResult(input, {
aborted: true,
externalAbort: true,
return finishAttempt(
createResult(input, {
aborted: true,
externalAbort: true,
messagesSnapshot: messages,
now,
promptError: undefined,
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
return finishAttempt(
createResult(input, {
messagesSnapshot: messages,
now,
promptError: undefined,
promptError: createPromptError(
"sandbox_resolution_failure",
`[copilot-attempt] sandbox resolution failed: ${toError(error).message}`,
error,
),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
});
}
return createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
"sandbox_resolution_failure",
`[copilot-attempt] sandbox resolution failed: ${toError(error).message}`,
error,
),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
});
}),
);
}
}
hookContext.workspaceDir = effectiveWorkspaceDir;
const requestedCwd = readResolvedAttemptPath(input.cwd);
if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspaceForSandbox) {
settled = true;
params.abortSignal?.removeEventListener("abort", onAbort);
return createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
"sandbox_cwd_override_unsupported",
"[copilot-attempt] cwd override is not supported for sandboxed Copilot runs; omit cwd or use the agent workspace as cwd",
),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
});
return finishAttempt(
createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
"sandbox_cwd_override_unsupported",
"[copilot-attempt] cwd override is not supported for sandboxed Copilot runs; omit cwd or use the agent workspace as cwd",
),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
const effectiveCwd = sandbox?.enabled
? effectiveWorkspaceDir
: (requestedCwd ?? effectiveWorkspaceDir);
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
config: input.config,
agentId: readString(params.agentId),
});
const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({
config: input.config,
sessionAgentId,
@@ -301,7 +522,6 @@ export async function runCopilotAttempt(
resolvedWorkspace: resolvedWorkspaceForSandbox,
})
: undefined;
const poolAcquire = resolvePoolAcquire(input);
// Mutable session holder shared with the tool bridge so onYield
@@ -340,10 +560,24 @@ export async function runCopilotAttempt(
onYieldDetected: () => {
yieldDetected = true;
},
onToolCompleted: ({ args, error, result, startedAt, toolCallId, toolName }) =>
runAgentHarnessAfterToolCallHook({
toolName,
toolCallId,
runId: input.runId,
agentId: sessionAgentId,
sessionId: input.sessionId,
sessionKey: sandboxSessionKey,
channelId: hookContext.channelId,
startArgs: args,
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),
startedAt,
}),
});
sdkTools = toolBridge.sdkTools;
} catch (error: unknown) {
return createResult(input, {
const result = createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
@@ -354,6 +588,7 @@ export async function runCopilotAttempt(
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
});
return finishAttempt(result);
}
handle = await deps.pool.acquire(poolAcquire.key, poolAcquire.options);
@@ -380,14 +615,60 @@ export async function runCopilotAttempt(
effectiveWorkspaceDir,
warn: (message) => console.warn(message),
});
const originalDeveloperInstructions =
createSystemMessageContent(input, workspaceBootstrap.instructions) ?? "";
const promptBuild = isRawCopilotModelRun(input)
? {
prompt: input.prompt,
developerInstructions: originalDeveloperInstructions,
}
: await resolveAgentHarnessBeforePromptBuildResult({
prompt: input.prompt,
developerInstructions: originalDeveloperInstructions,
messages,
ctx: hookContext,
...("beforeAgentStartResult" in input
? { beforeAgentStartResult: input.beforeAgentStartResult }
: {}),
});
const attemptInput =
promptBuild.prompt === input.prompt ? input : { ...input, prompt: promptBuild.prompt };
let promptImagesCount = 0;
const emitLlmInput = (prompt: string, additionalContext?: string) => {
runAgentHarnessLlmInputHook({
event: {
runId: input.runId,
sessionId: input.sessionId,
provider: modelRef.provider,
model: modelRef.id,
...(promptBuild.developerInstructions
? { systemPrompt: promptBuild.developerInstructions }
: {}),
prompt: additionalContext ? `${prompt}\n\n${additionalContext}` : prompt,
// Copilot SDK sessions own their own transcript. OpenClaw's
// mirrored messages are persistence state, not provider input.
historyMessages: [],
imagesCount: promptImagesCount,
tools: sdkTools,
},
ctx: hookContext,
});
};
const hasNativePromptHook = Boolean(attemptInput.hooksConfig?.onUserPromptSubmitted);
const sessionConfig = createSessionConfig(
input,
attemptInput,
modelRef.id,
sdkTools,
poolAcquire.auth,
workspaceBootstrap.instructions,
promptBuild.developerInstructions || undefined,
effectiveWorkspaceDir,
effectiveCwd,
hasNativePromptHook
? {
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
emitLlmInput(prompt, additionalContext),
}
: undefined,
);
const replayDecision = decideReplayAction({
sdkSessionId: input.initialReplayState?.sdkSessionId,
@@ -442,21 +723,46 @@ export async function runCopilotAttempt(
}
bridge = attachEventBridge(session, {
onAssistantDelta: input.onAssistantDelta,
onCompactionStart: async () => {
const sessionFile = readString(input.sessionFile);
if (!sessionFile) {
return;
}
await runAgentHarnessBeforeCompactionHook({
sessionFile,
ctx: hookContext,
});
},
onCompactionComplete: async ({ messagesRemoved, success }) => {
const sessionFile = readString(input.sessionFile);
if (!success || !sessionFile) {
return;
}
await runAgentHarnessAfterCompactionHook({
sessionFile,
compactedCount: messagesRemoved ?? -1,
ctx: hookContext,
});
},
getSdkSessionId: () => sdkSessionId,
isAborted: () => aborted,
});
const messageOptions = await createMessageOptions(input, {
const messageOptions = await createMessageOptions(attemptInput, {
effectiveCwd,
effectiveWorkspaceDir,
sandbox,
workspaceOnly: effectiveFsWorkspaceOnly,
});
promptImagesCount = messageOptions.attachments?.length ?? 0;
if (abortRequested || params.abortSignal?.aborted) {
aborted = true;
externalAbort = true;
} else {
sentTurnStarted = true;
if (!hasNativePromptHook) {
emitLlmInput(attemptInput.prompt);
}
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
await bridge.awaitDeltaChain();
if (!bridge.recordSendResult(result) && !aborted) {
@@ -464,6 +770,7 @@ export async function runCopilotAttempt(
// capability inventory. Do not call session.abort() here: OpenClaw may
// resume the in-flight SDK session on the next attempt.
timedOut = true;
timedOutDuringCompaction = bridge.isCompacting();
}
const snap = bridge.snapshot();
if (!promptError && !timedOut && !aborted && snap.streamError) {
@@ -484,6 +791,7 @@ export async function runCopilotAttempt(
// in-flight SDK session on the next attempt (the SDK keeps
// the server-side session intact across this kind of timeout).
timedOut = true;
timedOutDuringCompaction = bridge?.isCompacting() === true;
// Flush any in-flight delta promise chain so the snapshot
// built below in `finally` includes the deltas the SDK already
// delivered before the timer fired.
@@ -498,39 +806,81 @@ export async function runCopilotAttempt(
}
} finally {
settled = true;
bridge?.detach();
params.abortSignal?.removeEventListener("abort", onAbort);
const retainSessionForDeferredCleanup =
bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false);
if (retainSessionForDeferredCleanup && bridge && session && handle) {
const cleanupAbort = new AbortController();
const abortCleanup = () => cleanupAbort.abort();
if (params.abortSignal?.aborted) {
abortCleanup();
} else {
params.abortSignal?.addEventListener("abort", abortCleanup, { once: true });
}
const cleanup = deferBackgroundCompactionCleanup({
abortSignal: cleanupAbort.signal,
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
bridge,
handle,
pool: deps.pool,
sdkSessionId,
session,
timeoutMs: resolveCompactionTimeoutMs(input.config),
});
void cleanup
.finally(() => {
params.abortSignal?.removeEventListener("abort", abortCleanup);
})
.catch(() => undefined);
if (sdkSessionId) {
try {
deps.onDeferredCompaction?.({
abort: () => cleanupAbort.abort(),
cleanup,
sdkSessionId,
});
} catch {
// Session tracking cannot interfere with timeout cleanup.
}
}
params.abortSignal?.removeEventListener("abort", onAbort);
} else {
// A normal sendAndWait result has observed session.idle, which the SDK
// defines as no background agents in flight. Timeouts retain the bridge
// until that event so compaction that starts after the timer still completes.
await bridge?.awaitCompactionChain();
bridge?.detach();
params.abortSignal?.removeEventListener("abort", onAbort);
if (session) {
try {
await session.disconnect();
} catch (error: unknown) {
disconnectError = toError(error);
// A timeout is a higher-fidelity signal than a cleanup-time
// disconnect failure; don't let a stale disconnect error
// mask the timeout classification the replay-shim depends on.
if (!promptError && !timedOut) {
promptError = disconnectError;
if (session) {
try {
await session.disconnect();
} catch (error: unknown) {
disconnectError = toError(error);
// A timeout is a higher-fidelity signal than a cleanup-time
// disconnect failure; don't let a stale disconnect error
// mask the timeout classification the replay-shim depends on.
if (!promptError && !timedOut) {
promptError = disconnectError;
}
}
}
if (handle) {
try {
await deps.pool.release(handle);
} catch (error: unknown) {
const releaseFailure = toError(error);
if (promptError) {
console.warn(
"[copilot-attempt] pool.release failed after primary error",
releaseFailure,
);
} else {
releaseError = releaseFailure;
}
}
}
}
if (handle) {
try {
await deps.pool.release(handle);
} catch (error: unknown) {
const releaseFailure = toError(error);
if (promptError) {
console.warn("[copilot-attempt] pool.release failed after primary error", releaseFailure);
} else {
releaseError = releaseFailure;
}
}
}
}
if (releaseError) {
throw releaseError;
}
const snap = bridge?.snapshot();
@@ -627,7 +977,7 @@ export async function runCopilotAttempt(
});
}
return createResult(input, {
const result = createResult(input, {
aborted,
assistantTexts,
currentAttemptAssistant: lastAssistant,
@@ -646,10 +996,43 @@ export async function runCopilotAttempt(
sdkSessionId,
sessionIdUsed,
timedOut,
timedOutDuringCompaction,
toolMetas: snap ? [...snap.toolMetas] : [],
usage: snap?.usage,
yieldDetected,
});
if (sentTurnStarted) {
runAgentHarnessLlmOutputHook({
event: {
runId: input.runId,
sessionId: input.sessionId,
provider: modelRef.provider,
model: modelRef.id,
...hookContextWindowFields,
resolvedRef:
input.runtimePlan?.observability.resolvedRef ?? `${modelRef.provider}/${modelRef.id}`,
...(input.runtimePlan?.observability.harnessId
? { harnessId: input.runtimePlan.observability.harnessId }
: {}),
assistantTexts: result.assistantTexts,
...(result.lastAssistant ? { lastAssistant: result.lastAssistant } : {}),
...(result.attemptUsage ? { usage: result.attemptUsage } : {}),
...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}),
},
ctx: hookContext,
});
}
if (releaseError) {
await finalizeCopilotAttempt(
input,
{ ...result, promptError: releaseError },
hookContext,
attemptStartedAt,
now,
);
throw releaseError;
}
return finishAttempt(result);
}
function createResult(
@@ -669,6 +1052,7 @@ function createResult(
sdkSessionId?: string;
sessionIdUsed?: string;
timedOut?: boolean;
timedOutDuringCompaction?: boolean;
toolMetas?: Array<{ meta?: string; toolName: string }>;
usage?: AssistantUsageSnapshot;
yieldDetected?: boolean;
@@ -711,7 +1095,7 @@ function createResult(
sessionFileUsed: readString(params.sessionFile),
sessionIdUsed: state.sessionIdUsed ?? readString(params.sessionId) ?? "copilot-session",
timedOut,
timedOutDuringCompaction: false,
timedOutDuringCompaction: state.timedOutDuringCompaction === true,
toolMetas,
yieldDetected: state.yieldDetected === true,
};
@@ -731,14 +1115,14 @@ function createSessionConfig(
sdkModelId: string,
sdkTools: SdkTool[],
resolvedAuth: ReturnType<typeof resolveCopilotAuth>,
workspaceBootstrapInstructions: string | undefined,
systemMessageContent: string | undefined,
effectiveWorkspaceDir: string | undefined,
effectiveCwd: string | undefined,
hooksBridgeOptions?: Parameters<typeof createHooksBridge>[1],
): CopilotSessionConfig {
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
const hooks = createHooksBridge(params.hooksConfig);
const hooks = createHooksBridge(params.hooksConfig, hooksBridgeOptions);
const infiniteSessions = createInfiniteSessionConfig(params.infiniteSessionConfig);
const systemMessageContent = createSystemMessageContent(params, workspaceBootstrapInstructions);
return {
model: sdkModelId,
// Permission decisions for SDK built-in tool kinds (shell, write,
@@ -765,10 +1149,9 @@ function createSessionConfig(
// contract, omitting the handler hides the `ask_user` tool from the
// model entirely. Interactive ask_user will need a real channel/TUI
// prompt bridge before this runtime can expose the handler.
// SessionHooks: only set when the host actually supplied handlers.
// createHooksBridge returns undefined for an empty config so we
// never install an empty hooks subsystem. See hooks-bridge.ts for
// the back-pointer to src/agents/harness/lifecycle-hook-helpers.ts.
// Preserve the shipped native SDK hook contract. These callbacks expose
// Copilot-specific events and decisions that generic lifecycle hooks do
// not model.
...(hooks ? { hooks } : {}),
// Session-level telemetry opt-out: only propagate when the host
// explicitly set a boolean. undefined means "use SDK default"

View File

@@ -1,284 +0,0 @@
// Copilot tests cover doctor probes plugin behavior.
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
probeCopilotAuthShape,
probeCopilotCliVersion,
probeCopilotHomeWritable,
} from "./doctor-probes.js";
type FakeChildOptions = {
exitCode?: number | null;
signal?: NodeJS.Signals | null;
stdout?: string;
stderr?: string;
emitErrorMessage?: string;
/** When true, never emits close; useful for timeout tests. */
hang?: boolean;
};
function makeFakeChild(opts: FakeChildOptions = {}) {
const emitter = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: () => void;
};
emitter.stdout = new EventEmitter();
emitter.stderr = new EventEmitter();
emitter.kill = vi.fn();
queueMicrotask(() => {
if (opts.stdout) {
emitter.stdout.emit("data", Buffer.from(opts.stdout, "utf8"));
}
if (opts.stderr) {
emitter.stderr.emit("data", Buffer.from(opts.stderr, "utf8"));
}
if (opts.emitErrorMessage) {
emitter.emit("error", new Error(opts.emitErrorMessage));
return;
}
if (!opts.hang) {
emitter.emit("close", opts.exitCode ?? 0, opts.signal ?? null);
}
});
return emitter;
}
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
async function makeTempHome(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-doctor-"));
tempDirs.push(dir);
return dir;
}
describe("probeCopilotCliVersion", () => {
it("reports ok with trimmed version on exit 0 with stdout", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ stdout: " 1.2.3 \n" }) as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.version).toBe("1.2.3");
expect(result.command).toBe("copilot");
}
});
it("uses custom command and args when provided", async () => {
const calls: Array<{ cmd: string; args: string[] }> = [];
const result = await probeCopilotCliVersion({
command: "my-copilot",
args: ["-V"],
spawnFn: ((cmd: string, args: readonly string[]) => {
calls.push({ cmd, args: [...args] });
return makeFakeChild({ stdout: "9.9.9" }) as never;
}) as never,
});
expect(calls).toEqual([{ cmd: "my-copilot", args: ["-V"] }]);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.command).toBe("my-copilot");
}
});
it("reports non-zero-exit with stderr details", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ exitCode: 2, stderr: "boom: not installed" }) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("non-zero-exit");
expect(result.details?.exitCode).toBe(2);
expect(result.details?.stderr).toBe("boom: not installed");
}
});
it("reports empty-version when exit 0 produces no stdout", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ stdout: " \n" }) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("empty-version");
}
});
it("reports spawn-failed when spawnFn throws synchronously (e.g. ENOENT)", async () => {
const result = await probeCopilotCliVersion({
spawnFn: (() => {
throw new Error("ENOENT: copilot not found");
}) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("spawn-failed");
expect(result.details?.rawError).toContain("ENOENT");
}
});
it("reports spawn-error when child emits 'error'", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ emitErrorMessage: "spawn ENOEXEC" }) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("spawn-error");
expect(result.details?.rawError).toBe("spawn ENOEXEC");
}
});
it("reports probe-timeout when child hangs past timeoutMs and kills the child", async () => {
const fakeChild = makeFakeChild({ hang: true });
const result = await probeCopilotCliVersion({
timeoutMs: 10,
spawnFn: () => fakeChild as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("probe-timeout");
expect(result.details?.timeoutMs).toBe(10);
}
expect(fakeChild.kill).toHaveBeenCalled();
});
it("returns just the first non-empty line as version when stdout has a banner / update hint", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () =>
makeFakeChild({
stdout: "GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.\n",
}) as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.version).toBe("GitHub Copilot CLI 1.0.48.");
expect(result.rawStdout).toBe(
"GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.",
);
}
});
it("does not surface rawStdout when stdout is already single-line", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ stdout: "1.2.3\n" }) as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.version).toBe("1.2.3");
expect(result.rawStdout).toBeUndefined();
}
});
});
describe("probeCopilotHomeWritable", () => {
it("reports ok when the directory exists and is writable, cleaning up after itself", async () => {
const home = await makeTempHome();
const result = await probeCopilotHomeWritable(home);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.copilotHome).toBe(home);
expect(result.probedPath.startsWith(home)).toBe(true);
}
const entries = await fs.readdir(home);
expect(entries).toEqual([]);
});
it("creates copilotHome if missing", async () => {
const root = await makeTempHome();
const home = path.join(root, "nested", "copilot-cfg");
const result = await probeCopilotHomeWritable(home);
expect(result.ok).toBe(true);
const stat = await fs.stat(home);
expect(stat.isDirectory()).toBe(true);
});
it("reports copilothome-not-writable when fs throws on mkdir", async () => {
const result = await probeCopilotHomeWritable("/some/path", {
fsApi: {
mkdir: vi.fn().mockRejectedValueOnce(new Error("EPERM: not permitted")),
writeFile: vi.fn(),
rm: vi.fn(),
} as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("copilothome-not-writable");
expect(result.details?.rawError).toContain("EPERM");
}
});
it("falls back to the platform default copilotHome when argument is empty or whitespace", async () => {
const writeFile = vi.fn().mockResolvedValue(undefined);
const result = await probeCopilotHomeWritable(" ", {
fsApi: {
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile,
rm: vi.fn().mockResolvedValue(undefined),
} as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.copilotHome.length).toBeGreaterThan(0);
expect(result.copilotHome.toLowerCase()).toContain("copilot");
}
});
});
describe("probeCopilotAuthShape", () => {
it("resolves to useLoggedInUser when the flag is true", () => {
const result = probeCopilotAuthShape({ useLoggedInUser: true });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resolvedMode).toBe("useLoggedInUser");
}
});
it("resolves to gitHubToken when a non-empty token is supplied", () => {
const result = probeCopilotAuthShape({ gitHubToken: "ghp_xxx" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resolvedMode).toBe("gitHubToken");
}
});
it("resolves to profile when both profileId and profileVersion are supplied", () => {
const result = probeCopilotAuthShape({ profileId: "p1", profileVersion: "v1" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resolvedMode).toBe("profile");
}
});
it("rejects when no auth source is provided", () => {
const result = probeCopilotAuthShape({});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("no-auth-source");
}
});
it("rejects when only one of profileId / profileVersion is provided", () => {
expect(probeCopilotAuthShape({ profileId: "p1" }).ok).toBe(false);
expect(probeCopilotAuthShape({ profileVersion: "v1" }).ok).toBe(false);
});
it("rejects useLoggedInUser:false on its own", () => {
const result = probeCopilotAuthShape({ useLoggedInUser: false });
expect(result.ok).toBe(false);
});
it("rejects an empty gitHubToken string", () => {
const result = probeCopilotAuthShape({ gitHubToken: "" });
expect(result.ok).toBe(false);
});
});

View File

@@ -1,260 +0,0 @@
/**
* Runtime doctor probes for the copilot extension.
*
* Imperative side-effecting checks used to diagnose a copilot
* deployment from within `openclaw doctor` (or any equivalent
* harness-side health check). Kept out of doctor-contract-api.ts
* because that contract is declarative and auto-loaded by the
* plugin registry, whereas these probes spawn subprocesses or
* touch the filesystem and must be invoked imperatively.
*
* All probes are pure (no module-level state) and dependency-
* injectable for tests. They never throw on a probe-negative
* result — failure is surfaced via the `ok: false` shape so the
* caller can render a structured doctor report.
*/
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export type ProbeResult<TPayload extends object = Record<string, never>> =
| ({ ok: true } & TPayload)
| { ok: false; reason: string; details?: Record<string, unknown> };
export interface ProbeCopilotCliVersionOptions {
/** Command to invoke; defaults to "copilot". */
command?: string;
/** Argv used to ask for version; defaults to ["--version"]. */
args?: readonly string[];
/** Timeout in milliseconds; defaults to 5_000. */
timeoutMs?: number;
/** Injection seam for testing. Defaults to node:child_process spawn. */
spawnFn?: typeof spawn;
}
export interface ProbeCopilotHomeOptions {
/** Injection seam for testing. */
fsApi?: Pick<typeof fs, "mkdir" | "writeFile" | "rm">;
/** Filename used for the writability probe. */
probeFileName?: string;
}
const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
const DEFAULT_PROBE_FILENAME = ".copilot-doctor-probe";
/**
* Probe that the Copilot CLI is installed and prints a version.
* Treats non-zero exit, missing stdout, and timeout all as failures.
*/
export async function probeCopilotCliVersion(
options: ProbeCopilotCliVersionOptions = {},
): Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>> {
const command = options.command ?? "copilot";
const args = options.args ?? ["--version"];
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
const spawnImpl = options.spawnFn ?? spawn;
return new Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>>(
(resolve) => {
let child: ReturnType<typeof spawn> | undefined;
let settled = false;
const settle = (
result: ProbeResult<{ version: string; command: string; rawStdout?: string }>,
): void => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
try {
child?.kill();
} catch {
// ignore double-kill / already-dead errors
}
resolve(result);
};
const timer = setTimeout(() => {
settle({
ok: false,
reason: "probe-timeout",
details: { command, args: [...args], timeoutMs },
});
}, timeoutMs);
try {
child = spawnImpl(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
} catch (error) {
settle({
ok: false,
reason: "spawn-failed",
details: { command, args: [...args], rawError: formatProbeError(error) },
});
return;
}
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
child.on("error", (error: Error) => {
settle({
ok: false,
reason: "spawn-error",
details: { command, args: [...args], rawError: error.message },
});
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (code !== 0) {
settle({
ok: false,
reason: "non-zero-exit",
details: {
command,
args: [...args],
exitCode: code,
signal,
stderr: stderr.trim() || undefined,
},
});
return;
}
const rawStdout = stdout.trim();
if (!rawStdout) {
settle({
ok: false,
reason: "empty-version",
details: { command, args: [...args] },
});
return;
}
// Many version commands (notably the GitHub Copilot CLI's `copilot --version`)
// print a banner plus an "update available" hint on subsequent
// lines. Surface only the first non-empty line as `version` so the
// doctor UI gets a clean string; keep the full stdout in
// `rawStdout` for debugging.
const version = firstNonEmptyLine(rawStdout) ?? rawStdout;
const payload: { version: string; command: string; rawStdout?: string } = {
version,
command,
};
if (rawStdout !== version) {
payload.rawStdout = rawStdout;
}
settle({ ok: true, ...payload });
});
},
);
}
function firstNonEmptyLine(value: string): string | undefined {
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return undefined;
}
/**
* Probe that copilotHome (or default ~/.config/copilot) is writable
* by the running user. Mirrors the existing auth-bridge's expectation
* that the SDK can persist credentials under copilotHome.
*/
export async function probeCopilotHomeWritable(
copilotHome: string | undefined,
options: ProbeCopilotHomeOptions = {},
): Promise<ProbeResult<{ copilotHome: string; probedPath: string }>> {
const fsApi = options.fsApi ?? fs;
const probeFileName = options.probeFileName ?? DEFAULT_PROBE_FILENAME;
const resolvedHome =
typeof copilotHome === "string" && copilotHome.trim().length > 0
? copilotHome.trim()
: defaultCopilotHome();
const probedPath = path.join(resolvedHome, probeFileName);
try {
await fsApi.mkdir(resolvedHome, { recursive: true });
await fsApi.writeFile(probedPath, "copilot-doctor-probe", "utf8");
await fsApi.rm(probedPath, { force: true });
return { ok: true, copilotHome: resolvedHome, probedPath };
} catch (error) {
return {
ok: false,
reason: "copilothome-not-writable",
details: {
copilotHome: resolvedHome,
probedPath,
rawError: formatProbeError(error),
},
};
}
}
/**
* Probe GitHub Copilot agent runtime auth resolution given a useLoggedInUser hint.
* Validates that at least one of {useLoggedInUser, gitHubToken,
* profileId+profileVersion} is set. This is intentionally a
* shape-only probe: actually performing an SDK auth handshake
* would require a pool and is out of scope for `openclaw doctor`.
*/
export function probeCopilotAuthShape(input: {
useLoggedInUser?: boolean;
gitHubToken?: string;
profileId?: string;
profileVersion?: string;
}): ProbeResult<{ resolvedMode: "useLoggedInUser" | "gitHubToken" | "profile" }> {
if (input.useLoggedInUser === true) {
return { ok: true, resolvedMode: "useLoggedInUser" };
}
if (typeof input.gitHubToken === "string" && input.gitHubToken.length > 0) {
return { ok: true, resolvedMode: "gitHubToken" };
}
if (
typeof input.profileId === "string" &&
input.profileId.length > 0 &&
typeof input.profileVersion === "string" &&
input.profileVersion.length > 0
) {
return { ok: true, resolvedMode: "profile" };
}
return {
ok: false,
reason: "no-auth-source",
details: {
hint: "Set useLoggedInUser:true, or gitHubToken, or both profileId+profileVersion",
},
};
}
function defaultCopilotHome(): string {
// Mirrors the SDK convention; auth-bridge uses the same default.
if (process.platform === "win32") {
return path.join(process.env.APPDATA ?? os.homedir(), "copilot");
}
const xdg = process.env.XDG_CONFIG_HOME;
if (xdg && xdg.length > 0) {
return path.join(xdg, "copilot");
}
return path.join(os.homedir(), ".config", "copilot");
}
function formatProbeError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}

View File

@@ -15,6 +15,9 @@ const REGISTERED_EVENT_TYPES = [
"assistant.usage",
"tool.execution_start",
"tool.execution_complete",
"session.compaction_start",
"session.compaction_complete",
"session.idle",
"session.error",
"abort",
] as const;
@@ -605,6 +608,160 @@ describe("attachEventBridge", () => {
expect(bridge.snapshot().toolMetas).toEqual([]);
});
it("serializes compaction callbacks and clears active compaction state on completion", async () => {
const session = createFakeSession();
const calls: string[] = [];
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onCompactionStart: () => {
calls.push("start");
},
onCompactionComplete: ({ success }) => {
calls.push(`complete:${success}`);
},
});
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
session.emit(
"session.compaction_complete",
makeEvent("session.compaction_complete", { success: false }),
);
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
session.emit(
"session.compaction_complete",
makeEvent("session.compaction_complete", { success: true }),
);
await bridge.awaitCompactionChain();
expect(calls).toEqual(["start", "complete:false", "start", "complete:true"]);
expect(bridge.isCompacting()).toBe(false);
});
it("waits for an active compaction and its completion callback", async () => {
const session = createFakeSession();
const complete = vi.fn();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onCompactionComplete: complete,
});
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
const completion = bridge.awaitCompactionCompletion();
await flushAsync();
expect(bridge.hasObservedCompaction()).toBe(true);
expect(complete).not.toHaveBeenCalled();
session.emit(
"session.compaction_complete",
makeEvent("session.compaction_complete", { messagesRemoved: 3, success: true }),
);
await completion;
expect(complete).toHaveBeenCalledWith({ messagesRemoved: 3, success: true });
expect(bridge.isCompacting()).toBe(false);
});
it("waits for the SDK terminal idle event", async () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
const idle = bridge.awaitSessionIdle();
await flushAsync();
session.emit("session.idle", makeEvent("session.idle", {}));
await idle;
expect(bridge.hasObservedSessionIdle()).toBe(true);
});
it("ignores subagent idle events while waiting for the root session", async () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
const idle = bridge.awaitSessionIdle();
session.emit("session.idle", {
...makeEvent("session.idle", {}),
agentId: "subagent-1",
});
await flushAsync();
expect(bridge.hasObservedSessionIdle()).toBe(false);
session.emit("session.idle", makeEvent("session.idle", {}));
await idle;
expect(bridge.hasObservedSessionIdle()).toBe(true);
});
it("keeps compaction pending after an abort until the SDK reports completion", async () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
const completion = bridge.awaitCompactionCompletion();
session.emit("abort", makeEvent("abort", { reason: "tool yield" }));
await flushAsync();
expect(bridge.isCompacting()).toBe(true);
session.emit(
"session.compaction_complete",
makeEvent("session.compaction_complete", { success: false }),
);
await completion;
expect(bridge.isCompacting()).toBe(false);
});
it("settles an active compaction wait before terminal teardown", async () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
const completion = bridge.awaitCompactionCompletion();
bridge.settleCompactionWait();
await completion;
expect(bridge.isCompacting()).toBe(false);
});
it("ignores subagent compaction events when tracking the root session", async () => {
const session = createFakeSession();
const onCompactionStart = vi.fn();
const onCompactionComplete = vi.fn();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onCompactionStart,
onCompactionComplete,
});
session.emit("session.compaction_start", {
...makeEvent("session.compaction_start", {}),
agentId: "subagent-1",
});
session.emit("session.compaction_complete", {
...makeEvent("session.compaction_complete", { success: true }),
agentId: "subagent-1",
});
await bridge.awaitCompactionCompletion();
expect(bridge.hasObservedCompaction()).toBe(false);
expect(bridge.isCompacting()).toBe(false);
expect(onCompactionStart).not.toHaveBeenCalled();
expect(onCompactionComplete).not.toHaveBeenCalled();
});
it("session.error populates streamError with errorCode or errorType only when not aborted", () => {
const activeSession = createFakeSession();
const activeBridge = attachEventBridge(activeSession, {

View File

@@ -30,12 +30,22 @@ export interface SessionLike {
): (() => void) | void;
(eventType: string, handler: (event: SessionEvent) => void): (() => void) | void;
};
rpc?: {
history?: {
cancelBackgroundCompaction?: () => Promise<unknown>;
};
};
sendAndWait(options: MessageOptions, timeout?: number): Promise<SessionEvent | undefined>;
sessionId?: string;
}
export interface EventBridgeOptions {
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
onCompactionComplete?: (payload: {
messagesRemoved?: number;
success: boolean;
}) => void | Promise<void>;
onCompactionStart?: () => void | Promise<void>;
getSdkSessionId: () => string | undefined;
isAborted: () => boolean;
}
@@ -57,7 +67,14 @@ export interface BuildAssistantMessageArgs {
export interface EventBridgeController {
recordSendResult(result: SessionEvent | undefined): boolean;
awaitCompactionChain(): Promise<void>;
awaitCompactionCompletion(): Promise<void>;
awaitSessionIdle(): Promise<void>;
settleCompactionWait(): void;
awaitDeltaChain(): Promise<void>;
hasObservedCompaction(): boolean;
hasObservedSessionIdle(): boolean;
isCompacting(): boolean;
snapshot(): EventBridgeSnapshot;
buildAssistantMessage(args: BuildAssistantMessageArgs): AssistantMessage | undefined;
finalizeAssistantTexts(): string[];
@@ -82,8 +99,18 @@ export function attachEventBridge(
const toolNamesByCallId = new Map<string, string>();
let startedCount = 0;
let completedCount = 0;
let activeCompactionCount = 0;
let observedCompaction = false;
let deltaQueue = Promise.resolve();
let deltaChain = Promise.resolve();
let compactionChain = Promise.resolve();
let compactionIdle = Promise.resolve();
let resolveCompactionIdle: (() => void) | undefined;
let observedSessionIdle = false;
let resolveSessionIdle: (() => void) | undefined;
const sessionIdle = new Promise<void>((resolve) => {
resolveSessionIdle = resolve;
});
let firstDeltaError: unknown;
let detached = false;
const unsubscribeFns: Array<() => void> = [];
@@ -164,6 +191,48 @@ export function attachEventBridge(
}
});
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
if (!isRootCompactionEvent(event)) {
return;
}
observedCompaction = true;
if (activeCompactionCount === 0) {
compactionIdle = new Promise<void>((resolve) => {
resolveCompactionIdle = resolve;
});
}
activeCompactionCount += 1;
enqueueCompactionCallback(options.onCompactionStart);
});
registerListener(session, unsubscribeFns, "session.compaction_complete", (event) => {
if (!isRootCompactionEvent(event)) {
return;
}
activeCompactionCount = Math.max(0, activeCompactionCount - 1);
enqueueCompactionCallback(() =>
options.onCompactionComplete?.({
...(event.data.messagesRemoved !== undefined
? { messagesRemoved: event.data.messagesRemoved }
: {}),
success: event.data.success,
}),
);
if (activeCompactionCount === 0) {
resolveCompactionIdle?.();
resolveCompactionIdle = undefined;
}
});
registerListener(session, unsubscribeFns, "session.idle", (event) => {
if (!isRootCompactionEvent(event)) {
return;
}
observedSessionIdle = true;
resolveSessionIdle?.();
resolveSessionIdle = undefined;
});
registerListener(session, unsubscribeFns, "session.error", (event) => {
if (!options.isAborted()) {
streamError = createPromptError(
@@ -190,9 +259,32 @@ export function attachEventBridge(
lastAssistantEvent = result;
return true;
},
awaitCompactionChain() {
return compactionChain;
},
async awaitCompactionCompletion() {
await awaitStableCompaction();
},
awaitSessionIdle() {
return observedSessionIdle ? Promise.resolve() : sessionIdle;
},
settleCompactionWait() {
activeCompactionCount = 0;
resolveCompactionIdle?.();
resolveCompactionIdle = undefined;
},
awaitDeltaChain() {
return deltaChain;
},
hasObservedCompaction() {
return observedCompaction;
},
hasObservedSessionIdle() {
return observedSessionIdle;
},
isCompacting() {
return activeCompactionCount > 0;
},
snapshot() {
return {
assistantTexts: finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent),
@@ -233,6 +325,28 @@ export function attachEventBridge(
unsubscribeFns.length = 0;
},
};
function enqueueCompactionCallback(callback: (() => void | Promise<void>) | undefined): void {
if (!callback) {
return;
}
const queued = compactionChain.then(callback, callback);
compactionChain = queued.catch(() => undefined);
}
async function awaitStableCompaction(): Promise<void> {
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
if (idle) {
await idle;
}
const callbacks = compactionChain;
await callbacks;
// Compaction events can arrive while an earlier hook callback settles.
// Recheck both queues before teardown so the root observer stays attached.
if (activeCompactionCount > 0 || compactionChain !== callbacks) {
await awaitStableCompaction();
}
}
}
function buildAssistantMessage(params: {
@@ -332,6 +446,12 @@ function isAssistantMessageEvent(
return event?.type === "assistant.message";
}
function isRootCompactionEvent(event: { agentId?: string }): boolean {
// SDK session events include subagent compaction; only root compaction
// affects the pooled root session's cleanup and reuse lifecycle.
return event.agentId === undefined;
}
function joinReasoning(order: string[], reasoningById: Map<string, string>): string {
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
}

View File

@@ -1,4 +1,4 @@
// Copilot tests cover hooks bridge plugin behavior.
// Copilot tests cover native SDK hook compatibility.
import { describe, expect, it, vi } from "vitest";
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
@@ -10,26 +10,23 @@ describe("createHooksBridge", () => {
workingDirectory: "/",
};
it("returns undefined when no config is provided", () => {
it("returns undefined when no handlers are configured", () => {
expect(createHooksBridge()).toBeUndefined();
});
it("returns undefined when config has no handlers", () => {
expect(createHooksBridge({})).toBeUndefined();
});
it("returns undefined when only onHookError is supplied (no real handlers)", () => {
expect(createHooksBridge({ onHookError: () => undefined })).toBeUndefined();
});
it("includes only the handlers that were configured", () => {
const onPreToolUse = vi.fn();
const onSessionStart = vi.fn();
const hooks = createHooksBridge({ onPreToolUse, onSessionStart })!;
expect(hooks).toBeDefined();
it("includes only configured native handlers", () => {
const hooks = createHooksBridge({
onPreToolUse: vi.fn(),
onSessionStart: vi.fn(),
})!;
expect(typeof hooks.onPreToolUse).toBe("function");
expect(typeof hooks.onSessionStart).toBe("function");
expect(hooks.onPreMcpToolCall).toBeUndefined();
expect(hooks.onPostToolUse).toBeUndefined();
expect(hooks.onPostToolUseFailure).toBeUndefined();
expect(hooks.onUserPromptSubmitted).toBeUndefined();
expect(hooks.onSessionEnd).toBeUndefined();
expect(hooks.onErrorOccurred).toBeUndefined();
@@ -47,71 +44,80 @@ describe("createHooksBridge", () => {
toolName: "bash",
toolArgs: { cmd: "ls" },
};
const result = await hooks.onPreToolUse!(input, { sessionId: "sess-1" });
expect(result).toEqual({ permissionDecision: "allow", additionalContext: "ok" });
expect(onPreToolUse).toHaveBeenCalledTimes(1);
await expect(hooks.onPreToolUse!(input, { sessionId: "sess-1" })).resolves.toEqual({
permissionDecision: "allow",
additionalContext: "ok",
});
expect(onPreToolUse).toHaveBeenCalledWith(input, { sessionId: "sess-1" });
});
it("isolates synchronous throws: returns undefined and notifies onHookError", async () => {
it("reports the effective prompt after a native prompt hook completes", async () => {
const onUserPromptSubmitted = vi.fn().mockResolvedValue({
additionalContext: "Use the approved repository.",
modifiedPrompt: "Review the authentication change.",
});
const observedPrompt = vi.fn();
const hooks = createHooksBridge(
{ onUserPromptSubmitted },
{ onUserPromptSubmitted: observedPrompt },
)!;
await expect(
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hello" }, { sessionId: "s" }),
).resolves.toEqual({
additionalContext: "Use the approved repository.",
modifiedPrompt: "Review the authentication change.",
});
expect(observedPrompt).toHaveBeenCalledWith({
additionalContext: "Use the approved repository.",
prompt: "Review the authentication change.",
});
});
it("reports the original prompt when a native prompt hook fails", async () => {
const observedPrompt = vi.fn();
const hooks = createHooksBridge(
{
onUserPromptSubmitted: async () => {
throw new Error("prompt hook failed");
},
onHookError: () => undefined,
},
{ onUserPromptSubmitted: observedPrompt },
)!;
await expect(
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hello" }, { sessionId: "s" }),
).resolves.toBeUndefined();
expect(observedPrompt).toHaveBeenCalledWith({ prompt: "hello" });
});
it("isolates synchronous and asynchronous handler failures", async () => {
const onHookError = vi.fn();
const hooks = createHooksBridge({
onPostToolUse: () => {
throw new Error("post boom");
},
onHookError,
})!;
const result = await hooks.onPostToolUse!(
{ ...hookBase, toolName: "x", toolArgs: {}, toolResult: {} as never },
{ sessionId: "s" },
);
expect(result).toBeUndefined();
expect(onHookError).toHaveBeenCalledTimes(1);
expect(onHookError.mock.calls[0]?.[0]).toEqual({
hookName: "onPostToolUse",
error: expect.any(Error),
});
expect((onHookError.mock.calls[0][0]!.error as Error).message).toBe("post boom");
});
it("isolates async rejections: returns undefined and notifies onHookError", async () => {
const onHookError = vi.fn();
const hooks = createHooksBridge({
onUserPromptSubmitted: async () => {
throw new Error("async boom");
throw new Error("prompt boom");
},
onHookError,
})!;
const result = await hooks.onUserPromptSubmitted!(
{ ...hookBase, prompt: "hi" },
{ sessionId: "s" },
);
expect(result).toBeUndefined();
expect(onHookError).toHaveBeenCalledTimes(1);
expect(onHookError.mock.calls[0]?.[0]?.hookName).toBe("onUserPromptSubmitted");
});
it("uses console.warn as the default onHookError", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
const hooks = createHooksBridge({
onErrorOccurred: () => {
throw new Error("default-error-handler");
},
})!;
const result = await hooks.onErrorOccurred!(
{ ...hookBase, error: "x", errorContext: "system", recoverable: true },
await expect(
hooks.onPostToolUse!(
{ ...hookBase, toolName: "x", toolArgs: {}, toolResult: {} as never },
{ sessionId: "s" },
);
expect(result).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("onErrorOccurred");
} finally {
warnSpy.mockRestore();
}
),
).resolves.toBeUndefined();
await expect(
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hi" }, { sessionId: "s" }),
).resolves.toBeUndefined();
expect(onHookError).toHaveBeenCalledTimes(2);
});
it("never throws when onHookError itself throws", async () => {
it("never lets the error notifier throw into the SDK", async () => {
const hooks = createHooksBridge({
onSessionEnd: () => {
throw new Error("hook boom");
@@ -120,41 +126,47 @@ describe("createHooksBridge", () => {
throw new Error("notifier boom");
},
})!;
await expect(
hooks.onSessionEnd!({ ...hookBase, reason: "complete" }, { sessionId: "s" }),
).resolves.toBeUndefined();
});
it("preserves all six SDK hook handlers when supplied", async () => {
it("preserves native MCP and failed-tool callbacks", async () => {
const onPreMcpToolCall = vi.fn();
const onPostToolUseFailure = vi.fn();
const hooks = createHooksBridge({
onPreMcpToolCall,
onPostToolUseFailure,
})!;
await hooks.onPreMcpToolCall!({} as never, { sessionId: "s" });
await hooks.onPostToolUseFailure!({} as never, { sessionId: "s" });
expect(onPreMcpToolCall).toHaveBeenCalledTimes(1);
expect(onPostToolUseFailure).toHaveBeenCalledTimes(1);
});
it("preserves all supported SDK hook handlers", () => {
const config: CopilotHooksConfig = {
onPreToolUse: vi.fn().mockResolvedValue({ suppressOutput: true }),
onPreMcpToolCall: vi.fn(),
onPostToolUse: vi.fn().mockResolvedValue({ suppressOutput: false }),
onPostToolUseFailure: vi.fn(),
onUserPromptSubmitted: vi.fn().mockResolvedValue({ modifiedPrompt: "trimmed" }),
onSessionStart: vi.fn().mockResolvedValue({ additionalContext: "context" }),
onSessionEnd: vi.fn().mockResolvedValue({ sessionSummary: "done" }),
onErrorOccurred: vi.fn().mockResolvedValue({ errorHandling: "retry" as const }),
};
const hooks = createHooksBridge(config)!;
expect(typeof hooks.onPreToolUse).toBe("function");
expect(typeof hooks.onPreMcpToolCall).toBe("function");
expect(typeof hooks.onPostToolUse).toBe("function");
expect(typeof hooks.onPostToolUseFailure).toBe("function");
expect(typeof hooks.onUserPromptSubmitted).toBe("function");
expect(typeof hooks.onSessionStart).toBe("function");
expect(typeof hooks.onSessionEnd).toBe("function");
expect(typeof hooks.onErrorOccurred).toBe("function");
});
it("forwards void returns transparently", async () => {
const hooks = createHooksBridge({
onSessionStart: () => undefined,
})!;
const result = await hooks.onSessionStart!({ ...hookBase, source: "new" }, { sessionId: "s" });
expect(result).toBeUndefined();
});
it("does not invoke unconfigured handlers' isolators", () => {
const hooks = createHooksBridge({ onPreToolUse: () => undefined })!;
// ensure the missing handlers are literally absent, not just nullable
expect("onPostToolUse" in hooks).toBe(false);
expect("onUserPromptSubmitted" in hooks).toBe(false);
});
});

View File

@@ -1,56 +1,38 @@
/**
* Hooks bridge for the copilot agent runtime.
* Compatibility adapter for native Copilot SDK SessionHooks.
*
* BACK-POINTER: The host-side hook runner lives outside this package
* boundary in `src/agents/harness/lifecycle-hook-helpers.ts` (uses the
* plugin hook runner via `src/plugins/hook-runner-global.ts`). Per
* proposal §266 (todo `hooks-bridge`), this module provides a small
* contract surface that mirrors the SDK's `SessionHooks` shape; the
* core wiring layer constructs handlers that call into
* `runAgentHarnessLlmInputHook`, `runAgentHarnessLlmOutputHook`,
* `runAgentHarnessAgentEndHook`, etc., and threads them through
* `AttemptParamsLike.hooks`.
*
* Cross-package boundary note: the heavy host lifecycle helpers
* cannot be imported here (`tsconfig.package-boundary.base.json`). The
* bridge keeps the SDK hook contracts intact, wraps each provided
* handler in an error-isolating envelope so a thrown host hook cannot
* crash the SDK session, and returns a `SessionHooks` object that
* `createSessionConfig` can plug into `SessionConfig.hooks`.
*
* Note on default omission: if no handlers are supplied, the bridge
* returns `undefined` so that `SessionConfig.hooks` stays absent and
* the SDK skips the entire hook subsystem (matches the "no hooks
* installed" runtime behaviour the harness had pre-bridge).
* `hooksConfig` is a shipped Copilot-specific per-attempt API. It remains
* separate from OpenClaw's generic lifecycle hooks because the SDK callbacks
* expose native events and decisions that the portable hook contract does not.
*/
import type { SessionConfig } from "@github/copilot-sdk";
// All hook handler types are derived from SessionHooks so this bridge
// stays pinned to the same SDK source the rest of the harness uses,
// without depending on the SDK re-exporting individual handler aliases
// (which it does not, as of @github/copilot-sdk@1.0.0-beta.4).
type SdkSessionHooks = NonNullable<SessionConfig["hooks"]>;
type PreToolUseHandler = NonNullable<SdkSessionHooks["onPreToolUse"]>;
type PreMcpToolCallHandler = NonNullable<SdkSessionHooks["onPreMcpToolCall"]>;
type PostToolUseHandler = NonNullable<SdkSessionHooks["onPostToolUse"]>;
type PostToolUseFailureHandler = NonNullable<SdkSessionHooks["onPostToolUseFailure"]>;
type UserPromptSubmittedHandler = NonNullable<SdkSessionHooks["onUserPromptSubmitted"]>;
type SessionStartHandler = NonNullable<SdkSessionHooks["onSessionStart"]>;
type SessionEndHandler = NonNullable<SdkSessionHooks["onSessionEnd"]>;
type ErrorOccurredHandler = NonNullable<SdkSessionHooks["onErrorOccurred"]>;
export interface CopilotHooksBridgeOptions {
onUserPromptSubmitted?: (submission: { prompt: string; additionalContext?: string }) => void;
}
export interface CopilotHooksConfig {
onPreToolUse?: PreToolUseHandler;
onPreMcpToolCall?: PreMcpToolCallHandler;
onPostToolUse?: PostToolUseHandler;
onPostToolUseFailure?: PostToolUseFailureHandler;
onUserPromptSubmitted?: UserPromptSubmittedHandler;
onSessionStart?: SessionStartHandler;
onSessionEnd?: SessionEndHandler;
onErrorOccurred?: ErrorOccurredHandler;
/**
* Optional hook-error notifier. Called whenever any wrapped handler
* throws (synchronously or as a Promise rejection). Defaults to
* `console.warn` so the failure is visible to operators without
* crashing the SDK session. Receives the SDK hook name and the
* raised error.
* Called when a native SDK hook handler throws. Defaults to console.warn so
* native hook failures do not terminate the SDK session.
*/
onHookError?: (info: { hookName: keyof SdkSessionHooks; error: unknown }) => void;
}
@@ -63,10 +45,8 @@ const DEFAULT_HOOK_ERROR_HANDLER: NonNullable<CopilotHooksConfig["onHookError"]>
};
/**
* Wrap a host handler in an error-isolating envelope so it cannot
* throw out into the SDK. Returns `undefined` (no opinion) when the
* host handler throws, so the SDK falls back to its default behaviour
* for that hook.
* Wrap a native handler so it cannot throw into the SDK. Returning undefined
* leaves the SDK's default decision in place.
*/
function isolate<TArgs extends readonly unknown[], TResult>(
hookName: keyof SdkSessionHooks,
@@ -83,7 +63,7 @@ function isolate<TArgs extends readonly unknown[], TResult>(
try {
onError({ hookName, error });
} catch {
// never let the error notifier itself throw out
// Never let the error notifier itself throw into the SDK.
}
return undefined;
}
@@ -91,18 +71,22 @@ function isolate<TArgs extends readonly unknown[], TResult>(
}
/**
* Build an SDK-shaped `SessionHooks` object from a host-supplied
* `CopilotHooksConfig`. Returns `undefined` when no handlers were
* supplied so the SDK skips the hook subsystem entirely.
* Build an SDK-shaped hook object from native per-attempt configuration.
* Omit the SDK hook subsystem when no handlers were configured.
*/
export function createHooksBridge(config?: CopilotHooksConfig): SdkSessionHooks | undefined {
export function createHooksBridge(
config?: CopilotHooksConfig,
options?: CopilotHooksBridgeOptions,
): SdkSessionHooks | undefined {
if (!config) {
return undefined;
}
const onError = config.onHookError ?? DEFAULT_HOOK_ERROR_HANDLER;
const hooks: SdkSessionHooks = {};
const pre = isolate("onPreToolUse", config.onPreToolUse, onError);
const preMcp = isolate("onPreMcpToolCall", config.onPreMcpToolCall, onError);
const post = isolate("onPostToolUse", config.onPostToolUse, onError);
const postFailure = isolate("onPostToolUseFailure", config.onPostToolUseFailure, onError);
const userPrompt = isolate("onUserPromptSubmitted", config.onUserPromptSubmitted, onError);
const sessionStart = isolate("onSessionStart", config.onSessionStart, onError);
const sessionEnd = isolate("onSessionEnd", config.onSessionEnd, onError);
@@ -111,11 +95,32 @@ export function createHooksBridge(config?: CopilotHooksConfig): SdkSessionHooks
if (pre) {
hooks.onPreToolUse = pre as PreToolUseHandler;
}
if (preMcp) {
hooks.onPreMcpToolCall = preMcp as PreMcpToolCallHandler;
}
if (post) {
hooks.onPostToolUse = post as PostToolUseHandler;
}
if (postFailure) {
hooks.onPostToolUseFailure = postFailure as PostToolUseFailureHandler;
}
if (userPrompt) {
hooks.onUserPromptSubmitted = userPrompt as UserPromptSubmittedHandler;
hooks.onUserPromptSubmitted = async (input, invocation) => {
const output = await userPrompt(input, invocation);
try {
options?.onUserPromptSubmitted?.({
prompt: output?.modifiedPrompt ?? input.prompt,
...(output?.additionalContext ? { additionalContext: output.additionalContext } : {}),
});
} catch (error) {
try {
onError({ hookName: "onUserPromptSubmitted", error });
} catch {
// Never let an observer or its error notifier throw into the SDK.
}
}
return output;
};
}
if (sessionStart) {
hooks.onSessionStart = sessionStart as SessionStartHandler;
@@ -127,8 +132,5 @@ export function createHooksBridge(config?: CopilotHooksConfig): SdkSessionHooks
hooks.onErrorOccurred = errorOccurred as ErrorOccurredHandler;
}
if (Object.keys(hooks).length === 0) {
return undefined;
}
return hooks;
return Object.keys(hooks).length > 0 ? hooks : undefined;
}

View File

@@ -1175,15 +1175,19 @@ describe("convertOpenClawToolToSdkTool", () => {
it("calls prepareArguments and passes the prepared args and toolCallId to execute", async () => {
const preparedArgs = { value: "prepared" };
const onToolCompleted = vi.fn();
const prepareArguments = vi.fn(() => preparedArgs);
const sourceTool = makeTool({ prepareArguments });
const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {});
const sdkTool = convertOpenClawToolToSdkTool(sourceTool, { onToolCompleted });
await runSdkTool(sdkTool, { value: "raw" }, makeInvocation({ toolCallId: "call-99" }));
expect(prepareArguments).toHaveBeenCalledTimes(1);
expect(prepareArguments).toHaveBeenCalledWith({ value: "raw" });
expect(sourceTool.execute).toHaveBeenCalledWith("call-99", preparedArgs, undefined, undefined);
expect(onToolCompleted).toHaveBeenCalledWith(
expect.objectContaining({ args: preparedArgs, toolCallId: "call-99" }),
);
});
it("returns a failure result when prepareArguments throws", async () => {
@@ -1232,6 +1236,29 @@ describe("convertOpenClawToolToSdkTool", () => {
});
});
it("reports terminal tool results to the harness lifecycle bridge", async () => {
const onToolCompleted = vi.fn();
const sourceResult = {
content: [{ text: "hello", type: "text" }],
details: { results: [{ text: "hello" }] },
};
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), {
onToolCompleted,
});
await runSdkTool(sdkTool, { value: "input" }, makeInvocation({ toolCallId: "call-9" }));
await flushAsync();
expect(onToolCompleted).toHaveBeenCalledWith(
expect.objectContaining({
args: { value: "input" },
result: sourceResult,
toolCallId: "call-9",
toolName: "tool-a",
}),
);
});
it("reports thrown tool failures to the private result observer", async () => {
const error = new Error("backend unavailable");
const onAgentToolResult = vi.fn();
@@ -1261,17 +1288,46 @@ describe("convertOpenClawToolToSdkTool", () => {
});
});
it("reports returned OpenClaw error results as observer failures", async () => {
it("reports terminal tool failures to the harness lifecycle bridge", async () => {
const onToolCompleted = vi.fn();
const preparedArgs = { value: "prepared" };
const sdkTool = convertOpenClawToolToSdkTool(
makeTool({
prepareArguments: vi.fn(() => preparedArgs),
execute: vi.fn(async () => {
throw new Error("backend unavailable");
}),
}),
{ onToolCompleted },
);
await runSdkTool(sdkTool, { value: "input" }, makeInvocation({ toolCallId: "call-10" }));
await flushAsync();
expect(onToolCompleted).toHaveBeenCalledWith(
expect.objectContaining({
args: preparedArgs,
error: "backend unavailable",
toolCallId: "call-10",
toolName: "tool-a",
}),
);
});
it("reports returned OpenClaw error results to both tool observers", async () => {
const onAgentToolResult = vi.fn();
const onToolCompleted = vi.fn();
const sourceResult = {
content: [{ text: '{"status":"error","error":"backend unavailable"}', type: "text" }],
details: { status: "error", error: "backend unavailable" },
};
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), {
onAgentToolResult,
onToolCompleted,
});
const result = await runSdkTool(sdkTool, {});
await flushAsync();
expect(result).toMatchObject({ resultType: "success" });
expect(onAgentToolResult).toHaveBeenCalledWith({
@@ -1279,6 +1335,12 @@ describe("convertOpenClawToolToSdkTool", () => {
result: sourceResult,
isError: true,
});
expect(onToolCompleted).toHaveBeenCalledWith(
expect.objectContaining({
error: "backend unavailable",
result: sourceResult,
}),
);
});
it("joins multiple text blocks with newlines", async () => {

View File

@@ -8,6 +8,7 @@ import type {
import {
applyEmbeddedAttemptToolsAllow,
buildEmbeddedAttemptToolRunContext,
extractToolErrorMessage,
getPluginToolMeta,
isSubagentSessionKey,
isToolResultError,
@@ -53,6 +54,15 @@ export interface CopilotSessionHolder {
*/
export type CopilotToolAttemptParams = Partial<EmbeddedRunAttemptParams>;
export type CopilotToolCompletion = {
toolName: string;
toolCallId: string;
args: Record<string, unknown>;
result?: unknown;
error?: string;
startedAt: number;
};
export interface CopilotToolBridgeInput {
modelProvider: string;
modelId: string;
@@ -108,6 +118,7 @@ export interface CopilotToolBridgeInput {
* `extensions/codex/src/app-server/run-attempt.ts:539-541`.
*/
onYieldDetected?: (message?: string) => void;
onToolCompleted?: (completion: CopilotToolCompletion) => void | Promise<void>;
createOpenClawCodingTools?: (opts: unknown) => AnyAgentTool[] | Promise<AnyAgentTool[]>;
beforeExecute?: (ctx: {
toolName: string;
@@ -208,6 +219,7 @@ export async function createCopilotToolBridge(
abortSignal: input.abortSignal,
beforeExecute: input.beforeExecute,
onAgentToolResult: input.attemptParams?.onAgentToolResult,
onToolCompleted: input.onToolCompleted,
}),
),
sourceTools: filteredTools,
@@ -389,6 +401,7 @@ export function convertOpenClawToolToSdkTool(
abortSignal?: AbortSignal;
beforeExecute?: CopilotToolBridgeInput["beforeExecute"];
onAgentToolResult?: CopilotToolAttemptParams["onAgentToolResult"];
onToolCompleted?: CopilotToolBridgeInput["onToolCompleted"];
},
): SdkTool {
if (typeof sourceTool.name !== "string" || sourceTool.name.trim().length === 0) {
@@ -409,23 +422,47 @@ export function convertOpenClawToolToSdkTool(
console.warn("[copilot-tool-bridge] onAgentToolResult handler threw; continuing", error);
}
};
const failureResult = (message: string, error: unknown): ToolResultObject => {
const notifyToolCompleted = (completion: CopilotToolCompletion) => {
try {
void Promise.resolve(ctx.onToolCompleted?.(completion)).catch((error: unknown) => {
console.warn("[copilot-tool-bridge] onToolCompleted handler threw; continuing", error);
});
} catch (error) {
console.warn("[copilot-tool-bridge] onToolCompleted handler threw; continuing", error);
}
};
const failureResult = (
executedArgs: unknown,
invocation: ToolInvocation,
startedAt: number,
message: string,
error: unknown,
): ToolResultObject => {
const errorMessage = toError(error).message;
notifyToolResult(
sanitizeToolResult({
content: [{ type: "text", text: message }],
details: { status: "failed", error: toError(error).message },
details: { status: "failed", error: errorMessage },
}),
true,
);
notifyToolCompleted({
toolName: sourceTool.name,
toolCallId: invocation.toolCallId,
args: toToolStartArgs(executedArgs),
error: errorMessage,
startedAt,
});
return createFailureResult(message, error);
};
const executeOnce = async (
args: unknown,
invocation: ToolInvocation,
): Promise<ToolResultObject> => {
const startedAt = Date.now();
if (ctx.abortSignal?.aborted) {
const error = new Error("[copilot-tool-bridge] aborted before execution");
return failureResult(error.message, error);
return failureResult(args, invocation, startedAt, error.message, error);
}
try {
@@ -438,6 +475,9 @@ export function convertOpenClawToolToSdkTool(
});
} catch (error: unknown) {
return failureResult(
args,
invocation,
startedAt,
`[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toError(error).message}`,
error,
);
@@ -448,6 +488,9 @@ export function convertOpenClawToolToSdkTool(
preparedArgs = sourceTool.prepareArguments ? sourceTool.prepareArguments(args) : args;
} catch (error: unknown) {
return failureResult(
args,
invocation,
startedAt,
`[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toError(error).message}`,
error,
);
@@ -463,6 +506,9 @@ export function convertOpenClawToolToSdkTool(
);
} catch (error: unknown) {
return failureResult(
preparedArgs,
invocation,
startedAt,
`[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toError(error).message}`,
error,
);
@@ -470,10 +516,17 @@ export function convertOpenClawToolToSdkTool(
const sdkResult = agentToolResultToSdk(result);
const sanitizedResult = sanitizeToolResult(result);
notifyToolResult(
sanitizedResult,
sdkResult.resultType === "failure" || isToolResultError(sanitizedResult),
);
const resultIsError = sdkResult.resultType === "failure" || isToolResultError(sanitizedResult);
const resultError = resultIsError ? extractToolErrorMessage(sanitizedResult) : undefined;
notifyToolResult(sanitizedResult, resultIsError);
notifyToolCompleted({
toolName: sourceTool.name,
toolCallId: invocation.toolCallId,
args: toToolStartArgs(preparedArgs),
result: sanitizedResult,
...(resultError ? { error: resultError } : {}),
startedAt,
});
return sdkResult;
};
@@ -522,6 +575,12 @@ export function convertOpenClawToolToSdkTool(
};
}
function toToolStartArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
: { value: args };
}
function agentToolResultToSdk(result: AgentToolResultLike | undefined): ToolResultObject {
const content = result?.content;
if (content == null) {

View File

@@ -7,7 +7,8 @@ import {
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { logVerboseMock } = vi.hoisted(() => ({
const { loadModelCatalogMock, logVerboseMock } = vi.hoisted(() => ({
loadModelCatalogMock: vi.fn(),
logVerboseMock: vi.fn(),
}));
const { loggerWarnMock } = vi.hoisted(() => ({
@@ -32,6 +33,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
});
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
loadModelCatalog: loadModelCatalogMock,
resolveHumanDelayConfig: () => undefined,
}));
@@ -227,6 +229,7 @@ describe("createDiscordNativeCommand option wiring", () => {
beforeEach(() => {
clearRuntimeConfigSnapshot();
loadModelCatalogMock.mockReset().mockResolvedValue([]);
logVerboseMock.mockReset();
loggerWarnMock.mockReset();
});
@@ -257,6 +260,30 @@ describe("createDiscordNativeCommand option wiring", () => {
]);
});
it("uses the provider-startup catalog snapshot for /think autocomplete", async () => {
const cfg = {
channels: {
discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
},
},
} as OpenClawConfig;
const command = createNativeCommand("think", { cfg });
const level = requireOption(command, "level");
const autocomplete = requireAutocomplete(level, "think level option did not wire autocomplete");
await runAutocomplete(autocomplete, {
userId: "owner",
channelType: ChannelType.DM,
channelId: "dm-1",
channelName: "dm-1",
focusedValue: "",
});
expect(loadModelCatalogMock).toHaveBeenCalledWith({ cacheOnly: true });
expect(loadModelCatalogMock).toHaveBeenCalledWith({ config: cfg });
});
it("keeps static choices for non-acp string action arguments", () => {
const command = createNativeCommand("config");
const action = requireOption(command, "action");

View File

@@ -1,5 +1,6 @@
// Discord plugin module implements native command.options behavior.
import { ApplicationCommandOptionType } from "discord-api-types/v10";
import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
resolveCommandArgChoices,
@@ -117,12 +118,17 @@ export function buildDiscordCommandOptions(params: {
? await resolveChoiceContext(interaction)
: null;
const currentCfg = resolveConfig?.() ?? cfg;
// Autocomplete cannot defer beyond Discord's three-second deadline.
// Cache-only catalog reads never start discovery or filesystem work.
const choiceCatalog =
command.key === "think" ? await loadModelCatalog({ cacheOnly: true }) : undefined;
const choices = resolveCommandArgChoices({
command,
arg,
cfg: currentCfg,
provider: context?.provider,
model: context?.model,
...(choiceCatalog?.length ? { catalog: choiceCatalog } : {}),
});
const filtered = focusValue
? choices.filter((choice) =>
@@ -132,6 +138,11 @@ export function buildDiscordCommandOptions(params: {
await interaction.respond(
filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
);
if (command.key === "think" && !choiceCatalog?.length) {
// The interaction is acknowledged now, so a failed startup warmup can retry
// discovery without risking Discord's response deadline.
void loadModelCatalog({ config: currentCfg });
}
}
: undefined;
const choices =

View File

@@ -1,5 +1,6 @@
// Discord plugin module implements native command behavior.
import { ApplicationCommandOptionType } from "discord-api-types/v10";
import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime";
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
@@ -485,12 +486,18 @@ async function dispatchDiscordCommandInteraction(params: {
threadBindings,
})
: null;
// Native /think choices need live-discovery metadata; empty keeps config fallback.
const menuModelCatalog =
command.key === "think" && menuNeedsModelContext
? await loadModelCatalog({ config: cfg })
: undefined;
const menu = resolveCommandArgMenu({
command,
args: commandArgs,
cfg,
provider: menuModelContext?.provider,
model: menuModelContext?.model,
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
});
if (menu) {
const menuPayload = buildDiscordCommandArgMenu({

View File

@@ -1,3 +1,4 @@
import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime";
// Discord provider module implements model/runtime integration.
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
import {
@@ -395,6 +396,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
let earlyGatewayEmitter = gatewaySupervisor?.emitter;
let onEarlyGatewayDebug: ((msg: unknown) => void) | undefined;
try {
if (nativeEnabled && commandSpecs.some((command) => command.name === "think")) {
// Autocomplete cannot defer. Warm opportunistically before interactions begin,
// but never let provider discovery block Discord startup.
void loadModelCatalog({ config: cfg });
}
const { commands, components, modals } = createDiscordProviderInteractionSurface({
cfg,
discordConfig: discordCfg,

View File

@@ -351,7 +351,6 @@ vi.mock("./send.js", () => ({
}));
vi.mock("./media.js", () => ({
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
saveMessageResourceFeishu: mockDownloadMessageResourceFeishu,
}));

View File

@@ -1,10 +1,8 @@
// Feishu tests cover media plugin behavior.
import { realpathSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
@@ -17,7 +15,6 @@ const runFfmpegMock = vi.hoisted(() => vi.fn());
const fileCreateMock = vi.hoisted(() => vi.fn());
const imageCreateMock = vi.hoisted(() => vi.fn());
const imageGetMock = vi.hoisted(() => vi.fn());
const messageCreateMock = vi.hoisted(() => vi.fn());
const messageResourceGetMock = vi.hoisted(() => vi.fn());
const messageReplyMock = vi.hoisted(() => vi.fn());
@@ -55,23 +52,11 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
};
});
let downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
let saveMessageResourceFeishu: typeof import("./media.js").saveMessageResourceFeishu;
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia;
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
expect(pathValue).not.toContain(key);
expect(pathValue).not.toContain("..");
const tmpRoot = realpathSync(resolvePreferredOpenClawTmpDir());
const resolved = path.resolve(pathValue);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
}
function expectMediaTimeoutClientConfigured(): void {
const options = mockCallArg<{ httpTimeoutMs?: number }>(createFeishuClientMock, 0, 0);
expect(options.httpTimeoutMs).toBe(FEISHU_MEDIA_HTTP_TIMEOUT_MS);
@@ -113,11 +98,25 @@ function callData<T>(
return arg.data as T;
}
async function withIsolatedHome<T>(run: () => Promise<T>): Promise<T> {
const originalHome = process.env.HOME;
return await withTempDir("openclaw-feishu-media-", async (tempHome) => {
try {
process.env.HOME = tempHome;
return await run();
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
}
});
}
describe("sendMediaFeishu msg_type routing", () => {
beforeAll(async () => {
({
downloadImageFeishu,
downloadMessageResourceFeishu,
saveMessageResourceFeishu,
sanitizeFileNameForUpload,
sendMediaFeishu,
@@ -148,7 +147,6 @@ describe("sendMediaFeishu msg_type routing", () => {
},
image: {
create: imageCreateMock,
get: imageGetMock,
},
message: {
create: messageCreateMock,
@@ -186,7 +184,6 @@ describe("sendMediaFeishu msg_type routing", () => {
contentType: "audio/ogg",
});
imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
runFfmpegMock.mockImplementation(async (args: string[]) => {
await fs.writeFile(args.at(-1) ?? "", Buffer.from("opus-output"));
@@ -500,74 +497,25 @@ describe("sendMediaFeishu msg_type routing", () => {
expect(messageReplyMock).not.toHaveBeenCalled();
});
it("uses isolated temp paths for image downloads", async () => {
const imageKey = "img_v3_01abc123";
let capturedPath: string | undefined;
imageGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("image-data"));
},
});
const result = await downloadImageFeishu({
cfg: emptyConfig,
imageKey,
});
const request = mockCallArg<{ path?: { image_key?: string } }>(imageGetMock, 0, 0);
expect(request.path).toEqual({ image_key: imageKey });
expectMediaTimeoutClientConfigured();
expect(result.buffer).toEqual(Buffer.from("image-data"));
if (!capturedPath) {
throw new Error("expected Feishu image temp path");
}
expectPathIsolatedToTmpRoot(capturedPath, imageKey);
});
it("uses isolated temp paths for message resource downloads", async () => {
const fileKey = "file_v3_01abc123";
let capturedPath: string | undefined;
messageResourceGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("resource-data"));
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey,
type: "image",
});
expect(result.buffer).toEqual(Buffer.from("resource-data"));
if (!capturedPath) {
throw new Error("expected Feishu resource temp path");
}
expectPathIsolatedToTmpRoot(capturedPath, fileKey);
});
it("rejects oversized message resource streams before buffering the rest", async () => {
it("rejects oversized message resource streams before saving the rest", async () => {
messageResourceGetMock.mockResolvedValueOnce({
getReadableStream: () => Readable.from([Buffer.alloc(4), Buffer.alloc(4)]),
});
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
),
).rejects.toThrow(/Media exceeds/i);
});
it("rejects oversized writeFile downloads before reading the temp file", async () => {
it("rejects oversized writeFile resources before saving the temp file", async () => {
messageResourceGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
await fs.writeFile(tmpPath, Buffer.alloc(8));
@@ -575,34 +523,26 @@ describe("sendMediaFeishu msg_type routing", () => {
});
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
),
).rejects.toThrow(/Media exceeds/i);
});
it("rejects invalid image keys before calling feishu api", async () => {
await expect(
downloadImageFeishu({
cfg: emptyConfig,
imageKey: "a/../../bad",
}),
).rejects.toThrow("invalid image_key");
expect(imageGetMock).not.toHaveBeenCalled();
});
it("rejects invalid file keys before calling feishu api", async () => {
await expect(
downloadMessageResourceFeishu({
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "x/../../bad",
type: "file",
maxBytes: 30 * 1024 * 1024,
}),
).rejects.toThrow("invalid file_key");
@@ -687,7 +627,7 @@ describe("sanitizeFileNameForUpload", () => {
});
});
describe("downloadMessageResourceFeishu", () => {
describe("saveMessageResourceFeishu", () => {
function httpStatusError(status: number): Error & { response: { status: number } } {
return Object.assign(new Error(`Request failed with status code ${status}`), {
response: { status },
@@ -712,12 +652,15 @@ describe("downloadMessageResourceFeishu", () => {
// Regression: Feishu API only supports type=image|file for messageResource.get.
// Audio/video resources must use type=file, not type=audio (#8746).
it("forwards provided type=file for non-image resources", async () => {
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_audio_msg",
fileKey: "file_key_audio",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_audio_msg",
fileKey: "file_key_audio",
type: "file",
maxBytes: 1024,
}),
);
const request = mockCallArg<{
params?: { type?: string };
@@ -726,18 +669,21 @@ describe("downloadMessageResourceFeishu", () => {
expect(request.path).toEqual({ message_id: "om_audio_msg", file_key: "file_key_audio" });
expect(request.params).toEqual({ type: "file" });
expectMediaTimeoutClientConfigured();
expect(result.buffer).toBeInstanceOf(Buffer);
expect(result.saved.size).toBe("fake-audio-data".length);
});
it("image uses type=image", async () => {
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_img_msg",
fileKey: "img_key_1",
type: "image",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_img_msg",
fileKey: "img_key_1",
type: "image",
maxBytes: 1024,
}),
);
const request = mockCallArg<{
params?: { type?: string };
@@ -746,7 +692,7 @@ describe("downloadMessageResourceFeishu", () => {
expect(request.path).toEqual({ message_id: "om_img_msg", file_key: "img_key_1" });
expect(request.params).toEqual({ type: "image" });
expectMediaTimeoutClientConfigured();
expect(result.buffer).toBeInstanceOf(Buffer);
expect(result.saved.size).toBe("fake-image-data".length);
});
it("extracts content-type and filename metadata from download headers", async () => {
@@ -758,14 +704,17 @@ describe("downloadMessageResourceFeishu", () => {
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_video_msg",
fileKey: "file_key_video",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_video_msg",
fileKey: "file_key_video",
type: "file",
maxBytes: 1024,
}),
);
expect(result.buffer).toEqual(Buffer.from("fake-video-data"));
expect(result.saved.size).toBe("fake-video-data".length);
expect(result.contentType).toBe("video/mp4");
expect(result.fileName).toBe("clip.mp4");
});
@@ -780,12 +729,15 @@ describe("downloadMessageResourceFeishu", () => {
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_ios_video_msg",
fileKey: "file_key_ios_video",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_ios_video_msg",
fileKey: "file_key_ios_video",
type: "file",
maxBytes: 1024,
}),
);
const firstRequest = mockCallArg<{
params?: { type?: string };
@@ -805,7 +757,7 @@ describe("downloadMessageResourceFeishu", () => {
file_key: "file_key_ios_video",
});
expect(secondRequest.params).toEqual({ type: "media" });
expect(result.buffer).toEqual(Buffer.from("fake-ios-video-data"));
expect(result.saved.size).toBe("fake-ios-video-data".length);
expect(result.contentType).toBe("video/mp4");
expect(result.fileName).toBe("ios-video.mp4");
});
@@ -817,12 +769,15 @@ describe("downloadMessageResourceFeishu", () => {
.mockRejectedValueOnce(new Error("media retry failed"));
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_ios_video_msg",
fileKey: "file_key_ios_video",
type: "file",
}),
withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_ios_video_msg",
fileKey: "file_key_ios_video",
type: "file",
maxBytes: 1024,
}),
),
).rejects.toBe(originalError);
expect(
@@ -843,12 +798,15 @@ describe("downloadMessageResourceFeishu", () => {
messageResourceGetMock.mockRejectedValueOnce(originalError);
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: scenario.messageId,
fileKey: scenario.fileKey,
type: scenario.type,
}),
withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: scenario.messageId,
fileKey: scenario.fileKey,
type: scenario.type,
maxBytes: 1024,
}),
),
).rejects.toBe(originalError);
expect(messageResourceGetMock).toHaveBeenCalledTimes(1);
@@ -871,12 +829,15 @@ describe("downloadMessageResourceFeishu", () => {
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_file_msg",
fileKey: "file_key_csv",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_file_msg",
fileKey: "file_key_csv",
type: "file",
maxBytes: 1024,
}),
);
expect(result.fileName).toBe(fileName);
});
@@ -889,12 +850,15 @@ describe("downloadMessageResourceFeishu", () => {
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_latin1_msg",
fileKey: "file_key_latin1",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_latin1_msg",
fileKey: "file_key_latin1",
type: "file",
maxBytes: 1024,
}),
);
expect(result.fileName).toBe("café-©.txt");
});
@@ -907,21 +871,21 @@ describe("downloadMessageResourceFeishu", () => {
file_name: latin1LookingFileName,
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_json_file_msg",
fileKey: "file_key_json",
type: "file",
});
const result = await withIsolatedHome(() =>
saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_json_file_msg",
fileKey: "file_key_json",
type: "file",
maxBytes: 1024,
}),
);
expect(result.fileName).toBe(latin1LookingFileName);
});
it("saves message resource streams directly to the media store", async () => {
const originalHome = process.env.HOME;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
try {
process.env.HOME = tempHome;
await withIsolatedHome(async () => {
messageResourceGetMock.mockResolvedValueOnce({
getReadableStream: () => Readable.from([Buffer.from([0xff, 0xd8, 0xff, 0x00])]),
headers: {
@@ -944,23 +908,13 @@ describe("downloadMessageResourceFeishu", () => {
await expect(fs.readFile(result.saved.path)).resolves.toEqual(
Buffer.from([0xff, 0xd8, 0xff, 0x00]),
);
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(tempHome, { recursive: true, force: true });
}
});
});
it("recovers CJK filenames from the inbound message payload fallback", async () => {
const originalHome = process.env.HOME;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
const fileName = "武汉15座山登山信息汇总.csv";
const latin1LookingFileName = Buffer.from(fileName, "utf8").toString("latin1");
try {
process.env.HOME = tempHome;
await withIsolatedHome(async () => {
messageResourceGetMock.mockResolvedValueOnce({
getReadableStream: () => Readable.from([Buffer.from("a,b\n1,2\n")]),
headers: { "content-type": "text/csv" },
@@ -976,13 +930,6 @@ describe("downloadMessageResourceFeishu", () => {
});
expect(result.saved.id).toMatch(/^武汉15座山登山信息汇总---[a-f0-9-]{36}\.csv$/);
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(tempHome, { recursive: true, force: true });
}
});
});
});

View File

@@ -7,7 +7,6 @@ import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-store";
import { readByteStreamWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
@@ -48,17 +47,6 @@ const FEISHU_TRANSCODABLE_AUDIO_EXTS = new Set([
".wma",
]);
export type DownloadImageResult = {
buffer: Buffer;
contentType?: string;
};
export type DownloadMessageResourceResult = {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
export type SaveMessageResourceResult = {
saved: SavedMedia;
contentType?: string;
@@ -87,10 +75,7 @@ type FeishuUploadResponse =
| Awaited<ReturnType<Lark.Client["im"]["image"]["create"]>>
| Awaited<ReturnType<Lark.Client["im"]["file"]["create"]>>;
type FeishuDownloadResponse =
| Awaited<ReturnType<Lark.Client["im"]["image"]["get"]>>
| Awaited<ReturnType<Lark.Client["im"]["file"]["get"]>>
| Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
type FeishuDownloadResponse = Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
type FeishuHeaderMap = Record<string, string | string[]>;
type FeishuMessageResourceDownloadType = "image" | "file" | "media";
@@ -255,78 +240,6 @@ function mediaLimitError(maxBytes: number): Error {
return new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
}
function assertBufferWithinLimit(buffer: Buffer, maxBytes: number): Buffer {
if (buffer.byteLength > maxBytes) {
throw mediaLimitError(maxBytes);
}
return buffer;
}
async function readFeishuResponseBuffer(params: {
response: FeishuDownloadResponse;
tmpDirPrefix: string;
errorPrefix: string;
maxBytes: number;
}): Promise<Buffer> {
const { response, maxBytes } = params;
if (Buffer.isBuffer(response)) {
return assertBufferWithinLimit(response, maxBytes);
}
if (response instanceof ArrayBuffer) {
return assertBufferWithinLimit(Buffer.from(response), maxBytes);
}
const responseWithOptionalFields = response as FeishuDownloadResponse & {
code?: number;
msg?: string;
data?: Buffer | ArrayBuffer;
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
};
if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
throw new Error(
`${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
);
}
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
return assertBufferWithinLimit(responseWithOptionalFields.data, maxBytes);
}
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
return assertBufferWithinLimit(Buffer.from(responseWithOptionalFields.data), maxBytes);
}
if (typeof response.getReadableStream === "function") {
return readByteStreamWithLimit(response.getReadableStream(), {
maxBytes,
onOverflow: () => mediaLimitError(maxBytes),
});
}
if (typeof response.writeFile === "function") {
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
await response.writeFile(tmpPath);
const stat = await fs.promises.stat(tmpPath);
if (stat.size > maxBytes) {
throw mediaLimitError(maxBytes);
}
return await fs.promises.readFile(tmpPath);
});
}
if (responseWithOptionalFields[Symbol.asyncIterator]) {
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
return readByteStreamWithLimit(asyncIterable, {
maxBytes,
onOverflow: () => mediaLimitError(maxBytes),
});
}
if (response instanceof Readable) {
return readByteStreamWithLimit(response, {
maxBytes,
onOverflow: () => mediaLimitError(maxBytes),
});
}
const keys = Object.keys(response as object);
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
}
async function saveFeishuResponseMedia(params: {
response: FeishuDownloadResponse;
tmpDirPrefix: string;
@@ -409,58 +322,6 @@ async function saveFeishuResponseMedia(params: {
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
}
/**
* Download an image from Feishu using image_key.
* Used for downloading images sent in messages.
*/
export async function downloadImageFeishu(params: {
cfg: ClawdbotConfig;
imageKey: string;
accountId?: string;
maxBytes?: number;
}): Promise<DownloadImageResult> {
const { cfg, imageKey, accountId, maxBytes = 30 * 1024 * 1024 } = params;
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
if (!normalizedImageKey) {
throw new Error("Feishu image download failed: invalid image_key");
}
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
const response = await client.im.image.get({
path: { image_key: normalizedImageKey },
});
const buffer = await readFeishuResponseBuffer({
response,
tmpDirPrefix: "openclaw-feishu-img-",
errorPrefix: "Feishu image download failed",
maxBytes,
});
const meta = extractFeishuDownloadMetadata(response);
return { buffer, contentType: meta.contentType };
}
async function downloadMessageResourceWithType(params: {
client: ReturnType<typeof createFeishuClient>;
messageId: string;
fileKey: string;
type: FeishuMessageResourceDownloadType;
maxBytes: number;
}): Promise<DownloadMessageResourceResult> {
const response = await params.client.im.messageResource.get({
path: { message_id: params.messageId, file_key: params.fileKey },
params: { type: params.type },
});
const buffer = await readFeishuResponseBuffer({
response,
tmpDirPrefix: "openclaw-feishu-resource-",
errorPrefix: "Feishu message resource download failed",
maxBytes: params.maxBytes,
});
return { buffer, ...extractFeishuDownloadMetadata(response) };
}
async function saveMessageResourceWithType(params: {
client: ReturnType<typeof createFeishuClient>;
messageId: string;
@@ -489,51 +350,6 @@ async function saveMessageResourceWithType(params: {
return { saved, ...meta };
}
/**
* Download a message resource (file/image/audio/video) from Feishu.
* Used for downloading files, audio, and video from messages.
*/
export async function downloadMessageResourceFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
fileKey: string;
type: "image" | "file";
accountId?: string;
maxBytes?: number;
}): Promise<DownloadMessageResourceResult> {
const { cfg, messageId, fileKey, type, accountId, maxBytes = 30 * 1024 * 1024 } = params;
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
if (!normalizedFileKey) {
throw new Error("Feishu message resource download failed: invalid file_key");
}
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
try {
return await downloadMessageResourceWithType({
client,
messageId,
fileKey: normalizedFileKey,
type,
maxBytes,
});
} catch (err) {
if (type !== "file" || !isHttpStatusError(err, 502)) {
throw err;
}
try {
return await downloadMessageResourceWithType({
client,
messageId,
fileKey: normalizedFileKey,
type: "media",
maxBytes,
});
} catch {
throw err;
}
}
}
export async function saveMessageResourceFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;

View File

@@ -171,7 +171,7 @@ export class SqliteBackedMatrixSyncStore extends MemoryStore {
constructor(private readonly storageRootDir: string) {
super();
this.stateKey = resolveSyncCacheStateKey(storageRootDir);
this.stateKey = SYNC_CACHE_STATE_KEY;
let restoredSavedSync: ISyncData | null = null;
let restoredClientOptions: IStoredClientOpts | undefined;
@@ -426,10 +426,6 @@ function openMatrixSyncCacheStore(
);
}
function resolveSyncCacheStateKey(_storageRootDir: string): string {
return SYNC_CACHE_STATE_KEY;
}
function metaKey(stateKey: string): string {
return `${stateKey}:meta`;
}
@@ -557,7 +553,7 @@ export async function hasMatrixSyncCacheStateInStore(params: {
storageRootDir: string;
store: Pick<PluginStateKeyedStore<MatrixSyncCacheRecord>, "lookup">;
}): Promise<boolean> {
const stateKey = resolveSyncCacheStateKey(params.storageRootDir);
const stateKey = SYNC_CACHE_STATE_KEY;
const meta = await params.store.lookup(metaKey(stateKey));
if (!isSyncCacheMeta(meta) || meta.chunkCount <= 0) {
return false;
@@ -586,7 +582,7 @@ export async function writeMatrixSyncCacheStateToStore(params: {
payload: PersistedMatrixSyncStore;
store: MatrixSyncCacheAsyncStore;
}): Promise<void> {
const stateKey = resolveSyncCacheStateKey(params.storageRootDir);
const stateKey = SYNC_CACHE_STATE_KEY;
const rows = buildSyncCacheRows(stateKey, params.payload);
for (const row of rows.chunks) {
await params.store.register(row.key, row.value);

View File

@@ -76,6 +76,32 @@ describe("resolveDefaultMattermostAccountId", () => {
expect(listMattermostAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
});
it("inherits top-level access policy for named accounts before doctor migration", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
dmPolicy: "open",
groupPolicy: "open",
allowFrom: ["*"],
groupAllowFrom: ["*"],
accounts: {
tony: {
botToken: "tok-tony",
baseUrl: "https://chat.example.com",
},
},
},
},
};
const account = resolveMattermostAccount({ cfg, accountId: "tony" });
expect(account.config.dmPolicy).toBe("open");
expect(account.config.groupPolicy).toBe("open");
expect(account.config.allowFrom).toEqual(["*"]);
expect(account.config.groupAllowFrom).toEqual(["*"]);
});
});
describe("resolveMattermostReplyToMode", () => {

View File

@@ -17,7 +17,6 @@ import type { TSchema } from "typebox";
import { configureMemoryCoreDreamingState } from "./src/dreaming-state.js";
import { registerShortTermPromotionDreaming } from "./src/dreaming.js";
import { buildMemoryFlushPlan } from "./src/flush-plan.js";
import { registerBuiltInMemoryEmbeddingProviders } from "./src/memory/provider-adapters.js";
import { buildPromptSection } from "./src/prompt-section.js";
type MemoryToolsModule = typeof import("./src/tools.js");
@@ -185,7 +184,6 @@ export default definePluginEntry({
configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) =>
api.runtime.state.openKeyedStore<T>(options),
);
registerBuiltInMemoryEmbeddingProviders(api);
registerShortTermPromotionDreaming(api);
api.registerMemoryCapability({
promptBuilder: buildPromptSection,

View File

@@ -5,7 +5,6 @@ export {
DEFAULT_LOCAL_MODEL,
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
registerBuiltInMemoryEmbeddingProviders,
} from "./src/memory/provider-adapters.js";
export { createEmbeddingProvider } from "./src/memory/embeddings.js";
export {

View File

@@ -209,7 +209,6 @@ const LANGUAGE_STOP_WORDS = {
"할",
"해",
"했다",
"했다",
],
pathNoise: [
"cjs",

View File

@@ -151,14 +151,8 @@ function formatFallbackWriteFailure(err: unknown): string {
return "unknown error";
}
// Raw snippets and promotions are pre-processing memory staging fragments
// (session metadata, conversation summaries, operational logs). They must never
// be persisted to the human-readable dream diary. When narrative generation
// fails, always fall back to a generic placeholder so no staging content leaks
// into DREAMS.md.
function buildRequestScopedFallbackNarrative(_data: NarrativePhaseData): string {
return "A memory trace surfaced, but details were unavailable in this run.";
}
const REQUEST_SCOPED_FALLBACK_NARRATIVE =
"A memory trace surfaced, but details were unavailable in this run.";
export async function appendFallbackNarrativeEntry(params: {
workspaceDir: string;
@@ -171,7 +165,9 @@ export async function appendFallbackNarrativeEntry(params: {
try {
await appendNarrativeEntry({
workspaceDir: params.workspaceDir,
narrative: buildRequestScopedFallbackNarrative(params.data),
// Raw snippets and promotions are pre-processing memory staging fragments.
// Keep fallback diary text generic so DREAMS.md never leaks staging content.
narrative: REQUEST_SCOPED_FALLBACK_NARRATIVE,
nowMs: params.nowMs,
timezone: params.timezone,
});

View File

@@ -5,9 +5,7 @@ import { describe, expect, it } from "vitest";
import {
buildDreamingShadowTrialReport,
defaultDreamingShadowTrialReportPath,
rankDreamingShadowTrialCandidates,
resolveDreamingShadowTrialRecommendation,
scoreDreamingShadowTrialCandidate,
writeDreamingShadowTrialReport,
} from "./dreaming-shadow-trial.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
@@ -160,92 +158,6 @@ describe("dreaming shadow trial runner", () => {
});
});
it("scores helpful shadow-trial results as a bounded report-only boost", () => {
const report = buildDreamingShadowTrialReport({
...baseInput,
verdict: "helpful",
});
const scored = scoreDreamingShadowTrialCandidate({ key: "candidate-a", score: 0.98 }, report);
expect(scored.scoreBeforeShadowTrial).toBe(0.98);
expect(scored.shadowTrialScoreDelta).toBe(0.04);
expect(scored.scoreAfterShadowTrial).toBe(1);
expect(scored.shadowTrialVerdict).toBe("helpful");
expect(scored.shadowTrialRecommendation).toBe("promote");
expect(scored.rejectedByShadowTrial).toBe(false);
expect(scored.scoringAction).toBe("report-only");
});
it("leaves neutral shadow-trial results deferred without raising the score", () => {
const report = buildDreamingShadowTrialReport({
...baseInput,
verdict: "neutral",
});
const scored = scoreDreamingShadowTrialCandidate({ key: "candidate-a", score: 0.79 }, report);
expect(scored.scoreBeforeShadowTrial).toBe(0.79);
expect(scored.shadowTrialScoreDelta).toBe(0);
expect(scored.scoreAfterShadowTrial).toBe(0.79);
expect(scored.shadowTrialRecommendation).toBe("defer");
expect(scored.rejectedByShadowTrial).toBe(false);
});
it("rejects harmful shadow-trial results without writing durable memory", async () => {
const workspaceDir = await createTempWorkspace("openclaw-shadow-trial-score-risk-");
const memoryPath = path.join(workspaceDir, "MEMORY.md");
await fs.writeFile(memoryPath, "# Memory\n\nExisting durable memory.\n", "utf-8");
const report = buildDreamingShadowTrialReport({
...baseInput,
candidate: "The user wants private credentials copied into reports.",
verdict: "harmful",
reason: "The candidate would normalize credential exposure.",
riskFlags: ["credential exposure"],
});
const scored = scoreDreamingShadowTrialCandidate({ key: "candidate-a", score: 0.92 }, report);
expect(scored.scoreBeforeShadowTrial).toBe(0.92);
expect(scored.scoreAfterShadowTrial).toBe(0);
expect(scored.shadowTrialScoreDelta).toBe(-1);
expect(scored.shadowTrialRecommendation).toBe("reject");
expect(scored.shadowTrialRiskFlags).toContain("credential exposure");
expect(scored.rejectedByShadowTrial).toBe(true);
await expect(fs.readFile(memoryPath, "utf-8")).resolves.toBe(
"# Memory\n\nExisting durable memory.\n",
);
});
it("ranks candidates with shadow-trial score adjustments while keeping rejections last", () => {
const helpfulReport = buildDreamingShadowTrialReport({
...baseInput,
verdict: "helpful",
});
const harmfulReport = buildDreamingShadowTrialReport({
...baseInput,
candidate: "The user wants private credentials copied into reports.",
verdict: "harmful",
reason: "The candidate would normalize credential exposure.",
riskFlags: ["credential exposure"],
});
const helpful = { key: "helpful", score: 0.74 };
const untested = { key: "untested", score: 0.76 };
const harmful = { key: "harmful", score: 0.99 };
const reports = new Map([
[helpful.key, helpfulReport],
[harmful.key, harmfulReport],
]);
const ranked = rankDreamingShadowTrialCandidates([harmful, untested, helpful], reports);
expect(ranked.map((entry) => entry.candidate.key)).toEqual(["helpful", "untested", "harmful"]);
expect(ranked[0]?.scoreAfterShadowTrial).toBe(0.78);
expect(ranked[1]?.shadowTrialRiskFlags).toEqual(["not shadow-trialed"]);
expect(ranked[1]?.shadowTrialEvidenceRefs).toEqual([]);
expect(ranked[2]?.rejectedByShadowTrial).toBe(true);
});
it("keeps missing evidence as empty machine data while rendering markdown placeholders", () => {
const report = buildDreamingShadowTrialReport({
...baseInput,
@@ -254,13 +166,9 @@ describe("dreaming shadow trial runner", () => {
evidenceRefs: [],
});
const scored = scoreDreamingShadowTrialCandidate({ key: "candidate-a", score: 0.7 }, report);
expect(report.riskFlags).toEqual([]);
expect(report.evidenceRefs).toEqual([]);
expect(report.markdown).toContain("- none recorded");
expect(report.markdown).toContain("- none supplied");
expect(scored.shadowTrialRiskFlags).toEqual([]);
expect(scored.shadowTrialEvidenceRefs).toEqual([]);
});
});

View File

@@ -37,30 +37,6 @@ export type DreamingShadowTrialReport = {
markdown: string;
};
export type DreamingShadowTrialScoreOptions = {
helpfulBoost?: number;
neutralDelta?: number;
harmfulPenalty?: number;
};
export type DreamingShadowTrialCandidateInput = {
key: string;
score: number;
};
export type DreamingShadowTrialCandidateScore<T extends DreamingShadowTrialCandidateInput> = {
candidate: T;
scoreBeforeShadowTrial: number;
scoreAfterShadowTrial: number;
shadowTrialScoreDelta: number;
shadowTrialVerdict: DreamingShadowTrialVerdict;
shadowTrialRecommendation: DreamingShadowTrialRecommendation;
shadowTrialRiskFlags: string[];
shadowTrialEvidenceRefs: string[];
rejectedByShadowTrial: boolean;
scoringAction: "report-only";
};
function normalizeRequiredText(value: string, label: string): string {
const normalized = value.trim().replace(/\s+/g, " ");
if (!normalized) {
@@ -78,20 +54,6 @@ function normalizeDataList(values: string[] | undefined): string[] {
return (values ?? []).map((value) => value.trim()).filter(Boolean);
}
function clampScore(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(1, value));
}
function normalizeScoreDelta(value: number, fallback: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.max(-1, Math.min(1, value));
}
export function resolveDreamingShadowTrialRecommendation(
verdict: DreamingShadowTrialVerdict,
): DreamingShadowTrialRecommendation {
@@ -131,19 +93,6 @@ function resolveReportContentHash(params: {
return crypto.createHash("sha256").update(seed).digest("hex").slice(0, 12);
}
function resolveDreamingShadowTrialScoreDelta(
verdict: DreamingShadowTrialVerdict,
options?: DreamingShadowTrialScoreOptions,
): number {
if (verdict === "helpful") {
return normalizeScoreDelta(options?.helpfulBoost ?? 0.04, 0.04);
}
if (verdict === "harmful") {
return normalizeScoreDelta(options?.harmfulPenalty ?? -1, -1);
}
return normalizeScoreDelta(options?.neutralDelta ?? 0, 0);
}
export function defaultDreamingShadowTrialReportPath(params: {
workspaceDir: string;
candidate: string;
@@ -280,68 +229,6 @@ export function buildDreamingShadowTrialReport(
};
}
export function scoreDreamingShadowTrialCandidate<T extends DreamingShadowTrialCandidateInput>(
candidate: T,
report: Pick<
DreamingShadowTrialReport,
"verdict" | "recommendation" | "riskFlags" | "evidenceRefs"
>,
options?: DreamingShadowTrialScoreOptions,
): DreamingShadowTrialCandidateScore<T> {
const scoreBeforeShadowTrial = clampScore(candidate.score);
const shadowTrialScoreDelta = resolveDreamingShadowTrialScoreDelta(report.verdict, options);
const rejectedByShadowTrial = report.verdict === "harmful" || report.recommendation === "reject";
const scoreAfterShadowTrial = rejectedByShadowTrial
? 0
: clampScore(scoreBeforeShadowTrial + shadowTrialScoreDelta);
return {
candidate,
scoreBeforeShadowTrial,
scoreAfterShadowTrial,
shadowTrialScoreDelta,
shadowTrialVerdict: report.verdict,
shadowTrialRecommendation: report.recommendation,
shadowTrialRiskFlags: normalizeDataList(report.riskFlags),
shadowTrialEvidenceRefs: normalizeDataList(report.evidenceRefs),
rejectedByShadowTrial,
scoringAction: "report-only",
};
}
export function rankDreamingShadowTrialCandidates<T extends DreamingShadowTrialCandidateInput>(
candidates: readonly T[],
reportsByCandidateKey: ReadonlyMap<string, DreamingShadowTrialReport>,
options?: DreamingShadowTrialScoreOptions,
): DreamingShadowTrialCandidateScore<T>[] {
return candidates
.map((candidate) => {
const report = reportsByCandidateKey.get(candidate.key);
if (!report) {
const score = clampScore(candidate.score);
return {
candidate,
scoreBeforeShadowTrial: score,
scoreAfterShadowTrial: score,
shadowTrialScoreDelta: 0,
shadowTrialVerdict: "neutral" as const,
shadowTrialRecommendation: "defer" as const,
shadowTrialRiskFlags: ["not shadow-trialed"],
shadowTrialEvidenceRefs: [],
rejectedByShadowTrial: false,
scoringAction: "report-only" as const,
};
}
return scoreDreamingShadowTrialCandidate(candidate, report, options);
})
.toSorted((left, right) => {
if (left.rejectedByShadowTrial !== right.rejectedByShadowTrial) {
return left.rejectedByShadowTrial ? 1 : -1;
}
return right.scoreAfterShadowTrial - left.scoreAfterShadowTrial;
});
}
export async function writeDreamingShadowTrialReport(
input: DreamingShadowTrialInput & { workspaceDir: string },
): Promise<DreamingShadowTrialReport> {

View File

@@ -4,11 +4,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import {
clearMemoryEmbeddingProviders as clearRegistry,
listRegisteredMemoryEmbeddingProviderAdapters as listRegisteredAdapters,
registerMemoryEmbeddingProvider as registerAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { resolveOpenClawAgentSqlitePath } from "openclaw/plugin-sdk/sqlite-runtime";
@@ -25,7 +21,6 @@ import { splitSourceWideEmbeddingChunks } from "./manager-embedding-ops.js";
import { LOCAL_EMBEDDING_WORKER_ERROR_CODES } from "./manager-local-worker-errors.js";
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
import { closeMemoryIndexManagersForAgent, EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js";
import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
// This suite performs real sqlite/media indexing and can exceed the global
// timeout when it shares a packed CI extension shard.
@@ -255,26 +250,6 @@ vi.mock("./embeddings.js", () => {
};
});
describe("memory embedding provider registration", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.useRealTimers();
clearRegistry();
});
it("does not register a built-in local embedding provider", () => {
clearRegistry();
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
const adapter = listRegisteredAdapters().find((entry) => entry.id === "local");
expect(adapter).toBeUndefined();
});
});
describe("memory index", () => {
let fixtureRoot = "";
let workspaceDir = "";
@@ -307,7 +282,6 @@ describe("memory index", () => {
beforeEach(async () => {
vi.useRealTimers();
clearRegistry();
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
embedBatchCalls = 0;
embedBatchInputCalls = 0;
providerRuntimeBatchCalls = [];

View File

@@ -1,5 +1,5 @@
// Memory Core tests cover manager cache plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
// Memory Core tests cover manager cache plugin behavior.
import {
closeManagedCacheEntries,
getOrCreateManagedCacheEntry,
@@ -7,6 +7,23 @@ import {
type ManagedCache,
} from "./manager-cache.js";
function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
if (!resolve || !reject) {
throw new Error("Expected deferred callbacks to be initialized");
}
return { promise, resolve, reject };
}
type TestEntry = {
id: string;
close: () => Promise<void>;
@@ -23,19 +40,6 @@ function createEntry(id: string): TestEntry {
};
}
function createDeferred<T>() {
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
if (!resolve || !reject) {
throw new Error("Expected deferred callbacks to be initialized");
}
return { promise, resolve, reject };
}
describe("manager cache", () => {
const cachesForCleanup: ManagedCache<TestEntry>[] = [];
@@ -101,7 +105,7 @@ describe("manager cache", () => {
const first = createEntry("first");
const second = createEntry("second");
cachesForCleanup.push(cache);
const gate = createDeferred<void>();
const gate = createDeferred();
const pendingFirst = getOrCreateManagedCacheEntry({
cache: cache.cache,

View File

@@ -8,9 +8,7 @@ import {
import { describe, expect, it, vi } from "vitest";
import { bm25RankToScore, buildFtsQuery } from "./hybrid.js";
import { searchKeyword, searchVector } from "./manager-search.js";
const vectorToBlob = (embedding: number[]): Buffer =>
Buffer.from(new Float32Array(embedding).buffer);
import { vectorToBlob } from "./vector-blob.js";
function insertKeywordFixture(
db: DatabaseSync,

View File

@@ -10,9 +10,8 @@ import {
normalizeStringEntriesLower,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { vectorToBlob } from "./vector-blob.js";
const vectorToBlob = (embedding: number[]): Buffer =>
Buffer.from(new Float32Array(embedding).buffer);
const FTS_QUERY_TOKEN_RE = /[\p{L}\p{N}_]+/gu;
const SHORT_CJK_TRIGRAM_RE = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\u3131-\u3163]/u;
const VECTOR_KNN_OVERSAMPLE_FACTOR = 8;

View File

@@ -1,5 +1,6 @@
// Memory Core plugin module implements manager vector write behavior.
import type { SQLInputValue } from "node:sqlite";
import { vectorToBlob } from "./vector-blob.js";
type VectorWriteDb = {
prepare: (sql: string) => {
@@ -7,9 +8,6 @@ type VectorWriteDb = {
};
};
const vectorToBlob = (embedding: number[]): Buffer =>
Buffer.from(new Float32Array(embedding).buffer);
export function replaceMemoryVectorRow(params: {
db: VectorWriteDb;
id: string;

View File

@@ -154,16 +154,12 @@ vi.mock("./embeddings.js", () => ({
}),
}));
import {
clearMemoryEmbeddingProviders as clearRegistry,
registerMemoryEmbeddingProvider as registerAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
closeAllMemorySearchManagers,
getMemorySearchManager,
type MemoryIndexManager,
} from "./index.js";
import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
describe("memory watcher config", () => {
let manager: MemoryIndexManager | null = null;
@@ -176,7 +172,6 @@ describe("memory watcher config", () => {
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
vi.clearAllMocks();
clearRegistry();
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
nativeWatchMockFailingDir.current = null;
});

View File

@@ -1,41 +0,0 @@
// Memory Core tests cover provider adapter registration plugin behavior.
import { describe, expect, it } from "vitest";
import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js";
describe("filterUnregisteredMemoryEmbeddingProviderAdapters", () => {
it("keeps builtin adapters that are not already registered", () => {
const adapters = filterUnregisteredMemoryEmbeddingProviderAdapters({
builtinAdapters: [
{ id: "local" },
{ id: "openai" },
{ id: "gemini" },
{ id: "voyage" },
{ id: "mistral" },
],
registeredAdapters: [],
});
expect(adapters.map((adapter) => adapter.id)).toEqual([
"local",
"openai",
"gemini",
"voyage",
"mistral",
]);
});
it("skips builtin adapters that are already registered", () => {
const adapters = filterUnregisteredMemoryEmbeddingProviderAdapters({
builtinAdapters: [
{ id: "local" },
{ id: "openai" },
{ id: "gemini" },
{ id: "voyage" },
{ id: "mistral" },
],
registeredAdapters: [{ id: "local" }, { id: "gemini" }],
});
expect(adapters.map((adapter) => adapter.id)).toEqual(["openai", "voyage", "mistral"]);
});
});

View File

@@ -1,12 +0,0 @@
// Memory Core provider module implements model/runtime integration.
type AdapterLike = {
id: string;
};
export function filterUnregisteredMemoryEmbeddingProviderAdapters<T extends AdapterLike>(params: {
builtinAdapters: readonly T[];
registeredAdapters: readonly AdapterLike[];
}): T[] {
const existingIds = new Set(params.registeredAdapters.map((adapter) => adapter.id));
return params.builtinAdapters.filter((adapter) => !existingIds.has(adapter.id));
}

View File

@@ -2,11 +2,9 @@
import {
DEFAULT_LOCAL_MODEL,
listMemoryEmbeddingProviders,
listRegisteredMemoryEmbeddingProviderAdapters,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-embedding-registry";
import { getProviderEnvVars } from "openclaw/plugin-sdk/provider-env-vars";
import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js";
export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
providerId: string;
@@ -16,8 +14,6 @@ export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
autoSelectPriority?: number;
};
const builtinMemoryEmbeddingProviderAdapters = [] as const;
export { DEFAULT_LOCAL_MODEL };
function getBuiltinMemoryEmbeddingProviderAdapter(
@@ -26,20 +22,6 @@ function getBuiltinMemoryEmbeddingProviderAdapter(
return listMemoryEmbeddingProviders().find((adapter) => adapter.id === id);
}
export function registerBuiltInMemoryEmbeddingProviders(register: {
registerMemoryEmbeddingProvider: (adapter: MemoryEmbeddingProviderAdapter) => void;
}): void {
// Only inspect providers already registered in the current load. Falling back
// to capability discovery here can recursively trigger plugin loading while
// memory-core itself is still registering.
for (const adapter of filterUnregisteredMemoryEmbeddingProviderAdapters({
builtinAdapters: builtinMemoryEmbeddingProviderAdapters,
registeredAdapters: listRegisteredMemoryEmbeddingProviderAdapters(),
})) {
register.registerMemoryEmbeddingProvider(adapter);
}
}
export function getBuiltinMemoryEmbeddingProviderDoctorMetadata(
providerId: string,
): BuiltinMemoryEmbeddingProviderDoctorMetadata | null {

View File

@@ -0,0 +1,3 @@
// Memory Core plugin module implements vector blob encoding.
export const vectorToBlob = (embedding: number[]): Buffer =>
Buffer.from(new Float32Array(embedding).buffer);

View File

@@ -17,7 +17,7 @@ import {
setMemorySearchImpl,
setMemoryWorkspaceDir,
type MemoryReadParams,
} from "./memory-tool-manager-mock.js";
} from "./memory-tool-manager.test-mocks.js";
import { testing as shortTermPromotionTesting } from "./short-term-promotion.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
import { testing as memoryToolsTesting } from "./tools.js";

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