Compare commits

...

160 Commits

Author SHA1 Message Date
Tideclaw
24885a9bf5 test: allow qa parity release heap 2026-06-01 16:32:35 +00:00
Tideclaw
271ddaf115 ci: give qa runtime parity more build memory 2026-06-01 16:14:24 +00:00
Tideclaw
04b9f875dc test: finish bundled plugin config split 2026-06-01 15:39:12 +00:00
Tideclaw
8466ebbca4 test: keep bundled plugin probe lint-safe 2026-06-01 15:18:19 +00:00
Tideclaw
4affc5e6d3 test: stabilize alpha plugin prerelease sweep 2026-06-01 15:09:03 +00:00
Tideclaw
cea0bc4c27 chore: prepare alpha 2026.6.1-alpha.3 2026-06-01 14:25:44 +00:00
Tideclaw
00c53e7f10 fix: refresh alpha release generated metadata 2026-06-01 14:19:04 +00:00
Tideclaw
f5ac354b0f test: stabilize release docker e2e harnesses
(cherry picked from commit dfa0e9485c)
2026-06-01 14:14:26 +00:00
Tideclaw
0e8a7cc6cc test: align whatsapp envelope helper
(cherry picked from commit 8862bfba7c)
2026-06-01 14:14:26 +00:00
Tideclaw
b9a4f500d0 test: keep matrix async listener helpers lint-safe
(cherry picked from commit f7c78b382c)
2026-06-01 14:14:26 +00:00
Tideclaw
053f558e4f ci: honor matrix runners for dispatch checks
(cherry picked from commit 92d29a71a9)
2026-06-01 14:14:26 +00:00
Tideclaw
0716d87496 test: isolate heavy plugin batch tests
(cherry picked from commit 44ddefe84d)
2026-06-01 14:14:26 +00:00
Tideclaw
35d6f18446 test: keep matrix async listeners lint-safe
(cherry picked from commit b7a38bdd2f)
2026-06-01 14:14:26 +00:00
Tideclaw
e41df15a00 test: stabilize alpha plugin prerelease gates
(cherry picked from commit b25b6ce2ee)
2026-06-01 14:14:26 +00:00
Tideclaw
25843cf39a test: isolate install shell node floor check
(cherry picked from commit 598281eff5)
2026-06-01 14:14:26 +00:00
Tideclaw
88d3dd058d fix: satisfy deprecated jsdoc guard
(cherry picked from commit 139c8e9ab0)
2026-06-01 14:14:26 +00:00
Vincent Koc
9e58ef1c82 test(scripts): clean session log temp roots 2026-06-01 16:00:41 +02:00
Vincent Koc
eaeccf5fdf refactor: share node registry system run test helpers 2026-06-01 16:00:36 +02:00
Vincent Koc
2c0e835b48 test(codex): clean up fake timer spies 2026-06-01 14:57:47 +01:00
Vincent Koc
b942a958b3 test(qa): cover QA lab help runtime boundary 2026-06-01 15:54:16 +02:00
Vincent Koc
42bcf9cd0b fix(test): keep runtime tests raw-sync safe 2026-06-01 15:53:37 +02:00
Vincent Koc
a0fbb6cfe2 fix(test): keep app parity checks sparse safe 2026-06-01 15:53:37 +02:00
Vincent Koc
408fa6e951 fix(test): stabilize watch-node shutdown tests 2026-06-01 15:53:37 +02:00
Vincent Koc
671909d6d3 refactor: share server aux reload test helpers 2026-06-01 15:51:05 +02:00
Vincent Koc
409f78a1ea fix(e2e): clean OTEL collector startup failures 2026-06-01 15:46:02 +02:00
Vincent Koc
3e592a8bd7 refactor: share mcp http loopback test helpers 2026-06-01 15:39:28 +02:00
Vincent Koc
e895479a21 fix(ci): fail gateway watch spawn errors promptly 2026-06-01 15:38:16 +02:00
Peter Steinberger
930bc9691b fix(ci): page CI timing job reads 2026-06-01 14:33:39 +01:00
Vincent Koc
b9f181635f fix(ci): fail gateway CPU spawn errors 2026-06-01 15:27:13 +02:00
Vincent Koc
c2aaf8afec refactor: share sessions patch test helpers 2026-06-01 15:17:55 +02:00
Vincent Koc
cbc5f277bb refactor: share session reset hook test helpers 2026-06-01 15:11:10 +02:00
Vincent Koc
44b388f863 fix(e2e): keep kitchen-sink process snapshots wide 2026-06-01 15:09:33 +02:00
Vincent Koc
c0e49a2c52 fix(e2e): catch runtime package-manager descendants 2026-06-01 14:58:39 +02:00
Peter Steinberger
c1e132195d test(release): activate manifest channels in bundle smoke 2026-06-01 13:51:38 +01:00
Vincent Koc
5bd8dbd0b8 refactor: share system run approval test helpers 2026-06-01 14:44:46 +02:00
Vincent Koc
421ea1f458 fix(e2e): bound Parallels host VM commands 2026-06-01 14:41:46 +02:00
Vincent Koc
1f91e97353 refactor: share startup secrets test helpers 2026-06-01 14:31:58 +02:00
Vincent Koc
d4f6e0a1f2 fix(docs): clean link audit temp docs 2026-06-01 14:26:21 +02:00
Peter Steinberger
ec2455a842 test(memory): drive timeout tests with explicit fake clocks
(cherry picked from commit d75eea53c9)
2026-06-01 13:12:07 +01:00
Vincent Koc
1742f3f77c refactor: share mcp http test helpers 2026-06-01 14:10:41 +02:00
Vincent Koc
5117f457bb fix(ci): clean gateway watch temp home 2026-06-01 14:09:58 +02:00
Vincent Koc
8fe5e83462 refactor: share sessions list changed test helpers 2026-06-01 14:00:20 +02:00
Vincent Koc
27097bed65 fix(ci): bound deadcode knip scan 2026-06-01 13:57:16 +02:00
Vincent Koc
1849a86dd2 refactor: share session history revocation helpers 2026-06-01 13:47:39 +02:00
Vincent Koc
5280d1d95d fix(e2e): stream Parallels phase logs 2026-06-01 13:46:21 +02:00
Vincent Koc
bcdc93d651 refactor: share auth compat backend scope assertion 2026-06-01 13:31:03 +02:00
Vincent Koc
0751b6f2c9 fix(e2e): bound upgrade survivor config commands 2026-06-01 13:30:23 +02:00
Peter Steinberger
7d9fae5b3a fix(memory): keep embedding timeout watchdog active
(cherry picked from commit 591f310869)
2026-06-01 12:29:27 +01:00
Vincent Koc
a595aba60e refactor: share sessions send result assertions 2026-06-01 13:21:09 +02:00
Vincent Koc
75645aec08 fix(e2e): clean Telegram proof child processes 2026-06-01 13:20:03 +02:00
Vincent Koc
d10d71cdb6 fix(codex): stabilize app-server cleanup tests 2026-06-01 13:15:05 +02:00
Vincent Koc
c69a8d633d perf(control-ui): hydrate chat startup state
Add a combined chat.startup gateway method for Control UI startup hydration so first chat load can receive history and agents in one RPC, while falling back to chat.history for older/unadvertised gateways. Verified with focused UI/gateway tests, tsgo/oxlint/diff checks, clean autoreview, and Testbox changed gate tbx_01kt1dt6fqdtdbprsk48z8fn71.
2026-06-01 12:14:19 +01:00
Vincent Koc
d8ebbedf45 refactor: share plugin http auth request assertions 2026-06-01 13:10:09 +02:00
Peter Steinberger
9ed1766696 test(whatsapp): align direct last-route envelope
(cherry picked from commit 5d902b0f20)
2026-06-01 12:04:51 +01:00
Vincent Koc
bed0fb7bad refactor: share session resolve assertions 2026-06-01 13:00:51 +02:00
Vincent Koc
db6fc20559 fix(e2e): clean Windows background smoke timeouts 2026-06-01 12:55:15 +02:00
Vincent Koc
1364acbe4c refactor: share gateway http stage error assertions 2026-06-01 12:45:20 +02:00
Vincent Koc
d2988e0248 refactor: share preview resolve alias fixtures 2026-06-01 12:42:30 +02:00
Vincent Koc
8c8c8c8e32 perf(control-ui): prioritize first connect startup (#89030)
* perf(control-ui): prioritize first connect startup

* fix(control-ui): close connect timing gaps

* fix(control-ui): default embeds strict before bootstrap

* fix(control-ui): keep bootstrap identity deferred

* fix(control-ui): gate startup chat on bootstrap

* fix(control-ui): restore composer after hello

* fix(control-ui): restore drafts before hello
2026-06-01 11:41:22 +01:00
Vincent Koc
8bee3be90a fix(e2e): bound Parallels fresh lanes 2026-06-01 12:34:29 +02:00
Vincent Koc
87d890003d refactor: share shutdown drain session setup 2026-06-01 12:31:32 +02:00
Peter Steinberger
aed7de306e fix(qa-matrix): detect sqlite dedupe commits by payload
(cherry picked from commit 2fc497e67b)
2026-06-01 11:27:10 +01:00
Vincent Koc
859cb52b44 refactor: share unauthorized response assertions 2026-06-01 12:22:58 +02:00
Vincent Koc
4685a84e9b fix(e2e): bound bundled runtime gateway cleanup 2026-06-01 12:19:37 +02:00
Vincent Koc
f30235bed2 test: fix gateway test type fixtures 2026-06-01 12:13:36 +02:00
Vincent Koc
4f8f6c7693 refactor: share thinking e2e session setup 2026-06-01 12:13:36 +02:00
Peter Steinberger
055063f06b fix(qa-matrix): read sqlite inbound dedupe state 2026-06-01 11:07:53 +01:00
Vincent Koc
dac33c8ecb fix(e2e): cap pty transcript output 2026-06-01 11:49:58 +02:00
Vincent Koc
75ebf1c870 refactor: share device token authz test helpers 2026-06-01 11:49:06 +02:00
Vincent Koc
e4a32b9e8e lint(e2e): remove redundant channel fallback 2026-06-01 11:38:28 +02:00
Vincent Koc
22e3b2e94e fix(dev): wait for watch-node shutdown 2026-06-01 11:38:28 +02:00
Peter Steinberger
729420c34a test: split slow vitest shards 2026-06-01 05:34:59 -04:00
Peter Steinberger
0b5be66ef7 perf(gateway): trim startup plugin planning work 2026-06-01 10:33:28 +01:00
Peter Steinberger
8e28c773fe chore(release): prepare 2026.6.1 2026-06-01 10:30:15 +01:00
Vincent Koc
2dcb681f38 refactor: share session search test fixtures 2026-06-01 11:28:59 +02:00
Peter Steinberger
e733774e3c fix(test): repair telegram prerelease blockers 2026-06-01 10:26:12 +01:00
Mason Huang
004835f4c7 fix(plugins): block untrusted workspace setup-only channel loads (#86953)
Summary:
- This PR blocks disabled workspace-origin channel plugins from setup-only scoped imports, rejects their channel registrations at registry assembly, documents the trust rule, and adds regression coverage.
- PR surface: Source +46, Tests +610, Docs +13. Total +669 across 22 files.
- Reproducibility: yes. source inspection gives a high-confidence reproduction path: current main's setup-only ... ce channel plugin can be imported before this PR. I did not run the repro locally in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(plugins): cover workspace channel registry guard
- PR branch already contained follow-up commit before automerge: fix(plugins): isolate setup channel registration errors
- PR branch already contained follow-up commit before automerge: fix(channels): mark raw catalog listing internal
- PR branch already contained follow-up commit before automerge: test(channels): cover trusted catalog filtering
- PR branch already contained follow-up commit before automerge: test(channels): mock raw catalog helper
- PR branch already contained follow-up commit before automerge: docs(changelog): credit setup channel hardening

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

Prepared head SHA: 11438bc1a0
Review: https://github.com/openclaw/openclaw/pull/86953#issuecomment-4545730044

Co-authored-by: masonxhuang <masonxhuang@tencent.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-01 09:25:56 +00:00
Vincent Koc
97d373ff37 perf(ui): speed up first global chat sends
Speed up Control UI first global chat sends by letting safe literal-global startup refresh use the fresh hello default before agents.list finishes, while keeping stale carried/cached agent ids out of that fast path. Adds chat history/send and gateway chat.send timing markers for the next latency pass.
2026-06-01 10:25:22 +01:00
Vincent Koc
3119f08009 fix(scripts): bound shrinkwrap npm commands 2026-06-01 11:23:20 +02:00
Peter Steinberger
9d55fc4579 fix(plugins): skip peer links in rollback snapshots 2026-06-01 10:18:30 +01:00
Vincent Koc
2bac970abc refactor: share node invoke policy test setup 2026-06-01 11:17:38 +02:00
Vincent Koc
f8e9ba3718 fix(codex): prevent aborted app-server turn handles 2026-06-01 10:12:36 +01:00
Vincent Koc
26aaf03719 fix(scripts): clean control ui i18n timeouts 2026-06-01 11:10:57 +02:00
Vincent Koc
e85be626a4 refactor: share plugin runtime scope test setup 2026-06-01 11:07:29 +02:00
Vincent Koc
9cb052ccef refactor: share plugin http route test setup 2026-06-01 10:56:09 +02:00
Peter Steinberger
637b073119 test(ui): update gateway session chat mock 2026-06-01 04:53:51 -04:00
Vincent Koc
174e7711f3 fix(build): clean CLI startup metadata timeouts 2026-06-01 10:52:27 +02:00
Vincent Koc
b13af38f99 perf(ui): trace chat first output latency
Add chat-send first visible assistant output telemetry in the Control UI, plus Gateway diagnostics correlation attributes for chat.send dispatch spans. Verified with focused UI/Gateway tests, tsgo, oxlint, autoreview, PR checks, and Testbox-through-Crabbox check:changed.
2026-06-01 09:47:45 +01:00
Vincent Koc
4094c94a8f refactor: share event loop health expectation 2026-06-01 10:47:05 +02:00
Peter Steinberger
32113e38ab perf(ci): speed up prompt snapshot checks 2026-06-01 04:44:41 -04:00
Peter Steinberger
07a425aa14 fix: preserve colon slash commands 2026-06-01 09:41:19 +01:00
Vincent Koc
db5bb1cbe7 refactor: share auth state test setup 2026-06-01 10:38:12 +02:00
Vincent Koc
947dde976c fix(release): bound plugin npm verification commands 2026-06-01 10:36:46 +02:00
Peter Steinberger
1d4c1ba56d fix: harden memory envelope sanitization
Co-authored-by: amittell <mittell@me.com>
2026-06-01 09:30:08 +01:00
Vincent Koc
de3ee3daa6 refactor: share auth context test helpers 2026-06-01 10:24:04 +02:00
Vincent Koc
61574eb50b perf(ui): keep chat draft local while typing (#88998) 2026-06-01 09:19:53 +01:00
Vincent Koc
e680604577 fix(e2e): clean telegram credential timeouts 2026-06-01 10:13:57 +02:00
Vincent Koc
2ea7c518a5 test(agents): avoid provider runtime in subagent spawn tests 2026-06-01 09:13:36 +01:00
Vincent Koc
7f95733bee refactor: share handshake locality test inputs 2026-06-01 10:12:30 +02:00
Peter Steinberger
a4196a4445 fix(ci): cache plugin sdk declarations safely 2026-06-01 04:09:07 -04:00
Vincent Koc
688634ccb9 refactor: share ws health test harness setup 2026-06-01 10:01:27 +02:00
Vincent Koc
060d4a4d2d test(gateway): widen live helper connect budget 2026-06-01 09:00:47 +01:00
Vincent Koc
f2d0fe6417 fix(release): clean cross-os process groups 2026-06-01 10:00:23 +02:00
Vincent Koc
6627b4fbdd perf(ui): guard chat composer controls
Reduce Control UI draft-update work by guarding chat composer controls while keeping locale, session, model, settings, and busy-state invalidation. Verification: focused UI tests, format/lint/typecheck, autoreview clean, and changed gate tbx_01kt12rgjs8c077p2s0wmcsbyf.
2026-06-01 08:56:14 +01:00
Peter Steinberger
3b64ea83e8 fix: migrate legacy OpenAI Codex lastGood auth state 2026-06-01 03:47:43 -04:00
Vincent Koc
1d62f4c014 fix(ci): satisfy scripts lint spread rule 2026-06-01 08:45:42 +01:00
Vincent Koc
3feeb95668 refactor: share minimal gateway test helpers 2026-06-01 09:44:48 +02:00
Vincent Koc
402e2bb81a perf(ui): guard chat transcript rerenders
Reduce Control UI draft-update work by guarding transcript group rendering while preserving assistant attachment availability invalidation. Verification: focused UI tests, format/lint/typecheck, autoreview clean, and changed gate tbx_01kt11qyc20ejbsbt8kd79bamx.
2026-06-01 08:41:04 +01:00
Peter Steinberger
bc470713bb fix(e2e): enable smoke-tested plugin channels 2026-06-01 08:38:50 +01:00
Vincent Koc
3322212f14 fix(ci): tolerate pnpm workspace state on Windows hydrate 2026-06-01 09:36:41 +02:00
Peter Steinberger
7591dc6f4b test(telegram): reset spooled polling handler state 2026-06-01 08:36:32 +01:00
Vincent Koc
6640d57b64 refactor: share websocket connection test harness 2026-06-01 09:29:43 +02:00
Vincent Koc
ac734d8e16 fix(e2e): clean package candidate timeouts 2026-06-01 09:22:07 +02:00
Vincent Koc
0ece07cc20 fix(test): wait for telegram timer flushes
Revert release-time extension lane isolation for Telegram and memory, and make Telegram timer-flush tests wait for async side effects after manually firing timers.

Verification:
- pnpm test:serial extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts extensions/telegram/src/bot.create-telegram-bot.media-group-skip-warning.test.ts extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts test/vitest-scoped-config.test.ts
- pnpm exec oxfmt --check on touched files
- git diff --check on touched files
2026-06-01 08:17:33 +01:00
Vincent Koc
5e09113ede refactor: share selected global session test setup 2026-06-01 09:14:31 +02:00
Vincent Koc
bff66a3e49 perf(ui): skip closed slash menu rerenders
Reduce Control UI typing work by avoiding slash-menu rerenders for ordinary non-command drafts. Verification: focused UI tests, format/lint/typecheck, autoreview clean, and changed gate tbx_01kt1086xrbxfzm85vynsf25hq.
2026-06-01 08:14:16 +01:00
Vincent Koc
8071b06634 perf(ui): debounce chat draft persistence
Debounce draft-only Control UI chat composer persistence while snapshotting pending drafts so session changes and teardown still flush the correct state. Verified with focused UI lifecycle/composer tests, format, oxlint, tsgo core/UI test, clean autoreview, and PR checks.
2026-06-01 08:04:23 +01:00
Vincent Koc
61ffd6bc66 fix(ci): bootstrap raw changed gates from clean checkouts 2026-06-01 08:01:11 +01:00
Vincent Koc
474ec157bc test(scripts): use runner vitest resolver in expectations 2026-06-01 08:01:11 +01:00
Vincent Koc
1377fd82a9 refactor: share openai compat http test helpers 2026-06-01 08:55:28 +02:00
Vincent Koc
8fdb1d0f55 fix(e2e): stream Parallels fresh logs 2026-06-01 08:54:22 +02:00
Vincent Koc
68bfacae03 test(ci): wait for MCP tools list log 2026-06-01 07:49:01 +01:00
Vincent Koc
371617f9ed refactor: share gateway error response assertions 2026-06-01 08:42:59 +02:00
Vincent Koc
69b2c8bd15 perf(ui): record pending send paint timing (#88960) 2026-06-01 07:42:24 +01:00
Vincent Koc
c11ff35841 fix(e2e): bound Parallels update logs 2026-06-01 08:42:08 +02:00
Vincent Koc
ddbd595f2f fix(ci): link Windows hydrate node modules 2026-06-01 08:38:25 +02:00
Vincent Koc
01124cfca9 fix(e2e): clean secret proof timeouts 2026-06-01 08:30:17 +02:00
Vincent Koc
e8f3bce9f0 fix(ci): exempt child process test helper from sdk guard 2026-06-01 07:27:47 +01:00
Vincent Koc
cb0ad281ce perf(ui): cache chat transcript renders (#88952) 2026-06-01 07:27:08 +01:00
Vincent Koc
c429a3c472 fix(codex): skip stale bootstrap history without engine 2026-06-01 07:26:08 +01:00
Vincent Koc
444bdc4286 refactor: share child process test mock helper 2026-06-01 08:22:25 +02:00
Vincent Koc
28550c3847 fix(e2e): harden Parallels host timeouts 2026-06-01 08:15:34 +02:00
Vincent Koc
3e91c688ae fix(ui): scroll pending sends into view
Scroll the chat thread as soon as a submitted pending send is enqueued, so delayed `chat.send` ACKs no longer leave the user's just-sent message below the viewport.

Verification:
- focused UI Vitest suite: 86 tests passed
- oxlint, core tsgo, core-test tsgo, diff check
- Testbox changed gate: tbx_01kt0wspy1ks5wpb6kp5gr0512
- branch autoreview clean
2026-06-01 07:14:07 +01:00
Vincent Koc
4d49a76039 test(secrets): secure plugin exec fixtures 2026-06-01 07:11:28 +01:00
Vincent Koc
988ec0234e fix(agents): validate shell snapshots with trusted env 2026-06-01 07:11:28 +01:00
Vincent Koc
9a7e0d43da fix(codex): accept legacy app-server auth provider 2026-06-01 07:11:28 +01:00
Vincent Koc
f55ff8dd1b fix(codex): skip stale bootstrap history without engine 2026-06-01 07:11:28 +01:00
Vincent Koc
5314a39ee5 refactor: share usage UTC range assertions 2026-06-01 08:03:23 +02:00
Vincent Koc
44cad6f8a4 refactor: simplify wake APNs test mock 2026-06-01 07:59:17 +02:00
Vincent Koc
275caeb5f5 fix(ui): render pending sends in chat thread
Render submitted Control UI sends directly in the chat thread before the Gateway acknowledges `chat.send`.

Pending sends now share acknowledged user-message content rendering for text and attachments, stay searchable with active chat filters, and failed queued sends remain queue-only.

Verification:
- focused UI Vitest suite: 201 tests passed
- oxlint, core tsgo, core-test tsgo, diff check
- Testbox changed gate: tbx_01kt0vnr2bv55aa6x588r77x0z
- autoreview clean
2026-06-01 06:57:05 +01:00
Peter Steinberger
0f2732b066 test(release): isolate telegram extension vitest lane 2026-06-01 06:54:55 +01:00
Vincent Koc
59f1472bd5 refactor: share error coercion helper 2026-06-01 07:41:19 +02:00
Vincent Koc
630f0d6938 refactor: share push test response assertions 2026-06-01 07:36:51 +02:00
Peter Steinberger
6173a4babb docs(plugin-sdk): refresh API baseline 2026-06-01 06:29:51 +01:00
Vincent Koc
6a1b2e6463 refactor: share skills handler test helper 2026-06-01 07:27:52 +02:00
Vincent Koc
fb9e091852 fix(e2e): harden plugin gauntlet cleanup 2026-06-01 07:27:35 +02:00
Peter Steinberger
00399d6c75 test(release): repair beta validation blockers 2026-06-01 06:27:02 +01:00
Peter Steinberger
b23ace1d04 fix(agents): strip streamed reasoning tags (#88924) 2026-06-01 01:26:29 -04:00
Peter Steinberger
db4990d260 refactor: compact copilot sessions through sdk state
Route Copilot compaction through SDK-backed state, remove marker sidecars, preserve auth/session binding behavior in SQLite-backed plugin state, and route Copilot CLI budget compaction through native harness compaction.
2026-06-01 01:18:46 -04:00
Vincent Koc
4550cfa6a7 fix(qa): run plugin MCP probes from repo root 2026-06-01 07:13:24 +02:00
Chunyue Wang
c0195f7ed5 fix(diagnostics): clear embedded-run activity when recovery declares lane idle (#88820)
* fix(diagnostics): clear embedded-run activity when recovery declares lane idle

Stuck-session recovery transitions a lane to idle via the recovery
coordinator, but only mutated the session-state store. When an aborted
embedded run was removed without markDiagnosticEmbeddedRunEnded, the
activity store kept hasActiveEmbeddedRun set, so the liveness sweep
reported idle/embedded_run and isIdleQueuedRecoverableSessionStall
re-triggered recovery indefinitely.

Reconcile the activity store from the authoritative idle declaration by
clearing the session's embedded-run owners. The existing generation
guard already excludes any newer run that re-armed activity, so a live
requeued run is preserved.

* fix(diagnostics): reconcile tool/model activity on authoritative idle cleanup

clearDiagnosticEmbeddedRunActivityForSession (renamed from
clearDiagnosticEmbeddedRunsForSession) now clears the aborted run's tool and
model markers alongside the embedded-run owners, matching the default
markDiagnosticEmbeddedRunEnded teardown. Clearing only the owner set left the
lane as idle + orphaned tool/model activity, which
isIdleQueuedRecoverableSessionStall still treats as recoverable while work is
queued, so the liveness sweep kept re-triggering recovery instead of converging.
Adds regression cases with stale tool and model markers plus queued work.

* test(phone-control): align service mocks with keyed store API

* fix(diagnostics): preserve rearmed recovery activity

* fix(diagnostics): clear recovered owner markers

* fix(diagnostics): clear recovered embedded work keys

* fix(diagnostics): ignore stale same-key recovery owners

* fix(diagnostics): preserve same-session recovery rearm

* fix(diagnostics): ignore stale queued activity starts

* fix(diagnostics): record recovery cutoffs for empty activity

* fix(diagnostics): preserve fresh recovery markers

* fix(diagnostics): prune stale activity before fresh recovery block

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-01 01:07:35 -04:00
Tosko4
785849d395 fix(android): add notification app picker 2026-06-01 10:37:19 +05:30
Vincent Koc
12d5043913 refactor: share chat parentid test helpers 2026-06-01 07:06:05 +02:00
Peter Steinberger
d925249ac0 docs(plugin-sdk): refresh API baseline hash 2026-06-01 06:05:37 +01:00
Vincent Koc
74a075077c fix(e2e): harden docker all cleanup 2026-06-01 07:05:15 +02:00
Peter Steinberger
4e57546a87 test(memory): isolate qmd timer state in prerelease shard 2026-06-01 06:03:43 +01:00
Neerav Makwana
711ab45025 fix(agents): clear legacy auto fallback pins (#87484)
* fix(agents): clear legacy auto fallback pins

* fix(agents): repair legacy auto-fallback test mock and tighten review feedback

Add hasLegacyAutoFallbackWithoutOrigin to the live-model-switch agent-scope mock so the agents-core lane runs, simplify the redundant hasSessionModelOverride guard, use a single source of truth for the legacy-pin staleness check with a comment on the load-bearing modelKey guard, and add preservation/edge-case/guard regression coverage. Rename the misleading primary-probe agent test.

* style(agents): format rebased fallback fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-01 01:03:31 -04:00
Vincent Koc
e7e21caa20 fix(ui): keep first control chat sends responsive
Make first Control UI chat sends visibly queue during pending model saves, preserve early streaming deltas that arrive before chat.send ACK, and keep model-wait queued prompts scoped/retryable across session switches.
2026-06-01 05:59:04 +01:00
amittell
945faf8e67 fix(memory-lancedb): reject envelope metadata sludge
Summary:
- Strip memory-lancedb envelope and metadata sludge before auto-capture/recall, including pending history wrappers, current-message reply context, message-tool delivery hints, media annotations, and marker-free channel envelopes.
- Expose bundled chat-channel IDs/prefixes through the plugin SDK so sanitizer matching follows the channel catalog.
- Refactor cron tool schemas to fresh factory instances while preserving runtime nullable clears and provider-facing OpenAPI projection.

Verification:
- git diff --check origin/main...HEAD
- ./node_modules/.bin/oxfmt --check src/plugin-sdk/chat-channel-ids.ts src/plugin-sdk/chat-channel-ids.test.ts extensions/memory-lancedb/index.ts extensions/memory-lancedb/index.test.ts src/agents/tools/cron-tool.ts src/agents/tools/cron-tool.schema.test.ts
- pnpm plugin-sdk:api:check
- node scripts/run-vitest.mjs run src/plugin-sdk/chat-channel-ids.test.ts extensions/memory-lancedb src/agents/tools/cron-tool.schema.test.ts src/agents/tools/cron-tool.test.ts --reporter=dot
- pnpm lint:extensions --threads=8
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub checks on 62d1da1257: 139 pass, 0 pending, 0 fail, 22 skipped.
2026-06-01 00:57:25 -04:00
Vincent Koc
1aa1a70ac5 test(installer): isolate install shell HOME 2026-06-01 05:55:34 +01:00
516 changed files with 22030 additions and 8069 deletions

View File

@@ -1086,7 +1086,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1193,7 +1193,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false

View File

@@ -431,6 +431,25 @@ jobs:
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$workspaceNodeModules = Join-Path $workspace "node_modules"
if (Test-Path $workspaceNodeModules) {
$workspaceNodeModulesItem = Get-Item $workspaceNodeModules -Force
if (($workspaceNodeModulesItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) {
$nodeModulesChildren = @(Get-ChildItem -LiteralPath $workspaceNodeModules -Force)
$hasOnlyPnpmWorkspaceState = $nodeModulesChildren.Count -eq 1 -and $nodeModulesChildren[0].Name -eq ".pnpm-workspace-state-v1.json"
if ($nodeModulesChildren.Count -ne 0 -and -not $hasOnlyPnpmWorkspaceState) {
throw "workspace node_modules exists and is not a link: $workspaceNodeModules"
}
foreach ($nodeModulesChild in $nodeModulesChildren) {
Remove-Item -LiteralPath $nodeModulesChild.FullName -Force
}
Remove-Item -LiteralPath $workspaceNodeModules -Force
New-Item -ItemType Junction -Path $workspaceNodeModules -Target $env:PNPM_CONFIG_MODULES_DIR | Out-Null
}
} else {
New-Item -ItemType Junction -Path $workspaceNodeModules -Target $env:PNPM_CONFIG_MODULES_DIR | Out-Null
}
$corepackShimDir = Join-Path $nodeBin "node_modules\corepack\shims"
if (Test-Path $corepackShimDir) {
$env:PNPM_HOME = $corepackShimDir

View File

@@ -902,7 +902,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 45
permissions:
contents: read
@@ -933,7 +933,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run runtime parity lane

View File

@@ -2,7 +2,13 @@
Docs: https://docs.openclaw.ai
## 2026.5.31
## 2026.6.1-alpha.3
### Fixes
- Alpha release candidate with refreshed release metadata, shrinkwrap alignment, and release-gate stabilization fixes.
## 2026.6.1
### Highlights
@@ -13,10 +19,10 @@ Docs: https://docs.openclaw.ai
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
- Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, and expose calmer composer controls. (#88772, #88825)
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, trace first-output latency, and expose calmer composer controls. (#88772, #88825, #88998) Thanks @vincentkoc.
- Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)
- iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
- Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, and rollback snapshots so failures report bounded proof instead of stalling.
### Changes
@@ -35,7 +41,7 @@ Docs: https://docs.openclaw.ai
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
- Code mode: add MCP API files and docs for code-mode integrations.
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
- Control UI: add calmer chat composer controls for active chat entry. (#88772)
- Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
- Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)
- Providers: add MiniMax M3 model support. (#88860)
@@ -49,6 +55,7 @@ Docs: https://docs.openclaw.ai
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
- Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex `lastGood` auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
- Agents/auth: write auth profiles atomically, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state.
@@ -58,9 +65,10 @@ Docs: https://docs.openclaw.ai
- CLI/desktop: bridge WSL clipboard operations through the shell and recognize manual-update launchd jobs. (#88764)
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
- Plugins: preserve npm plugin roots after blocked installs, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
- Plugins: preserve npm plugin roots after blocked installs, skip plugin-local `openclaw` peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
- Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.
- Memory: serialize QMD update/embed writes per store, preserve phase signals on read errors, harden envelope metadata sanitization, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931) Thanks @openperf and @amittell.
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512)
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
@@ -70,14 +78,15 @@ Docs: https://docs.openclaw.ai
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
- Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, honor Chromium executable overrides, and detect system Chromium for E2E.
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.
- OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)

View File

@@ -66,7 +66,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 2026053101
versionName = "2026.5.31"
versionName = "2026.6.1"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -0,0 +1,82 @@
package ai.openclaw.app.ui
import ai.openclaw.app.node.DeviceNotificationListenerService
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
/** App entry shown in the notification-forwarding package picker. */
data class InstalledApp(
val label: String,
val packageName: String,
val isSystemApp: Boolean,
)
/** Reads launcher, recent-notification, and configured packages for the picker. */
internal fun queryInstalledApps(
context: Context,
configuredPackages: Set<String>,
): List<InstalledApp> {
val packageManager = context.packageManager
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
val launcherPackages =
packageManager
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
.asSequence()
.mapNotNull {
it.activityInfo
?.packageName
?.trim()
?.takeIf(String::isNotEmpty)
}.toMutableSet()
val recentNotificationPackages =
DeviceNotificationListenerService
.recentPackages(context)
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toList()
val candidatePackages =
resolveNotificationCandidatePackages(
launcherPackages = launcherPackages,
recentPackages = recentNotificationPackages,
configuredPackages = configuredPackages,
appPackageName = context.packageName,
)
return candidatePackages
.asSequence()
.mapNotNull { packageName ->
runCatching {
val appInfo = packageManager.getApplicationInfo(packageName, 0)
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
InstalledApp(
label = if (label.isEmpty()) packageName else label,
packageName = packageName,
isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
)
}.getOrNull()
}.sortedWith(compareBy<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
/** Merges package sources while excluding OpenClaw from its own forwarding filter. */
internal fun resolveNotificationCandidatePackages(
launcherPackages: Set<String>,
recentPackages: List<String>,
configuredPackages: Set<String>,
appPackageName: String,
): Set<String> {
val blockedPackage = appPackageName.trim()
return sequenceOf(
configuredPackages.asSequence(),
launcherPackages.asSequence(),
recentPackages.asSequence(),
).flatten()
.map { it.trim() }
.filter { it.isNotEmpty() && it != blockedPackage }
.toSet()
}

View File

@@ -493,6 +493,8 @@ private fun playVoiceSetupTone() {
Handler(Looper.getMainLooper()).postDelayed({ tone.release() }, 300L)
}
private const val NOTIFICATION_PICKER_RESULT_LIMIT = 40
@Composable
private fun NotificationSettingsScreen(
viewModel: MainViewModel,
@@ -507,6 +509,19 @@ private fun NotificationSettingsScreen(
val quietEnd by viewModel.notificationForwardingQuietEnd.collectAsState()
val maxEventsPerMinute by viewModel.notificationForwardingMaxEventsPerMinute.collectAsState()
val modeLabel = if (mode == NotificationPackageFilterMode.Blocklist) "Blocklist" else "Allowlist"
val installedApps = remember(context, packages) { queryInstalledApps(context, packages) }
var notificationPickerExpanded by remember { mutableStateOf(false) }
var notificationAppSearch by remember { mutableStateOf("") }
var notificationShowSystemApps by remember { mutableStateOf(false) }
val filteredApps =
remember(installedApps, packages, notificationAppSearch, notificationShowSystemApps) {
filterNotificationAppsForPicker(
apps = installedApps,
selectedPackages = packages,
query = notificationAppSearch,
showSystemApps = notificationShowSystemApps,
)
}
var listenerEnabled by remember { mutableStateOf(DeviceNotificationListenerService.isAccessEnabled(context)) }
val notificationPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
@@ -567,6 +582,124 @@ private fun NotificationSettingsScreen(
)
}
}
NotificationPackagePickerPanel(
mode = mode,
selectedPackages = packages,
apps = filteredApps,
search = notificationAppSearch,
showSystemApps = notificationShowSystemApps,
expanded = notificationPickerExpanded,
onSearchChange = { notificationAppSearch = it },
onShowSystemAppsChange = { notificationShowSystemApps = it },
onExpandedChange = { notificationPickerExpanded = it },
onPackageSelectionChange = { packageName, selected ->
val next = packages.toMutableSet()
if (selected) {
next.add(packageName)
} else {
next.remove(packageName)
}
viewModel.setNotificationForwardingPackagesCsv(next.sorted().joinToString(","))
},
)
}
}
@Composable
private fun NotificationPackagePickerPanel(
mode: NotificationPackageFilterMode,
selectedPackages: Set<String>,
apps: List<InstalledApp>,
search: String,
showSystemApps: Boolean,
expanded: Boolean,
onSearchChange: (String) -> Unit,
onShowSystemAppsChange: (Boolean) -> Unit,
onExpandedChange: (Boolean) -> Unit,
onPackageSelectionChange: (String, Boolean) -> Unit,
) {
val visibleApps = apps.take(NOTIFICATION_PICKER_RESULT_LIMIT)
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "App Filter", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text = notificationPackageSelectionSummary(mode = mode, selectedCount = selectedPackages.size),
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
ClawSecondaryButton(
text = if (expanded) "Close App Picker" else "Open App Picker",
onClick = { onExpandedChange(!expanded) },
modifier = Modifier.fillMaxWidth(),
)
if (expanded) {
ClawTextField(value = search, onValueChange = onSearchChange, placeholder = "Search apps")
SettingsToggleListRow(
SettingsToggleRow(
title = "Show System Apps",
subtitle = "Include Android and background packages.",
icon = Icons.Default.Storage,
checked = showSystemApps,
onCheckedChange = onShowSystemAppsChange,
),
)
if (visibleApps.isEmpty()) {
Text(text = "No matching apps.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
} else {
ClawSeparatedColumn(items = visibleApps) { app ->
NotificationPackageAppRow(
app = app,
selected = selectedPackages.contains(app.packageName),
onSelectedChange = { selected -> onPackageSelectionChange(app.packageName, selected) },
)
}
if (apps.size > visibleApps.size) {
Text(
text = "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
}
}
}
}
@Composable
private fun NotificationPackageAppRow(
app: InstalledApp,
selected: Boolean,
onSelectedChange: (Boolean) -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 58.dp)
.clickable { onSelectedChange(!selected) }
.padding(vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
ClawTextBadge(text = notificationAppBadge(app.label))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
text = app.label,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = app.packageName,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Switch(checked = selected, onCheckedChange = onSelectedChange)
}
}
@@ -1112,6 +1245,55 @@ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus {
}
}
internal fun filterNotificationAppsForPicker(
apps: List<InstalledApp>,
selectedPackages: Set<String>,
query: String,
showSystemApps: Boolean,
): List<InstalledApp> {
val normalizedQuery = query.trim().lowercase()
return apps.filter { app ->
val selected = app.packageName in selectedPackages
val visibleByType = showSystemApps || !app.isSystemApp || selected
val visibleBySearch =
normalizedQuery.isEmpty() ||
app.label.lowercase().contains(normalizedQuery) ||
app.packageName.lowercase().contains(normalizedQuery)
visibleByType && visibleBySearch
}
}
private fun notificationPackageSelectionSummary(
mode: NotificationPackageFilterMode,
selectedCount: Int,
): String =
when (mode) {
NotificationPackageFilterMode.Allowlist ->
if (selectedCount == 0) {
"No apps selected. Nothing forwards until you add apps."
} else {
"$selectedCount ${if (selectedCount == 1) "app" else "apps"} allowed to forward."
}
NotificationPackageFilterMode.Blocklist ->
if (selectedCount == 0) {
"No apps blocked. Apps can forward unless you add blocks."
} else {
"$selectedCount ${if (selectedCount == 1) "app" else "apps"} blocked from forwarding."
}
}
private fun notificationAppBadge(label: String): String {
val initials =
label
.split(' ', '-', '_', '.')
.asSequence()
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.joinToString("")
return initials.ifBlank { "A" }
}
/**
* Converts cron wake times into short relative labels for scheduled-work rows.
*/

View File

@@ -1222,82 +1222,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
/** App entry shown in the notification-forwarding package picker. */
data class InstalledApp(
val label: String,
val packageName: String,
val isSystemApp: Boolean,
)
/** Reads launcher, recent-notification, and configured packages for the picker. */
private fun queryInstalledApps(
context: Context,
configuredPackages: Set<String>,
): List<InstalledApp> {
val packageManager = context.packageManager
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
val launcherPackages =
packageManager
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
.asSequence()
.mapNotNull {
it.activityInfo
?.packageName
?.trim()
?.takeIf(String::isNotEmpty)
}.toMutableSet()
val recentNotificationPackages =
DeviceNotificationListenerService
.recentPackages(context)
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toList()
val candidatePackages =
resolveNotificationCandidatePackages(
launcherPackages = launcherPackages,
recentPackages = recentNotificationPackages,
configuredPackages = configuredPackages,
appPackageName = context.packageName,
)
return candidatePackages
.asSequence()
.mapNotNull { packageName ->
runCatching {
val appInfo = packageManager.getApplicationInfo(packageName, 0)
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
InstalledApp(
label = if (label.isEmpty()) packageName else label,
packageName = packageName,
isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
)
}.getOrNull()
}.sortedWith(compareBy<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
/** Merges package sources while excluding OpenClaw from its own forwarding filter. */
internal fun resolveNotificationCandidatePackages(
launcherPackages: Set<String>,
recentPackages: List<String>,
configuredPackages: Set<String>,
appPackageName: String,
): Set<String> {
val blockedPackage = appPackageName.trim()
return sequenceOf(
configuredPackages.asSequence(),
launcherPackages.asSequence(),
recentPackages.asSequence(),
).flatten()
.map { it.trim() }
.filter { it.isNotEmpty() && it != blockedPackage }
.toSet()
}
/** Shared Material text-field colors for the legacy mobile settings sheet. */
@Composable
private fun settingsTextFieldColors() =

View File

@@ -32,4 +32,46 @@ class SettingsSheetNotificationAppsTest {
assertEquals(setOf("com.example.recent", "com.example.configured"), packages)
}
@Test
fun filterNotificationAppsForPicker_keepsSelectedSystemPackagesVisible() {
val apps =
listOf(
InstalledApp(label = "Android System", packageName = "android", isSystemApp = true),
InstalledApp(label = "Phone Services", packageName = "com.android.phone", isSystemApp = true),
InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false),
)
val filtered =
filterNotificationAppsForPicker(
apps = apps,
selectedPackages = setOf("com.android.phone"),
query = "",
showSystemApps = false,
)
assertEquals(
listOf("com.android.phone", "com.google.android.gm"),
filtered.map { it.packageName },
)
}
@Test
fun filterNotificationAppsForPicker_matchesLabelsAndPackageNames() {
val apps =
listOf(
InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false),
InstalledApp(label = "Calendar", packageName = "com.google.android.calendar", isSystemApp = false),
)
val filtered =
filterNotificationAppsForPicker(
apps = apps,
selectedPackages = emptySet(),
query = "gm",
showSystemApps = false,
)
assertEquals(listOf("com.google.android.gm"), filtered.map { it.packageName })
}
}

View File

@@ -1,6 +1,6 @@
# OpenClaw iOS Changelog
## 2026.5.31 - 2026-05-31
## 2026.6.1 - 2026-06-01
Maintenance update for the current OpenClaw release.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.5.31
OPENCLAW_MARKETING_VERSION = 2026.5.31
OPENCLAW_IOS_VERSION = 2026.6.1
OPENCLAW_MARKETING_VERSION = 2026.6.1
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1,3 +1,3 @@
{
"version": "2026.5.31"
"version": "2026.6.1"
}

View File

@@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.5.31</string>
<string>2026.6.1</string>
<key>CFBundleVersion</key>
<string>2026053100</string>
<key>CFBundleIconFile</key>

View File

@@ -1,4 +1,4 @@
cc0fb4e3f1a7e8f233626adb80d686608ddac8c177fe6a55b33970c2baf4ace4 config-baseline.json
67914673462dfdc43d383568208d8c562fc49a66d2a1c1953b8e76e956001cc7 config-baseline.json
042ca98e6200a365accda00e5a6f3e72bdae5853f39ff0cdc3b2cb9c0d6f8f3e config-baseline.core.json
cbf81829dcc8cfd0a16435912da709f8c1d508707385b6493f94cafe211ec67c config-baseline.channel.json
3c67681c98170fa88c78db31fc431ed34b2161219d8ee4d4f5152e4599af3971 config-baseline.channel.json
4012b1f8de6f9527c47320a6c7120f30dc30ac1b5524ed63dadef890aad44b20 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
47d4365c4133f57769758907b7cf1a43d17e040db0570a1433e2f03e4fb0bd02 plugin-sdk-api-baseline.json
161027bba89497f5e30127dd0f57b4da623270cfc771b989e29262da5760e723 plugin-sdk-api-baseline.jsonl
63d49032a9b4dc4874a0ca17be73ecc97a2df5d1f47b4e72db34868423370558 plugin-sdk-api-baseline.json
af79f7d711afa0a8563782b8f5cdd7e46b9aea245f5e7ebc464327a8969ed65e plugin-sdk-api-baseline.jsonl

View File

@@ -329,6 +329,19 @@ openclaw plugins install -l ./my-plugin
Standalone plugin files must be listed in `plugins.load.paths` rather than placed directly in `~/.openclaw/extensions` or `<workspace>/.openclaw/extensions`. Those auto-discovered roots load plugin package or bundle directories, while top-level script files are treated as local helpers and skipped.
<Note>
Workspace-origin plugins discovered from a workspace extensions root are not
imported or executed until they are explicitly enabled. For local development,
run `openclaw plugins enable <plugin-id>` or set
`plugins.entries.<plugin-id>.enabled: true`; if your config uses
`plugins.allow`, include the same plugin id there too. This fail-closed rule
also applies when channel setup explicitly targets a workspace-origin plugin for
setup-only loading, so local channel plugin setup code will not run while that
workspace plugin remains disabled or excluded from the allowlist. Linked installs
and explicit `plugins.load.paths` entries follow the normal policy for their
resolved plugin origin. See
[Configure plugin policy](/tools/plugin#configure-plugin-policy)
and [Configuration reference](/gateway/configuration-reference#plugins).
`--force` is not supported with `--link` because linked installs reuse the source path instead of copying over a managed install target.
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in the managed plugin index while keeping the default behavior unpinned.

View File

@@ -368,7 +368,7 @@ If discovery fails or times out, OpenClaw uses a bundled fallback catalog for:
- GPT-5.4 mini
- GPT-5.2
The current bundled harness is `@openai/codex` `0.134.0`. A `model/list` probe
The current bundled harness is `@openai/codex` `0.135.0`. A `model/list` probe
against that bundled app-server returned:
| Model id | Default | Hidden | Input modalities | Reasoning efforts |

View File

@@ -190,11 +190,10 @@ plugins, channels, and core code only see the standard
When `harness.compact` runs, the Copilot SDK harness:
1. Enables `infiniteSessions` on the SDK session.
2. Lets the SDK perform its native compaction.
3. Writes an OpenClaw-shaped marker at
`workspacePath/files/openclaw-compaction-<ts>.json` so existing OpenClaw
transcript readers still see a familiar artifact.
1. Resumes the tracked SDK session without continuing pending work.
2. Calls the SDK's session-scoped history compaction RPC.
3. Returns the SDK compaction outcome without writing compatibility marker
files under the workspace.
The OpenClaw side transcript mirror (see below) continues to receive the
post-compaction messages, so user-facing chat history stays consistent.

View File

@@ -107,7 +107,7 @@ commands.
| [oc-path](/plugins/reference/oc-path) | Adds the openclaw path CLI for oc:// workspace file addressing. | `@openclaw/oc-path`<br />included in OpenClaw | plugin |
| [ollama](/plugins/reference/ollama) | Adds Ollama, Ollama Cloud model provider support to OpenClaw. | `@openclaw/ollama-provider`<br />included in OpenClaw | providers: ollama, ollama-cloud; contracts: memoryEmbeddingProviders, webSearchProviders |
| [open-prose](/plugins/reference/open-prose) | OpenProse VM skill pack with a /prose slash command. | `@openclaw/open-prose`<br />included in OpenClaw | skills |
| [openai](/plugins/reference/openai) | Adds OpenAI model provider support to OpenClaw, including ChatGPT/Codex OAuth. | `@openclaw/openai-provider`<br />included in OpenClaw | providers: openai; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, speechProviders, videoGenerationProviders |
| [openai](/plugins/reference/openai) | Adds OpenAI model provider support to OpenClaw. | `@openclaw/openai-provider`<br />included in OpenClaw | providers: openai; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, speechProviders, videoGenerationProviders |
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |

View File

@@ -95,7 +95,7 @@ pnpm plugins:inventory:gen
| [oc-path](/plugins/reference/oc-path) | Adds the openclaw path CLI for oc:// workspace file addressing. | `@openclaw/oc-path`<br />included in OpenClaw | plugin |
| [ollama](/plugins/reference/ollama) | Adds Ollama, Ollama Cloud model provider support to OpenClaw. | `@openclaw/ollama-provider`<br />included in OpenClaw | providers: ollama, ollama-cloud; contracts: memoryEmbeddingProviders, webSearchProviders |
| [open-prose](/plugins/reference/open-prose) | OpenProse VM skill pack with a /prose slash command. | `@openclaw/open-prose`<br />included in OpenClaw | skills |
| [openai](/plugins/reference/openai) | Adds OpenAI model provider support to OpenClaw, including ChatGPT/Codex OAuth. | `@openclaw/openai-provider`<br />included in OpenClaw | providers: openai; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, speechProviders, videoGenerationProviders |
| [openai](/plugins/reference/openai) | Adds OpenAI model provider support to OpenClaw. | `@openclaw/openai-provider`<br />included in OpenClaw | providers: openai; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, speechProviders, videoGenerationProviders |
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |

View File

@@ -1,5 +1,5 @@
---
summary: "Adds OpenAI model provider support to OpenClaw, including ChatGPT/Codex OAuth."
summary: "Adds OpenAI model provider support to OpenClaw."
read_when:
- You are installing, configuring, or auditing the openai plugin
title: "OpenAI plugin"
@@ -7,7 +7,7 @@ title: "OpenAI plugin"
# OpenAI plugin
Adds OpenAI model provider support to OpenClaw, including ChatGPT/Codex OAuth.
Adds OpenAI model provider support to OpenClaw.
## Distribution

View File

@@ -76,6 +76,7 @@ by package contract guardrails.
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` |
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives plus Zod and direct JSON/TypeBox builders |
| `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only |
| `plugin-sdk/chat-channel-ids` | `BUNDLED_CHAT_CHANNEL_IDS`, `BUNDLED_CHAT_CHANNEL_ENVELOPE_PREFIXES`, `ChatChannelId`. Canonical bundled/official chat channel ids plus formatter labels/aliases for plugins that need to recognize envelope-prefixed text without hardcoding their own table. |
| `plugin-sdk/channel-config-schema-legacy` | Deprecated compatibility alias for bundled-channel config schemas |
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |

View File

@@ -457,6 +457,10 @@ The branch already has a real shared SQLite base:
- GitHub Copilot token exchange cache uses the shared SQLite plugin-state table
under `github-copilot/token-cache/default`. It is provider-owned cache state,
so it intentionally does not add a host schema table.
- GitHub Copilot compaction no longer writes `openclaw-compaction-*.json`
workspace sidecars. The harness calls the SDK history compaction RPC for the
tracked SDK session, and OpenClaw keeps durable session/transcript state in
SQLite instead of compatibility marker files.
- The shared Swift runtime (`OpenClawKit`) uses the same
`state/openclaw.sqlite` rows for device identity and device auth. macOS app
helpers import the shared SQLite helpers instead of owning a second JSON or

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/acpx",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/acpx",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw ACP runtime backend with plugin-owned session and transport management.",
"repository": {
"type": "git",
@@ -26,10 +26,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31",
"openclawVersion": "2026.6.1-alpha.3",
"staticAssets": [
{
"source": "./src/runtime-internals/mcp-proxy.mjs",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/admin-http-rpc",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw admin HTTP RPC endpoint",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/alibaba-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@anthropic-ai/sdk": "0.100.1",
"@aws/bedrock-token-generator": "1.1.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
"repository": {
"type": "git",
@@ -24,10 +24,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31",
"openclawVersion": "2026.6.1-alpha.3",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1056.0",
"@aws-sdk/client-bedrock-runtime": "3.1056.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
"repository": {
"type": "git",
@@ -28,10 +28,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31",
"openclawVersion": "2026.6.1-alpha.3",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@anthropic-ai/vertex-sdk": "0.16.1"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
"repository": {
"type": "git",
@@ -23,10 +23,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31",
"openclawVersion": "2026.6.1-alpha.3",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Arcee provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/azure-speech",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Azure Speech plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bonjour",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw Bonjour/mDNS gateway discovery",
"type": "module",
"dependencies": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/brave-plugin",
"version": "2026.5.31"
"version": "2026.6.1-alpha.3"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw Brave Search provider plugin for web search.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31"
"openclawVersion": "2026.6.1-alpha.3"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/byteplus-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw BytePlus provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/canvas-plugin",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Canvas plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cerebras-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Cerebras provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Chutes.ai provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/clickclack",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw ClickClack channel plugin",
"type": "module",
@@ -18,7 +18,7 @@
"openclaw": "2026.5.28"
},
"peerDependencies": {
"openclaw": ">=2026.5.31"
"openclaw": ">=2026.6.1-alpha.3"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex-supervisor",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Codex app-server fleet supervision plugin.",
"type": "module",

View File

@@ -253,6 +253,8 @@ describe("codex media understanding provider", () => {
expect(result?.text).toBe("A red square.");
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
} finally {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
}
});

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/codex",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/codex",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@openai/codex": "0.135.0",
"typebox": "1.1.39",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.",
"repository": {
"type": "git",
@@ -26,10 +26,10 @@
"minHostVersion": ">=2026.5.1-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31"
"openclawVersion": "2026.6.1-alpha.3"
},
"release": {
"publishToClawHub": true,

View File

@@ -121,12 +121,14 @@ async function waitForThreadStart(harness: ClientHarness): Promise<{ id?: number
describe("startCodexAttemptThread", () => {
beforeEach(() => {
vi.useRealTimers();
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
clearSharedCodexAppServerClient();
});
afterEach(() => {
vi.useRealTimers();
clearSharedCodexAppServerClient();
vi.restoreAllMocks();
vi.unstubAllEnvs();

View File

@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createCodexSteeringQueue } from "./attempt-steering.js";
describe("Codex app-server steering queue", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
@@ -16,7 +20,9 @@ describe("Codex app-server steering queue", () => {
signal: new AbortController().signal,
});
await queue.queue("accepted", { debounceMs: 0 });
const queued = queue.queue("accepted", { debounceMs: 0 });
await vi.advanceTimersByTimeAsync(0);
await queued;
expect(request).toHaveBeenCalledWith("turn/steer", {
threadId: "thread-1",
@@ -37,9 +43,10 @@ describe("Codex app-server steering queue", () => {
signal: new AbortController().signal,
});
await expect(queue.queue("rejected", { debounceMs: 0 })).rejects.toThrow(
"cannot steer a compact turn",
);
const queued = queue.queue("rejected", { debounceMs: 0 });
const rejected = expect(queued).rejects.toThrow("cannot steer a compact turn");
await vi.advanceTimersByTimeAsync(0);
await rejected;
expect(request).toHaveBeenCalledWith("turn/steer", {
threadId: "thread-1",
expectedTurnId: "turn-1",
@@ -48,7 +55,6 @@ describe("Codex app-server steering queue", () => {
});
it("rejects queued steering when the run aborts before debounce flush", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const request = vi.fn(async () => ({ turnId: "turn-1" }));
const queue = createCodexSteeringQueue({

View File

@@ -82,6 +82,10 @@ export function createCodexSteeringQueue(params: {
batchedTexts.push({ text, resolve, reject });
clearBatchTimer();
const debounceMs = normalizeCodexSteerDebounceMs(options?.debounceMs);
if (debounceMs === 0) {
void flushBatch().catch(() => undefined);
return;
}
batchTimer = setTimeout(() => {
batchTimer = undefined;
void flushBatch().catch(() => undefined);

View File

@@ -9,6 +9,8 @@ describe("Codex app-server attempt turn watches", () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
@@ -91,6 +93,28 @@ describe("Codex app-server attempt turn watches", () => {
expect(harness.abortController.signal.reason).toBe("turn_completion_idle_timeout");
});
it("prefers completion idle timeout when completion and progress watches are due together", () => {
const harness = createController();
harness.controller.armAttemptIdleWatch();
harness.controller.touchActivity("request:item/tool/call:response", {
arm: true,
attemptProgress: true,
attemptTimeoutMs: 10,
});
vi.advanceTimersByTime(10);
expect(harness.timeouts).toMatchObject([
{
kind: "completion",
idleMs: 10,
timeoutMs: 10,
lastActivityReason: "request:item/tool/call:response",
},
]);
expect(harness.abortController.signal.reason).toBe("turn_completion_idle_timeout");
});
it("clamps oversized completion idle timeouts before scheduling", () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const harness = createController({

View File

@@ -166,6 +166,23 @@ export function createCodexAttemptTurnWatchController(params: {
scheduleTerminalIdleWatch();
}
function isCompletionIdleTimeoutDueBeforeAttempt(timeoutMs: number) {
if (
params.isCompleted() ||
params.isTerminalTurnNotificationQueued() ||
params.signal.aborted ||
!completionIdleWatchArmed ||
params.getActiveAppServerTurnRequests() > 0
) {
return false;
}
const completionTimeoutMs = completionIdleTimeoutOverrideMs ?? turnCompletionIdleTimeoutMs;
if (completionTimeoutMs > timeoutMs) {
return false;
}
return Math.max(0, Date.now() - completionLastActivityAt) >= completionTimeoutMs;
}
function recordAttemptProgress(
reason: string,
options?: { details?: Record<string, unknown>; attemptTimeoutMs?: number },
@@ -236,6 +253,10 @@ export function createCodexAttemptTurnWatchController(params: {
scheduleAttemptIdleWatch();
return;
}
if (isCompletionIdleTimeoutDueBeforeAttempt(timeoutMs)) {
fireCompletionIdleTimeout();
return;
}
const timeout = {
kind: "progress" as const,
idleMs,

View File

@@ -27,6 +27,11 @@ import type {
import { resolveCodexAppServerSpawnEnv } from "./transport-stdio.js";
const CODEX_APP_SERVER_AUTH_PROVIDER = "openai";
const LEGACY_CODEX_APP_SERVER_AUTH_PROVIDER = "codex-cli";
const CODEX_APP_SERVER_EXTERNAL_CLI_PROVIDER_IDS = [
CODEX_APP_SERVER_AUTH_PROVIDER,
LEGACY_CODEX_APP_SERVER_AUTH_PROVIDER,
];
const OPENAI_PROVIDER = "openai";
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai:default";
const CODEX_HOME_ENV_VAR = "CODEX_HOME";
@@ -120,7 +125,7 @@ function ensureCodexAppServerAuthProfileStore(params: {
return ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
config: params.config,
externalCliProviderIds: [CODEX_APP_SERVER_AUTH_PROVIDER],
externalCliProviderIds: CODEX_APP_SERVER_EXTERNAL_CLI_PROVIDER_IDS,
...(params.authProfileId ? { externalCliProfileIds: [params.authProfileId] } : {}),
});
}
@@ -599,7 +604,13 @@ async function resolveOAuthCredentialForCodexAppServer(
}
function isCodexAppServerAuthProvider(provider: string, config?: AuthProfileOrderConfig): boolean {
return resolveProviderIdForAuth(provider, { config }) === CODEX_APP_SERVER_AUTH_PROVIDER;
const resolvedProvider = resolveProviderIdForAuth(provider, { config });
return (
resolvedProvider === CODEX_APP_SERVER_AUTH_PROVIDER ||
// Older Codex auth profiles stored the CLI runtime id here. The app-server
// login protocol still receives the same externally managed ChatGPT token.
resolvedProvider === LEGACY_CODEX_APP_SERVER_AUTH_PROVIDER
);
}
function isOpenAIApiKeyBackupCredential(

View File

@@ -157,10 +157,12 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
let tmpDir: string;
beforeEach(async () => {
vi.useRealTimers();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-"));
});
afterEach(async () => {
vi.useRealTimers();
abortAgentHarnessRun(AUTH_PROFILE_RUNTIME_CONTRACT.sessionId);
resetCodexAppServerClientFactoryForTest();
await fs.rm(tmpDir, { recursive: true, force: true });

View File

@@ -29,8 +29,8 @@ describe("CodexAppServerClient", () => {
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
for (const client of clients) {
client.close();
}

View File

@@ -17,8 +17,8 @@ import type { CodexDynamicToolCallResponse } from "./protocol.js";
describe("dynamic tool execution helpers", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
});
it("keeps explicit dynamic tool timeouts above the default bridge deadline", () => {

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
abortAndDrainAgentHarnessRun,
nativeHookRelayTesting,
queueAgentHarnessMessage,
resetAgentEventsForTest,
@@ -30,6 +31,8 @@ const appServerHarnessWait = { interval: 1, timeout: 120_000 } as const;
const activeAppServerAttemptsForTest = new Set<{
abortController?: AbortController;
promise: Promise<unknown>;
sessionId: string;
sessionKey?: string;
}>();
type RunCodexAppServerAttemptOptions = NonNullable<
@@ -62,6 +65,8 @@ export function runCodexAppServerAttempt(
const entry = {
abortController,
promise: undefined as unknown as Promise<unknown>,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
};
const promise = runCodexAppServerAttemptImpl(
trackedParams,
@@ -76,6 +81,7 @@ export function runCodexAppServerAttempt(
}
async function drainActiveAppServerAttemptsForTest(): Promise<void> {
vi.useRealTimers();
const attempts = [...activeAppServerAttemptsForTest];
if (attempts.length === 0) {
return;
@@ -83,12 +89,33 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
for (const attempt of attempts) {
attempt.abortController?.abort("test_cleanup");
}
await Promise.race([
Promise.allSettled(attempts.map((attempt) => attempt.promise)),
new Promise<void>((resolve) => {
setTimeout(resolve, 5_000);
const drainedSessions = new Set<string>();
const sessionDrains = attempts.flatMap((attempt) => {
if (!attempt.sessionId || drainedSessions.has(attempt.sessionId)) {
return [];
}
drainedSessions.add(attempt.sessionId);
return [
abortAndDrainAgentHarnessRun({
sessionId: attempt.sessionId,
sessionKey: attempt.sessionKey,
settleMs: 1_000,
forceClear: true,
reason: "test_cleanup",
}).catch(() => undefined),
];
});
const drainResult = await Promise.race([
Promise.allSettled([...attempts.map((attempt) => attempt.promise), ...sessionDrains]).then(
() => "settled" as const,
),
new Promise<"timeout">((resolve) => {
setTimeout(() => resolve("timeout"), 5_000);
}),
]);
if (drainResult === "settled") {
activeAppServerAttemptsForTest.clear();
}
}
export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
@@ -465,6 +492,7 @@ export function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTes
export function setupRunAttemptTestHooks(): void {
beforeEach(async () => {
vi.useRealTimers();
clearInternalHooks();
resetAgentEventsForTest();
resetDiagnosticEventsForTest();
@@ -489,8 +517,8 @@ export function setupRunAttemptTestHooks(): void {
resetGlobalHookRunner();
clearInternalHooks();
defaultCodexAppInventoryCache.clear();
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllEnvs();
await closeCodexSandboxExecServersForTests();
await fs.rm(tempDir, { recursive: true, force: true });

View File

@@ -84,6 +84,7 @@ function turnStartResult(turnId = "turn-1") {
describe("Codex app-server main thread cleanup", () => {
beforeEach(async () => {
vi.useRealTimers();
resetAgentEventsForTest();
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
vi.stubEnv("CODEX_API_KEY", "");
@@ -92,6 +93,7 @@ describe("Codex app-server main thread cleanup", () => {
});
afterEach(async () => {
vi.useRealTimers();
resetAgentEventsForTest();
vi.restoreAllMocks();
vi.unstubAllEnvs();

View File

@@ -804,6 +804,52 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
});
it("keeps mirrored history when an inactive per-turn context-engine binding starts fresh", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage(userMessage("previous per-turn request", 10) as never);
sessionManager.appendMessage(assistantMessage("previous per-turn answer", 11) as never);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-per-turn-context",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
},
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
if (method === "thread/resume") {
throw new Error("inactive context-engine bindings should start a fresh thread");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
]);
const inputText = getRequestInputText(harness);
expect(inputText).toContain("OpenClaw assembled context for this turn:");
expect(inputText).toContain("previous per-turn request");
expect(inputText).toContain("previous per-turn answer");
expect(inputText).toContain("Current user request:");
expect(inputText).toContain("hello");
await harness.completeTurn("completed", "thread-fresh");
await run;
});
it("starts a fresh Codex thread and reprojects when context-engine epoch changes", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
const sessionFile = path.join(tempDir, "session.jsonl");

View File

@@ -18,24 +18,44 @@ import {
setupRunAttemptTestHooks();
function createSteeringParams(name: string) {
const params = createParams(
path.join(tempDir, `${name}.jsonl`),
path.join(tempDir, `${name}-workspace`),
);
params.sessionId = `session-${name}`;
params.sessionKey = `agent:main:session-${name}`;
return params;
}
async function queueActiveRunMessageEventually(
sessionId: string,
text: string,
options?: Parameters<typeof queueActiveRunMessageForTest>[2],
) {
await vi.waitFor(
() => expect(queueActiveRunMessageForTest(sessionId, text, options)).toBe(true),
fastWait,
);
}
describe("runCodexAppServerAttempt steering", () => {
it("forwards queued user input and aborts the active app-server turn", async () => {
const { requests, waitForMethod } = createStartedThreadHarness();
const params = createSteeringParams("steering-forward");
const run = runCodexAppServerAttempt(
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
{ pluginConfig: { appServer: { mode: "yolo" } } },
);
const run = runCodexAppServerAttempt(params, { pluginConfig: { appServer: { mode: "yolo" } } });
await waitForMethod("turn/start");
expect(queueActiveRunMessageForTest("session-1", "more context", { debounceMs: 1 })).toBe(true);
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain("turn/steer"), {
interval: 1,
});
expect(abortAgentHarnessRun("session-1")).toBe(true);
await queueActiveRunMessageEventually(params.sessionId, "more context", { debounceMs: 1 });
await vi.waitFor(
() => expect(requests.map((entry) => entry.method)).toContain("turn/steer"),
fastWait,
);
expect(abortAgentHarnessRun(params.sessionId)).toBe(true);
await vi.waitFor(
() => expect(requests.map((entry) => entry.method)).toContain("turn/interrupt"),
{ interval: 1 },
fastWait,
);
const result = await run;
@@ -67,22 +87,21 @@ describe("runCodexAppServerAttempt steering", () => {
it("accepts message-tool-only steering for active Codex app-server source replies", async () => {
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
const params = createSteeringParams("steering-message-tool");
params.sourceReplyDeliveryMode = "message_tool_only";
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
expect(
queueActiveRunMessageForTest("session-1", "subagent complete", {
await queueActiveRunMessageEventually(
params.sessionId,
"subagent complete",
{
debounceMs: 1,
steeringMode: "all",
sourceReplyDeliveryMode: "message_tool_only",
}),
).toBe(true);
},
);
await vi.waitFor(
() =>
@@ -96,7 +115,7 @@ describe("runCodexAppServerAttempt steering", () => {
},
},
]),
{ interval: 1 },
fastWait,
);
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
@@ -105,14 +124,13 @@ describe("runCodexAppServerAttempt steering", () => {
it("batches default queued steering before sending turn/steer", async () => {
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const params = createSteeringParams("steering-batch-default");
const run = runCodexAppServerAttempt(
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
);
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
expect(queueActiveRunMessageForTest("session-1", "first", { debounceMs: 5 })).toBe(true);
expect(queueActiveRunMessageForTest("session-1", "second", { debounceMs: 5 })).toBe(true);
await queueActiveRunMessageEventually(params.sessionId, "first", { debounceMs: 5 });
expect(queueActiveRunMessageForTest(params.sessionId, "second", { debounceMs: 5 })).toBe(true);
await vi.waitFor(
() =>
@@ -129,7 +147,7 @@ describe("runCodexAppServerAttempt steering", () => {
},
},
]),
{ interval: 1 },
fastWait,
);
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
@@ -138,15 +156,12 @@ describe("runCodexAppServerAttempt steering", () => {
it("flushes pending default queued steering during normal turn cleanup", async () => {
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const params = createSteeringParams("steering-flush");
const run = runCodexAppServerAttempt(
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
);
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
expect(queueActiveRunMessageForTest("session-1", "late steer", { debounceMs: 30_000 })).toBe(
true,
);
await queueActiveRunMessageEventually(params.sessionId, "late steer", { debounceMs: 30_000 });
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -165,17 +180,20 @@ describe("runCodexAppServerAttempt steering", () => {
it("batches explicit all-mode steering before sending turn/steer", async () => {
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const params = createSteeringParams("steering-batch-all");
const run = runCodexAppServerAttempt(
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
);
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
await queueActiveRunMessageEventually(params.sessionId, "first", {
debounceMs: 5,
steeringMode: "all",
});
expect(
queueActiveRunMessageForTest("session-1", "first", { debounceMs: 5, steeringMode: "all" }),
).toBe(true);
expect(
queueActiveRunMessageForTest("session-1", "second", { debounceMs: 5, steeringMode: "all" }),
queueActiveRunMessageForTest(params.sessionId, "second", {
debounceMs: 5,
steeringMode: "all",
}),
).toBe(true);
await vi.waitFor(
@@ -193,7 +211,7 @@ describe("runCodexAppServerAttempt steering", () => {
},
},
]),
{ interval: 1 },
fastWait,
);
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
@@ -235,10 +253,7 @@ describe("runCodexAppServerAttempt steering", () => {
}) as never,
);
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
const params = createSteeringParams("steering-request-input");
params.onBlockReply = vi.fn();
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
@@ -271,7 +286,7 @@ describe("runCodexAppServerAttempt steering", () => {
});
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1), fastWait);
expect(queueActiveRunMessageForTest("session-1", "2")).toBe(true);
await queueActiveRunMessageEventually(params.sessionId, "2");
await expect(response).resolves.toEqual({
answers: { mode: { answers: ["Deep"] } },
});

View File

@@ -3937,6 +3937,46 @@ describe("runCodexAppServerAttempt", () => {
}
});
it("does not install an active run handle when turn start resolves after abort", async () => {
let resolveTurnStart: ((value: ReturnType<typeof turnStartResult>) => void) | undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
}
if (method === "turn/start") {
return await new Promise<ReturnType<typeof turnStartResult>>((resolve) => {
resolveTurnStart = resolve;
});
}
return {};
});
setCodexAppServerClientFactoryForTest(
async () =>
({
request,
addNotificationHandler: () => () => undefined,
addRequestHandler: () => () => undefined,
}) as never,
);
const abortController = new AbortController();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.abortSignal = abortController.signal;
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
() => expect(request.mock.calls.map(([method]) => method)).toContain("turn/start"),
fastWait,
);
abortController.abort("test_abort");
resolveTurnStart?.(turnStartResult());
await expect(run).rejects.toThrow("test_abort");
expect(queueActiveRunMessageForTest("session-1", "after abort")).toBe(false);
});
it("keeps extended history enabled when resuming a bound Codex thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -443,17 +443,26 @@ export async function runCodexAppServerAttempt(
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId);
preDynamicStartupStages.mark("session-agent");
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
: undefined;
const isInactiveThreadBootstrapBinding = (binding: CodexAppServerThreadBinding | undefined) =>
!activeContextEngine && binding?.contextEngine?.projection?.mode === "thread_bootstrap";
let startupBinding = await readCodexAppServerBinding(params.sessionFile);
preDynamicStartupStages.mark("read-binding");
const startupBindingAuthProfileId = startupBinding?.authProfileId;
const initialStartupBindingHadInactiveThreadBootstrap =
isInactiveThreadBootstrapBinding(startupBinding);
startupBinding = await rotateOversizedCodexAppServerStartupBinding({
binding: startupBinding,
sessionFile: params.sessionFile,
agentDir,
codexHome: appServer.start.env?.CODEX_HOME,
config: params.config,
contextEngineActive: isActiveHarnessContextEngine(params.contextEngine),
contextEngineActive: Boolean(activeContextEngine),
});
const initialInactiveThreadBootstrapBindingForcedFreshStart =
initialStartupBindingHadInactiveThreadBootstrap && !startupBinding?.threadId;
preDynamicStartupStages.mark("rotate-binding");
const startupAuthProfileCandidate =
params.runtimePlan?.auth.forwardedAuthProfileId ??
@@ -520,9 +529,6 @@ export async function runCodexAppServerAttempt(
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
: undefined;
if (activeContextEngine) {
assertContextEngineHostSupport({
contextEngine: activeContextEngine,
@@ -684,6 +690,8 @@ export async function runCodexAppServerAttempt(
let contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
let precomputedStaleBindingContinuityProjectionApplied = false;
let staleBindingContinuityForcedFreshStart = false;
let inactiveThreadBootstrapBindingForcedFreshStart =
initialInactiveThreadBootstrapBindingForcedFreshStart;
const applyFreshThreadContinuityProjection = () => {
const projection = projectContextEngineAssemblyForCodex({
assembledMessages: historyMessages,
@@ -875,6 +883,10 @@ export async function runCodexAppServerAttempt(
if (activeContextEngine || !binding?.threadId) {
return false;
}
if (isInactiveThreadBootstrapBinding(binding)) {
inactiveThreadBootstrapBindingForcedFreshStart = true;
return false;
}
const projected = applyResumeStaleBindingContinuityProjection(binding);
precomputedStaleBindingContinuityProjectionApplied = projected;
return projected;
@@ -892,6 +904,12 @@ export async function runCodexAppServerAttempt(
if (action === "started" && staleBindingContinuityForcedFreshStart) {
return true;
}
if (action === "started" && inactiveThreadBootstrapBindingForcedFreshStart) {
// A retired thread-bootstrap context engine already forced Codex onto a
// clean native thread; without that engine active, mirrored history would
// re-inject stale bootstrap context as a new user turn.
return false;
}
if (action === "resumed" && binding) {
return applyResumeStaleBindingContinuityProjection(binding);
}
@@ -909,6 +927,7 @@ export async function runCodexAppServerAttempt(
return;
}
const previousThreadId = startupBinding.threadId;
const hadInactiveThreadBootstrapBinding = isInactiveThreadBootstrapBinding(startupBinding);
const projectedTurnTokens = estimateCodexAppServerProjectedTurnTokens({
prompt: codexTurnPromptText,
developerInstructions: buildRenderedCodexDeveloperInstructions(),
@@ -925,7 +944,10 @@ export async function runCodexAppServerAttempt(
if (startupBinding?.threadId) {
return;
}
staleBindingContinuityForcedFreshStart = precomputedStaleBindingContinuityProjectionApplied;
inactiveThreadBootstrapBindingForcedFreshStart = hadInactiveThreadBootstrapBinding;
staleBindingContinuityForcedFreshStart =
precomputedStaleBindingContinuityProjectionApplied &&
!inactiveThreadBootstrapBindingForcedFreshStart;
if (activeContextEngine) {
contextEngineProjection = undefined;
try {
@@ -1810,6 +1832,22 @@ export async function runCodexAppServerAttempt(
});
let turn: CodexTurnStartResponse | undefined;
const throwIfTurnStartAcceptedAfterAbort = () => {
if (!runAbortController.signal.aborted) {
return;
}
const reason = runAbortController.signal.reason;
if (reason instanceof Error) {
throw reason;
}
const error = new Error(
typeof reason === "string" && reason.length > 0
? reason
: "codex app-server turn start aborted before acceptance",
);
error.name = "AbortError";
throw error;
};
const startCodexTurn = async (): Promise<CodexTurnStartResponse> => {
const turnStartParams = buildTurnStartParams(params, {
threadId: thread.threadId,
@@ -1825,12 +1863,14 @@ export async function runCodexAppServerAttempt(
workspaceBootstrapContext.heartbeatCollaborationInstructions,
});
codexModelCallDiagnostics.setRequestPayloadBytes(utf8JsonByteLength(turnStartParams));
return assertCodexTurnStartResponse(
const startedTurn = assertCodexTurnStartResponse(
await client.request("turn/start", turnStartParams, {
timeoutMs: params.timeoutMs,
signal: runAbortController.signal,
}),
);
throwIfTurnStartAcceptedAfterAbort();
return startedTurn;
};
try {
codexModelCallDiagnostics.emitStarted();
@@ -2101,7 +2141,7 @@ export async function runCodexAppServerAttempt(
kind: "embedded" as const,
queueMessage: async (text: string, optionsLocal?: CodexSteeringQueueOptions) =>
activeSteeringQueue.queue(text, optionsLocal),
isStreaming: () => !completed,
isStreaming: () => !completed && !runAbortController.signal.aborted,
isCompacting: () => projectorRef.current?.isCompacting() ?? false,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
cancel: () => runAbortController.abort("cancelled"),

View File

@@ -133,8 +133,8 @@ describe("shared Codex app-server client", () => {
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.applyCodexAppServerAuthProfile.mockClear();
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockClear();

View File

@@ -262,7 +262,12 @@ export async function createIsolatedCodexAppServerClient(
export function resetSharedCodexAppServerClientForTests(): void {
const state = getSharedCodexAppServerClientState();
const clients = collectSharedClients(state);
state.clients.clear();
state.leasedReleases = new WeakMap();
for (const client of clients) {
client.close();
}
}
export function clearSharedCodexAppServerClient(): void {

View File

@@ -2,4 +2,4 @@ export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
export const MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION = "0.132.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
// Keep this in sync with the Codex CLI live-test package pin.
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.134.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.135.0";

View File

@@ -186,6 +186,7 @@ describe("codex conversation turn collector", () => {
await vi.advanceTimersByTimeAsync(100);
await assertion;
} finally {
vi.restoreAllMocks();
vi.useRealTimers();
}
});
@@ -206,6 +207,8 @@ describe("codex conversation turn collector", () => {
await expect(completion).resolves.toEqual({ replyText: "" });
} finally {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
}
});

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/comfy-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw ComfyUI provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,16 +1,26 @@
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CopilotClientPool } from "./harness.js";
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
const mocks = vi.hoisted(() => ({
runCopilotAttempt: vi.fn(),
resolvePoolAcquire: vi.fn(
() =>
({
auth: {
agentId: "test",
authMode: "useLoggedInUser",
copilotHome: "/tmp/copilot",
},
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/tmp/copilot" },
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
}) as any,
),
createCopilotClientPool: vi.fn(),
}));
vi.mock("./src/attempt.js", () => ({
resolvePoolAcquire: mocks.resolvePoolAcquire,
runCopilotAttempt: mocks.runCopilotAttempt,
}));
@@ -20,6 +30,12 @@ vi.mock("./src/runtime.js", () => ({
const ATTEMPT_PARAMS = { provider: "github-copilot", model: "gpt-4.1" } as any;
const ATTEMPT_RESULT = { ok: true } as any;
const TEST_SESSION_CONFIG = {
availableTools: [],
model: "gpt-4.1",
tools: [],
workingDirectory: "/workspace",
};
function makePoolMock(): CopilotClientPool {
return {
@@ -63,8 +79,18 @@ async function flushAsyncWork() {
describe("createCopilotAgentHarness", () => {
beforeEach(() => {
mocks.runCopilotAttempt.mockReset();
mocks.resolvePoolAcquire.mockClear();
mocks.createCopilotClientPool.mockReset();
mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT);
mocks.resolvePoolAcquire.mockReturnValue({
auth: {
agentId: "test",
authMode: "useLoggedInUser",
copilotHome: "/tmp/copilot",
},
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/tmp/copilot" },
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
});
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
});
@@ -504,7 +530,7 @@ describe("createCopilotAgentHarness", () => {
function makeAttemptParams(overrides: Record<string, unknown> = {}): any {
return {
provider: "github-copilot",
model: { provider: "github-copilot", id: "gpt-4.1" },
model: "gpt-4.1",
cwd: "/ws",
workspaceDir: "/ws",
agentDir: "/home",
@@ -585,6 +611,36 @@ describe("createCopilotAgentHarness", () => {
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
});
it("does not seed when compatibility fingerprint differs (model API change)", async () => {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-api",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeAttemptParams({
runId: "t1",
model: { api: "chat", provider: "github-copilot", id: "gpt-4.1" },
}),
);
await harness.runAttempt(
makeAttemptParams({
runId: "t2",
model: { api: "responses", provider: "github-copilot", id: "gpt-4.1" },
}),
);
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
});
it("does not seed when compatibility fingerprint differs (legacy auth.gitHubToken rotation)", async () => {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
@@ -779,7 +835,7 @@ describe("createCopilotAgentHarness", () => {
expect(sessionStore.store.register).toHaveBeenCalledWith(
"oc-sess-reuse",
expect.objectContaining({
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId: "sdk-sess-sqlite",
}),
);
@@ -789,6 +845,45 @@ describe("createCopilotAgentHarness", () => {
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-sqlite");
});
it("resumes shipped schema v1 plugin-state bindings for attempts", async () => {
const sessionStore = makeSessionStoreMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-current",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const firstHarness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
await firstHarness.runAttempt(makeAttemptParams({ runId: "t1" }));
const stored = sessionStore.entries.get("oc-sess-reuse");
if (!stored) {
throw new Error("expected persisted binding");
}
sessionStore.entries.set("oc-sess-reuse", {
schemaVersion: 1,
sdkSessionId: "sdk-sess-v1",
compatKey: stored.compatKey,
updatedAt: Date.now(),
} as never);
mocks.runCopilotAttempt.mockClear();
const secondHarness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
await secondHarness.runAttempt(makeAttemptParams({ runId: "t2" }));
const secondCallParams = mocks.runCopilotAttempt.mock.calls[0]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-v1");
});
it("starts a fresh SDK session when persisted binding lookup fails", async () => {
const sessionStore = makeSessionStoreMock();
sessionStore.store.lookup.mockImplementation(() => {
@@ -814,9 +909,11 @@ describe("createCopilotAgentHarness", () => {
it("keeps the in-memory binding when durable register fails", async () => {
const sessionStore = makeSessionStoreMock();
sessionStore.entries.set("oc-sess-reuse", {
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId: "sdk-sess-stale",
compatKey: "stale",
compactKey: "stale",
authMode: "useLoggedInUser",
updatedAt: 1,
});
sessionStore.store.register.mockImplementation(() => {
@@ -962,9 +1059,11 @@ describe("createCopilotAgentHarness", () => {
it("deletes persisted sdkSessionId on reset even when no in-memory client is tracked", async () => {
const sessionStore = makeSessionStoreMock();
sessionStore.entries.set("oc-sess-reuse", {
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId: "sdk-sess-orphan",
compatKey: "compat",
compactKey: "compat",
authMode: "useLoggedInUser",
updatedAt: 1,
});
const harness = createCopilotAgentHarness({
@@ -1038,6 +1137,20 @@ describe("createCopilotAgentHarness", () => {
});
describe("compact", () => {
function makeCompactParams(overrides: Record<string, unknown> = {}): any {
return {
provider: "github-copilot",
model: { provider: "github-copilot", id: "gpt-4.1" },
cwd: "/ws",
workspaceDir: "/ws",
agentDir: "/home",
copilotHome: "/copilot-home",
auth: { useLoggedInUser: true },
sessionId: "oc-sess-compact",
...overrides,
};
}
it("returns ok:false when sessionId is missing", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({ workspaceDir: "/ws" } as any);
@@ -1048,124 +1161,667 @@ describe("createCopilotAgentHarness", () => {
});
});
it("returns ok:false when workspaceDir is missing", async () => {
it("returns ok:false when the SDK session is not tracked", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({ sessionId: "s" } as any);
const result = await harness.compact?.({
sessionId: "oc-sess-compact-1",
trigger: "budget",
currentTokenCount: 12345,
} as any);
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing-required-params",
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("writes an OpenClaw marker under <workspaceDir>/files and returns ok:true,compacted:false", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-"));
try {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({
it("calls the SDK history compaction RPC without requiring a workspace sidecar", async () => {
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
}));
const disconnect = vi.fn(async () => {
throw new Error("disconnect failed");
});
const resumeSession = vi.fn(async () => ({
disconnect,
rpc: { history: { compact } },
}));
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
const release = vi.fn(async () => undefined);
pool.release = release;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-compact",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeCompactParams({
agentId: "main",
sessionId: "oc-sess-compact-1",
workspaceDir,
trigger: "budget",
currentTokenCount: 12345,
} as any);
expect(result).toEqual({
ok: true,
compacted: false,
reason: "deferred-to-sdk-infinite-sessions",
});
const files = await readdir(join(workspaceDir, "files"));
const marker = files.find((f) => f.startsWith("openclaw-compaction-"));
expect(marker).toBeDefined();
expect(marker).toMatch(/openclaw-compaction-\d+-oc-sess-compact-1\.json/);
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker!), "utf8"));
expect(contents).toMatchObject({
version: 1,
source: "copilot-harness",
sessionId: "oc-sess-compact-1",
compacted: false,
trigger: "budget",
currentTokenCount: 12345,
reason: "deferred-to-sdk-infinite-sessions",
});
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("records the tracked sdkSessionId in the marker when an attempt has run", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-tracked-"));
try {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-tracked",
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-sess-tracked" });
await harness.compact?.({
sessionId: "oc-sess-tracked",
workspaceDir,
trigger: "manual",
} as any);
const files = await readdir(join(workspaceDir, "files"));
const marker = files.find((f) => f.startsWith("openclaw-compaction-"))!;
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker), "utf8"));
expect(contents.sdkSessionId).toBe("sdk-sess-tracked");
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("records force:true in the marker and surfaces a force-specific reason", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-force-"));
try {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({
sessionId: "oc-sess-force",
workspaceDir,
force: true,
} as any);
expect(result).toEqual({
ok: true,
compacted: false,
reason: "force-requested-but-sdk-has-no-synchronous-compact-api",
});
const files = await readdir(join(workspaceDir, "files"));
const marker = files.find((f) => f.startsWith("openclaw-compaction-"))!;
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker), "utf8"));
expect(contents.force).toBe(true);
expect(contents.reason).toBe("force-requested-but-sdk-has-no-synchronous-compact-api");
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("returns ok:false with structured failure when the marker write throws", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
// Use a path with a NUL character which Node rejects synchronously
// on every platform, simulating a write failure that the harness
// must convert into a structured failure instead of throwing.
const badWorkspace = "/this\u0000is/illegal";
sessionKey: "agent:main:main",
}),
);
const result = await harness.compact?.({
sessionId: "oc-sess-bad",
workspaceDir: badWorkspace,
} as any);
...makeCompactParams({ sessionId: "oc-sess-compact-1" }),
model: "gpt-4.1",
sessionKey: "agent:main:main",
sessionId: "oc-sess-compact-1",
workspaceDir: "/this\u0000is/illegal",
customInstructions: "Keep decisions.",
});
expect(result?.ok).toBe(false);
expect(result?.compacted).toBe(false);
expect(result?.reason).toBe("marker-write-failed");
expect(result?.failure?.reason).toBe("marker-write-failed");
expect(typeof result?.failure?.rawError).toBe("string");
expect(result?.failure?.rawError?.length ?? 0).toBeGreaterThan(0);
expect(resumeSession).toHaveBeenCalledWith(
"sdk-sess-compact",
expect.objectContaining({
availableTools: [],
continuePendingWork: false,
model: "gpt-4.1",
suppressResumeEvent: true,
tools: [],
workingDirectory: "/workspace",
}),
);
expect(compact).toHaveBeenCalledWith({ customInstructions: "Keep decisions." });
expect(disconnect).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(1);
expect(result).toEqual({
ok: true,
compacted: true,
reason: "copilot-sdk-history-compacted",
});
});
it("disconnects the resumed SDK session when compact aborts after resume", async () => {
const abortController = new AbortController();
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
}));
const disconnect = vi.fn(async () => undefined);
const resumeSession = vi.fn(async () => {
abortController.abort(new Error("stop compact"));
return {
disconnect,
rpc: { history: { compact } },
};
});
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
const release = vi.fn(async () => undefined);
pool.release = release;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-abort",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeCompactParams({
agentId: "main",
sessionId: "oc-sess-abort",
sessionKey: "agent:main:main",
}),
);
const result = await harness.compact?.({
...makeCompactParams({ sessionId: "oc-sess-abort" }),
abortSignal: abortController.signal,
model: "gpt-4.1",
sessionKey: "agent:main:main",
sessionId: "oc-sess-abort",
});
expect(resumeSession).toHaveBeenCalledTimes(1);
expect(compact).not.toHaveBeenCalled();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(1);
expect(result).toEqual({
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: {
reason: "copilot-sdk-history-compact-failed",
rawError: "stop compact",
},
});
});
it("requires matching token auth before compacting a tracked token-auth SDK session", async () => {
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 45,
messagesRemoved: 2,
}));
const resumeSession = vi.fn(async () => ({
disconnect: vi.fn(async () => undefined),
rpc: { history: { compact } },
}));
const pool = makePoolMock();
const acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.acquire = acquire;
pool.release = vi.fn(async () => undefined);
mocks.resolvePoolAcquire
.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v1",
copilotHome: "/copilot-home",
gitHubToken: "ghp_test",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
})
.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "useLoggedInUser",
copilotHome: "/copilot-home",
},
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", useLoggedInUser: true },
})
.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v1",
copilotHome: "/copilot-home",
gitHubToken: "ghp_test",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
});
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-token",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeCompactParams({
auth: { gitHubToken: "ghp_test", profileId: "p1", profileVersion: "v1" },
sessionId: "oc-sess-token",
}),
);
const result = await harness.compact?.(
makeCompactParams({
auth: undefined,
sessionId: "oc-sess-token",
}),
);
expect(acquire).not.toHaveBeenCalled();
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
const matchingResult = await harness.compact?.(
makeCompactParams({
auth: undefined,
authProfileId: "p1",
resolvedApiKey: "ghp_test",
sessionId: "oc-sess-token",
}),
);
expect(resumeSession).toHaveBeenCalledWith(
"sdk-sess-token",
expect.objectContaining({
continuePendingWork: false,
gitHubToken: "ghp_test",
model: "gpt-4.1",
suppressResumeEvent: true,
workingDirectory: "/workspace",
}),
);
expect(matchingResult?.compacted).toBe(true);
});
it("does not compact a tracked SDK session after model changes", async () => {
const resumeSession = vi.fn();
const pool = makePoolMock();
const acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.acquire = acquire;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-model",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-model" }));
const result = await harness.compact?.(
makeCompactParams({ model: "gpt-5", sessionId: "oc-sess-model" }),
);
expect(acquire).not.toHaveBeenCalled();
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("does not compact a logged-in-user SDK session for a token-auth compact request", async () => {
const resumeSession = vi.fn();
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-login",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-login" }));
mocks.resolvePoolAcquire.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v1",
copilotHome: "/copilot-home",
gitHubToken: "ghp_test",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
});
const result = await harness.compact?.(
makeCompactParams({
auth: { gitHubToken: "ghp_test", profileId: "p1", profileVersion: "v1" },
sessionId: "oc-sess-login",
}),
);
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("classifies missing SDK sessions as stale bindings for host recovery", async () => {
const sessionStore = makeSessionStoreMock();
const resumeSession = vi.fn(async () => {
throw new Error("session not found");
});
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-stale",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-stale" }));
const result = await harness.compact?.(makeCompactParams({ sessionId: "oc-sess-stale" }));
expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-stale");
expect(result).toEqual({
ok: false,
compacted: false,
reason: "stale_thread_binding",
failure: { reason: "stale_thread_binding", rawError: "session not found" },
});
});
it("does not start SDK compaction when the compact call is already aborted", async () => {
const abort = new AbortController();
abort.abort(new Error("caller canceled"));
const resumeSession = vi.fn();
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-abort",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-abort" }));
const result = await harness.compact?.(
makeCompactParams({ abortSignal: abort.signal, sessionId: "oc-sess-abort" }),
);
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: {
reason: "copilot-sdk-history-compact-failed",
rawError: "caller canceled",
},
});
});
it("aborts the SDK manual history compaction when the compact call is canceled", async () => {
const abort = new AbortController();
let rejectCompact: ((reason?: unknown) => void) | undefined;
const compact = vi.fn(
() =>
new Promise<never>((_resolve, reject) => {
rejectCompact = reject;
}),
);
const abortManualCompaction = vi.fn(async () => {
rejectCompact?.(new Error("manual compaction aborted"));
return { aborted: true };
});
const disconnect = vi.fn(async () => undefined);
const resumeSession = vi.fn(async () => ({
disconnect,
rpc: { history: { abortManualCompaction, compact } },
}));
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-cancel",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-cancel" }));
const resultPromise = harness.compact?.(
makeCompactParams({ abortSignal: abort.signal, sessionId: "oc-sess-cancel" }),
);
await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1));
abort.abort(new Error("caller canceled"));
const result = await resultPromise;
expect(abortManualCompaction).toHaveBeenCalledTimes(1);
expect(result).toEqual({
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: {
reason: "copilot-sdk-history-compact-failed",
rawError: "caller canceled",
},
});
});
it("refuses persisted token-auth bindings without matching token auth", async () => {
const sessionStore = makeSessionStoreMock();
mocks.resolvePoolAcquire.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v1",
copilotHome: "/copilot-home",
gitHubToken: "ghp_test",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
});
mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-persisted-token",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession: vi.fn() } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const firstHarness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
await firstHarness.runAttempt(
makeCompactParams({
auth: { gitHubToken: "ghp_test", profileId: "p1", profileVersion: "v1" },
sessionId: "oc-sess-persisted-token",
}),
);
const resumeSession = vi.fn();
const secondPool = makePoolMock();
const secondAcquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
secondPool.acquire = secondAcquire;
const secondHarness = createCopilotAgentHarness({
pool: secondPool,
sessionStore: sessionStore.store,
});
const result = await secondHarness.compact?.(
makeCompactParams({ auth: undefined, sessionId: "oc-sess-persisted-token" }),
);
expect(secondAcquire).not.toHaveBeenCalled();
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
mocks.resolvePoolAcquire.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v2",
copilotHome: "/copilot-home",
gitHubToken: "ghp_other",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_other" },
});
const rotatedPool = makePoolMock();
const rotatedAcquire = vi.fn();
rotatedPool.acquire = rotatedAcquire;
const rotatedHarness = createCopilotAgentHarness({
pool: rotatedPool,
sessionStore: sessionStore.store,
});
const rotatedResult = await rotatedHarness.compact?.(
makeCompactParams({
auth: { gitHubToken: "ghp_other", profileId: "p1", profileVersion: "v2" },
sessionId: "oc-sess-persisted-token",
}),
);
expect(rotatedAcquire).not.toHaveBeenCalled();
expect(rotatedResult).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("does not compact a persisted SDK binding after harness restart", async () => {
const sessionStore = makeSessionStoreMock();
const firstHarness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-persisted",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession: vi.fn() } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
await firstHarness.runAttempt(makeCompactParams({ sessionId: "oc-sess-persisted" }));
const resumeSession = vi.fn();
const secondPool = makePoolMock();
const secondAcquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
secondPool.acquire = secondAcquire;
secondPool.release = vi.fn(async () => undefined);
const secondHarness = createCopilotAgentHarness({
pool: secondPool,
sessionStore: sessionStore.store,
});
const result = await secondHarness.compact?.(
makeCompactParams({ sessionId: "oc-sess-persisted" }),
);
expect(secondAcquire).not.toHaveBeenCalled();
expect(resumeSession).not.toHaveBeenCalled();
expect(sessionStore.store.delete).not.toHaveBeenCalledWith("oc-sess-persisted");
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("reports SDK history compaction no-ops without writing compatibility state", async () => {
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 0,
messagesRemoved: 0,
}));
const resumeSession = vi.fn(async () => ({
disconnect: vi.fn(async () => undefined),
rpc: { history: { compact } },
}));
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-noop",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-noop" }));
const result = await harness.compact?.({
...makeCompactParams({ sessionId: "oc-sess-noop" }),
sessionId: "oc-sess-noop",
workspaceDir: "/this\u0000is/illegal",
});
expect(compact).toHaveBeenCalledWith(undefined);
expect(result).toEqual({
ok: true,
compacted: false,
reason: "already under target",
});
});
});

View File

@@ -1,16 +1,24 @@
import type { CopilotClient } from "@github/copilot-sdk";
import type {
AgentHarness,
AgentHarnessAttemptParams,
AgentHarnessAttemptResult,
AgentHarnessCompactParams,
AgentHarnessCompactResult,
AgentHarnessResetParams,
import {
compactWithSafetyTimeout,
resolveCompactionTimeoutMs,
type AgentHarness,
type AgentHarnessAttemptParams,
type AgentHarnessAttemptResult,
type AgentHarnessCompactParams,
type AgentHarnessCompactResult,
type AgentHarnessResetParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import type { CopilotSessionConfig } from "./src/attempt.js";
import { resolveCopilotAuth } from "./src/auth-bridge.js";
import { writeOpenClawCompactionMarker } from "./src/compaction-bridge.js";
import type { CopilotClientPool, CopilotClientPoolOptions, PooledClient } from "./src/runtime.js";
import type {
ClientCreateOptions,
CopilotClientPool,
CopilotClientPoolOptions,
PooledClient,
PoolKey,
} from "./src/runtime.js";
export type { CopilotClientPool, CopilotClientPoolOptions };
@@ -28,6 +36,9 @@ export interface CreateCopilotAgentHarnessOptions {
interface TrackedSession {
sdkSessionId: string;
client: CopilotClient;
clientOptions: ClientCreateOptions;
poolKey: PoolKey;
sessionConfig: CopilotSessionConfig;
// Compatibility fingerprint of the params that created the SDK
// session. We only reuse the tracked SDK session when the next
// attempt's fingerprint matches — different provider/model/cwd/auth
@@ -36,49 +47,153 @@ interface TrackedSession {
// `createSession` (no resume injection) and the new sdkSessionId
// replaces this entry via `onSessionEstablished`.
compatKey: string;
compactKey: string;
authMode: "gitHubToken" | "useLoggedInUser";
authProfileId?: string;
authProfileVersion?: string;
}
interface CopilotHistoryCompactResult {
success: boolean;
tokensRemoved: number;
messagesRemoved: number;
summaryContent?: string;
}
interface CopilotHistoryCompactSession {
abort(): Promise<void>;
disconnect(): Promise<void>;
rpc: {
history: {
abortManualCompaction(): Promise<{ aborted: boolean }>;
compact(params?: { customInstructions?: string }): Promise<CopilotHistoryCompactResult>;
};
};
}
export type CopilotSessionBinding = {
schemaVersion: 2;
sdkSessionId: string;
compatKey: string;
compactKey: string;
authMode: "gitHubToken" | "useLoggedInUser";
authProfileId?: string;
authProfileVersion?: string;
updatedAt: number;
};
type LegacyCopilotSessionBinding = {
schemaVersion: 1;
sdkSessionId: string;
compatKey: string;
updatedAt: number;
};
type CopilotAttemptSessionBinding = Pick<CopilotSessionBinding, "compatKey" | "sdkSessionId">;
type CopilotSessionBindingStore = Pick<
PluginStateSyncKeyedStore<CopilotSessionBinding>,
"delete" | "lookup" | "register"
>;
type CopilotSessionAuth = Pick<
CopilotSessionBinding,
"authMode" | "authProfileId" | "authProfileVersion"
>;
function sessionAuthFields(auth: CopilotSessionAuth): CopilotSessionAuth {
return auth.authMode === "gitHubToken"
? {
authMode: "gitHubToken",
authProfileId: auth.authProfileId,
authProfileVersion: auth.authProfileVersion,
}
: { authMode: "useLoggedInUser" };
}
function sessionAuthMatches(stored: CopilotSessionAuth, current: CopilotSessionAuth): boolean {
if (stored.authMode !== current.authMode) {
return false;
}
if (stored.authMode === "useLoggedInUser") {
return true;
}
return (
current.authMode === "gitHubToken" &&
stored.authProfileId === current.authProfileId &&
stored.authProfileVersion === current.authProfileVersion
);
}
function normalizeBinding(
value: CopilotSessionBinding | undefined,
): CopilotSessionBinding | undefined {
if (
!value ||
value.schemaVersion !== 1 ||
value.schemaVersion !== 2 ||
typeof value.sdkSessionId !== "string" ||
value.sdkSessionId.trim() === "" ||
typeof value.compatKey !== "string" ||
value.compatKey.trim() === "" ||
typeof value.compactKey !== "string" ||
value.compactKey.trim() === "" ||
(value.authMode !== "gitHubToken" && value.authMode !== "useLoggedInUser") ||
(value.authMode === "gitHubToken" &&
(typeof value.authProfileId !== "string" ||
value.authProfileId.trim() === "" ||
typeof value.authProfileVersion !== "string" ||
value.authProfileVersion.trim() === "")) ||
typeof value.updatedAt !== "number" ||
!Number.isFinite(value.updatedAt)
) {
return undefined;
}
return {
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId: value.sdkSessionId.trim(),
compatKey: value.compatKey,
compactKey: value.compactKey,
authMode: value.authMode,
...(value.authMode === "gitHubToken"
? {
authProfileId: value.authProfileId,
authProfileVersion: value.authProfileVersion,
}
: {}),
updatedAt: value.updatedAt,
};
}
function normalizeAttemptBinding(value: unknown): CopilotAttemptSessionBinding | undefined {
const current = normalizeBinding(value as CopilotSessionBinding | undefined);
if (current) {
return current;
}
const legacy = value as LegacyCopilotSessionBinding | undefined;
if (
!legacy ||
legacy.schemaVersion !== 1 ||
typeof legacy.sdkSessionId !== "string" ||
legacy.sdkSessionId.trim() === "" ||
typeof legacy.compatKey !== "string" ||
legacy.compatKey.trim() === "" ||
typeof legacy.updatedAt !== "number" ||
!Number.isFinite(legacy.updatedAt)
) {
return undefined;
}
return {
sdkSessionId: legacy.sdkSessionId.trim(),
compatKey: legacy.compatKey,
};
}
function lookupStoredBinding(
store: CopilotSessionBindingStore | undefined,
key: string,
): CopilotSessionBinding | undefined {
): CopilotAttemptSessionBinding | undefined {
try {
return normalizeBinding(store?.lookup(key));
return normalizeAttemptBinding(store?.lookup(key));
} catch {
try {
store?.delete(key);
@@ -118,6 +233,58 @@ function deleteStoredBinding(store: CopilotSessionBindingStore | undefined, key:
}
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (!signal?.aborted) {
return;
}
const reason = "reason" in signal ? signal.reason : undefined;
if (reason instanceof Error) {
throw reason;
}
const error = reason ? new Error("aborted", { cause: reason }) : new Error("aborted");
error.name = "AbortError";
throw error;
}
function isStaleSdkSessionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /\b(404|not found|no such session|unknown session|stale|deleted|does not exist)\b/i.test(
message,
);
}
async function compactTrackedSdkSession(params: {
abortSignal?: AbortSignal;
client: CopilotClient;
customInstructions?: string;
gitHubToken?: string;
onSession?: (session: CopilotHistoryCompactSession) => void;
sessionConfig: CopilotSessionConfig;
sdkSessionId: string;
}): Promise<CopilotHistoryCompactResult> {
throwIfAborted(params.abortSignal);
const session = (await params.client.resumeSession(params.sdkSessionId, {
...params.sessionConfig,
continuePendingWork: false,
...(params.gitHubToken ? { gitHubToken: params.gitHubToken } : {}),
suppressResumeEvent: true,
})) as unknown as CopilotHistoryCompactSession;
params.onSession?.(session);
const request = params.customInstructions?.trim()
? { customInstructions: params.customInstructions }
: undefined;
try {
throwIfAborted(params.abortSignal);
return await session.rpc.history.compact(request);
} finally {
try {
await session.disconnect();
} catch {
// Preserve the compaction or cancellation outcome; cleanup is best-effort here.
}
}
}
// Build a string fingerprint of the attempt params that must agree
// across turns for SDK-session reuse to be safe. Keep this list
// conservative: any field whose change would invalidate the SDK
@@ -135,8 +302,21 @@ function deleteStoredBinding(store: CopilotSessionBindingStore | undefined, key:
// the token (see `tokenFingerprint` in `src/auth-bridge.ts`), so
// rotating the token under the same profile id still invalidates
// the compat key without ever serializing the raw credential.
function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
const p = params as AgentHarnessAttemptParams & {
type CopilotSessionCompatParams = AgentHarnessAttemptParams | AgentHarnessCompactParams;
function readAgentIdFromSessionKey(sessionKey: unknown): string | undefined {
if (typeof sessionKey !== "string") {
return undefined;
}
const parts = sessionKey.trim().split(":");
return parts[0] === "agent" && parts[1]?.trim() ? parts[1].trim() : undefined;
}
function computeSessionKey(
params: CopilotSessionCompatParams,
options: { includeApi: boolean; includeAuth: boolean },
): string {
const p = params as CopilotSessionCompatParams & {
auth?: {
gitHubToken?: string;
profileId?: string;
@@ -144,18 +324,26 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
useLoggedInUser?: boolean;
};
agentId?: string;
agentDir?: string;
authProfileId?: string;
copilotHome?: string;
cwd?: string;
modelId?: string;
model?: string | { api?: string; id?: string; provider?: string };
profileVersion?: string;
resolvedApiKey?: string;
sessionKey?: string;
workspaceDir?: string;
};
const modelObj: { api?: string; id?: string; provider?: string } =
p.model && typeof p.model === "object"
? p.model
: { id: typeof p.model === "string" ? p.model : undefined };
const provider = modelObj.provider ?? (typeof p.provider === "string" ? p.provider : "");
const modelId =
modelObj.id ??
(typeof p.modelId === "string" ? p.modelId : undefined) ??
(typeof p.model === "string" ? p.model : "");
// resolveCopilotAuth can throw when an explicit `auth.gitHubToken`
// is supplied without profileId + profileVersion (the existing
// pool-key safety invariant). That same error would surface
@@ -169,7 +357,7 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
let resolvedCopilotHome = "";
try {
const resolved = resolveCopilotAuth({
agentId: typeof p.agentId === "string" ? p.agentId : undefined,
agentId: typeof p.agentId === "string" ? p.agentId : readAgentIdFromSessionKey(p.sessionKey),
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
@@ -189,19 +377,27 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
authParts = ["auth=unresolvable"];
}
const parts = [
`provider=${modelObj.provider ?? ""}`,
`model=${modelObj.id ?? ""}`,
`api=${modelObj.api ?? ""}`,
`provider=${provider}`,
`model=${modelId}`,
...(options.includeApi ? [`api=${modelObj.api ?? ""}`] : []),
`cwd=${p.cwd ?? p.workspaceDir ?? ""}`,
`agentId=${resolvedAgentId}`,
`agentDir=${p.agentDir ?? ""}`,
`copilotHome=${p.copilotHome ?? ""}`,
`resolvedCopilotHome=${resolvedCopilotHome}`,
...authParts,
...(options.includeAuth ? authParts : []),
];
return parts.join("|");
}
function computeSessionCompatKey(params: CopilotSessionCompatParams): string {
return computeSessionKey(params, { includeApi: true, includeAuth: true });
}
function computeSessionCompactKey(params: CopilotSessionCompatParams): string {
return computeSessionKey(params, { includeApi: false, includeAuth: false });
}
export function createCopilotAgentHarness(
options?: CreateCopilotAgentHarnessOptions,
): AgentHarness {
@@ -257,10 +453,11 @@ export function createCopilotAgentHarness(
if (disposed) {
throw new Error("[copilot] harness has been disposed; cannot start new attempts");
}
const { runCopilotAttempt } = await import("./src/attempt.js");
const { resolvePoolAcquire, runCopilotAttempt } = await import("./src/attempt.js");
if (disposed) {
throw new Error("[copilot] harness was disposed while starting an attempt");
}
const poolAcquire = resolvePoolAcquire(params as never);
const pool = await getPool();
if (disposed) {
throw new Error("[copilot] harness was disposed while starting an attempt");
@@ -289,6 +486,7 @@ export function createCopilotAgentHarness(
// back to `createSession`, so a stale-session error never
// surfaces as a prompt error.
const currentCompatKey = computeSessionCompatKey(params);
const currentCompactKey = computeSessionCompactKey(params);
const tracked = openclawSessionId ? trackedSessions.get(openclawSessionId) : undefined;
const stored = openclawSessionId
? resetBlockedStoredSessions.has(openclawSessionId)
@@ -317,19 +515,28 @@ export function createCopilotAgentHarness(
? ({
sdkSessionId,
pooledClient,
sessionConfig,
}: {
sdkSessionId: string;
pooledClient: PooledClient;
sessionConfig: CopilotSessionConfig;
}) => {
trackedSessions.set(openclawSessionId, {
sdkSessionId,
client: pooledClient.client,
clientOptions: poolAcquire.options,
compatKey: currentCompatKey,
compactKey: currentCompactKey,
poolKey: pooledClient.key,
sessionConfig,
...sessionAuthFields(poolAcquire.auth),
});
const persisted = registerStoredBinding(options?.sessionStore, openclawSessionId, {
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId,
compatKey: currentCompatKey,
compactKey: currentCompactKey,
...sessionAuthFields(poolAcquire.auth),
updatedAt: Date.now(),
});
if (persisted) {
@@ -376,20 +583,12 @@ export function createCopilotAgentHarness(
async compact(
params: AgentHarnessCompactParams,
): Promise<AgentHarnessCompactResult | undefined> {
// The GitHub Copilot agent runtime manages compaction automatically via
// `SessionConfig.infiniteSessions` (background-async when
// utilization crosses `backgroundCompactionThreshold`). There is
// no synchronous compact RPC, so the harness cannot honour
// `params.force === true` directly. Instead this method writes
// an OpenClaw-shaped marker file under
// `<workspaceDir>/files/openclaw-compaction-<ts>-<sessionId>.json`
// so existing OpenClaw transcript readers see a familiar
// compaction artifact when the host calls compact(). See
// src/compaction-bridge.ts for the bridge boundary.
// The SDK owns Copilot history compaction. OpenClaw only resumes
// the tracked SDK session and calls the session-scoped RPC; durable
// OpenClaw session/transcript state stays in SQLite, with no marker
// sidecars under the workspace.
const openclawSessionId = typeof params.sessionId === "string" ? params.sessionId : undefined;
const workspaceDir =
typeof params.workspaceDir === "string" ? params.workspaceDir : undefined;
if (!openclawSessionId || !workspaceDir) {
if (!openclawSessionId) {
return {
ok: false,
compacted: false,
@@ -397,34 +596,106 @@ export function createCopilotAgentHarness(
};
}
const tracked = trackedSessions.get(openclawSessionId);
const reason = params.force
? "force-requested-but-sdk-has-no-synchronous-compact-api"
: "deferred-to-sdk-infinite-sessions";
try {
await writeOpenClawCompactionMarker({
sessionId: openclawSessionId,
workspaceDir,
trigger: params.trigger,
currentTokenCount: params.currentTokenCount,
sdkSessionId: tracked?.sdkSessionId,
force: params.force,
reason,
});
} catch (err) {
const currentCompactKey = computeSessionCompactKey(params);
const { resolvePoolAcquire } = await import("./src/attempt.js");
const resolvedPoolAcquire = resolvePoolAcquire(params as never);
const currentAuth = sessionAuthFields(resolvedPoolAcquire.auth);
const compatibleTracked =
tracked?.compactKey === currentCompactKey && sessionAuthMatches(tracked, currentAuth)
? tracked
: undefined;
if (!compatibleTracked) {
// Durable bindings only carry SDK session ids. Manual SDK compaction also
// needs the live SessionConfig with OpenClaw hooks/tools, so preserve the
// binding for the next attempt and let the host compact transcript state.
return {
ok: false,
compacted: false,
reason: "marker-write-failed",
failure: {
reason: "marker-write-failed",
rawError: err instanceof Error ? err.message : String(err),
},
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
};
}
const poolAcquire = compatibleTracked
? { key: compatibleTracked.poolKey, options: compatibleTracked.clientOptions }
: resolvedPoolAcquire;
let compactResult: CopilotHistoryCompactResult;
let handle: PooledClient | undefined;
let pool: CopilotClientPool | undefined;
let activeSdkSession: CopilotHistoryCompactSession | undefined;
try {
throwIfAborted(params.abortSignal);
pool = await getPool();
handle = await pool.acquire(poolAcquire.key, poolAcquire.options);
const client = handle.client;
compactResult = await compactWithSafetyTimeout(
(abortSignal) =>
compactTrackedSdkSession({
abortSignal,
client,
customInstructions: params.customInstructions,
gitHubToken:
compatibleTracked?.clientOptions.gitHubToken ??
(resolvedPoolAcquire.auth.authMode === "gitHubToken"
? resolvedPoolAcquire.auth.gitHubToken
: undefined),
onSession: (session) => {
activeSdkSession = session;
},
sessionConfig: compatibleTracked.sessionConfig,
sdkSessionId: compatibleTracked.sdkSessionId,
}),
resolveCompactionTimeoutMs(
(params as { config?: Parameters<typeof resolveCompactionTimeoutMs>[0] }).config,
),
{
abortSignal: params.abortSignal,
onCancel: () =>
void activeSdkSession?.rpc.history.abortManualCompaction().catch(() => undefined),
},
);
} catch (err) {
const rawError = err instanceof Error ? err.message : String(err);
if (isStaleSdkSessionError(err)) {
trackedSessions.delete(openclawSessionId);
deleteStoredBinding(options?.sessionStore, openclawSessionId);
return {
ok: false,
compacted: false,
reason: "stale_thread_binding",
failure: { reason: "stale_thread_binding", rawError },
};
}
return {
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: {
reason: "copilot-sdk-history-compact-failed",
rawError,
},
};
} finally {
if (pool && handle) {
try {
await pool.release(handle);
} catch {
// Pool release failure must not mask the compaction outcome.
}
}
}
if (!compactResult.success) {
return {
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: { reason: "copilot-sdk-history-compact-failed" },
};
}
const compacted = compactResult.tokensRemoved > 0 || compactResult.messagesRemoved > 0;
return {
ok: true,
compacted: false,
reason,
compacted,
reason: compacted ? "copilot-sdk-history-compacted" : "already under target",
};
},

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/copilot",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/copilot",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@github/copilot-sdk": "1.0.0-beta.9"
}

View File

@@ -2,7 +2,7 @@
"id": "copilot",
"name": "GitHub Copilot agent runtime",
"description": "Registers the GitHub Copilot agent runtime.",
"version": "2026.5.31",
"version": "2026.6.1",
"activation": {
"onStartup": false,
"onAgentHarnesses": ["copilot"]

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw GitHub Copilot agent runtime plugin (registers a `github-copilot` AgentHarness backed by @github/copilot-sdk over JSON-RPC to the GitHub Copilot CLI)",
"repository": {
"type": "git",
@@ -25,10 +25,10 @@
"minHostVersion": ">=2026.5.28"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31",
"openclawVersion": "2026.6.1-alpha.3",
"bundledDist": false
},
"release": {

View File

@@ -50,6 +50,21 @@ const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
export type CopilotSessionConfig = Pick<
SessionConfig,
| "availableTools"
| "enableSessionTelemetry"
| "gitHubToken"
| "hooks"
| "instructionDirectories"
| "infiniteSessions"
| "model"
| "onPermissionRequest"
| "reasoningEffort"
| "systemMessage"
| "tools"
| "workingDirectory"
>;
// NOTE(plugin-sdk-widening): AttemptParamsLike can be removed once
// openclaw/plugin-sdk/agent-harness-runtime declares auth, messages,
// onAssistantDelta, and initialReplayState.sdkSessionId fields. Tracked by
@@ -107,7 +122,11 @@ export interface CopilotAttemptDeps {
* thrown from this callback are swallowed so they cannot break the
* attempt.
*/
onSessionEstablished?: (info: { sdkSessionId: string; pooledClient: PooledClient }) => void;
onSessionEstablished?: (info: {
sdkSessionId: string;
pooledClient: PooledClient;
sessionConfig: CopilotSessionConfig;
}) => void;
}
export async function runCopilotAttempt(
@@ -415,7 +434,7 @@ export async function runCopilotAttempt(
sessionIdUsed = sdkSessionId ?? input.sessionId;
if (sdkSessionId && deps.onSessionEstablished) {
try {
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle });
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle, sessionConfig });
} catch {
// never let session-tracking callbacks break attempts
}
@@ -714,21 +733,7 @@ function createSessionConfig(
workspaceBootstrapInstructions: string | undefined,
effectiveWorkspaceDir: string | undefined,
effectiveCwd: string | undefined,
): Pick<
SessionConfig,
| "availableTools"
| "enableSessionTelemetry"
| "gitHubToken"
| "hooks"
| "instructionDirectories"
| "infiniteSessions"
| "model"
| "onPermissionRequest"
| "reasoningEffort"
| "systemMessage"
| "tools"
| "workingDirectory"
> {
): CopilotSessionConfig {
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
const hooks = createHooksBridge(params.hooksConfig);
const infiniteSessions = createInfiniteSessionConfig(params.infiniteSessionConfig);

View File

@@ -1,8 +1,5 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createInfiniteSessionConfig, writeOpenClawCompactionMarker } from "./compaction-bridge.js";
import { describe, expect, it } from "vitest";
import { createInfiniteSessionConfig } from "./compaction-bridge.js";
describe("createInfiniteSessionConfig", () => {
it("returns undefined when no options provided", () => {
@@ -59,184 +56,3 @@ describe("createInfiniteSessionConfig", () => {
expect(result).not.toHaveProperty("bufferExhaustionThreshold");
});
});
describe("writeOpenClawCompactionMarker", () => {
it("writes a JSON marker with expected shape under <workspaceDir>/files", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-compaction-"));
try {
const written = await writeOpenClawCompactionMarker(
{
sessionId: "openclaw-sess-123",
workspaceDir,
trigger: "manual",
currentTokenCount: 42,
sdkSessionId: "sdk-sess-abc",
reason: "deferred-to-sdk-infinite-sessions",
},
{ now: () => 1_700_000_000_000 },
);
expect(written.path).toBe(
join(workspaceDir, "files", "openclaw-compaction-1700000000000-openclaw-sess-123.json"),
);
expect(written.marker).toEqual({
version: 1,
source: "copilot-harness",
sessionId: "openclaw-sess-123",
ts: 1_700_000_000_000,
compacted: false,
trigger: "manual",
sdkSessionId: "sdk-sess-abc",
currentTokenCount: 42,
reason: "deferred-to-sdk-infinite-sessions",
});
const contents = await readFile(written.path, "utf8");
expect(contents.endsWith("\n")).toBe(true);
expect(JSON.parse(contents)).toEqual(written.marker);
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("records force:true in the marker without acting on it", async () => {
const writes: Array<{ path: string; contents: string }> = [];
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async (path: string, contents: string) => {
writes.push({ path, contents });
}),
};
const written = await writeOpenClawCompactionMarker(
{
sessionId: "s1",
workspaceDir: "/ws",
force: true,
reason: "force-requested-but-sdk-has-no-synchronous-compact-api",
},
{ now: () => 1, fs: fs as never },
);
expect(written.marker.force).toBe(true);
expect(written.marker.compacted).toBe(false);
expect(writes).toHaveLength(1);
expect(JSON.parse(writes[0].contents)).toMatchObject({ force: true });
});
it("omits force / trigger / sdkSessionId / currentTokenCount when undefined", async () => {
const writes: Array<{ path: string; contents: string }> = [];
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async (path: string, contents: string) => {
writes.push({ path, contents });
}),
};
const written = await writeOpenClawCompactionMarker(
{ sessionId: "s1", workspaceDir: "/ws" },
{ now: () => 7, fs: fs as never },
);
expect(written.marker).toEqual({
version: 1,
source: "copilot-harness",
sessionId: "s1",
ts: 7,
compacted: false,
});
const parsed = JSON.parse(writes[0].contents);
expect(parsed).not.toHaveProperty("force");
expect(parsed).not.toHaveProperty("trigger");
expect(parsed).not.toHaveProperty("sdkSessionId");
expect(parsed).not.toHaveProperty("currentTokenCount");
expect(parsed).not.toHaveProperty("reason");
});
it("sanitizes sessionId chars in the filename", async () => {
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const written = await writeOpenClawCompactionMarker(
{ sessionId: "abc:/?\\@!def", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
);
expect(written.path).toContain("openclaw-compaction-1-abc______def.json");
// sessionId in the marker body stays the original unsanitized value.
expect(written.marker.sessionId).toBe("abc:/?\\@!def");
});
it("creates the subdir recursively before writing", async () => {
const calls: Array<{ kind: "mkdir" | "write"; path: string; opts?: unknown }> = [];
const fs = {
mkdir: vi.fn(async (path: string, opts: unknown) => {
calls.push({ kind: "mkdir", path, opts });
}),
writeFile: vi.fn(async (path: string) => {
calls.push({ kind: "write", path });
}),
};
await writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
);
expect(calls[0]).toEqual({ kind: "mkdir", path: "/ws/files", opts: { recursive: true } });
expect(calls[1]?.kind).toBe("write");
});
it("honours a custom subdir option", async () => {
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const written = await writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never, subdir: "compaction" },
);
expect(written.path).toBe("/ws/compaction/openclaw-compaction-1-s.json");
});
it("surfaces mkdir failures", async () => {
const fs = {
mkdir: vi.fn(async () => {
throw new Error("EACCES");
}),
writeFile: vi.fn(async () => undefined),
};
await expect(
writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
),
).rejects.toThrow("EACCES");
expect(fs.writeFile).not.toHaveBeenCalled();
});
it("surfaces writeFile failures", async () => {
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => {
throw new Error("ENOSPC");
}),
};
await expect(
writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
),
).rejects.toThrow("ENOSPC");
});
it("throws on missing sessionId", async () => {
await expect(
writeOpenClawCompactionMarker({ sessionId: "", workspaceDir: "/ws" }),
).rejects.toThrow(/sessionId is required/);
});
it("throws on missing workspaceDir", async () => {
await expect(
writeOpenClawCompactionMarker({ sessionId: "s", workspaceDir: "" }),
).rejects.toThrow(/workspaceDir is required/);
});
});

View File

@@ -1,25 +1,11 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { SessionConfig } from "@github/copilot-sdk";
// Compaction bridge for the GitHub Copilot agent runtime.
//
// Two responsibilities:
//
// 1. Shape `SessionConfig.infiniteSessions` from a typed options bag
// so attempt.ts can opt the SDK in to background auto-compaction
// at session creation. The SDK manages the actual compaction
// under the `infiniteSessions` config (background at
// `backgroundCompactionThreshold`, blocking at
// `bufferExhaustionThreshold`).
//
// 2. Write an OpenClaw-shaped JSON marker file at
// `<workspaceDir>/files/openclaw-compaction-<sessionId>-<ts>.json`
// whenever the host calls `harness.compact(params)`. Existing
// OpenClaw transcript readers look in `workspacePath/files/` for
// compaction artifacts; the marker keeps them informed even
// though the SDK now owns the actual context-window mechanics
// under infiniteSessions.
// Shapes `SessionConfig.infiniteSessions` from a typed options bag so
// attempt.ts can opt the SDK in to background auto-compaction at session
// creation. The SDK manages the actual compaction under the `infiniteSessions`
// config and the session-scoped history compaction RPC.
//
// Host back-pointers (NOT imported here to keep the package boundary
// clean):
@@ -64,120 +50,3 @@ export function createInfiniteSessionConfig(
}
return Object.keys(result).length > 0 ? result : undefined;
}
export interface OpenClawCompactionMarkerInput {
/** OpenClaw session id (CompactEmbeddedPiSessionParams.sessionId). */
readonly sessionId: string;
/** Workspace dir (CompactEmbeddedPiSessionParams.workspaceDir). */
readonly workspaceDir: string;
/** Compaction trigger from CompactEmbeddedPiSessionParams.trigger. */
readonly trigger?: "budget" | "overflow" | "manual";
/** Optional caller-observed token count at compaction time. */
readonly currentTokenCount?: number;
/** Optional active SDK session id when the marker is written. */
readonly sdkSessionId?: string;
/** Optional reason string for the marker. */
readonly reason?: string;
/**
* Whether the host passed `force: true` in CompactEmbeddedPiSessionParams.
* Recorded for diagnostics — the harness cannot synchronously force
* compaction since the SDK has no on-demand compact RPC.
*/
readonly force?: boolean;
}
export interface OpenClawCompactionMarkerOptions {
/** Override `Date.now`. Default: `Date.now`. */
readonly now?: () => number;
/** Override `node:fs/promises` writers. Useful in tests. */
readonly fs?: Pick<typeof import("node:fs/promises"), "mkdir" | "writeFile">;
/**
* Subdirectory under workspaceDir that holds the markers. Default
* `files` to match the proposal-defined location.
*/
readonly subdir?: string;
}
export interface OpenClawCompactionMarker {
readonly version: 1;
readonly source: "copilot-harness";
readonly sessionId: string;
readonly ts: number;
/**
* Whether actual compaction occurred. Always false from the harness
* path: SDK auto-compaction runs asynchronously in the background
* and the harness does not synchronously force it.
*/
readonly compacted: false;
readonly trigger?: "budget" | "overflow" | "manual";
readonly force?: boolean;
readonly sdkSessionId?: string;
readonly currentTokenCount?: number;
readonly reason?: string;
}
export interface WrittenOpenClawCompactionMarker {
readonly path: string;
readonly marker: OpenClawCompactionMarker;
}
function compactJsonValue<T extends Record<string, unknown>>(input: T): T {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
out[key] = value;
}
}
return out as T;
}
/**
* Write an OpenClaw-shaped compaction marker JSON file under
* `<workspaceDir>/<subdir>/openclaw-compaction-<sessionId>-<ts>.json`.
*
* Returns the resolved file path and the marker payload that was
* written. Throws if the workspaceDir or sessionId is missing/empty
* (the caller should not invoke this without those — the harness
* `compact()` must validate first).
*/
export async function writeOpenClawCompactionMarker(
input: OpenClawCompactionMarkerInput,
options: OpenClawCompactionMarkerOptions = {},
): Promise<WrittenOpenClawCompactionMarker> {
if (!input.workspaceDir || typeof input.workspaceDir !== "string") {
throw new Error("[copilot:compaction-bridge] workspaceDir is required to write a marker");
}
if (!input.sessionId || typeof input.sessionId !== "string") {
throw new Error("[copilot:compaction-bridge] sessionId is required to write a marker");
}
const now = options.now ?? Date.now;
const fs = options.fs ?? { mkdir, writeFile };
const subdir = options.subdir ?? "files";
const ts = now();
const safeSessionId = input.sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
// Filename pattern: ts-first so listings sort chronologically. Suffix
// sessionId for collision safety when multiple sessions share a
// workspace. Matches the proposal's `openclaw-compaction-<ts>` prefix.
const filename = `openclaw-compaction-${ts}-${safeSessionId}.json`;
const dirPath = join(input.workspaceDir, subdir);
const filePath = join(dirPath, filename);
const marker: OpenClawCompactionMarker = compactJsonValue({
version: 1 as const,
source: "copilot-harness" as const,
sessionId: input.sessionId,
ts,
compacted: false as const,
trigger: input.trigger,
force: input.force,
sdkSessionId: input.sdkSessionId,
currentTokenCount: input.currentTokenCount,
reason: input.reason,
});
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");
return { path: filePath, marker };
}

View File

@@ -223,6 +223,6 @@ describe("sdk dependency constants", () => {
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
});
it("COPILOT_SDK_SPEC pins the canonical SDK spec", () => {
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.4");
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.9");
});
});

View File

@@ -11,7 +11,7 @@ export function resolveCopilotSdkFallbackDir(env: NodeJS.ProcessEnv = process.en
export const COPILOT_SDK_FALLBACK_DIR = resolveCopilotSdkFallbackDir();
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.4";
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.9";
let cached: Promise<typeof Sdk> | undefined;

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepgram-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Deepgram media-understanding provider",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepinfra-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw DeepInfra provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepseek-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw DeepSeek provider plugin",
"type": "module",

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/diagnostics-otel",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@opentelemetry/api": "1.9.1",
"@opentelemetry/api-logs": "0.218.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw diagnostics OpenTelemetry exporter for metrics and traces.",
"repository": {
"type": "git",
@@ -34,10 +34,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31"
"openclawVersion": "2026.6.1-alpha.3"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/diagnostics-prometheus",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/diagnostics-prometheus",
"version": "2026.5.31"
"version": "2026.6.1-alpha.3"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-prometheus",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw diagnostics Prometheus exporter for runtime metrics.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31"
"openclawVersion": "2026.6.1-alpha.3"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/diffs-language-pack",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/diffs-language-pack",
"version": "2026.5.31"
"version": "2026.6.1-alpha.3"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diffs-language-pack",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw diffs viewer syntax highlighting language pack",
"repository": {
"type": "git",
@@ -22,13 +22,13 @@
"minHostVersion": ">=2026.5.27"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"assetScripts": {
"build": "node ../../scripts/build-diffs-viewer-runtime.mjs full"
},
"build": {
"openclawVersion": "2026.5.31",
"openclawVersion": "2026.6.1-alpha.3",
"staticAssets": [
{
"source": "./assets/viewer-runtime.js",

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/diffs",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/diffs",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@pierre/diffs": "1.2.4",
"@pierre/theme": "1.0.3",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diffs",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw read-only diff viewer plugin and file renderer for agents.",
"repository": {
"type": "git",
@@ -29,13 +29,13 @@
"minHostVersion": ">=2026.4.30"
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"assetScripts": {
"build": "node ../../scripts/build-diffs-viewer-runtime.mjs curated"
},
"build": {
"openclawVersion": "2026.5.31",
"openclawVersion": "2026.6.1-alpha.3",
"staticAssets": [
{
"source": "./assets/viewer-runtime.js",

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/discord",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/discord",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@discordjs/voice": "0.19.2",
"discord-api-types": "0.38.48",
@@ -16,7 +16,7 @@
"ws": "8.21.0"
},
"peerDependencies": {
"openclaw": ">=2026.5.31"
"openclaw": ">=2026.6.1-alpha.3"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.",
"repository": {
"type": "git",
@@ -20,7 +20,7 @@
"openclaw": "2026.5.28"
},
"peerDependencies": {
"openclaw": ">=2026.5.31"
"openclaw": ">=2026.6.1-alpha.3"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -67,10 +67,10 @@
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.5.31"
"pluginApi": ">=2026.6.1-alpha.3"
},
"build": {
"openclawVersion": "2026.5.31"
"openclawVersion": "2026.6.1-alpha.3"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/document-extract-plugin",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw local document extraction plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/duckduckgo-plugin",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw DuckDuckGo plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/elevenlabs-speech",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw ElevenLabs speech plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/exa-plugin",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw Exa plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/fal-provider",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"private": true,
"description": "OpenClaw fal provider plugin",
"type": "module",

View File

@@ -1,19 +1,19 @@
{
"name": "@openclaw/feishu",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/feishu",
"version": "2026.5.31",
"version": "2026.6.1-alpha.3",
"dependencies": {
"@larksuiteoapi/node-sdk": "1.66.0",
"typebox": "1.1.39",
"zod": "4.4.3"
},
"peerDependencies": {
"openclaw": ">=2026.5.31"
"openclaw": ">=2026.6.1-alpha.3"
},
"peerDependenciesMeta": {
"openclaw": {

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