Compare commits

..

134 Commits

Author SHA1 Message Date
Vincent Koc
9ff7abc898 test(ci): read sparse android guard files from git 2026-06-23 23:50:51 +08:00
Vincent Koc
dc9c11be91 fix(qa): reject duplicate plugin gauntlet controls 2026-06-23 17:37:53 +02:00
Vincent Koc
58552f6d7c ci: make release maturity scorecard opt-in 2026-06-23 23:32:45 +08:00
Vincent Koc
b8811b7dde fix(qa): reject duplicate gateway cpu controls 2026-06-23 17:30:37 +02:00
Vincent Koc
0850d83de1 fix(qa): reject duplicate model resolution perf controls 2026-06-23 17:24:41 +02:00
Jesse Merhi
92c10d4edc Fix WebChat dispatch failure session status (#84352)
Merged via squash.

Prepared head SHA: 562f2ac5a8
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
Reviewed-by: @jesse-merhi
2026-06-24 01:19:21 +10:00
Vincent Koc
b22ae2a4da fix(qa): reject duplicate model bench controls 2026-06-23 17:18:47 +02:00
Vincent Koc
a822c9abaa fix(qa): reject duplicate gateway restart controls 2026-06-23 17:13:39 +02:00
Vincent Koc
c308295cd3 fix(qa): reject duplicate gateway startup controls 2026-06-23 17:08:08 +02:00
Vincent Koc
524e19726f fix(qa): reject duplicate cli bench controls 2026-06-23 17:01:42 +02:00
Vincent Koc
bc243568e7 chore(acpx): bump bundled client to 0.11.2 (#96124) 2026-06-23 22:54:51 +08:00
Vincent Koc
2cbb4e70cc fix(qa): reject duplicate telegram proof controls 2026-06-23 16:54:31 +02:00
Vincent Koc
e9b017d9dc fix(qa): reject duplicate abort leak controls 2026-06-23 16:46:39 +02:00
Vincent Koc
bde5be874a fix(qa): reject duplicate sqlite bench controls 2026-06-23 16:41:01 +02:00
Vincent Koc
8c09419f20 fix(qa): reject unknown docker timing options 2026-06-23 16:26:42 +02:00
Tony Wei
71f84f910a fix(acpx): detect wrapper orphan on any PPID change, not just init reparenting (#96032)
* fix(acpx): detect wrapper orphan on any PPID change, not just init reparenting

The codex / claude adapter wrapper's orphan watcher (emitted by
buildAdapterWrapperScript) skipped cleanup when `process.ppid !== 1`,
intending to wait for the kernel to reparent the orphaned wrapper to
PID 1 (init). This only works on bare-metal hosts without an active
user-session manager.

On systemd-managed deployments (EC2 user services, most container
runtimes), an orphaned process is reparented to the user-session
manager or container init — not to init itself. The watcher therefore
never fires, and when the gateway exits, the adapter wrapper survives
and holds its child process group (codex-acp.js + native binary)
running indefinitely.

Real-world symptom: each gateway restart accumulates 3-process trees of
leftover codex adapters. Subsequent ACP spawns then contend with these
orphans, the main event loop is starved by acpx-runtime reap attempts,
and new sessions stall at "waiting for tool execution" for minutes.

Fix: trigger orphan cleanup as soon as PPID changes from the recorded
original, regardless of what the new PPID is. The killChildTree path
already covers process-group cleanup via `kill(-pid, SIGTERM)`, so
once the watcher fires, grandchildren are reaped correctly.

Adds a regression test asserting the wrapper template does not
re-introduce the `process.ppid !== 1` guard.

* test: document maturity ref handoff

---------

Co-authored-by: t2wei <t2wei@me.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-23 22:24:29 +08:00
Vincent Koc
8e6624cb6c fix(qa): reject duplicate sibling bench cases 2026-06-23 16:20:46 +02:00
Vincent Koc
273eed4c51 fix(harness): recover Copilot native subagent tasks 2026-06-23 22:13:59 +08:00
Vincent Koc
0bc5fb86a8 feat(copilot): mirror native plan and subagent events 2026-06-23 22:13:59 +08:00
Vincent Koc
7bde374c47 fix(qa): reject duplicate startup bench cases 2026-06-23 16:11:44 +02:00
Vincent Koc
fa263affd5 test(extensions): use real chutes response mocks 2026-06-23 22:00:32 +08:00
Vincent Koc
fa0427347a test(extensions): use real provider response mocks 2026-06-23 22:00:32 +08:00
Vincent Koc
aad78d399c test(extensions): use real response mocks 2026-06-23 22:00:32 +08:00
Vincent Koc
928607ac4a fix(qa): reject missing memory fd args 2026-06-23 15:58:33 +02:00
Vincent Koc
010c7f7110 fix(qa): disable pnpm verify in cpu scenarios 2026-06-23 15:50:39 +02:00
Vincent Koc
69891cf2ac fix(maint): protect pending hosted CI reruns 2026-06-23 21:49:36 +08:00
Vincent Koc
541f9b25d2 fix(maint): choose latest hosted CI run 2026-06-23 21:49:36 +08:00
Vincent Koc
c045fbf8ec fix(maint): use rebase PR landing 2026-06-23 21:49:36 +08:00
Shakker
e63d11ea24 test: scope transcript reader env setup 2026-06-23 14:46:20 +01:00
Shakker
fed369085f fix: restore task state env through helper 2026-06-23 14:36:55 +01:00
Shakker
6834a2d47b test: scope send state env helper 2026-06-23 14:33:32 +01:00
Vincent Koc
52251261ca fix(qa): reject duplicate gauntlet selectors 2026-06-23 15:32:12 +02:00
Shakker
e94deea4f2 fix: simplify Fly Machine env cleanup 2026-06-23 14:27:02 +01:00
Shakker
b827629418 test: route network runtime env setup 2026-06-23 14:25:43 +01:00
Vincent Koc
02556f9caf fix(qa): reject polluted Tool Search proof lanes 2026-06-23 15:25:27 +02:00
Vincent Koc
3f2b205dde fix(qa): require MCP API list evidence 2026-06-23 15:20:29 +02:00
Vincent Koc
3d2c52c935 fix(qa): reject duplicate RPC RTT methods 2026-06-23 15:14:40 +02:00
Shakker
e11539234b fix: route shared auth secret env writes 2026-06-23 14:10:02 +01:00
Vincent Koc
720e295cff fix(qa): require Telegram proof report before publish 2026-06-23 15:09:04 +02:00
Shakker
20d1dc8f0a test: route shared token reload env writes 2026-06-23 14:02:24 +01:00
Vincent Koc
d3ac8e3caa fix(qa): reject duplicate Parallels platforms 2026-06-23 15:01:33 +02:00
Shakker
93cfd59dd6 fix: clear config secret refs through env helper 2026-06-23 13:55:23 +01:00
Vincent Koc
5078ffdeb4 fix(qa): reject duplicate gateway smoke options 2026-06-23 14:52:37 +02:00
Josh Lehman
475252453b refactor: add transcript update identity contract (#89912) 2026-06-23 05:52:08 -07:00
Vincent Koc
d38fb7456a fix(qa): reject duplicate otel smoke options 2026-06-23 14:46:43 +02:00
Vincent Koc
08f8de3aee fix(qa): reject duplicate ux evidence options 2026-06-23 14:41:32 +02:00
Vincent Koc
a02a8cca79 fix(qa): reject duplicate hosted gate options 2026-06-23 14:35:37 +02:00
Vincent Koc
c638f2beda fix(release): reject duplicate candidate checklist options 2026-06-23 14:30:29 +02:00
Vincent Koc
34d2d54d6c fix(plugin-sdk): refresh api baseline hash 2026-06-23 20:27:52 +08:00
Vincent Koc
7cc0879d0e fix(qa): reject duplicate docker package options 2026-06-23 14:24:41 +02:00
Vincent Koc
2af06042c2 fix(qa): reject duplicate package candidate options 2026-06-23 14:19:42 +02:00
Vincent Koc
8cda4399d0 fix(qa): reject duplicate dependency evidence options 2026-06-23 14:14:43 +02:00
Vincent Koc
3d6127f7e4 fix(qa): reject duplicate test report controls 2026-06-23 14:06:41 +02:00
Vincent Koc
72816124c9 fix(qa): reject duplicate single-value flags 2026-06-23 14:01:29 +02:00
Vincent Koc
0e091482a3 fix(qa): reject ambiguous dependency report inputs 2026-06-23 13:43:44 +02:00
Vincent Koc
d51582a936 fix(qa): reject duplicate report artifacts 2026-06-23 13:38:40 +02:00
Vincent Koc
7374ecc777 fix(qa): reject duplicate qa e2e outputs 2026-06-23 13:34:20 +02:00
Vincent Koc
e856a24754 fix(qa): bound docker e2e log replay 2026-06-23 13:26:09 +02:00
Vincent Koc
9dbdefd43c fix(ci): keep release QA evidence branch-compatible 2026-06-23 19:24:27 +08:00
Vincent Koc
0177521375 fix(ci): pass resolved ref to maturity QA evidence 2026-06-23 19:18:13 +08:00
Vincent Koc
c714bfd8b6 fix(ci): allow release QA evidence workflow calls 2026-06-23 19:06:49 +08:00
Vincent Koc
d980f2555a fix(qa): preserve active mac restart locks 2026-06-23 13:02:39 +02:00
Vincent Koc
300b09b33f fix(acpx): consume acpx 0.11.1 model capability errors
* fix(acpx): consume acpx 0.11.1 model capability errors

* fix(acpx): refresh npm shrinkwrap for 0.11.1

* test: include workflow checks in tooling plan
2026-06-23 18:55:46 +08:00
Vincent Koc
2429585046 fix(qa): isolate docker rerun artifact downloads 2026-06-23 12:55:05 +02:00
Vincent Koc
a972855150 fix(qa): avoid extension memory report collisions 2026-06-23 12:48:16 +02:00
Vincent Koc
306f0ec37f test(ci): update tooling route expectation 2026-06-23 18:40:54 +08:00
Vincent Koc
cb6b15f782 fix(qa): avoid vitest report path collisions 2026-06-23 12:40:25 +02:00
Vincent Koc
44d77de0c5 fix(qa): isolate parallels plugin temp script 2026-06-23 12:34:05 +02:00
Vincent Koc
bd9f2a5e2e fix(ci): refresh dependency audit locks 2026-06-23 18:28:29 +08:00
Vincent Koc
b3b210b706 fix(qa): allow web search smoke gateway port override 2026-06-23 12:26:07 +02:00
Vincent Koc
536b437454 fix(docs): keep maturity taxonomy renderer formatted 2026-06-23 18:10:26 +08:00
ooiuuii
dd055c4f7c fix: npm plugin updates break running gateway imports (#95589)
Merged via squash.

Prepared head SHA: 74ecbbbb98
Co-authored-by: ooiuuii <169449607+ooiuuii@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 18:07:57 +08:00
Vincent Koc
f484bf9985 fix(qa): avoid plugin update registry port collisions 2026-06-23 12:01:34 +02:00
Vincent Koc
04575a97b6 fix(qa): avoid telegram proof artifact collisions 2026-06-23 11:57:34 +02:00
Vincent Koc
318f95417a docs: place maturity pages under release reference (#96061)
Merged via squash.

Prepared head SHA: 7ab898262e
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 17:55:58 +08:00
Vincent Koc
1aad7d4e50 fix(ci): repair maturity docs checks 2026-06-23 17:50:47 +08:00
Vincent Koc
c313642ae2 fix(qa-lab): use scoped crabline package 2026-06-23 17:47:07 +08:00
Vincent Koc
932b58b94b fix(qa): avoid matrix qa artifact collisions 2026-06-23 11:45:29 +02:00
Vincent Koc
d37300f357 fix(anthropic): narrow stream block index guard 2026-06-23 17:45:15 +08:00
Vincent Koc
9e63323388 perf(anthropic): index active stream blocks 2026-06-23 17:45:15 +08:00
Vincent Koc
00f8b10567 perf(usage): bound session log retention 2026-06-23 17:42:28 +08:00
Vincent Koc
4dac8f47ed perf(agents): index displaced tool results 2026-06-23 17:40:21 +08:00
Vincent Koc
9089a8ab32 docs: redesign maturity scorecard pages (#96057)
Merged via squash.

Prepared head SHA: d2c680a48e
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 17:37:58 +08:00
Vincent Koc
67b26126ce fix(qa): avoid direct smoke artifact collisions 2026-06-23 11:29:24 +02:00
Vincent Koc
307300ac97 fix(ci): report missing workflow pre-commit runtime 2026-06-23 17:27:32 +08:00
Dallin Romney
7e0083ce0b ci: add release QA profile evidence (#95094)
* ci: add release qa profile evidence

* ci: simplify release qa profile evidence

* ci: reuse qa profile evidence workflow

* ci: remove inherited secrets lint comment

* ci: pass qa profile evidence secret explicitly

* ci: run maturity scorecard in release checks

* ci: declare maturity scorecard reusable secret
2026-06-23 02:27:00 -07:00
maweibin
740578b596 fix: assistant reply lost between compaction summary and first kept user in successor transcript (#95484)
Merged via squash.

Prepared head SHA: eff5894fb8
Co-authored-by: maweibin <18023423+maweibin@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 17:25:45 +08:00
Vincent Koc
0a986f893a fix(daemon): type Windows task env fixture 2026-06-23 17:21:05 +08:00
Sliverp
9535b102d3 Gate private QQBot group commands (#92154)
* fix: gate private qqbot group commands

* fix(qqbot): keep authorized stop urgent in groups

* fix(qqbot): preserve omitted group command level

* fix(qqbot): preserve ignore-other-mentions gate

* test(qqbot): avoid unbound mention gate mock

* fix(qqbot): close strict command visibility gaps

* fix(qqbot): gate private group commands and close strict command visibility gaps (#92154) (thanks @sliverp)
2026-06-23 17:20:50 +08:00
Dallin Romney
1ce8eb3993 docs: rename top maturity tier (#96044) 2026-06-23 02:19:45 -07:00
Vincent Koc
f354889efa fix(crabbox): share Windows hydrate handoff path 2026-06-23 11:16:13 +02:00
Vincent Koc
cdf35e83f3 fix(qa): avoid live artifact directory collisions 2026-06-23 11:11:47 +02:00
Vincent Koc
8a8c6b2a27 fix(copilot): preserve compaction metadata 2026-06-23 17:11:24 +08:00
mikasa
f5148aff25 fix #89231: [Bug]: Windows installer-created scheduled task launches gateway.cmd with visible console — should use windowless launcher (#95480)
Merged via squash.

Prepared head SHA: 8b57b0377a
Co-authored-by: mikasa0818 <244515412+mikasa0818@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 17:05:23 +08:00
Alex Knight
7bec91c8d8 fix(mattermost): block-bodied promise executor in participation test (oxlint) 2026-06-23 18:58:38 +10:00
Alex Knight
64c81f25c0 fix(mattermost): record thread participation on preview-finalized replies; document thread mention exception 2026-06-23 18:58:38 +10:00
Alex Knight
13ecb5c55e feat(mattermost): persist participated threads for mention-free follow-ups 2026-06-23 18:58:38 +10:00
Dallin Romney
db212e572e test(qa): gate maturity docs on passing evidence (#96017)
* docs: refresh maturity scorecard evidence

* test(qa): gate maturity docs on passing evidence

* test(qa): ensure UX matrix video dependencies

* test(qa): simplify maturity evidence result text

* test: align maturity docs test routing
2026-06-23 01:58:34 -07:00
Vincent Koc
5738cfb6df fix(qa): avoid default artifact directory collisions 2026-06-23 10:53:46 +02:00
Vincent Koc
33b8b72ad3 fix(qa): avoid self-check report clobbering 2026-06-23 10:46:25 +02:00
Moeed Ahmed
e998986889 fix(auto-reply): keep drain/restart-abort reply paths silent (#95431)
Merged via squash.

Prepared head SHA: edb75a944f
Co-authored-by: moeedahmed <5780040+moeedahmed@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:46:04 +08:00
Yuval Dinodia
9549545dd0 fix(reply): suppress per-message finals across multi-message block streaming (#95432)
Merged via squash.

Prepared head SHA: 7d7c61f3d7
Co-authored-by: yetval <102706514+yetval@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:44:02 +08:00
Yuval Dinodia
9f0d2427cd fix(agents): keep post-compaction user re-issue of a kept-tail prompt during compaction rotation (#94328)
Merged via squash.

Prepared head SHA: 05981b6c9f
Co-authored-by: yetval <102706514+yetval@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:41:12 +08:00
Vincent Koc
a59b2f2958 fix(scripts): catch namespace plugin sdk wildcard exports 2026-06-23 10:35:28 +02:00
Vincent Koc
c061373ede fix(release): track CommonJS package dist imports 2026-06-23 10:31:05 +02:00
Vincent Koc
ea0330963c fix(release): surface installed extension manifest errors 2026-06-23 10:26:10 +02:00
tangtaizong666
43890ebc3b fix(heartbeat): skip reasoning payloads when selecting heartbeat reply (#92356)
Merged via squash.

Prepared head SHA: 5885fbba0c
Co-authored-by: tangtaizong666 <212687958+tangtaizong666@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:24:15 +08:00
Dallin Romney
e2bcde9b1c ci: add codex maturity scorecard agent (#95919) 2026-06-23 01:22:21 -07:00
Rohit
695cea68f5 Fix recent session resume with long headers (#94578)
Merged via squash.

Prepared head SHA: 8102961184
Co-authored-by: rohitjavvadi <76606932+rohitjavvadi@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:21:15 +08:00
SunnyShu0925
dd76fdceb6 fix(memory-wiki): exclude durable reference pages from stale report (#94369)
Merged via squash.

Prepared head SHA: c2dca7ed9b
Co-authored-by: SunnyShu0925 <265248434+SunnyShu0925@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:19:36 +08:00
Dallin Romney
32dc664b4b fix(qa-lab): avoid duplicate child evidence files (#96030) 2026-06-23 01:18:24 -07:00
Vincent Koc
3d8d45fb0d fix(qa): reject out-of-range lab CLI ports 2026-06-23 10:16:45 +02:00
Coder
d63a73a1b8 fix(model-usage): coerce numeric-string costs and ignore non-finite values (#87861)
Merged via squash.

Prepared head SHA: 11bb5719ca
Co-authored-by: coder999999999 <83845889+coder999999999@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 16:16:31 +08:00
Vincent Koc
ca5905eb90 fix(crabbox): reclaim sparse reused leases 2026-06-23 10:09:38 +02:00
Vincent Koc
023394000c fix(release): reject malformed candidate API timeouts 2026-06-23 10:04:15 +02:00
Vincent Koc
a0f93cf88f fix(agents): gate subagent stream suppression 2026-06-23 15:54:57 +08:00
chenhaoqiang
1876e3e1c1 perf: skip per-chunk live parsing for subagents
Subagent runs do not have a live stream consumer; their result is delivered from
the terminal message path after the child run finishes. The intermediate
message_update stream work only feeds live preview output.

Thread suppressLiveStreamOutput from the subagent lane into the embedded runner
subscription and return from handleMessageUpdate after accumulating the raw
chunk. This keeps final delivery unchanged while skipping per-chunk visible text
and reasoning stream parsing for subagents, which reduces event-loop pressure
when multiple child agents stream long answers in parallel.

Interactive and Control UI runs keep the existing live preview path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
(cherry picked from commit e0382c2c58c3eabdf64638777ec82cb1e68514e9)
2026-06-23 15:54:57 +08:00
Jason (Json)
6f63140902 fix: avoid false macOS update failures during gateway shutdown (#95886)
Merged via squash.

Prepared head SHA: 400e87c937
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Reviewed-by: @fuller-stack-dev
2026-06-23 01:53:38 -06:00
Vincent Koc
d0f591893b fix(release): validate DMG resize slack 2026-06-23 09:51:38 +02:00
Vincent Koc
da32c7fe53 chore(sdk): update public surface budget 2026-06-23 15:48:27 +08:00
Vincent Koc
d3019e6127 fix(copilot): tighten harness sdk boundaries 2026-06-23 15:48:27 +08:00
Vincent Koc
21d67b168a feat(copilot): wire harness parity helpers 2026-06-23 15:48:27 +08:00
Vincent Koc
2824c02a42 fix(qa): avoid lab artifact directory collisions 2026-06-23 09:39:04 +02:00
youngting520
4c9c6f5116 fix(sessions): honor configured store for transcript mirrors (#95782) 2026-06-23 07:35:36 +00:00
Del
90d4aa7a8e fix(xiaomi): correct mimo-v2.5 and mimo-v2.5-pro max output tokens to 128K (#95934)
* fix(xiaomi): correct mimo-v2.5 and mimo-v2.5-pro max output tokens to 131072

* docs(xiaomi): correct mimo-v2.5 and mimo-v2.5-pro max output tokens to 131072
2026-06-23 07:35:10 +00:00
Yuval Dinodia
f826a665a2 fix(compaction): trim prefix when transcript ends in an oversized tool result (#95860)
findCutPoint defaulted cutIndex to the earliest valid cut (cutPoints[0],
keep everything) and only moved it forward to a cut point at or after the
backward token cursor. When the final entry is a toolResult whose estimate
alone meets keepRecentTokens, the cursor stops at that trailing toolResult
index, no valid cut point sits at or after it (toolResult entries are not
valid cut points), and the default stuck at keep-everything. Compaction then
summarized zero messages, so preflight and overflow compaction silently
no-op and the session loops on a context it cannot shrink.

Default cutIndex to the most recent valid cut before the forward search.
When a cut point exists at or after the cursor the search still finds it and
behavior is unchanged; only the trailing-tool-result case now keeps the
recent tail and summarizes the prefix.
2026-06-23 07:34:33 +00:00
Vincent Koc
add9f3c6d3 fix(test): reject pathological Docker E2E limits 2026-06-23 09:26:54 +02:00
Vincent Koc
603b250125 fix(qa): omit local temp roots from gateway artifacts 2026-06-23 09:18:41 +02:00
joshavant
19ddaa28b9 fix: harden ios screenshot uploads 2026-06-23 02:14:15 -05:00
Dallin Romney
f6b2a5ffb4 test(qa): harden all-profile evidence scenarios (#96003) 2026-06-23 00:07:51 -07:00
Vincent Koc
78a8caef38 fix(release): require postpublish evidence artifact 2026-06-23 14:53:14 +08:00
Chunyue Wang
e0d7776fff fix(context-engine): forward abortSignal through delegation bridge to runtime compaction (#89886)
Merged via squash.

Prepared head SHA: ff5a439d76
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 14:41:08 +08:00
joshavant
8efed50c4e fix: gate ios push enrollment on notification consent 2026-06-23 01:38:24 -05:00
Vincent Koc
3e84836b01 fix(ci): require release QA evidence artifacts 2026-06-23 14:30:11 +08:00
370 changed files with 21136 additions and 3688 deletions

View File

@@ -146,7 +146,7 @@ Default guidance:
Default Completeness bands:
- `Lovable` (95-100): complete across expected workflows, variants, and
- `Clawesome` (95-100): complete across expected workflows, variants, and
recovery branches, with only minor polish gaps.
- `Stable` (80-95): the expected workflow set is broadly present, with only
bounded missing branches.
@@ -172,7 +172,7 @@ Default Completeness bands:
Bands:
- `Lovable`: 95-100
- `Clawesome`: 95-100
- `Stable`: 80-95
- `Beta`: 70-80
- `Alpha`: 50-70

View File

@@ -0,0 +1,23 @@
# OpenClaw Maturity Scorecard Agent
You are refreshing the OpenClaw maturity score source for a release scorecard.
Goal: use the `$claw-score` skill to refresh `qa/maturity-scores.yaml` for every active surface in `taxonomy.yaml`, using the current repository and the release evidence artifacts in `.artifacts/maturity-evidence`.
Allowed tracked paths:
- `qa/maturity-scores.yaml`
Hard limits:
- Do not edit generated docs, taxonomy, workflows, scripts, package metadata, lockfiles, tests, or application code.
- Do not render docs. The workflow renders docs after validating the score source.
- Keep the score source schema valid for QA Lab maturity score validation.
Required workflow:
1. Use the `$claw-score` skill before editing.
2. Read `taxonomy.yaml`, any existing maturity score file, and the release evidence artifacts.
3. Refresh scores for every active surface in `taxonomy.yaml`.
4. Run the QA Lab maturity score validation used by this repository.
5. If no defensible score update is possible, leave a valid `qa/maturity-scores.yaml` and explain the uncertainty in the final message.

View File

@@ -490,7 +490,7 @@ jobs:
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
Write-Error "Invalid crabbox_id"
}
$actionsRoot = Join-Path $HOME ".crabbox\actions"
$actionsRoot = "C:\ProgramData\crabbox\actions"
New-Item -ItemType Directory -Force $actionsRoot | Out-Null
$state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
$envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
@@ -546,7 +546,7 @@ jobs:
if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
$minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
}
$stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
$stop = Join-Path "C:\ProgramData\crabbox\actions" "$env:CRABBOX_ID.stop"
$deadline = (Get-Date).AddMinutes($minutes)
while ((Get-Date) -lt $deadline) {
if (Test-Path $stop) {

View File

@@ -12,6 +12,40 @@ on:
required: true
default: main
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
workflow_call:
inputs:
qa_evidence_run_id:
description: Optional workflow run id containing qa-evidence.json
required: false
default: ""
type: string
ref:
description: OpenClaw branch, tag, or SHA containing the maturity score source
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
description: OpenAI API key used by live QA profile scenarios
required: true
OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY:
description: Optional OpenAI API key used by maturity scorecard agent steps
required: false
GH_APP_PRIVATE_KEY:
description: Optional GitHub App private key for generated docs PR creation
required: false
GH_APP_PRIVATE_KEY_FALLBACK:
description: Optional fallback GitHub App private key for generated docs PR creation
required: false
permissions:
actions: read
@@ -43,14 +77,25 @@ jobs:
- name: Validate selected ref
id: validate
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_revision="$(git rev-parse HEAD)"
expected_sha="${EXPECTED_SHA,,}"
trusted_reason=""
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
@@ -87,8 +132,9 @@ jobs:
if: ${{ inputs.qa_evidence_run_id == '' }}
uses: ./.github/workflows/qa-profile-evidence.yml
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: all
ref: ${{ inputs.ref }}
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: release
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -192,8 +238,8 @@ jobs:
}
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (evidence.profile !== "all") {
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
if (evidence.profile !== "release") {
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
}
const artifactDir = path.dirname(evidencePath);
@@ -210,14 +256,75 @@ jobs:
const manifestPath = path.join(artifactDir, manifestNames[0]);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestProfile = manifest.qaProfile ?? evidence.profile;
if (manifestProfile !== "all") {
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
if (manifestProfile !== "release") {
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
}
if (manifest.targetSha !== targetSha) {
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
}
NODE
- name: Ensure maturity scorecard agent key exists
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
exit 1
fi
- name: Run Codex maturity scorecard agent
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
MATURITY_SCORES_PATH: qa/maturity-scores.yaml
MATURITY_TAXONOMY_PATH: taxonomy.yaml
with:
openai-api-key: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/maturity-scorecard-agent.md
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
effort: high
sandbox: workspace-write
safety-strategy: drop-sudo
- name: Enforce focused maturity score patch
run: |
set -euo pipefail
git restore --staged :/
allowed='^qa/maturity-scores\.yaml$'
bad_tracked="$(
git diff --name-only HEAD -- | while IFS= read -r path; do
if [[ ! "$path" =~ $allowed ]]; then
printf '%s\n' "$path"
fi
done
)"
if [[ -n "$bad_tracked" ]]; then
echo "Maturity scorecard agent touched forbidden tracked paths:"
printf '%s\n' "$bad_tracked"
exit 1
fi
bad_untracked="$(
git ls-files --others --exclude-standard | while IFS= read -r path; do
if [[ "$path" != "qa/maturity-scores.yaml" ]]; then
printf '%s\n' "$path"
fi
done
)"
if [[ -n "$bad_untracked" ]]; then
echo "Maturity scorecard agent created forbidden untracked paths:"
printf '%s\n' "$bad_untracked"
exit 1
fi
if [[ ! -f qa/maturity-scores.yaml ]]; then
echo "Maturity scorecard agent must produce qa/maturity-scores.yaml." >&2
exit 1
fi
- name: Validate maturity score sources
run: |
node --import tsx --input-type=module <<'NODE'
@@ -260,6 +367,7 @@ jobs:
--strict-inputs
- name: Create generated docs PR app token
if: ${{ github.event_name == 'workflow_dispatch' }}
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
@@ -270,7 +378,7 @@ jobs:
permission-pull-requests: write
- name: Create generated docs PR fallback app token
if: ${{ steps.app-token.outcome == 'failure' }}
if: ${{ github.event_name == 'workflow_dispatch' && steps.app-token.outcome == 'failure' }}
id: app-token-fallback
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
@@ -280,6 +388,7 @@ jobs:
permission-pull-requests: write
- name: Open generated docs PR
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
@@ -291,7 +400,7 @@ jobs:
exit 1
fi
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
{
echo
echo "- Pull request: skipped; generated scorecard matches selected ref"
@@ -311,9 +420,6 @@ jobs:
git fetch --no-tags --depth=1 origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
git switch -C "$branch"
git add qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md
if git ls-files --error-unmatch docs/maturity-scores.yaml >/dev/null 2>&1 || [[ -e docs/maturity-scores.yaml ]]; then
git add docs/maturity-scores.yaml
fi
git commit -m "docs: update maturity scorecard"
git push --force-with-lease origin "$branch"

View File

@@ -44,6 +44,11 @@ on:
required: false
default: false
type: boolean
run_maturity_scorecard:
description: Render advisory maturity scorecard release docs; default release checks rely on dedicated package, QA, live, and E2E gates
required: false
default: false
type: boolean
rerun_group:
description: Release check group to run
required: false
@@ -106,6 +111,7 @@ jobs:
mode: ${{ steps.inputs.outputs.mode }}
release_profile: ${{ steps.inputs.outputs.release_profile }}
run_release_soak: ${{ steps.inputs.outputs.run_release_soak }}
run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }}
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }}
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
@@ -279,6 +285,7 @@ jobs:
RELEASE_MODE_INPUT: ${{ inputs.mode }}
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }}
RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }}
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }}
RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }}
@@ -319,6 +326,12 @@ jobs:
else
run_release_soak=true
fi
run_maturity_scorecard="$(printf '%s' "$RELEASE_RUN_MATURITY_SCORECARD_INPUT" | tr '[:upper:]' '[:lower:]')"
if [[ "$run_maturity_scorecard" != "true" && "$run_maturity_scorecard" != "1" && "$run_maturity_scorecard" != "yes" ]]; then
run_maturity_scorecard=false
else
run_maturity_scorecard=true
fi
release_profile="$RELEASE_PROFILE_INPUT"
if [[ "$release_profile" == "minimum" ]]; then
release_profile=beta
@@ -422,6 +435,7 @@ jobs:
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
printf 'release_profile=%s\n' "$release_profile"
printf 'run_release_soak=%s\n' "$run_release_soak"
printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard"
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT"
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
@@ -444,6 +458,7 @@ jobs:
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }}
RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }}
RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }}
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
@@ -461,6 +476,7 @@ jobs:
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`"
echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`"
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then
echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`"
@@ -767,6 +783,20 @@ jobs:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
maturity_scorecard_release_checks:
name: Render maturity scorecard release docs
needs: [resolve_target]
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.run_maturity_scorecard == 'true'
permissions:
actions: read
contents: read
uses: ./.github/workflows/maturity-scorecard.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
expected_sha: ${{ needs.resolve_target.outputs.revision }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
qa_lab_parity_lane_release_checks:
name: Run QA Lab parity lane (${{ matrix.lane }})
needs: [resolve_target]
@@ -853,7 +883,7 @@ jobs:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -959,7 +989,7 @@ jobs:
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1131,7 +1161,7 @@ jobs:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1241,13 +1271,13 @@ jobs:
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
- name: Upload runtime tool coverage artifacts
if: always()
if: ${{ always() && steps.verify_runtime_parity_status.outputs.ready == 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/runtime-parity-standard-report/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
@@ -1327,7 +1357,7 @@ jobs:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1467,7 +1497,7 @@ jobs:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1607,7 +1637,7 @@ jobs:
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1750,7 +1780,7 @@ jobs:
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1890,7 +1920,7 @@ jobs:
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Record advisory status
if: always()
@@ -1946,6 +1976,7 @@ jobs:
- docker_e2e_release_checks
- package_acceptance_release_checks
- qa_lab_parity_lane_release_checks
- maturity_scorecard_release_checks
- qa_lab_parity_report_release_checks
- qa_lab_runtime_parity_release_checks
- runtime_tool_coverage_release_checks
@@ -2031,6 +2062,7 @@ jobs:
"docker_e2e_release_checks=${{ needs.docker_e2e_release_checks.result }}" \
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
"maturity_scorecard_release_checks=${{ needs.maturity_scorecard_release_checks.result }}" \
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \

View File

@@ -1466,9 +1466,9 @@ jobs:
fi
- name: Upload postpublish evidence
if: ${{ always() }}
if: ${{ always() && inputs.publish_openclaw_npm }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
if-no-files-found: ignore
if-no-files-found: error

View File

@@ -89,6 +89,13 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Reusable workflow jobs inherit the caller event but run as
// github-actions[bot]; selected ref validation still gates secrets.
if (context.actor === "github-actions[bot]") {
core.info("Skipping manual actor permission check for a reusable workflow call.");
core.setOutput("authorized", "true");
return;
}
if (context.eventName !== "workflow_dispatch") {
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
core.setOutput("authorized", "true");
@@ -243,6 +250,9 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
run: node scripts/build-all.mjs qaRuntime
- name: Ensure Playwright Chromium
run: node scripts/ensure-playwright-chromium.mjs
- name: Run QA profile
id: run_profile
env:

View File

@@ -37,6 +37,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
#### Pull requests
- **PR #92154** fix(qqbot): gate private group commands and close strict command visibility gaps. Thanks @sliverp.
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.

View File

@@ -152,6 +152,7 @@ extension SettingsProTab {
}
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
self.applyNotificationStatus(notificationSettings.authorizationStatus)
self.registerForRemoteNotificationsIfEnrollmentReady()
let issueCount = SettingsDiagnostics.issueCount(
gatewayConnected: self.gatewayDiagnosticConnected,
@@ -417,6 +418,7 @@ extension SettingsProTab {
let status = settings.authorizationStatus
Task { @MainActor in
self.applyNotificationStatus(status)
self.registerForRemoteNotificationsIfEnrollmentReady()
}
}
}
@@ -437,6 +439,7 @@ extension SettingsProTab {
func requestNotificationAuthorizationFromSettings() {
guard !self.isRequestingNotificationAuthorization else { return }
PushEnrollmentConsent.markDisclosureAccepted()
self.isRequestingNotificationAuthorization = true
Task {
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
@@ -448,12 +451,19 @@ extension SettingsProTab {
await MainActor.run {
self.isRequestingNotificationAuthorization = false
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
guard granted, self.notificationStatus.allowsNotifications else { return }
UIApplication.shared.registerForRemoteNotifications()
guard granted else { return }
self.registerForRemoteNotificationsIfEnrollmentReady()
}
}
}
@MainActor
func registerForRemoteNotificationsIfEnrollmentReady() {
guard PushEnrollmentConsent.disclosureAccepted else { return }
guard self.notificationStatus.allowsNotifications else { return }
UIApplication.shared.registerForRemoteNotifications()
}
@MainActor
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
self.notificationStatus = SettingsNotificationStatus(status)

View File

@@ -4103,6 +4103,9 @@ extension NodeAppModel {
private func registerAPNsTokenIfNeeded() async {
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else {
return
}
guard self.gatewayConnected else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("gateway_offline")
@@ -4163,6 +4166,23 @@ extension NodeAppModel {
}
}
private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool {
guard PushEnrollmentConsent.disclosureAccepted else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
}
return false
}
let status = await self.notificationAuthorizationStatus()
guard Self.isNotificationAuthorizationAllowed(status) else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized")
}
return false
}
return true
}
private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
let response = try await self.operatorGateway.request(
method: "gateway.identity.get",
@@ -5126,6 +5146,10 @@ extension NodeAppModel {
self.setOperatorConnected(connected)
}
func _test_canPublishAPNsRegistration(usesRelayTransport: Bool = true) async -> Bool {
await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport)
}
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
self.makeWatchChatItems(from: raw)
}

View File

@@ -123,10 +123,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.delegate = self
ExecApprovalNotificationBridge.registerCategory(center: notificationCenter)
application.registerForRemoteNotifications()
Task { @MainActor in
await self.registerForRemoteNotificationsIfEnrollmentReady(application)
}
return true
}
private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async {
guard PushEnrollmentConsent.disclosureAccepted else { return }
guard await Self.isNotificationAuthorizationAllowed() else { return }
application.registerForRemoteNotifications()
}
private static func isNotificationAuthorizationAllowed() async -> Bool {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return true
case .denied, .notDetermined:
return false
@unknown default:
return false
}
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if let appModel = self.resolvedAppModel() {
Task { @MainActor in

View File

@@ -0,0 +1,19 @@
import Foundation
enum PushEnrollmentConsent {
static let disclosureAcceptedKey = "push.enrollment.disclosureAccepted"
static var disclosureAccepted: Bool {
UserDefaults.standard.bool(forKey: disclosureAcceptedKey)
}
static func markDisclosureAccepted() {
UserDefaults.standard.set(true, forKey: self.disclosureAcceptedKey)
}
#if DEBUG
static func reset() {
UserDefaults.standard.removeObject(forKey: self.disclosureAcceptedKey)
}
#endif
}

View File

@@ -76,6 +76,7 @@ Sources/Permissions/PermissionRequestBridge.swift
Sources/Push/ExecApprovalNotificationBridge.swift
Sources/Push/BackgroundAliveBeacon.swift
Sources/Push/PushBuildConfig.swift
Sources/Push/PushEnrollmentConsent.swift
Sources/Push/PushRegistrationManager.swift
Sources/Push/PushRelayClient.swift
Sources/Push/PushRelayKeychainStore.swift

View File

@@ -1377,6 +1377,24 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(center.addCalls == 1)
}
@Test @MainActor func apnsRegistrationRequiresDisclosureAndNotificationAuthorization() async {
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
PushEnrollmentConsent.reset()
defer { PushEnrollmentConsent.reset() }
#expect(await appModel._test_canPublishAPNsRegistration() == false)
#expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false) == false)
PushEnrollmentConsent.markDisclosureAccepted()
center.status = .notDetermined
#expect(await appModel._test_canPublishAPNsRegistration() == false)
center.status = .authorized
#expect(await appModel._test_canPublishAPNsRegistration())
}
@Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .notDetermined

View File

@@ -550,6 +550,20 @@ struct RootTabsSourceGuardTests {
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
}
@Test func `push enrollment stays behind notification disclosure flow`() throws {
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
#expect(appSource.contains("PushEnrollmentConsent.disclosureAccepted"))
#expect(appSource.contains("await Self.isNotificationAuthorizationAllowed()"))
#expect(actionsSource.contains("PushEnrollmentConsent.markDisclosureAccepted()"))
#expect(actionsSource.contains("self.registerForRemoteNotificationsIfEnrollmentReady()"))
#expect(modelSource.contains("PushEnrollmentConsent.disclosureAccepted"))
#expect(modelSource.contains("notifications_not_authorized"))
#expect(modelSource.contains("enrollment_disclosure_not_accepted"))
}
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
@@ -786,6 +800,13 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
}
private static func openClawAppSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/OpenClawApp.swift")
}
private static func notificationPermissionGuidanceDialogSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()

View File

@@ -284,6 +284,7 @@ def normalize_watch_screenshot_status_bar(path)
script = <<~SWIFT
import AppKit
import Foundation
import ImageIO
let path = CommandLine.arguments[1]
let timeText = CommandLine.arguments[2]
@@ -295,36 +296,37 @@ def normalize_watch_screenshot_status_bar(path)
exit(2)
}
let width = CGFloat(cgImage.width)
let height = CGFloat(cgImage.height)
guard let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(width),
pixelsHigh: Int(height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0),
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
let width = cgImage.width
let height = cgImage.height
let drawWidth = CGFloat(width)
let drawHeight = CGFloat(height)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let bitmapContext = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
else {
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
exit(3)
}
bitmap.size = NSSize(width: width, height: height)
let graphicsContext = NSGraphicsContext(cgContext: bitmapContext, flipped: false)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = graphicsContext
NSColor.black.setFill()
NSBezierPath(rect: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight)).fill()
source.draw(
in: NSRect(x: 0, y: 0, width: width, height: height),
from: NSRect(x: 0, y: 0, width: width, height: height),
operation: .copy,
in: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
from: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
operation: .sourceOver,
fraction: 1.0)
NSColor.black.setFill()
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
NSBezierPath(rect: NSRect(x: drawWidth - 146, y: drawHeight - 92, width: 124, height: 70)).fill()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
@@ -334,17 +336,26 @@ def normalize_watch_screenshot_status_bar(path)
.paragraphStyle: paragraphStyle,
]
timeText.draw(
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
in: NSRect(x: drawWidth - 134, y: drawHeight - 82, width: 102, height: 44),
withAttributes: attributes)
NSGraphicsContext.restoreGraphicsState()
guard let png = bitmap.representation(using: .png, properties: [:])
guard let output = bitmapContext.makeImage(),
let destination = CGImageDestinationCreateWithURL(
URL(fileURLWithPath: path) as CFURL,
"public.png" as CFString,
1,
nil)
else {
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
exit(4)
}
try png.write(to: URL(fileURLWithPath: path))
CGImageDestinationAddImage(destination, output, nil)
guard CGImageDestinationFinalize(destination) else {
fputs("Failed to write normalized screenshot at \\(path)\\n", stderr)
exit(5)
}
SWIFT
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|

View File

@@ -1,2 +1,2 @@
da3373338b7f9c5f5639ad8233a32897d2346a0babe69a77386a7bff154cdcb1 plugin-sdk-api-baseline.json
17404d885e0d64ebc8e3c99443921058a8f1aebf76a5e612eb1f0cd7817d48f0 plugin-sdk-api-baseline.jsonl
57b3f65c9d8c4edddea6ffa86584756234e761cc1cdd561e4f57c8c072baaad2 plugin-sdk-api-baseline.json
1c20edb5599d0050382a32272ff3708e969f4605a2dca3db8b5cef9ab7680bd6 plugin-sdk-api-baseline.jsonl

View File

@@ -155,6 +155,7 @@ Notes:
- `onchar` still responds to explicit @mentions.
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
- After the bot sends a visible reply in a channel thread, later messages in that same thread are answered without a new @mention or `onchar` prefix, so multi-turn thread conversations keep flowing. Participation is remembered for 7 days of thread inactivity (refreshed on each reply) and persists across gateway restarts. Threads the bot has only observed are unaffected; start a new top-level message to require an explicit mention again.
## Threading and sessions

View File

@@ -151,6 +151,7 @@ to a group, then mention it or configure the group to run without a mention.
groups: {
"*": {
requireMention: true,
commandLevel: "all",
historyLimit: 50,
tools: { deny: ["exec", "read", "write"] },
},
@@ -158,6 +159,7 @@ to a group, then mention it or configure the group to run without a mention.
name: "Release room",
requireMention: false,
ignoreOtherMentions: true,
commandLevel: "safety",
historyLimit: 20,
prompt: "Keep replies short and operational.",
},
@@ -172,6 +174,9 @@ to a group, then mention it or configure the group to run without a mention.
settings include:
- `requireMention`: require an @mention before the bot replies. Default: `true`.
- `commandLevel`: control which built-in slash commands can run in groups.
Default: `all`, which preserves the pre-existing QQBot group behavior when the
setting is omitted.
- `ignoreOtherMentions`: drop messages that mention someone else but not the bot.
- `historyLimit`: keep recent non-mention group messages as context for the next mentioned turn. Set `0` to disable.
- `tools`: allow/deny tools for the whole group.
@@ -179,6 +184,17 @@ settings include:
- `name`: friendly label used in logs and group context.
- `prompt`: per-group behavior prompt appended to the agent context.
`commandLevel` accepts:
- `all`: keep recognized built-in commands available as before. Some commands may
stay hidden from menus, but authorized users can still run them in the group.
- `safety`: allow common collaboration commands such as `/help`, `/btw`, and
`/stop`; ask users to run sensitive commands such as `/config`, `/tools`, and
`/bash` in private chat.
- `strict`: only allow the group-session controls needed for strict group
operation. `/stop` still stays urgent so an authorized sender can interrupt an
active run.
Old QQBot `toolPolicy` entries are retired. Run `openclaw doctor --fix` to migrate them to `tools`.
Activation modes are `mention` and `always`. `requireMention: true` maps to

View File

@@ -198,7 +198,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured
## Full Release Validation
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, maturity scorecard rendering from QA profile evidence, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
See [Full release validation](/reference/full-release-validation) for the
stage matrix, exact workflow job names, profile differences, artifacts, and

View File

@@ -68,6 +68,14 @@
"source": "/reference/openclaw-sdk-api-design",
"destination": "/gateway/external-apps"
},
{
"source": "/reference/maturity-scorecard",
"destination": "/maturity/scorecard"
},
{
"source": "/reference/maturity-taxonomy",
"destination": "/maturity/taxonomy"
},
{
"source": "/mcp",
"destination": "/cli/mcp"
@@ -1852,6 +1860,8 @@
{
"group": "Release and CI",
"pages": [
"maturity/scorecard",
"maturity/taxonomy",
"reference/RELEASING",
"reference/full-release-validation",
"reference/release-performance-sweep",

View File

@@ -110,14 +110,18 @@ systemctl --user daemon-reload
### Windows (Scheduled Task)
Default task name is `OpenClaw Gateway` (or `OpenClaw Gateway (<profile>)`).
The task script lives under your state dir.
The task script lives under your state dir as `gateway.cmd`; current installs may
also create a windowless `gateway.vbs` launcher that Task Scheduler runs instead
of opening `gateway.cmd` directly.
```powershell
schtasks /Delete /F /TN "OpenClaw Gateway"
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd"
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd" -ErrorAction SilentlyContinue
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.vbs" -ErrorAction SilentlyContinue
```
If you used a profile, delete the matching task name and `~\.openclaw-<profile>\gateway.cmd`.
If you used a profile, delete the matching task name and the `gateway.cmd` /
`gateway.vbs` files under `~\.openclaw-<profile>`.
## Normal install vs source checkout

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -124,8 +124,11 @@ openclaw gateway status --json
```
Native Windows CLI and Gateway flows are supported and continue to improve.
Managed startup uses Windows Scheduled Tasks when available and falls back to a
per-user Startup-folder login item if task creation is denied.
Managed startup uses Windows Scheduled Tasks when available. The task keeps the
readable `gateway.cmd` script in the OpenClaw state dir, but launches it through
a generated `gateway.vbs` WScript wrapper so the background Gateway does not open
a visible console window. If task creation is denied, OpenClaw falls back to a
per-user Startup-folder login item.
To install the Gateway service:

View File

@@ -259,14 +259,10 @@ under `describe("runSideQuestion")`.
fallback for whatever runtimes do not have a peer surface.
- PI session state is not migrated when an agent switches to `copilot`.
Selection is per attempt; existing PI sessions remain valid.
- **Interactive `ask_user` is not yet wired.** The SDK's
`onUserInputRequest` handler is intentionally not registered, which
per the SDK contract hides the `ask_user` tool from the model
entirely. Agents running under this harness make best-judgment
decisions from the initial prompt rather than asking clarifying
questions mid-turn. A follow-up will port the codex pattern at
`extensions/codex/src/app-server/user-input-bridge.ts` to route SDK
`UserInputRequest`s through the OpenClaw channel/TUI prompt path.
- `ask_user` uses the same OpenClaw prompt-and-reply path as the Codex
harness. When the Copilot SDK asks for user input, OpenClaw posts a
blocking prompt to the active channel/TUI and the next queued user
message resolves the SDK request.
## Permissions and ask_user
@@ -328,11 +324,15 @@ the tool bridge. The bridge also forwards the bounded tool-construction
controls it can enforce at the SDK boundary: `includeCoreTools`, the
runtime tool allowlist, and `toolConstructionPlan`.
The remaining PI tool-search/code-mode fields are intentionally **not**
forwarded at MVP and tracked as follow-ups: `toolSearchCatalogRef`,
`includeToolSearchControls`, and `toolSearchCatalogExecutor`. Those
controls drive PI's native tool-search UI and have no direct Copilot SDK
analog yet.
The bridge also uses the shared harness tool-surface helper from
`openclaw/plugin-sdk/agent-harness-tool-runtime` for PI parity. When
tool-search is enabled, the SDK sees compact control tools plus a hidden
catalog executor instead of every OpenClaw tool schema. When code mode is
enabled, the helper builds the same code-mode control surface and catalog
lifecycle used by other agent harnesses. Local-model lean defaults,
runtime-compatible schema filtering, directory hydration, and catalog
cleanup all stay in the shared helper so Copilot and Codex-adjacent
harnesses do not drift.
### Session-level GitHub token
@@ -349,7 +349,10 @@ When the resolved mode is `useLoggedInUser`, the session-level field
is omitted so the SDK keeps deriving identity from the logged-in
identity.
`ask_user` is intentionally hidden — see Limitations above.
`ask_user` uses `SessionConfig.onUserInputRequest`. The bridge accepts
choice indexes or labels for fixed-choice requests, accepts free-form
answers when the SDK request allows them, and cancels a pending request
when the OpenClaw attempt is aborted.
## Related

View File

@@ -196,6 +196,23 @@ finish. Both helpers accept the same `{ event, ctx }` payload as
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
attempt result.
### User input and tool surfaces
Native harnesses that expose a runtime-level user-input request should use the
user-input helpers from `openclaw/plugin-sdk/agent-harness-runtime` to format
the prompt, deliver it through OpenClaw's blocking reply path, and normalize
choice/free-form answers back into the runtime's native response shape. The
helper keeps channel/TUI presentation consistent while each harness keeps its
own protocol parsing and pending-request lifecycle.
Native harnesses that need PI-like compact tool routing should use
`createAgentHarnessToolSurfaceRuntime(...)` from
`openclaw/plugin-sdk/agent-harness-tool-runtime`. It owns
tool-search/code-mode control selection, local-model lean defaults,
runtime-compatible schema filtering, hidden catalog execution, directory
hydration, and catalog cleanup. Harnesses still own their SDK-specific tool
conversion and native execution callback.
### Native Codex harness mode
The bundled `codex` harness is the native Codex mode for embedded OpenClaw

View File

@@ -84,8 +84,8 @@ Choose the Token Plan auth choice that matches the regional base URL shown in Xi
| Model ref | Input | Context | Max output | Reasoning | Notes |
| --------------------------------- | ----------- | --------- | ---------- | --------- | ------------- |
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 32,000 | Yes | Default model |
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 32,000 | Yes | Multimodal |
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 131,072 | Yes | Default model |
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 131,072 | Yes | Multimodal |
<Tip>
Token Plan onboarding validates the key shape and warns when a `tp-...` key is entered into the pay-as-you-go path, or an `sk-...` key is entered into the Token Plan path.
@@ -222,7 +222,7 @@ Token Plan:
reasoning: true,
input: ["text"],
contextWindow: 1048576,
maxTokens: 32000,
maxTokens: 131072,
},
{
id: "mimo-v2.5",
@@ -230,7 +230,7 @@ Token Plan:
reasoning: true,
input: ["text", "image"],
contextWindow: 1048576,
maxTokens: 32000,
maxTokens: 131072,
},
],
},

View File

@@ -135,3 +135,753 @@ html.dark .nav-tabs-underline {
grid-template-columns: 1fr;
}
}
.maturity-hero {
display: grid;
gap: 14px;
margin: 10px 0 38px;
padding: 4px 0 26px 20px;
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 22%, transparent);
border-left: 3px solid rgb(var(--primary));
}
.maturity-hero-compact {
margin-bottom: 34px;
}
.maturity-hero h2 {
max-width: 46rem;
margin: 0;
font-size: clamp(26px, 3vw, 38px);
line-height: 1.08;
letter-spacing: -0.01em;
}
.maturity-hero-title {
max-width: 46rem;
margin: 0;
font-size: clamp(26px, 3vw, 38px);
font-weight: 750;
line-height: 1.08;
letter-spacing: -0.01em;
}
.maturity-hero > p:not(.maturity-kicker):not(.maturity-jump-links) {
max-width: 58rem;
margin: 0;
font-size: 16px;
line-height: 1.65;
opacity: 0.76;
}
.maturity-kicker {
margin: 0;
color: rgb(var(--primary));
font-size: 10px;
font-weight: 750;
letter-spacing: 0.1em;
line-height: 1.3;
text-transform: uppercase;
}
.maturity-jump-links {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
.maturity-jump-links a {
text-decoration: none;
}
.maturity-jump-links a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.maturity-score-stable,
.maturity-band-stable {
color: #4ca574;
}
.maturity-score-beta,
.maturity-band-beta {
color: #849fd2;
}
.maturity-score-alpha,
.maturity-band-alpha {
color: #d39a4b;
}
.maturity-score-experimental,
.maturity-band-experimental {
color: #dc7669;
}
.maturity-score-clawesome,
.maturity-band-clawesome {
color: #46b59a;
}
.maturity-level-pill {
display: inline-flex;
align-items: center;
gap: 6px;
width: max-content;
max-width: 100%;
padding: 3px 8px;
border: 1px solid color-mix(in oklab, currentColor 32%, transparent);
border-radius: 999px;
background: color-mix(in oklab, currentColor 10%, transparent);
color: inherit;
font-size: 10px;
font-weight: 750;
line-height: 1.25;
white-space: nowrap;
}
.maturity-level-code {
font-size: 9px;
letter-spacing: 0.04em;
opacity: 0.72;
}
.maturity-level-experimental {
color: #dc7669;
}
.maturity-level-alpha {
color: #d39a4b;
}
.maturity-level-beta {
color: #849fd2;
}
.maturity-level-stable {
color: #4ca574;
}
.maturity-level-clawesome {
color: #46b59a;
}
.maturity-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 14px 0 20px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
}
.maturity-summary-item {
display: grid;
gap: 10px;
min-width: 0;
padding: 18px 20px 18px 0;
}
.maturity-summary-item + .maturity-summary-item {
padding-left: 20px;
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
}
.maturity-summary-heading {
display: flex;
align-items: baseline;
gap: 9px;
}
.maturity-summary-value {
display: inline-block;
font-size: 30px;
font-weight: 750;
letter-spacing: -0.04em;
}
.maturity-summary-heading > span:not(.maturity-summary-value) {
font-size: 13px;
font-weight: 700;
}
.maturity-summary-bar {
height: 7px;
overflow: hidden;
background: color-mix(in oklab, currentColor 14%, transparent);
}
.maturity-summary-bar span {
display: block;
width: calc(var(--score) * 1%);
height: 100%;
background: currentColor;
}
.maturity-summary-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 11px;
line-height: 1.4;
}
.maturity-summary-meta span:first-child {
font-weight: 700;
}
.maturity-summary-meta span:last-child {
opacity: 0.62;
}
.maturity-band-list {
display: flex;
margin: 12px 0 30px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
}
.maturity-band {
display: grid;
flex: 1 1 0;
gap: 3px;
padding: 10px 12px 11px 0;
}
.maturity-band + .maturity-band {
padding-left: 12px;
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
}
.maturity-band-title {
font-size: 12px;
font-weight: 700;
}
.maturity-band-title + span {
color: inherit;
font-size: 11px;
opacity: 0.7;
}
.maturity-band > span:last-child {
color: inherit;
font-size: 11px;
opacity: 0.7;
}
.maturity-band span {
color: inherit;
font-size: 11px;
opacity: 0.7;
}
.maturity-band .maturity-level-pill {
font-size: 10px;
opacity: 1;
}
.maturity-band .maturity-level-pill span {
font-size: inherit;
opacity: inherit;
}
.maturity-score {
display: grid;
gap: 4px;
min-width: 0;
color: inherit;
font-size: 11px;
font-weight: 700;
}
.maturity-score-label {
display: flex;
justify-content: space-between;
gap: 6px;
line-height: 1.2;
}
.maturity-score-label > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.maturity-score-label > span:last-child {
flex: 0 0 auto;
}
.maturity-score-label .maturity-level-pill {
gap: 4px;
padding: 2px 6px;
font-size: 9px;
}
.maturity-score-label-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.maturity-summary-meta .maturity-level-pill {
opacity: 1;
}
.maturity-meter {
display: inline-block;
width: 100%;
height: 3px;
overflow: hidden;
background: color-mix(in oklab, currentColor 15%, transparent);
vertical-align: middle;
}
.maturity-meter > span {
display: block;
width: 0;
height: 100%;
background: currentColor;
}
.maturity-score-unscored,
.maturity-lts-none {
color: inherit;
opacity: 0.52;
}
.maturity-surface-table {
display: grid;
gap: 0;
margin: 8px 0 22px;
}
.maturity-surface-row {
display: grid;
grid-template-columns: minmax(190px, 1.55fr) repeat(3, minmax(110px, 1fr)) minmax(72px, 0.55fr);
gap: 12px;
align-items: center;
padding: 13px 0;
border-top: 1px solid color-mix(in oklab, currentColor 14%, transparent);
}
.maturity-surface-row-header {
padding: 0 0 9px;
border-top: 0;
color: inherit;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
opacity: 0.56;
text-transform: uppercase;
}
.maturity-surface-name {
display: grid;
gap: 3px;
min-width: 0;
border-bottom: 0 !important;
text-decoration: none;
}
.maturity-surface-name:hover .maturity-surface-title {
color: rgb(var(--primary));
}
.maturity-surface-title {
overflow-wrap: anywhere;
font-size: 13px;
font-weight: 700;
line-height: 1.25;
}
.maturity-surface-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.maturity-surface-meta > span:not(.maturity-level-pill) {
font-size: 11px;
opacity: 0.62;
}
.maturity-surface-meta .maturity-level-pill {
opacity: 1;
}
.maturity-surface-metric {
min-width: 0;
}
.maturity-surface-metric-label {
display: none;
font-size: 10px;
opacity: 0.62;
}
.maturity-surface-support {
justify-self: start;
}
.maturity-lts {
display: inline-flex;
align-items: center;
gap: 5px;
color: inherit;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.maturity-lts::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
content: "";
}
.maturity-lts-partial {
color: #d39a4b;
}
.maturity-lts-full {
color: #4ca574;
}
.maturity-evidence-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 14px 0 24px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
}
.maturity-evidence-card {
display: grid;
gap: 4px;
min-width: 0;
padding: 14px 16px 14px 0;
}
.maturity-evidence-card + .maturity-evidence-card {
padding-left: 16px;
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
}
.maturity-evidence-title {
font-size: 13px;
font-weight: 700;
}
.maturity-evidence-card span {
font-size: 11px;
line-height: 1.4;
opacity: 0.68;
}
.maturity-readiness-summary {
margin: 0 0 12px;
font-size: 12px;
opacity: 0.65;
}
.maturity-readiness-list {
display: grid;
margin: 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
}
.maturity-readiness-row {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(120px, 0.8fr) minmax(110px, 0.7fr);
gap: 14px;
align-items: center;
padding: 11px 0;
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 10%, transparent);
font-size: 11px;
}
.maturity-readiness-row-header {
padding: 8px 0;
border-bottom-color: color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
opacity: 0.56;
text-transform: uppercase;
}
.maturity-readiness-area {
display: grid;
gap: 3px;
min-width: 0;
}
.maturity-readiness-title {
overflow-wrap: anywhere;
font-weight: 700;
}
.maturity-readiness-status {
font-size: 10px;
opacity: 0.62;
}
.maturity-readiness-status-ready {
color: #4ca574;
}
.maturity-readiness-status-partially-reviewed {
color: #d39a4b;
}
.maturity-readiness-status-needs-review {
color: #dc7669;
}
.maturity-category-list {
display: grid;
width: 100%;
margin-top: 4px;
overflow: hidden;
}
.maturity-category-row {
display: grid;
grid-template-columns: minmax(180px, 1.55fr) repeat(3, minmax(100px, 1fr)) minmax(140px, 1.2fr);
gap: 12px;
align-items: center;
padding: 12px 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
font-size: 11px;
}
.maturity-category-row-header {
padding: 8px 0;
border-top: 0;
color: inherit;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
opacity: 0.56;
text-transform: uppercase;
}
.maturity-category-area {
display: grid;
gap: 3px;
min-width: 0;
}
.maturity-category-title {
overflow-wrap: anywhere;
font-weight: 700;
}
.maturity-category-area > span:last-child {
font-size: 10px;
opacity: 0.62;
}
.maturity-category-docs {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.4;
}
.maturity-category-docs a {
text-decoration: none;
}
.maturity-category-docs a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.maturity-level-list {
display: grid;
margin: 12px 0 28px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
}
.maturity-level-row {
display: grid;
grid-template-columns: minmax(130px, 0.32fr) minmax(0, 1fr);
gap: 4px 14px;
padding: 13px 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
}
.maturity-level-row:first-child {
border-top: 0;
}
.maturity-level-title {
grid-row: span 2;
font-size: 13px;
font-weight: 700;
}
.maturity-level-title .maturity-level-pill {
opacity: 1;
}
.maturity-level-row span,
.maturity-level-promotion {
font-size: 12px;
line-height: 1.45;
opacity: 0.68;
}
.maturity-surface-link {
display: grid;
gap: 3px;
margin: 0;
padding: 11px 0;
border-bottom: 1px solid color-mix(in oklab, currentColor 14%, transparent);
text-decoration: none;
}
.maturity-surface-link:hover {
color: rgb(var(--primary));
}
.maturity-surface-link .maturity-surface-title {
font-size: 13px;
font-weight: 700;
}
.maturity-surface-link > .maturity-surface-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.maturity-surface-link > .maturity-surface-meta > span:not(.maturity-level-pill) {
font-size: 11px;
opacity: 0.68;
}
.maturity-surface-link > .maturity-surface-meta .maturity-level-pill {
opacity: 1;
}
.maturity-surface-rollup {
display: flex;
flex-wrap: wrap;
gap: 5px 14px;
margin: 0 0 14px;
padding: 9px 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
}
.maturity-surface-rollup > span {
color: inherit;
font-size: 11px;
font-weight: 700;
}
#content table .maturity-score,
#content table .maturity-lts {
white-space: nowrap;
}
@media (max-width: 960px) {
.maturity-summary-grid,
.maturity-evidence-grid {
grid-template-columns: 1fr;
}
.maturity-summary-item,
.maturity-summary-item + .maturity-summary-item,
.maturity-evidence-card,
.maturity-evidence-card + .maturity-evidence-card {
padding: 14px 0;
border-left: 0;
}
.maturity-summary-item + .maturity-summary-item,
.maturity-evidence-card + .maturity-evidence-card {
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
}
.maturity-surface-row {
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(98px, 1fr)) minmax(70px, 0.5fr);
gap: 8px;
}
.maturity-category-row {
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(86px, 1fr)) minmax(110px, 1fr);
gap: 8px;
}
}
@media (max-width: 640px) {
.maturity-hero {
padding-left: 14px;
}
.maturity-surface-row-header {
display: none;
}
.maturity-surface-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 9px 12px;
}
.maturity-surface-metric {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 6px;
}
.maturity-surface-metric-label {
display: block;
}
.maturity-surface-support {
justify-self: end;
}
.maturity-readiness-row,
.maturity-category-row {
grid-template-columns: 1fr;
gap: 5px;
}
.maturity-readiness-row-header,
.maturity-category-row-header {
display: none;
}
.maturity-category-docs {
padding-top: 3px;
}
.maturity-band-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maturity-band + .maturity-band {
padding-left: 0;
border-left: 0;
}
.maturity-level-row {
grid-template-columns: 1fr;
}
.maturity-level-title {
grid-row: auto;
}
}

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",
"acpx": "0.10.0",
"acpx": "0.11.2",
"zod": "4.4.3"
}
},
@@ -196,9 +196,9 @@
}
},
"node_modules/@clack/core": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz",
"integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
"integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==",
"license": "MIT",
"dependencies": {
"fast-wrap-ansi": "^0.2.0",
@@ -209,12 +209,12 @@
}
},
"node_modules/@clack/prompts": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz",
"integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz",
"integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.4.1",
"@clack/core": "1.3.1",
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
"sisteransi": "^1.0.5"
@@ -701,6 +701,7 @@
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.15.0.tgz",
"integrity": "sha512-eAv7sGBeiYrYkOulF729nrM51szS7WIhBtugRj5wWq6csRKZUhAZfoUZlF8xUWdHPtOIzd/eT6MNG6gMHu6z0w==",
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"bin": {
"codex-acp": "bin/codex-acp.js"
@@ -721,6 +722,7 @@
"cpu": [
"arm64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -737,6 +739,7 @@
"cpu": [
"x64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -753,6 +756,7 @@
"cpu": [
"arm64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -769,6 +773,7 @@
"cpu": [
"x64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -785,6 +790,7 @@
"cpu": [
"arm64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -801,6 +807,7 @@
"cpu": [
"x64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -824,15 +831,15 @@
}
},
"node_modules/acpx": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.10.0.tgz",
"integrity": "sha512-hd48XV03gG3sd409T1lDrOKJTTz1ap4g0wrndXjxQ590tN85pBYlvfNLyerybvGRrtUGsZjNdt99r1jpIt6ukA==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.11.2.tgz",
"integrity": "sha512-ksTmfJDVqUAJJXsNDamEno03AMZ/aAZzXk/h5nt61VsLc/jcpoDMfCVpErzuYNJjwCd0V6Zm5o6F8OoqxsjQWA==",
"license": "MIT",
"dependencies": {
"@agentclientprotocol/sdk": "^0.22.1",
"commander": "^14.0.3",
"skillflag": "^0.1.4",
"tsx": "^4.22.0",
"@agentclientprotocol/sdk": "^0.28.1",
"commander": "^15.0.0",
"skillflag": "^0.2.0",
"tsx": "^4.22.4",
"zod": "^4.4.3"
},
"bin": {
@@ -842,6 +849,15 @@
"node": ">=22.13.0"
}
},
"node_modules/acpx/node_modules/@agentclientprotocol/sdk": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -1043,12 +1059,12 @@
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=22.12.0"
}
},
"node_modules/content-disposition": {
@@ -2045,9 +2061,9 @@
"license": "MIT"
},
"node_modules/skillflag": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.1.4.tgz",
"integrity": "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.2.0.tgz",
"integrity": "sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.0.1",

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",
"acpx": "0.10.0",
"acpx": "0.11.2",
"zod": "4.4.3"
},
"devDependencies": {

View File

@@ -251,6 +251,15 @@ describe("prepareAcpxCodexAuthConfig", () => {
expect(wrapper).not.toMatch(
/forceKillTimer = setTimeout\(\(\) => killChildTree\("SIGKILL"\), 1_500\);\s*forceKillTimer\.unref\?\.\(\);\s*process\.exit\(1\);/s,
);
// Orphan detection must trigger on any PPID change, not only when the new
// PPID is init (1). Systemd user services and container init reparent
// orphaned processes to a session manager or container init (PID != 1),
// and the older `process.ppid !== 1` guard would silently leak the codex
// adapter tree there.
expect(wrapper).not.toContain("process.ppid !== 1");
expect(wrapper).toMatch(
/setInterval\(\(\) => \{[\s\S]*?if \(process\.ppid === originalParentPid\) \{\s*return;\s*\}/,
);
});
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {

View File

@@ -475,7 +475,13 @@ const parentWatcher =
process.platform === "win32"
? undefined
: setInterval(() => {
if (process.ppid === originalParentPid || process.ppid !== 1) {
// Orphan detection: parent PID changed means our original parent died.
// The new parent could be PID 1 (init) on bare-metal hosts, OR a
// systemd user-session manager, OR a container init, OR a session
// leader — depending on environment. Previously this only triggered
// on PPID == 1, which missed all systemd-managed deployments and
// leaked codex-acp adapter trees on every gateway restart.
if (process.ppid === originalParentPid) {
return;
}
if (orphanCleanupStarted) {

View File

@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { RequestedModelUnsupportedError } from "acpx/runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AcpRuntimeError,
@@ -708,6 +709,100 @@ describe("AcpxRuntime fresh reset wrapper", () => {
});
});
it("retries without a model when ACPX reports missing model capability", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
agentRegistry: {
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
list: () => ["opencode"],
},
});
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(
new RequestedModelUnsupportedError(
"Cannot apply --model: the ACP agent did not advertise model support",
"missing-capability",
),
)
.mockResolvedValueOnce({
sessionKey: "agent:opencode:acp:test",
backend: "acpx",
runtimeSessionName: "opencode",
});
await runtime.ensureSession({
sessionKey: "agent:opencode:acp:test",
agent: "opencode",
mode: "persistent",
model: "openrouter/owl-alpha",
});
expect(ensure).toHaveBeenCalledTimes(2);
expect(readFirstEnsureSessionInput(ensure)).toMatchObject({
model: "openrouter/owl-alpha",
sessionOptions: { model: "openrouter/owl-alpha" },
});
const [, secondCall] = ensure.mock.calls;
expect(secondCall?.[0]).not.toHaveProperty("sessionOptions");
expect((secondCall?.[0] as { model?: string } | undefined)?.model).toBeUndefined();
});
it("does not retry when ACPX rejects an explicitly unsupported model id", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
agentRegistry: {
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
list: () => ["opencode"],
},
});
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(
new RequestedModelUnsupportedError(
"Cannot apply --model: the ACP agent did not advertise that model",
"unadvertised-model",
),
);
await expect(
runtime.ensureSession({
sessionKey: "agent:opencode:acp:test",
agent: "opencode",
mode: "persistent",
model: "unknown/model",
}),
).rejects.toThrow("did not advertise that model");
expect(ensure).toHaveBeenCalledTimes(1);
});
it("does not retry an unrelated error with similar wording", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore);
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(new Error("the ACP agent did not advertise model support"));
await expect(
runtime.ensureSession({
sessionKey: "agent:main:acp:test",
agent: "main",
mode: "persistent",
model: "openrouter/owl-alpha",
}),
).rejects.toThrow("did not advertise model support");
expect(ensure).toHaveBeenCalledTimes(1);
});
it("injects Codex ACP startup config into the scoped registry", () => {
expect(testing.isCodexAcpCommand(CODEX_ACP_COMMAND)).toBe(true);
expect(testing.isCodexAcpCommand(CODEX_ACP_WRAPPER_COMMAND)).toBe(true);

View File

@@ -13,6 +13,7 @@ import {
createFileSessionStore,
decodeAcpxRuntimeHandleState,
encodeAcpxRuntimeHandleState,
isRequestedModelUnsupportedError,
type AcpAgentRegistry,
type AcpRuntimeDoctorReport,
type AcpRuntimeEvent,
@@ -586,6 +587,26 @@ function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegate
} as AcpxDelegateEnsureInput;
}
function isAcpModelCapabilityMissingError(error: unknown): boolean {
return isRequestedModelUnsupportedError(error) && error.reason === "missing-capability";
}
// ACPX owns the distinction between missing model capability and an invalid model id.
// Retry only the former so explicit model mistakes remain visible to the caller.
async function ensureDelegateSessionWithModelFallback(
delegate: BaseAcpxRuntime,
input: OpenClawRuntimeEnsureInput,
): Promise<AcpRuntimeHandle> {
try {
return await delegate.ensureSession(withAcpxSessionOptions(input));
} catch (error) {
if (!input.model || !isAcpModelCapabilityMissingError(error)) {
throw error;
}
return await delegate.ensureSession(withAcpxSessionOptions({ ...input, model: undefined }));
}
}
function quoteShellArg(value: string): string {
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value;
@@ -989,7 +1010,7 @@ export class AcpxRuntime implements AcpRuntime {
this.withCodexWrapperDiagnostics({
command: stableLaunchCommand,
fallbackCode: "ACP_SESSION_INIT_FAILED",
run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)),
run: () => ensureDelegateSessionWithModelFallback(delegate, ensureInput),
}),
});
}

View File

@@ -43,23 +43,39 @@ afterAll(() => {
vi.resetModules();
});
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
function emptyWebSearchResponse(): Response {
return jsonResponse({ web: { results: [] } });
}
function installBraveLlmContextFetch() {
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({
grounding: {
generic: [
{
url: "https://example.com/context",
title: "Context",
snippets: ["snippet"],
},
],
},
sources: [],
}),
} as unknown as Response;
return jsonResponse({
grounding: {
generic: [
{
url: "https://example.com/context",
title: "Context",
snippets: ["snippet"],
},
],
},
sources: [],
});
});
global.fetch = mockFetch as typeof global.fetch;
return mockFetch;
@@ -254,10 +270,7 @@ describe("brave web search provider", () => {
it("uses configured Brave baseUrl for web search requests", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -310,12 +323,7 @@ describe("brave web search provider", () => {
it("reports malformed Brave web search JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response;
return malformedJsonResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -339,12 +347,7 @@ describe("brave web search provider", () => {
it("reports malformed Brave llm-context JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response;
return malformedJsonResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -428,10 +431,7 @@ describe("brave web search provider", () => {
it("keeps Brave cache entries isolated by baseUrl", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -573,10 +573,7 @@ describe("brave web search provider", () => {
it("sends Brave web auth in the X-Subscription-Token header", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -732,10 +729,7 @@ describe("brave web search provider", () => {
it("falls back unsupported country values before calling Brave", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
@@ -763,21 +757,17 @@ describe("brave web search provider", () => {
it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return {
ok: true,
status: 200,
json: async () => ({
web: {
results: [
{
title: "Diagnostics",
url: "https://example.com/diagnostics",
description: "debug details",
},
],
},
}),
} as unknown as Response;
return jsonResponse({
web: {
results: [
{
title: "Diagnostics",
url: "https://example.com/diagnostics",
description: "debug details",
},
],
},
});
});
global.fetch = mockFetch as typeof global.fetch;

View File

@@ -15,6 +15,14 @@ function restoreEnvVar(name: string, value: string | undefined): void {
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function runChutesCatalog(params: { apiKey?: string; discoveryApiKey?: string }) {
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
@@ -44,10 +52,9 @@ async function withRealChutesDiscovery<T>(
delete process.env.VITEST;
delete process.env.NODE_ENV;
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: "chutes/private-model" }] }),
});
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [{ id: "chutes/private-model" }] }));
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {

View File

@@ -15,6 +15,14 @@ function restoreEnvVar(name: string, value: string | undefined): void {
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveChutesDiscovery<T>(
fetchMock: ReturnType<typeof vi.fn>,
run: () => Promise<T>,
@@ -45,12 +53,11 @@ async function withLiveChutesDiscovery<T>(
function createAuthEchoFetchMock() {
return vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
const auth = readAuthorizationHeader(init);
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: auth ? `${auth}-model` : "public-model" }],
}),
});
);
});
}
@@ -124,9 +131,8 @@ describe("chutes-models", () => {
});
it("discoverChutesModels correctly maps API response when not in test env", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
{ id: "zai-org/GLM-4.7-TEE" },
{
@@ -140,7 +146,7 @@ describe("chutes-models", () => {
{ id: "new-provider/simple-model" },
],
}),
});
);
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("test-token-real-fetch");
expect(models.length).toBeGreaterThan(0);
@@ -158,9 +164,8 @@ describe("chutes-models", () => {
});
it("falls back from malformed live token metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
{
id: "provider/bad-window",
@@ -174,7 +179,7 @@ describe("chutes-models", () => {
},
],
}),
});
);
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("malformed-token-metadata");
@@ -195,14 +200,10 @@ describe("chutes-models", () => {
it("discoverChutesModels retries without auth on 401", async () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
if (readAuthorizationHeader(init) === "Bearer test-token-error") {
return Promise.resolve({
ok: false,
status: 401,
});
return Promise.resolve(new Response("", { status: 401 }));
}
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [
{
id: "Qwen/Qwen3-32B",
@@ -232,7 +233,7 @@ describe("chutes-models", () => {
},
],
}),
});
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("test-token-error");
@@ -242,10 +243,7 @@ describe("chutes-models", () => {
});
it("does not cache fallback static catalog for non-OK responses", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
});
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
await withLiveChutesDiscovery(mockFetch, async () => {
const first = await discoverChutesModels("chutes-fallback-token");
@@ -260,27 +258,24 @@ describe("chutes-models", () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
const auth = readAuthorizationHeader(init);
if (auth === "Bearer chutes-token-a") {
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: "private/model-a" }],
}),
});
);
}
if (auth === "Bearer chutes-token-b") {
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: "private/model-b" }],
}),
});
);
}
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: "public/model" }],
}),
});
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
const modelsA = await discoverChutesModels("chutes-token-a");
@@ -325,17 +320,13 @@ describe("chutes-models", () => {
it("does not cache 401 fallback under the failed token key", async () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
if (readAuthorizationHeader(init) === "Bearer failed-token") {
return Promise.resolve({
ok: false,
status: 401,
});
return Promise.resolve(new Response("", { status: 401 }));
}
return Promise.resolve({
ok: true,
json: async () => ({
return Promise.resolve(
jsonResponse({
data: [{ id: "public/model" }],
}),
});
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
await discoverChutesModels("failed-token");

View File

@@ -360,6 +360,15 @@ export async function mirrorCodexAppServerTranscript(params: {
sessionFile: params.sessionFile,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId && params.sessionKey && params.agentId
? {
target: {
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
},
}
: {}),
message: update.message,
messageId: update.messageId,
messageSeq: update.messageSeq,

View File

@@ -3,7 +3,12 @@
* turns replies into app-server answer payloads.
*/
import {
buildAgentHarnessUserInputAnswers,
deliverAgentHarnessUserInputPrompt,
embeddedAgentLog,
emptyAgentHarnessUserInputAnswers,
type AgentHarnessUserInputOption,
type AgentHarnessUserInputQuestion,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { formatCodexDisplayText } from "../command-formatters.js";
@@ -19,25 +24,11 @@ type PendingUserInput = {
threadId: string;
turnId: string;
itemId: string;
questions: UserInputQuestion[];
questions: AgentHarnessUserInputQuestion[];
resolve: (value: JsonValue) => void;
cleanup: () => void;
};
type UserInputQuestion = {
id: string;
header: string;
question: string;
isOther: boolean;
isSecret: boolean;
options: UserInputOption[] | null;
};
type UserInputOption = {
label: string;
description: string;
};
type CodexUserInputBridge = {
handleRequest: (request: {
id: number | string;
@@ -142,7 +133,7 @@ function readUserInputParams(value: JsonValue | undefined):
threadId: string;
turnId: string;
itemId: string;
questions: UserInputQuestion[];
questions: AgentHarnessUserInputQuestion[];
}
| undefined {
if (!isJsonObject(value)) {
@@ -157,11 +148,11 @@ function readUserInputParams(value: JsonValue | undefined):
}
const questions = questionsRaw
.map(readQuestion)
.filter((question): question is UserInputQuestion => Boolean(question));
.filter((question): question is AgentHarnessUserInputQuestion => Boolean(question));
return { threadId, turnId, itemId, questions };
}
function readQuestion(value: JsonValue): UserInputQuestion | undefined {
function readQuestion(value: JsonValue): AgentHarnessUserInputQuestion | undefined {
if (!isJsonObject(value)) {
return undefined;
}
@@ -181,17 +172,17 @@ function readQuestion(value: JsonValue): UserInputQuestion | undefined {
};
}
function readOptions(value: JsonValue | undefined): UserInputOption[] | null {
function readOptions(value: JsonValue | undefined): AgentHarnessUserInputOption[] | null {
if (!Array.isArray(value)) {
return null;
}
const options = value
.map(readOption)
.filter((option): option is UserInputOption => Boolean(option));
.filter((option): option is AgentHarnessUserInputOption => Boolean(option));
return options.length > 0 ? options : null;
}
function readOption(value: JsonValue): UserInputOption | undefined {
function readOption(value: JsonValue): AgentHarnessUserInputOption | undefined {
if (!isJsonObject(value)) {
return undefined;
}
@@ -202,116 +193,25 @@ function readOption(value: JsonValue): UserInputOption | undefined {
async function deliverUserInputPrompt(
params: EmbeddedRunAttemptParams,
questions: UserInputQuestion[],
questions: AgentHarnessUserInputQuestion[],
): Promise<void> {
const text = formatUserInputPrompt(questions);
if (params.onBlockReply) {
await params.onBlockReply({ text });
return;
}
await params.onPartialReply?.({ text });
}
function formatUserInputPrompt(questions: UserInputQuestion[]): string {
const lines = ["Codex needs input:"];
questions.forEach((question, index) => {
if (questions.length > 1) {
lines.push(
"",
`${index + 1}. ${formatCodexDisplayText(question.header)}`,
formatCodexDisplayText(question.question),
);
} else {
lines.push(
"",
formatCodexDisplayText(question.header),
formatCodexDisplayText(question.question),
);
}
if (question.isSecret) {
lines.push("This channel may show your reply to other participants.");
}
question.options?.forEach((option, optionIndex) => {
lines.push(
`${optionIndex + 1}. ${formatCodexDisplayText(option.label)}${
option.description ? ` - ${formatCodexDisplayText(option.description)}` : ""
}`,
);
});
if (question.isOther) {
lines.push("Other: reply with your own answer.");
}
await deliverAgentHarnessUserInputPrompt(params, questions, {
formatText: formatCodexDisplayText,
intro: "Codex needs input:",
});
return lines.join("\n");
}
function buildUserInputResponse(questions: UserInputQuestion[], inputText: string): JsonObject {
function buildUserInputResponse(
questions: AgentHarnessUserInputQuestion[],
inputText: string,
): JsonObject {
// Multi-question replies may use "header: answer" or numbered lines. Keep the
// parser permissive so chat-channel replies remain ergonomic.
const answers: JsonObject = {};
if (questions.length === 1) {
const question = questions[0];
if (question) {
const answer = normalizeAnswer(inputText, question);
answers[question.id] = { answers: answer ? [answer] : [] };
}
return { answers };
}
const keyed = parseKeyedAnswers(inputText);
const fallbackLines = inputText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
questions.forEach((question, index) => {
const key =
keyed.get(question.id.toLowerCase()) ??
keyed.get(question.header.toLowerCase()) ??
keyed.get(question.question.toLowerCase()) ??
keyed.get(String(index + 1));
const answer = key ?? fallbackLines[index] ?? "";
const normalized = answer ? normalizeAnswer(answer, question) : undefined;
answers[question.id] = { answers: normalized ? [normalized] : [] };
});
return { answers };
}
function normalizeAnswer(answer: string, question: UserInputQuestion): string | undefined {
const trimmed = answer.trim();
const options = question.options ?? [];
const optionIndex = /^\d+$/.test(trimmed) ? Number(trimmed) - 1 : -1;
const indexed = optionIndex >= 0 ? options[optionIndex] : undefined;
if (indexed) {
return indexed.label;
}
const exact = options.find((option) => option.label.toLowerCase() === trimmed.toLowerCase());
if (exact) {
return exact.label;
}
if (options.length > 0 && !question.isOther) {
return undefined;
}
return trimmed || undefined;
}
function parseKeyedAnswers(inputText: string): Map<string, string> {
const answers = new Map<string, string>();
for (const line of inputText.split(/\r?\n/)) {
const match = line.match(/^\s*([^:=-]+?)\s*[:=-]\s*(.+?)\s*$/);
if (!match) {
continue;
}
const key = match[1]?.trim().toLowerCase();
const value = match[2]?.trim();
if (key && value) {
answers.set(key, value);
}
}
return answers;
return buildAgentHarnessUserInputAnswers(questions, inputText) as unknown as JsonObject;
}
function emptyUserInputResponse(): JsonObject {
return { answers: {} };
return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject;
}
function readString(record: JsonObject, key: string): string | undefined {

View File

@@ -1609,6 +1609,12 @@ describe("createCopilotAgentHarness", () => {
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
summaryContent: "compacted summary",
contextWindow: {
tokenLimit: 1000,
currentTokens: 777,
messagesLength: 12,
},
}));
const disconnect = vi.fn(async () => {
throw new Error("disconnect failed");
@@ -1649,6 +1655,7 @@ describe("createCopilotAgentHarness", () => {
model: "gpt-4.1",
sessionKey: "agent:main:main",
sessionId: "oc-sess-compact-1",
currentTokenCount: 900,
workspaceDir: "/this\u0000is/illegal",
customInstructions: "Keep decisions.",
});
@@ -1684,6 +1691,25 @@ describe("createCopilotAgentHarness", () => {
ok: true,
compacted: true,
reason: "copilot-sdk-history-compacted",
result: {
summary: "compacted summary",
firstKeptEntryId: "",
tokensBefore: 900,
tokensAfter: 777,
details: {
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
summaryContent: "compacted summary",
contextWindow: {
tokenLimit: 1000,
currentTokens: 777,
messagesLength: 12,
},
},
sessionId: "oc-sess-compact-1",
sessionFile: "/session.json",
},
});
});

View File

@@ -62,6 +62,14 @@ interface CopilotHistoryCompactResult {
tokensRemoved: number;
messagesRemoved: number;
summaryContent?: string;
contextWindow?: {
tokenLimit: number;
currentTokens: number;
messagesLength: number;
systemTokens?: number;
conversationTokens?: number;
toolDefinitionsTokens?: number;
};
}
interface CopilotHistoryCompactSession {
@@ -872,6 +880,21 @@ export function createCopilotAgentHarness(
ok: true,
compacted,
reason: compacted ? "copilot-sdk-history-compacted" : "already under target",
...(compacted
? {
result: {
summary: compactResult.summaryContent ?? "",
firstKeptEntryId: "",
tokensBefore:
params.currentTokenCount ??
(compactResult.contextWindow?.currentTokens ?? 0) + compactResult.tokensRemoved,
tokensAfter: compactResult.contextWindow?.currentTokens,
details: compactResult,
sessionId: params.sessionId,
sessionFile: params.sessionFile,
},
}
: {}),
};
},

View File

@@ -3,9 +3,11 @@ import fsp from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
import type {
AgentHarnessAttemptParams,
AgentHarnessAttemptResult,
import {
abortAgentHarnessRun,
queueAgentHarnessMessage,
type AgentHarnessAttemptParams,
type AgentHarnessAttemptResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
@@ -1171,6 +1173,36 @@ describe("runCopilotAttempt", () => {
expect(result.externalAbort).toBe(true);
});
it("active-run abort path marks the attempt as externally aborted", async () => {
const sendDeferred = createDeferred<SessionEventShape | undefined>();
const sessionCreated = createDeferred<FakeSession>();
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockReturnValue(sendDeferred.promise);
session.abort.mockImplementationOnce(async () => {
sendDeferred.resolve(undefined);
});
sessionCreated.resolve(session);
},
});
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
const runPromise = runCopilotAttempt(makeParams(), {
createToolBridge,
pool,
});
const session = await sessionCreated.promise;
await vi.waitFor(() => expect(session.sendAndWait).toHaveBeenCalledTimes(1));
expect(abortAgentHarnessRun("session-1")).toBe(true);
const result = await runPromise;
expect(session.abort).toHaveBeenCalledTimes(1);
expect(result.aborted).toBe(true);
expect(result.externalAbort).toBe(true);
});
it("abort path (signal already aborted)", async () => {
const controller = new AbortController();
controller.abort();
@@ -1447,18 +1479,42 @@ describe("runCopilotAttempt", () => {
expect(result.feedback).toContain("no permission policy installed");
});
it("does not register onUserInputRequest (ask_user hidden from the model in MVP)", async () => {
const sdk = makeFakeSdk();
it("registers ask_user and resolves it from the active OpenClaw queue", async () => {
const onBlockReply = vi.fn();
const sdk = makeFakeSdk({
onCreateSession: (session, cfg) => {
session.sendAndWait.mockImplementationOnce(async () => {
const handler = cfg.onUserInputRequest;
if (typeof handler !== "function") {
throw new Error("expected onUserInputRequest handler");
}
const response = await handler(
{
question: "Pick a mode",
choices: ["Fast", "Deep"],
allowFreeform: false,
},
{ sessionId: session.sessionId },
);
return makeAssistantMessageEvent(`selected ${response.answer}`);
});
},
});
const pool = makeFakePool(sdk);
await runCopilotAttempt(makeParams(), { pool });
const attempt = runCopilotAttempt(makeParams({ onBlockReply }), { pool });
await vi.waitFor(() => expect(onBlockReply).toHaveBeenCalledTimes(1));
expect(queueAgentHarnessMessage("session-1", "2")).toBe(true);
const result = await attempt;
const cfg = sdk.createSession.mock.calls[0]?.[0];
// Per the SDK contract (types.d.ts: `When provided, enables the
// ask_user tool allowing the agent to ask questions`), omitting the
// handler hides ask_user from the model entirely. The MVP keeps it
// hidden until a real channel/TUI prompt bridge exists.
expect("onUserInputRequest" in cfg).toBe(false);
expect(typeof cfg.onUserInputRequest).toBe("function");
expect(onBlockReply.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ text: expect.stringContaining("Pick a mode") }),
);
expect(result.assistantTexts).toEqual(["selected Deep"]);
expect(queueAgentHarnessMessage("session-1", "late")).toBe(false);
});
it("enableSessionTelemetry is omitted from createSession when undefined (SDK default)", async () => {
@@ -1854,6 +1910,7 @@ describe("runCopilotAttempt", () => {
it("retains a timed-out session until later compaction reaches session.idle", async () => {
const afterCompaction = vi.fn();
const onDeferredCompaction = vi.fn();
const cleanupToolBridge = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
);
@@ -1866,8 +1923,14 @@ describe("runCopilotAttempt", () => {
);
},
});
const createToolBridge = vi.fn(async () => ({
cleanup: cleanupToolBridge,
sdkTools: [],
sourceTools: [],
}));
const result = await runCopilotAttempt(makeParams(), {
createToolBridge,
onDeferredCompaction,
pool: makeFakePool(sdk),
});
@@ -1877,6 +1940,7 @@ describe("runCopilotAttempt", () => {
expect(onDeferredCompaction).toHaveBeenCalledWith(
expect.objectContaining({ sdkSessionId: "sess-1" }),
);
expect(cleanupToolBridge).not.toHaveBeenCalled();
expect(activeSession?.disconnect).not.toHaveBeenCalled();
activeSession?.emit("session.compaction_start", {});
@@ -1890,6 +1954,7 @@ describe("runCopilotAttempt", () => {
await vi.waitFor(() => {
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
});
expect(cleanupToolBridge).toHaveBeenCalledTimes(1);
});
it("does not mark a timeout after SDK compaction has completed as active compaction", async () => {
@@ -3066,7 +3131,8 @@ describe("runCopilotAttempt", () => {
// permission policy and pollute the catalog under the default reject
// policy. `createSessionConfig` derives `availableTools` from the
// post-filter `sdkTools` so create- and resume-session always carry
// exactly the names of the tools the bridge actually exposed.
// exactly the names of the tools the bridge actually exposed plus the
// built-in `ask_user` tool owned by the registered user-input handler.
describe("availableTools surface restriction (PR #86155 [P1] round-8)", () => {
function makeFakeSdkTool(name: string): SdkTool {
return {
@@ -3090,7 +3156,11 @@ describe("runCopilotAttempt", () => {
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["read", "edit"]);
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([
"read",
"edit",
"builtin:ask_user",
]);
});
it("forwards `[]` to the SDK when the bridge returns no tools (disable / raw / fully filtered)", async () => {
@@ -3100,12 +3170,13 @@ describe("runCopilotAttempt", () => {
// (`modelRun: true` or `promptMode: "none"`), an empty
// `toolsAllow: []`, and an unsupported provider to `sdkTools: []`.
// Whatever the upstream reason, `availableTools` must be the same
// empty list so the SDK cannot fall back to its native catalog.
// ask_user-only list so the SDK cannot fall back to its native
// catalog while the registered user-input handler remains usable.
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([]);
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["builtin:ask_user"]);
});
it("forwards the full bridged set when the run is unrestricted (no toolsAllow)", async () => {
@@ -3131,6 +3202,7 @@ describe("runCopilotAttempt", () => {
"edit",
"exec",
"message",
"builtin:ask_user",
]);
});
@@ -3156,7 +3228,7 @@ describe("runCopilotAttempt", () => {
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
expect(resumeCfg?.availableTools).toEqual(["read"]);
expect(resumeCfg?.availableTools).toEqual(["read", "builtin:ask_user"]);
});
it("forwards `[]` to resumeSession when the bridge returns no tools", async () => {
@@ -3175,7 +3247,7 @@ describe("runCopilotAttempt", () => {
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
expect(resumeCfg?.availableTools).toEqual([]);
expect(resumeCfg?.availableTools).toEqual(["builtin:ask_user"]);
});
});

View File

@@ -24,6 +24,8 @@ import {
runAgentEndSideEffects,
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
clearActiveEmbeddedRun,
setActiveEmbeddedRun,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveCopilotAuth } from "./auth-bridge.js";
import {
@@ -42,6 +44,7 @@ import {
type SessionLike,
} from "./event-bridge.js";
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
import { createCopilotNativeSubagentTaskMirror } from "./native-subagent-task-mirror.js";
import {
createPermissionBridge,
rejectAllPolicy,
@@ -55,10 +58,12 @@ import {
} from "./replay-shim.js";
import type { ClientCreateOptions, CopilotClientPool, PoolKey, PooledClient } from "./runtime.js";
import { createCopilotToolBridge } from "./tool-bridge.js";
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
import { resolveCopilotWorkspaceBootstrapContext } from "./workspace-bootstrap.js";
const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
const BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS = 5_000;
const COPILOT_ASK_USER_AVAILABLE_TOOLS = ["builtin:ask_user"] as const;
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
@@ -73,6 +78,7 @@ export type CopilotSessionConfig = Pick<
| "infiniteSessions"
| "model"
| "onPermissionRequest"
| "onUserInputRequest"
| "reasoningEffort"
| "systemMessage"
| "tools"
@@ -222,6 +228,8 @@ function deferBackgroundCompactionCleanup(params: {
bridge: ReturnType<typeof attachEventBridge>;
handle: PooledClient;
pool: CopilotClientPool;
cleanupToolBridge?: () => void;
finalizeNativeSubagents?: () => void;
sdkSessionId?: string;
session: SessionLike;
timeoutMs: number;
@@ -244,12 +252,14 @@ function deferBackgroundCompactionCleanup(params: {
await cancelBackgroundCompactionBeforeTeardown(params.session);
params.bridge.settleCompactionWait();
}
params.finalizeNativeSubagents?.();
params.bridge.detach();
try {
await params.session.disconnect();
} catch {
// The attempt has already returned its timeout result.
}
params.cleanupToolBridge?.();
if (outcome !== "completed" && params.sdkSessionId) {
try {
await params.handle.client.deleteSession(params.sdkSessionId);
@@ -403,6 +413,14 @@ export async function runCopilotAttempt(
let handle: PooledClient | undefined;
let session: SessionLike | undefined;
let bridge: ReturnType<typeof attachEventBridge> | undefined;
const nativeSubagentTaskMirror = createCopilotNativeSubagentTaskMirror({
agentId: sessionAgentId,
now,
scope: input.agentHarnessTaskRuntimeScope,
});
let activeRunHandleRef: Parameters<typeof clearActiveEmbeddedRun>[1] | undefined;
let userInputBridgeRef: ReturnType<typeof createCopilotUserInputBridge> | undefined;
let cleanupToolBridge: (() => void) | undefined;
let releaseError: Error | undefined;
let downgradedFromResume = false;
let resumeFailureRecovered = false;
@@ -415,16 +433,24 @@ export async function runCopilotAttempt(
// `src/agents/pi-embedded-runner/run/types.ts:139`.
let yieldDetected = false;
const onAbort = () => {
const markExternalAbort = () => {
abortRequested = true;
externalAbort = true;
aborted = true;
};
const abortActiveSession = () => {
markExternalAbort();
if (settled || !sentTurnStarted || !session) {
return;
}
void session.abort().catch(() => undefined);
};
const onAbort = () => {
abortActiveSession();
};
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
// Sandbox parity with PI (`src/agents/pi-embedded-runner/run/attempt.ts:1232-1244`):
@@ -575,6 +601,7 @@ export async function runCopilotAttempt(
startedAt,
}),
});
cleanupToolBridge = toolBridge.cleanup;
sdkTools = toolBridge.sdkTools;
} catch (error: unknown) {
const result = createResult(input, {
@@ -655,6 +682,11 @@ export async function runCopilotAttempt(
});
};
const hasNativePromptHook = Boolean(attemptInput.hooksConfig?.onUserPromptSubmitted);
const userInputBridge = createCopilotUserInputBridge({
paramsForRun: attemptInput,
signal: params.abortSignal,
});
userInputBridgeRef = userInputBridge;
const sessionConfig = createSessionConfig(
attemptInput,
modelRef.id,
@@ -663,6 +695,7 @@ export async function runCopilotAttempt(
promptBuild.developerInstructions || undefined,
effectiveWorkspaceDir,
effectiveCwd,
userInputBridge.onUserInputRequest,
hasNativePromptHook
? {
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
@@ -723,6 +756,8 @@ export async function runCopilotAttempt(
}
bridge = attachEventBridge(session, {
onAssistantDelta: input.onAssistantDelta,
onAgentEvent: input.onAgentEvent,
onNativeSubagentEvent: (event) => nativeSubagentTaskMirror?.handleEvent(event),
onCompactionStart: async () => {
const sessionFile = readString(input.sessionFile);
if (!sessionFile) {
@@ -748,6 +783,29 @@ export async function runCopilotAttempt(
isAborted: () => aborted,
});
const activeRunHandle = {
kind: "embedded" as const,
queueMessage: async (text: string) => {
if (userInputBridge.handleQueuedMessage(text)) {
return;
}
throw new Error("Copilot runtime is not waiting for user input.");
},
isStreaming: () => !settled && !aborted,
isCompacting: () => bridge?.isCompacting() ?? false,
sourceReplyDeliveryMode: input.sourceReplyDeliveryMode,
cancel: () => {
userInputBridge.cancelPending();
abortActiveSession();
},
abort: () => {
userInputBridge.cancelPending();
abortActiveSession();
},
};
setActiveEmbeddedRun(input.sessionId, activeRunHandle, input.sessionKey, input.sessionFile);
activeRunHandleRef = activeRunHandle;
const messageOptions = await createMessageOptions(attemptInput, {
effectiveCwd,
effectiveWorkspaceDir,
@@ -765,6 +823,7 @@ export async function runCopilotAttempt(
}
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
await bridge.awaitDeltaChain();
await bridge.awaitAgentEventChain();
if (!bridge.recordSendResult(result) && !aborted) {
// SDK sendAndWait returning undefined is treated as a timeout by the
// capability inventory. Do not call session.abort() here: OpenClaw may
@@ -800,12 +859,22 @@ export async function runCopilotAttempt(
} catch {
// delta-flush failure must not mask the timeout state
}
await bridge?.awaitAgentEventChain();
} else {
promptError = toError(error);
}
}
} finally {
settled = true;
userInputBridgeRef?.cancelPending();
if (activeRunHandleRef) {
clearActiveEmbeddedRun(
input.sessionId,
activeRunHandleRef,
input.sessionKey,
input.sessionFile,
);
}
const retainSessionForDeferredCleanup =
bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false);
if (retainSessionForDeferredCleanup && bridge && session && handle) {
@@ -820,6 +889,8 @@ export async function runCopilotAttempt(
abortSignal: cleanupAbort.signal,
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
bridge,
cleanupToolBridge,
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
handle,
pool: deps.pool,
sdkSessionId,
@@ -848,6 +919,9 @@ export async function runCopilotAttempt(
// defines as no background agents in flight. Timeouts retain the bridge
// until that event so compaction that starts after the timer still completes.
await bridge?.awaitCompactionChain();
await bridge?.awaitAgentEventChain();
nativeSubagentTaskMirror?.finalizeActiveRuns();
cleanupToolBridge?.();
bridge?.detach();
params.abortSignal?.removeEventListener("abort", onAbort);
@@ -959,6 +1033,7 @@ export async function runCopilotAttempt(
await dualWriteCopilotTranscriptBestEffort({
sessionFile: sessionFileForMirror,
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
sessionId: readString(input.sessionId),
agentId: readString(input.agentId),
messages: taggedMessages,
idempotencyScope: sessionIdForScope ? `copilot:${sessionIdForScope}` : undefined,
@@ -1118,6 +1193,7 @@ function createSessionConfig(
systemMessageContent: string | undefined,
effectiveWorkspaceDir: string | undefined,
effectiveCwd: string | undefined,
onUserInputRequest: NonNullable<SessionConfig["onUserInputRequest"]>,
hooksBridgeOptions?: Parameters<typeof createHooksBridge>[1],
): CopilotSessionConfig {
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
@@ -1145,10 +1221,9 @@ function createSessionConfig(
// tool wrapper, and the SDK gate is a safety net for kinds we
// don't surface. See permission-bridge.ts and docs/plugins/copilot.md.
onPermissionRequest: createPermissionBridge(permissionPolicy),
// `onUserInputRequest` is intentionally NOT registered: per the SDK
// contract, omitting the handler hides the `ask_user` tool from the
// model entirely. Interactive ask_user will need a real channel/TUI
// prompt bridge before this runtime can expose the handler.
// Registers the SDK ask_user bridge. The bridge itself owns pending
// reply routing so generic mid-run steering still fails closed.
onUserInputRequest,
// Preserve the shipped native SDK hook contract. These callbacks expose
// Copilot-specific events and decisions that generic lifecycle hooks do
// not model.
@@ -1166,8 +1241,9 @@ function createSessionConfig(
...(infiniteSessions ? { infiniteSessions } : {}),
reasoningEffort: params.reasoningEffort,
tools: sdkTools,
// Restrict the SDK's tool catalog to exactly the bridged tool names
// returned by `createCopilotToolBridge`. Without this, the SDK
// Restrict the SDK's tool catalog to the bridged tool names returned
// by `createCopilotToolBridge` plus the built-in `ask_user` tool owned
// by `onUserInputRequest`. Without this, the SDK
// would still expose its native read/write/shell/url/mcp/memory/
// hook tools to the model alongside our overrides, which would
// bypass OpenClaw's wrapped-tool enforcement under any permissive
@@ -1182,7 +1258,7 @@ function createSessionConfig(
// `@github/copilot-sdk/dist/types.d.ts:1198` (it picks
// `availableTools`, so the spread into `resumeSession` covers
// the resume path too).
availableTools: sdkTools.map((tool) => tool.name),
availableTools: buildCopilotAvailableTools(sdkTools),
workingDirectory:
effectiveCwd ?? effectiveWorkspaceDir ?? readResolvedAttemptPath(params.workspaceDir),
// When a task runs from a sub-cwd, keep SDK-native project docs
@@ -1228,6 +1304,10 @@ function createSessionConfig(
};
}
function buildCopilotAvailableTools(sdkTools: SdkTool[]): string[] {
return [...new Set([...sdkTools.map((tool) => tool.name), ...COPILOT_ASK_USER_AVAILABLE_TOOLS])];
}
async function createMessageOptions(
params: AttemptParamsLike,
context: {

View File

@@ -96,6 +96,7 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
export interface MirrorCopilotTranscriptParams {
sessionFile: string;
sessionKey?: string;
sessionId?: string;
agentId?: string;
messages: AgentMessage[];
/**
@@ -168,7 +169,20 @@ export async function mirrorCopilotTranscript(
}
if (params.sessionKey) {
emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey });
emitSessionTranscriptUpdate({
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId && params.agentId
? {
target: {
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
},
}
: {}),
});
} else {
emitSessionTranscriptUpdate(params.sessionFile);
}

View File

@@ -15,6 +15,11 @@ const REGISTERED_EVENT_TYPES = [
"assistant.usage",
"tool.execution_start",
"tool.execution_complete",
"session.plan_changed",
"exit_plan_mode.requested",
"subagent.started",
"subagent.completed",
"subagent.failed",
"session.compaction_start",
"session.compaction_complete",
"session.idle",
@@ -455,6 +460,78 @@ describe("attachEventBridge", () => {
});
});
it("projects Copilot plan events through the generic plan stream", async () => {
const session = createFakeSession();
const onAgentEvent = vi.fn().mockResolvedValue(undefined);
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAgentEvent,
});
session.emit(
"session.plan_changed",
makeEvent("session.plan_changed", { operation: "update" }),
);
session.emit(
"exit_plan_mode.requested",
makeEvent("exit_plan_mode.requested", {
actions: ["approve", "edit"],
planContent: "# Plan\n- inspect\n- patch",
recommendedAction: "approve",
requestId: "request-1",
summary: "Plan ready",
}),
);
await bridge.awaitAgentEventChain();
expect(onAgentEvent).toHaveBeenCalledTimes(2);
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
operation: "update",
},
});
expect(onAgentEvent).toHaveBeenNthCalledWith(2, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
explanation: "Plan ready",
steps: ["# Plan", "inspect", "patch"],
actions: ["approve", "edit"],
requestId: "request-1",
recommendedAction: "approve",
},
});
});
it("forwards native Copilot subagent lifecycle events to the adapter", () => {
const session = createFakeSession();
const onNativeSubagentEvent = vi.fn();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onNativeSubagentEvent,
});
const event = makeEvent("subagent.started", {
agentDescription: "inspect the repository",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
});
session.emit("subagent.started", event);
expect(onNativeSubagentEvent).toHaveBeenCalledWith(event);
bridge.detach();
});
it("preserves all-zero usage snapshot after an invalid assistant.usage event", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {

View File

@@ -41,6 +41,16 @@ export interface SessionLike {
export interface EventBridgeOptions {
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
onAgentEvent?: (event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}) => void | Promise<void>;
onNativeSubagentEvent?: (
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
) => void;
onCompactionComplete?: (payload: {
messagesRemoved?: number;
success: boolean;
@@ -72,6 +82,7 @@ export interface EventBridgeController {
awaitSessionIdle(): Promise<void>;
settleCompactionWait(): void;
awaitDeltaChain(): Promise<void>;
awaitAgentEventChain(): Promise<void>;
hasObservedCompaction(): boolean;
hasObservedSessionIdle(): boolean;
isCompacting(): boolean;
@@ -103,6 +114,7 @@ export function attachEventBridge(
let observedCompaction = false;
let deltaQueue = Promise.resolve();
let deltaChain = Promise.resolve();
let agentEventChain = Promise.resolve();
let compactionChain = Promise.resolve();
let compactionIdle = Promise.resolve();
let resolveCompactionIdle: (() => void) | undefined;
@@ -191,6 +203,51 @@ export function attachEventBridge(
}
});
registerListener(session, unsubscribeFns, "session.plan_changed", (event) => {
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
operation: event.data.operation,
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "exit_plan_mode.requested", (event) => {
const steps = splitPlanText(event.data.planContent);
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
...(event.data.summary ? { explanation: event.data.summary } : {}),
...(steps.length > 0 ? { steps } : {}),
...(event.data.actions.length > 0 ? { actions: event.data.actions } : {}),
...(event.data.requestId ? { requestId: event.data.requestId } : {}),
...(event.data.recommendedAction
? { recommendedAction: event.data.recommendedAction }
: {}),
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.completed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.failed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
if (!isRootCompactionEvent(event)) {
return;
@@ -276,6 +333,9 @@ export function attachEventBridge(
awaitDeltaChain() {
return deltaChain;
},
awaitAgentEventChain() {
return agentEventChain;
},
hasObservedCompaction() {
return observedCompaction;
},
@@ -334,6 +394,31 @@ export function attachEventBridge(
compactionChain = queued.catch(() => undefined);
}
function enqueueAgentEvent(event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}): void {
const callback = options.onAgentEvent;
if (!callback) {
return;
}
const invoke = () => callback(event);
agentEventChain = agentEventChain.then(invoke, invoke).catch(() => undefined);
}
function forwardNativeSubagentEvent(
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
): void {
try {
options.onNativeSubagentEvent?.(event);
} catch {
// Native task mirroring must not corrupt the Copilot turn.
}
}
async function awaitStableCompaction(): Promise<void> {
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
if (idle) {
@@ -456,6 +541,13 @@ function joinReasoning(order: string[], reasoningById: Map<string, string>): str
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
}
function splitPlanText(text: string | undefined): string[] {
return (text ?? "")
.split(/\r?\n/)
.map((line) => line.trim().replace(/^[-*]\s+/, ""))
.filter((line) => line.length > 0);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

View File

@@ -0,0 +1,200 @@
import type { SessionEvent } from "@github/copilot-sdk";
import type {
AgentHarnessTaskRecord,
AgentHarnessTaskRuntime,
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
import { describe, expect, it, vi } from "vitest";
import {
CopilotNativeSubagentTaskMirror,
createCopilotNativeSubagentTaskMirror,
} from "./native-subagent-task-mirror.js";
type NativeSubagentEventType = "subagent.started" | "subagent.completed" | "subagent.failed";
function makeEvent<T extends NativeSubagentEventType>(
type: T,
data: Extract<SessionEvent, { type: T }>["data"],
agentId?: string,
): Extract<SessionEvent, { type: T }> {
return {
data,
id: `${type}-id`,
parentId: null,
timestamp: "2024-01-01T00:00:00.000Z",
type,
...(agentId ? { agentId } : {}),
} as Extract<SessionEvent, { type: T }>;
}
function createRuntime() {
const task = {} as AgentHarnessTaskRecord;
return {
tryCreateRunningTaskRun: vi.fn(() => task),
recordTaskRunProgressByRunId: vi.fn(() => []),
finalizeTaskRunByRunId: vi.fn(() => []),
} satisfies Pick<
AgentHarnessTaskRuntime,
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
}
describe("CopilotNativeSubagentTaskMirror", () => {
it("does not create a mirror without a host-issued task scope", () => {
expect(createCopilotNativeSubagentTaskMirror({})).toBeUndefined();
});
it("mirrors start and completion using agentId with toolCallId fallback", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror(
{ agentId: "parent-agent", now: () => 100 },
runtime,
);
mirror.handleEvent(
makeEvent(
"subagent.started",
{
agentDescription: "inspect the repository",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
},
"child-1",
),
);
mirror.handleEvent(
makeEvent(
"subagent.completed",
{
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
totalToolCalls: 2,
totalTokens: 30,
},
"child-1",
),
);
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
sourceId: "call-1",
agentId: "parent-agent",
runId: "copilot-agent:child-1",
label: "Researcher",
task: "inspect the repository",
notifyPolicy: "silent",
deliveryStatus: "not_applicable",
preferMetadata: true,
startedAt: 100,
lastEventAt: 100,
progressSummary: "Copilot native subagent started.",
});
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
runId: "copilot-agent:child-1",
status: "succeeded",
endedAt: 100,
lastEventAt: 100,
progressSummary: "Copilot native subagent completed.",
terminalSummary: "Copilot native subagent completed (2 tool calls, 30 tokens).",
});
});
it("uses toolCallId when the SDK omits agentId", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 200 }, runtime);
mirror.handleEvent(
makeEvent("subagent.started", {
agentDescription: "",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-2",
}),
);
mirror.handleEvent(
makeEvent("subagent.failed", {
agentDisplayName: "Researcher",
agentName: "researcher",
error: "failed",
toolCallId: "call-2",
}),
);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
expect.objectContaining({
runId: "copilot-agent:call-2",
status: "failed",
error: "failed",
}),
);
});
it("keeps parallel subagents distinct when they share a parent tool call", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 250 }, runtime);
for (const agentId of ["child-1", "child-2"]) {
mirror.handleEvent(
makeEvent(
"subagent.started",
{
agentDescription: `inspect ${agentId}`,
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-shared",
},
agentId,
),
);
}
for (const agentId of ["child-1", "child-2"]) {
mirror.handleEvent(
makeEvent(
"subagent.completed",
{
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-shared",
},
agentId,
),
);
}
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(2);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(2);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ runId: "copilot-agent:child-1" }),
);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ runId: "copilot-agent:child-2" }),
);
});
it("finalizes active tasks when the parent attempt tears down", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 300 }, runtime);
mirror.handleEvent(
makeEvent("subagent.started", {
agentDescription: "inspect",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-3",
}),
);
mirror.finalizeActiveRuns();
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
runId: "copilot-agent:call-3",
status: "cancelled",
endedAt: 300,
lastEventAt: 300,
error: "Copilot native subagent ended with its parent attempt.",
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
terminalSummary: "Copilot native subagent cancelled.",
});
});
});

View File

@@ -0,0 +1,199 @@
import type { SessionEvent } from "@github/copilot-sdk";
import {
createAgentHarnessTaskRuntime,
type AgentHarnessTaskRuntime,
type AgentHarnessTaskRuntimeScope,
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
const COPILOT_NATIVE_SUBAGENT_TASK_KIND = "copilot-native";
const COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX = "copilot-agent:";
type CopilotNativeSubagentEvent = Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>;
type TaskLifecycleRuntime = Pick<
AgentHarnessTaskRuntime,
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
export function createCopilotNativeSubagentTaskMirror(params: {
agentId?: string;
now?: () => number;
scope?: AgentHarnessTaskRuntimeScope;
}): CopilotNativeSubagentTaskMirror | undefined {
if (!params.scope) {
return undefined;
}
return new CopilotNativeSubagentTaskMirror(
{
agentId: params.agentId,
now: params.now,
},
createAgentHarnessTaskRuntime({
runtime: "subagent",
taskKind: COPILOT_NATIVE_SUBAGENT_TASK_KIND,
scope: params.scope,
runIdPrefix: COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX,
}),
);
}
export class CopilotNativeSubagentTaskMirror {
private readonly runIdByAgentId = new Map<string, string>();
private readonly runIdByToolCallId = new Map<string, string>();
private readonly terminalRunIds = new Set<string>();
private readonly activeRunIds = new Set<string>();
private readonly now: () => number;
constructor(
private readonly params: { agentId?: string; now?: () => number },
private readonly runtime: TaskLifecycleRuntime,
) {
this.now = params.now ?? Date.now;
}
handleEvent(event: CopilotNativeSubagentEvent): void {
const toolCallId = event.data.toolCallId.trim();
if (!toolCallId) {
return;
}
const runId = this.resolveRunId(event);
if (event.type === "subagent.started") {
this.handleStarted(event, runId, toolCallId);
return;
}
if (event.type === "subagent.completed") {
this.handleCompleted(event, runId);
return;
}
this.handleFailed(event, runId);
}
finalizeActiveRuns(): void {
const eventAt = this.now();
for (const runId of this.activeRunIds) {
this.terminalRunIds.add(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "cancelled",
endedAt: eventAt,
lastEventAt: eventAt,
error: "Copilot native subagent ended with its parent attempt.",
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
terminalSummary: "Copilot native subagent cancelled.",
});
}
this.activeRunIds.clear();
}
private handleStarted(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.started" }>,
runId: string,
toolCallId: string,
): void {
const agentId = event.agentId?.trim();
const existingRunId = agentId
? this.runIdByAgentId.get(agentId)
: this.runIdByToolCallId.get(toolCallId);
if (existingRunId) {
return;
}
const eventAt = this.now();
const label = event.data.agentDisplayName.trim() || event.data.agentName.trim();
const task = event.data.agentDescription.trim() || `Copilot native subagent ${label}`;
const taskRecord = this.runtime.tryCreateRunningTaskRun({
sourceId: toolCallId,
agentId: this.params.agentId,
runId,
label: label || "Copilot subagent",
task,
notifyPolicy: "silent",
deliveryStatus: "not_applicable",
preferMetadata: true,
startedAt: eventAt,
lastEventAt: eventAt,
progressSummary: "Copilot native subagent started.",
});
if (!taskRecord) {
return;
}
if (agentId) {
this.runIdByAgentId.set(agentId, runId);
} else {
this.runIdByToolCallId.set(toolCallId, runId);
}
this.terminalRunIds.delete(runId);
this.activeRunIds.add(runId);
}
private handleCompleted(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
runId: string,
): void {
if (this.terminalRunIds.has(runId)) {
return;
}
const eventAt = this.now();
this.terminalRunIds.add(runId);
this.activeRunIds.delete(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "succeeded",
endedAt: eventAt,
lastEventAt: eventAt,
progressSummary: "Copilot native subagent completed.",
terminalSummary: buildCompletionSummary(event),
});
}
private handleFailed(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.failed" }>,
runId: string,
): void {
if (this.terminalRunIds.has(runId)) {
return;
}
const eventAt = this.now();
this.terminalRunIds.add(runId);
this.activeRunIds.delete(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "failed",
endedAt: eventAt,
lastEventAt: eventAt,
error: event.data.error,
progressSummary: "Copilot native subagent failed.",
terminalSummary: "Copilot native subagent failed.",
});
}
private resolveRunId(event: CopilotNativeSubagentEvent): string {
const agentId = event.agentId?.trim();
if (agentId) {
const existing = this.runIdByAgentId.get(agentId);
if (existing) {
return existing;
}
}
const existing = this.runIdByToolCallId.get(event.data.toolCallId);
if (existing) {
return existing;
}
const identity = agentId || event.data.toolCallId.trim();
return `${COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX}${identity}`;
}
}
function buildCompletionSummary(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
): string {
const details = [
event.data.totalToolCalls !== undefined ? `${event.data.totalToolCalls} tool calls` : undefined,
event.data.totalTokens !== undefined ? `${event.data.totalTokens} tokens` : undefined,
].filter((value): value is string => value !== undefined);
return details.length > 0
? `Copilot native subagent completed (${details.join(", ")}).`
: "Copilot native subagent completed.";
}

View File

@@ -156,11 +156,184 @@ describe("createCopilotToolBridge", () => {
sessionId: "session-1",
});
expect(result.sourceTools).toBe(sourceTools);
expect(result.sourceTools).toEqual(sourceTools);
expect(result.sdkTools).toHaveLength(2);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a", "tool-b"]);
});
it("compacts the Copilot tool surface behind tool_search controls when enabled", async () => {
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
const includeToolSearchControls = Boolean(
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
);
return includeToolSearchControls
? [
makeTool({ name: "tool_search_code" }),
makeTool({ name: "fake_hidden" }),
makeTool({ name: "read" }),
]
: [makeTool({ name: "fake_hidden" }), makeTool({ name: "read" })];
});
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { toolSearch: true } },
runId: "run-tool-search",
sessionKey: "agent:main:main",
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
expect.objectContaining({
includeToolSearchControls: true,
toolSearchCatalogRef: expect.any(Object),
toolSearchCatalogExecutor: expect.any(Function),
}),
);
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
});
it("keeps tool_search controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
const includeToolSearchControls = Boolean(
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
);
return includeToolSearchControls
? [makeTool({ name: "tool_search_code" }), makeTool({ name: "read" })]
: [makeTool({ name: "read" })];
});
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { toolSearch: true } },
runId: "run-tool-search",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
});
it("filters the hidden tool_search catalog before compacting narrowed tools", async () => {
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
return [
makeTool({ name: "tool_search_code" }),
makeTool({ name: "read" }),
makeTool({ name: "edit" }),
makeTool({ name: "write" }),
];
});
await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { toolSearch: true } },
runId: "run-tool-search",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
});
it("compacts the Copilot tool surface behind code-mode exec/wait when enabled", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
makeTool({ name: "read" }),
]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionKey: "agent:main:main",
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
expect.objectContaining({
includeToolSearchControls: false,
toolSearchCatalogRef: expect.any(Object),
toolSearchCatalogExecutor: expect.any(Function),
}),
);
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
makeTool({ name: "read" }),
]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("filters the hidden code-mode catalog before compacting narrowed tools", async () => {
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
return [makeTool({ name: "read" }), makeTool({ name: "edit" }), makeTool({ name: "write" })];
});
await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
});
it("throws when createOpenClawCodingTools returns a non-array", async () => {
await expect(
createCopilotToolBridge({

View File

@@ -17,10 +17,15 @@ import {
resolveModelAuthMode,
sanitizeToolResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
type CreateOpenClawCodingTools =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type OpenClawCodingToolsOptions = NonNullable<Parameters<CreateOpenClawCodingTools>[0]>;
type AgentHarnessToolSurfaceRuntime = ReturnType<typeof createAgentHarnessToolSurfaceRuntime>;
type CatalogExecuteParams = Parameters<
NonNullable<AgentHarnessToolSurfaceRuntime["toolSearchCatalogExecutor"]>
>[0];
type AgentToolResultLike = {
content?: unknown;
@@ -130,6 +135,7 @@ export interface CopilotToolBridgeInput {
}
export interface CopilotToolBridge {
cleanup?: () => void;
sdkTools: SdkTool[];
sourceTools: AnyAgentTool[];
}
@@ -178,7 +184,31 @@ export async function createCopilotToolBridge(
input.createOpenClawCodingTools ??
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
const toolOptions = buildOpenClawCodingToolsOptions(input, effectiveToolPlan);
const toolSurfaceRuntime = createAgentHarnessToolSurfaceRuntime({
abortSignal: input.abortSignal,
agentId: input.agentId,
config: attemptParams.config,
disableTools: attemptParams.disableTools,
executeTool: (toolParams) => executeCatalogTool(input, toolParams),
forceMessageTool: shouldForceCopilotMessageTool(attemptParams),
isRawModelRun: isCopilotRawModelRun(attemptParams),
modelToolsEnabled: true,
prompt: attemptParams.prompt,
runId: attemptParams.runId,
runtimeToolAllowlist: effectiveToolPlan.runtimeToolAllowlist,
sessionId: input.sessionId,
sessionKey: attemptParams.sandboxSessionKey ?? attemptParams.sessionKey ?? input.sessionKey,
sourceReplyDeliveryMode: attemptParams.sourceReplyDeliveryMode,
toolsAllow: attemptParams.toolsAllow,
});
const toolOptions = buildOpenClawCodingToolsOptions(
input,
{
...effectiveToolPlan,
runtimeToolAllowlist: toolSurfaceRuntime.runtimeToolAllowlist,
},
toolSurfaceRuntime,
);
let sourceTools: unknown;
try {
@@ -196,13 +226,19 @@ export async function createCopilotToolBridge(
);
}
const plannedTools = filterCopilotToolsForConstructionPlan(
const allowedSourceTools = filterCopilotToolsForAllowlist(
sourceTools as AnyAgentTool[],
toolSurfaceRuntime.runtimeToolAllowlist,
);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
const plannedTools = filterCopilotToolsForConstructionPlan(
compactedTools.tools,
effectiveToolPlan.codingToolConstructionPlan,
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
);
const filteredTools = filterCopilotToolsForAllowlist(
plannedTools,
effectiveToolPlan.runtimeToolAllowlist,
toolSurfaceRuntime.runtimeToolAllowlist,
);
// Run duplicate detection after filtering so a duplicate in a
@@ -214,6 +250,7 @@ export async function createCopilotToolBridge(
}
return {
cleanup: toolSurfaceRuntime.cleanup,
sdkTools: filteredTools.map((sourceTool) =>
convertOpenClawToolToSdkTool(sourceTool, {
abortSignal: input.abortSignal,
@@ -251,6 +288,7 @@ export async function createCopilotToolBridge(
function buildOpenClawCodingToolsOptions(
input: CopilotToolBridgeInput,
toolPlan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>,
toolSurfaceRuntime?: ReturnType<typeof createAgentHarnessToolSurfaceRuntime>,
): OpenClawCodingToolsOptions {
const a = input.attemptParams ?? ({} as CopilotToolAttemptParams);
@@ -339,11 +377,14 @@ function buildOpenClawCodingToolsOptions(
// `resolveSandboxContext`).
sandbox,
spawnWorkspaceDir,
config: a.config,
config: toolSurfaceRuntime?.config ?? a.config,
abortSignal: input.abortSignal,
modelProvider: input.modelProvider,
modelId: input.modelId,
includeCoreTools: toolPlan.includeCoreTools,
includeToolSearchControls: toolSurfaceRuntime?.includeToolSearchControls,
toolSearchCatalogRef: toolSurfaceRuntime?.toolSearchCatalogRef,
toolSearchCatalogExecutor: toolSurfaceRuntime?.toolSearchCatalogExecutor,
runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
modelCompat,
@@ -575,6 +616,63 @@ export function convertOpenClawToolToSdkTool(
};
}
async function executeCatalogTool(
input: CopilotToolBridgeInput,
params: CatalogExecuteParams,
): Promise<Awaited<ReturnType<AnyAgentTool["execute"]>>> {
const sourceTool = params.tool as AnyAgentTool;
const startedAt = Date.now();
let preparedArgs: unknown = params.input;
try {
preparedArgs = sourceTool.prepareArguments
? sourceTool.prepareArguments(params.input)
: params.input;
const result = await sourceTool.execute(
params.toolCallId,
preparedArgs,
params.signal ?? input.abortSignal,
params.onUpdate,
);
const sanitizedResult = sanitizeToolResult(result);
const isError = isToolResultError(sanitizedResult);
input.attemptParams?.onAgentToolResult?.({
toolName: params.toolName,
result: sanitizedResult,
isError,
});
await input.onToolCompleted?.({
toolName: params.toolName,
toolCallId: params.toolCallId,
args: toToolStartArgs(preparedArgs),
result: sanitizedResult,
...(isError
? { error: extractToolErrorMessage(sanitizedResult) ?? "tool returned an error" }
: {}),
startedAt,
});
return result;
} catch (error: unknown) {
const message = toError(error).message;
const failure = sanitizeToolResult({
content: [{ type: "text", text: message }],
details: { status: "failed", error: message },
});
input.attemptParams?.onAgentToolResult?.({
toolName: params.toolName,
result: failure,
isError: true,
});
await input.onToolCompleted?.({
toolName: params.toolName,
toolCallId: params.toolCallId,
args: toToolStartArgs(preparedArgs),
error: message,
startedAt,
});
throw error;
}
}
function toToolStartArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
@@ -712,11 +810,16 @@ function filterCopilotToolsForAllowlist<T extends { name: string }>(
function filterCopilotToolsForConstructionPlan<T extends { name: string }>(
tools: T[],
plan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>["codingToolConstructionPlan"],
options: { preserveToolNames?: readonly string[] } = {},
): T[] {
if (plan.includeBaseCodingTools && plan.includeShellTools) {
return tools;
}
const preserveToolNames = new Set(options.preserveToolNames);
return tools.filter((tool) => {
if (preserveToolNames.has(tool.name)) {
return true;
}
if (!plan.includeBaseCodingTools && BASE_COPILOT_CODING_TOOL_NAMES.has(tool.name)) {
return false;
}

View File

@@ -0,0 +1,121 @@
// Copilot tests cover SDK ask_user bridge behavior.
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it, vi } from "vitest";
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
function createParams(): EmbeddedRunAttemptParams {
return {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
onBlockReply: vi.fn(),
} as unknown as EmbeddedRunAttemptParams;
}
function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string {
const onBlockReply = params.onBlockReply;
if (!onBlockReply) {
throw new Error("Expected onBlockReply callback");
}
const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0];
if (typeof payload?.text !== "string") {
throw new Error("Expected first block reply text");
}
return payload.text;
}
describe("Copilot user input bridge", () => {
it("prompts through OpenClaw and resolves the SDK request from the next queued message", async () => {
const params = createParams();
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
const response = bridge.onUserInputRequest(
{
question: "Pick a mode",
choices: ["Fast", "Deep"],
allowFreeform: false,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
expect(expectFirstBlockReplyText(params)).toContain("Pick a mode");
expect(bridge.handleQueuedMessage("2")).toBe(true);
await expect(response).resolves.toEqual({ answer: "Deep", wasFreeform: false });
});
it("returns free-form answers when Copilot allows them", async () => {
const params = createParams();
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
const response = bridge.onUserInputRequest(
{
question: "Which branch?",
allowFreeform: true,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
expect(bridge.handleQueuedMessage("fix/harness-parity")).toBe(true);
await expect(response).resolves.toEqual({
answer: "fix/harness-parity",
wasFreeform: true,
});
});
it("escapes SDK-controlled prompt text before channel delivery", async () => {
const params = createParams();
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
void bridge.onUserInputRequest(
{
question: "Pick [trusted](https://evil) <@U123> @here\u202e",
choices: ["One @everyone", "Two `code`"],
allowFreeform: false,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
const text = expectFirstBlockReplyText(params);
expect(text).not.toContain("@here");
expect(text).not.toContain("@everyone");
expect(text).not.toContain("<@U123>");
expect(text).not.toContain("[trusted](https://evil)");
expect(text).not.toContain("`code`");
expect(text).toContain("\uff20here");
expect(text).toContain("\uff3btrusted\uff3d");
});
it("rejects queued messages when no ask_user request is pending", () => {
const bridge = createCopilotUserInputBridge({ paramsForRun: createParams() });
expect(bridge.handleQueuedMessage("late")).toBe(false);
});
it("resolves pending requests with an empty answer when aborted", async () => {
const params = createParams();
const controller = new AbortController();
const bridge = createCopilotUserInputBridge({
paramsForRun: params,
signal: controller.signal,
});
const response = bridge.onUserInputRequest(
{
question: "Continue?",
choices: ["Yes", "No"],
allowFreeform: false,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
controller.abort();
await expect(response).resolves.toEqual({ answer: "", wasFreeform: true });
expect(bridge.handleQueuedMessage("1")).toBe(false);
});
});

View File

@@ -0,0 +1,161 @@
import type { SessionConfig } from "@github/copilot-sdk";
import {
buildAgentHarnessUserInputAnswers,
deliverAgentHarnessUserInputPrompt,
embeddedAgentLog,
type AgentHarnessUserInputQuestion,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
type PendingCopilotUserInput = {
question: AgentHarnessUserInputQuestion;
resolve: (value: CopilotUserInputResponse) => void;
cleanup: () => void;
};
type CopilotUserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
type CopilotUserInputRequest = Parameters<CopilotUserInputHandler>[0];
type CopilotUserInputResponse = Awaited<ReturnType<CopilotUserInputHandler>>;
type CopilotUserInputBridge = {
onUserInputRequest: CopilotUserInputHandler;
handleQueuedMessage: (text: string) => boolean;
cancelPending: () => void;
};
const COPILOT_USER_INPUT_QUESTION_ID = "answer";
export function createCopilotUserInputBridge(params: {
paramsForRun: EmbeddedRunAttemptParams;
signal?: AbortSignal;
}): CopilotUserInputBridge {
let pending: PendingCopilotUserInput | undefined;
const resolvePending = (value: CopilotUserInputResponse) => {
const current = pending;
if (!current) {
return;
}
pending = undefined;
current.cleanup();
current.resolve(value);
};
return {
onUserInputRequest(request) {
const question = toQuestion(request);
resolvePending(emptyCopilotUserInputResponse());
return new Promise<CopilotUserInputResponse>((resolve) => {
const abortListener = () => resolvePending(emptyCopilotUserInputResponse());
const cleanup = () => params.signal?.removeEventListener("abort", abortListener);
pending = { question, resolve, cleanup };
params.signal?.addEventListener("abort", abortListener, { once: true });
if (params.signal?.aborted) {
resolvePending(emptyCopilotUserInputResponse());
return;
}
void deliverAgentHarnessUserInputPrompt(params.paramsForRun, [question], {
intro: "Copilot needs input:",
formatText: formatCopilotDisplayText,
}).catch((error: unknown) => {
embeddedAgentLog.warn("failed to deliver copilot user input prompt", { error });
});
});
},
handleQueuedMessage(text) {
const current = pending;
if (!current) {
return false;
}
resolvePending(buildCopilotUserInputResponse(current.question, text));
return true;
},
cancelPending() {
resolvePending(emptyCopilotUserInputResponse());
},
};
}
function toQuestion(request: CopilotUserInputRequest): AgentHarnessUserInputQuestion {
return {
id: COPILOT_USER_INPUT_QUESTION_ID,
header: "Copilot needs input",
question: request.question,
isOther: request.allowFreeform !== false,
isSecret: false,
options:
request.choices && request.choices.length > 0
? request.choices.map((choice: string) => ({ label: choice }))
: null,
};
}
function buildCopilotUserInputResponse(
question: AgentHarnessUserInputQuestion,
inputText: string,
): CopilotUserInputResponse {
const rawAnswers = buildAgentHarnessUserInputAnswers([question], inputText);
const selected = rawAnswers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? "";
return {
answer: selected,
wasFreeform: !isChoiceAnswer(question, selected),
};
}
function emptyCopilotUserInputResponse(): CopilotUserInputResponse {
return { answer: "", wasFreeform: true };
}
function isChoiceAnswer(question: AgentHarnessUserInputQuestion, answer: string): boolean {
return Boolean(
answer &&
question.options?.some((option) => option.label.toLowerCase() === answer.toLowerCase()),
);
}
function formatCopilotDisplayText(value: string): string {
const safe = sanitizeCopilotDisplayText(value).trim();
return escapeCopilotChatText(safe || "<unknown>");
}
function sanitizeCopilotDisplayText(value: string): string {
let safe = "";
for (const character of value) {
const codePoint = character.codePointAt(0);
safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character;
}
return safe;
}
function escapeCopilotChatText(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("@", "\uff20")
.replaceAll("`", "\uff40")
.replaceAll("[", "\uff3b")
.replaceAll("]", "\uff3d")
.replaceAll("(", "\uff08")
.replaceAll(")", "\uff09")
.replaceAll("*", "\u2217")
.replaceAll("_", "\uff3f")
.replaceAll("~", "\uff5e")
.replaceAll("|", "\uff5c");
}
function isUnsafeDisplayCodePoint(codePoint: number): boolean {
return (
codePoint <= 0x001f ||
(codePoint >= 0x007f && codePoint <= 0x009f) ||
codePoint === 0x00ad ||
codePoint === 0x061c ||
codePoint === 0x180e ||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2060 && codePoint <= 0x206f) ||
codePoint === 0xfeff ||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
(codePoint >= 0xe0000 && codePoint <= 0xe007f)
);
}

View File

@@ -49,6 +49,14 @@ function makeAgentModelEntry(id = "profile/live-model") {
};
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveDiscoveryTestEnv(
mockFetch: ReturnType<typeof vi.fn>,
runAssertions: () => Promise<void>,
@@ -122,10 +130,9 @@ describe("deepinfra augmentModelCatalog", () => {
it("uses config-backed API keys to enable live model catalog augmentation", async () => {
resetDeepInfraModelCacheForTest();
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry("config/live-model")] }),
});
const mockFetch = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("config/live-model")] }));
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
await withLiveDiscoveryTestEnv(mockFetch, async () => {
@@ -151,10 +158,9 @@ describe("deepinfra augmentModelCatalog", () => {
it("still runs live discovery when ctx.entries includes custom DeepInfra rows", async () => {
resetDeepInfraModelCacheForTest();
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry("custom/live-model")] }),
});
const mockFetch = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("custom/live-model")] }));
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
const seededDeepInfraCount = DEEPINFRA_MODEL_CATALOG.length + 5;
@@ -230,10 +236,7 @@ describe("deepinfra capability registration", () => {
it("uses profile-resolved API keys for live text catalog discovery", async () => {
resetDeepInfraModelCacheForTest();
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry()] }),
});
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
const captured = createCapturedPluginRegistration();
deepinfraPlugin.register(captured.api);
const provider = captured.providers[0];

View File

@@ -48,6 +48,14 @@ function makeAgentModelEntry(overrides: Record<string, unknown> = {}) {
};
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function expectedStaticChatCatalog() {
return DEEPINFRA_MODEL_CATALOG.map((model) => {
const compat = Object.assign({}, model.compat, {
@@ -195,10 +203,7 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
});
it("fetches the openclaw-projection endpoint and parses chat-surface entries when an API key is configured", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry()] }),
});
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const models = await discoverDeepInfraModels();
@@ -228,21 +233,19 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
});
it("skips entries with no metadata or no surface tag, and deduplicates ids", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
{ id: "BAAI/bge-m3", object: "model", metadata: null },
makeAgentModelEntry({
id: "untagged/model",
metadata: { context_length: 1, max_tokens: 1, pricing: {}, tags: [] },
}),
makeAgentModelEntry(),
makeAgentModelEntry(),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
{ id: "BAAI/bge-m3", object: "model", metadata: null },
makeAgentModelEntry({
id: "untagged/model",
metadata: { context_length: 1, max_tokens: 1, pricing: {}, tags: [] },
}),
makeAgentModelEntry(),
makeAgentModelEntry(),
],
}),
);
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const models = await discoverDeepInfraModels();
@@ -283,7 +286,7 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
});
it("falls back to the static catalog on non-2xx HTTP responses", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const models = await discoverDeepInfraModels();
@@ -294,14 +297,10 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
it("falls back without caching malformed successful model list payloads", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: {} }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
});
.mockResolvedValueOnce(jsonResponse({ data: {} }))
.mockResolvedValueOnce(
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
);
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
@@ -328,14 +327,8 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
it("caches successful discovery responses only", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "first/model" })] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "second/model" })] }),
});
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "first/model" })] }))
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "second/model" })] }));
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const expectedIds = expectedLiveChatCatalog([
@@ -359,14 +352,10 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
it("does not cache successful responses that produce no live catalog rows", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
});
.mockResolvedValueOnce(jsonResponse({ data: [] }))
.mockResolvedValueOnce(
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
);
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
@@ -393,67 +382,65 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
it("buckets dynamic entries by short-alias surface tag", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
makeAgentModelEntry({
id: "anthropic/claude-sonnet-4-6",
metadata: {
description: "claude sonnet 4.6",
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat", "vlm", "vision", "prompt_cache"],
},
}),
makeAgentModelEntry({
id: "BAAI/bge-m3",
metadata: {
description: "bge-m3",
pricing: { input_tokens: 0.01 },
tags: ["embed"],
},
}),
makeAgentModelEntry({
id: "black-forest-labs/FLUX-1-schnell",
metadata: {
description: "FLUX schnell",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: 1024,
default_height: 1024,
default_iterations: 4,
},
}),
makeAgentModelEntry({
id: "Wan-AI/Wan2.6-T2V",
metadata: {
description: "Wan T2V",
pricing: { output_seconds: 0.05 },
tags: ["video-gen"],
},
}),
makeAgentModelEntry({
id: "Qwen/Qwen3-TTS",
metadata: {
description: "Qwen3 TTS",
pricing: { input_characters: 0.65 },
tags: ["tts"],
},
}),
makeAgentModelEntry({
id: "openai/whisper-large-v3-turbo",
metadata: {
description: "whisper",
pricing: { input_seconds: 0.00004 },
tags: ["stt"],
},
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeAgentModelEntry({
id: "anthropic/claude-sonnet-4-6",
metadata: {
description: "claude sonnet 4.6",
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat", "vlm", "vision", "prompt_cache"],
},
}),
makeAgentModelEntry({
id: "BAAI/bge-m3",
metadata: {
description: "bge-m3",
pricing: { input_tokens: 0.01 },
tags: ["embed"],
},
}),
makeAgentModelEntry({
id: "black-forest-labs/FLUX-1-schnell",
metadata: {
description: "FLUX schnell",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: 1024,
default_height: 1024,
default_iterations: 4,
},
}),
makeAgentModelEntry({
id: "Wan-AI/Wan2.6-T2V",
metadata: {
description: "Wan T2V",
pricing: { output_seconds: 0.05 },
tags: ["video-gen"],
},
}),
makeAgentModelEntry({
id: "Qwen/Qwen3-TTS",
metadata: {
description: "Qwen3 TTS",
pricing: { input_characters: 0.65 },
tags: ["tts"],
},
}),
makeAgentModelEntry({
id: "openai/whisper-large-v3-turbo",
metadata: {
description: "whisper",
pricing: { input_seconds: 0.00004 },
tags: ["stt"],
},
}),
],
}),
);
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const catalog = await discoverDeepInfraSurfaces();
@@ -471,35 +458,33 @@ describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
});
it("drops malformed live numeric metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
makeAgentModelEntry({
id: "bad/chat",
metadata: {
description: "bad chat",
context_length: -1,
max_tokens: 1.5,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat"],
},
}),
makeAgentModelEntry({
id: "bad/image",
metadata: {
description: "bad image",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: Number.POSITIVE_INFINITY,
default_height: 1024.5,
default_iterations: 0,
},
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeAgentModelEntry({
id: "bad/chat",
metadata: {
description: "bad chat",
context_length: -1,
max_tokens: 1.5,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat"],
},
}),
makeAgentModelEntry({
id: "bad/image",
metadata: {
description: "bad image",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: Number.POSITIVE_INFINITY,
default_height: 1024.5,
default_iterations: 0,
},
}),
],
}),
);
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const catalog = await discoverDeepInfraSurfaces();

View File

@@ -49,6 +49,14 @@ const surfaceEntry = (id: string, surfaceTag: string, extra: Record<string, unkn
},
});
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveFetch(mockFetch: ReturnType<typeof vi.fn>, run: () => Promise<void>) {
const env = { ...process.env };
delete process.env.NODE_ENV;
@@ -86,19 +94,17 @@ describe("DeepInfra generation catalogs", () => {
describe("listDeepInfraImageGenCatalog", () => {
it("returns null when live discovery succeeds but the response has zero image-gen entries", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
);
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
@@ -115,28 +121,26 @@ describe("listDeepInfraImageGenCatalog", () => {
});
it("projects discovered image-gen entries when a key is configured and discovery is live", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
default_width: 1024,
default_height: 1024,
default_iterations: 28,
}),
surfaceEntry("ByteDance/Seedream-4", "image-gen", {
pricing: { per_image_unit: 0.03 },
}),
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
default_width: 1024,
default_height: 1024,
default_iterations: 28,
}),
surfaceEntry("ByteDance/Seedream-4", "image-gen", {
pricing: { per_image_unit: 0.03 },
}),
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
);
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
@@ -161,22 +165,20 @@ describe("listDeepInfraVideoGenCatalog", () => {
// produces zero video-gen entries. We must return null so the registered
// provider's static fallback list is consulted instead of an empty
// "live" answer.
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
}),
],
}),
);
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
@@ -185,20 +187,18 @@ describe("listDeepInfraVideoGenCatalog", () => {
});
it("projects discovered video-gen entries with capability shape", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
surfaceEntry("ByteDance/Seedance-2.0", "video-gen", {
pricing: { output_seconds: 0.08 },
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
surfaceEntry("ByteDance/Seedance-2.0", "video-gen", {
pricing: { output_seconds: 0.08 },
}),
],
}),
);
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
@@ -214,17 +214,15 @@ describe("listDeepInfraVideoGenCatalog", () => {
describe("resolveDeepInfraVideoModelCapabilities", () => {
it("returns capabilities for a discovered video-gen model", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
);
await withLiveFetch(mockFetch, async () => {
const caps = await resolveDeepInfraVideoModelCapabilities({
@@ -236,17 +234,15 @@ describe("resolveDeepInfraVideoModelCapabilities", () => {
});
it("strips the deepinfra/ prefix when matching", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
);
await withLiveFetch(mockFetch, async () => {
const caps = await resolveDeepInfraVideoModelCapabilities({
@@ -257,17 +253,15 @@ describe("resolveDeepInfraVideoModelCapabilities", () => {
});
it("returns undefined for an unknown model", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
);
await withLiveFetch(mockFetch, async () => {
const caps = await resolveDeepInfraVideoModelCapabilities({

View File

@@ -464,11 +464,7 @@ describe("fetchCopilotModelCatalog", () => {
};
it("maps Copilot /models entries to ModelDefinitionConfig with real context windows", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => sampleApiResponse,
});
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, sampleApiResponse));
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -539,11 +535,7 @@ describe("fetchCopilotModelCatalog", () => {
});
it("strips trailing slash from baseUrl when building the /models URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ data: [] }),
});
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, { data: [] }));
await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -555,10 +547,8 @@ describe("fetchCopilotModelCatalog", () => {
});
it("dedupes by id when API returns duplicates", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
const fetchImpl = vi.fn().mockResolvedValue(
makeResponse(200, {
data: [
{
id: "gpt-5.5",
@@ -580,7 +570,7 @@ describe("fetchCopilotModelCatalog", () => {
},
],
}),
});
);
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -593,10 +583,8 @@ describe("fetchCopilotModelCatalog", () => {
});
it("falls back from malformed live token limits", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
const fetchImpl = vi.fn().mockResolvedValue(
makeResponse(200, {
data: [
{
id: "gpt-bad-window",
@@ -624,7 +612,7 @@ describe("fetchCopilotModelCatalog", () => {
},
],
}),
});
);
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -646,11 +634,7 @@ describe("fetchCopilotModelCatalog", () => {
});
it("throws on non-2xx HTTP responses so the caller can fall back to the static catalog", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({}),
});
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(401, {}));
await expect(
fetchCopilotModelCatalog({
@@ -663,11 +647,7 @@ describe("fetchCopilotModelCatalog", () => {
it("throws provider-owned errors for malformed successful /models payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => payload,
});
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, payload));
await expect(
fetchCopilotModelCatalog({

View File

@@ -14,16 +14,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js";
type MockKilocodeFetchResponse = {
ok: boolean;
status?: number;
json?: () => Promise<unknown>;
};
type MockKilocodeFetch = ((
url: string,
init?: RequestInit,
) => Promise<MockKilocodeFetchResponse>) & {
type MockKilocodeFetch = ((url: string, init?: RequestInit) => Promise<Response>) & {
mock: { calls: unknown[][] };
};
@@ -115,6 +106,14 @@ function makeAutoModel(overrides: Record<string, unknown> = {}) {
});
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () => Promise<void>) {
const release = vi.fn(async () => {});
vi.stubEnv("NODE_ENV", "");
@@ -165,13 +164,11 @@ describe("discoverKilocodeModels", () => {
describe("discoverKilocodeModels (fetch path)", () => {
it("parses gateway models with correct pricing conversion", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [makeAutoModel(), makeGatewayModel()],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [makeAutoModel(), makeGatewayModel()],
}),
);
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
@@ -217,10 +214,7 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
it("falls back to static catalog on HTTP error", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
@@ -229,10 +223,7 @@ describe("discoverKilocodeModels (fetch path)", () => {
it("falls back to static catalog for malformed successful model list payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(payload),
});
const mockFetch = vi.fn().mockResolvedValue(jsonResponse(payload));
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
@@ -241,24 +232,22 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
it("falls back from malformed live token metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
makeGatewayModel({
id: "some/bad-window",
context_length: -1,
top_provider: { max_completion_tokens: 8192.5 },
}),
makeGatewayModel({
id: "some/bad-output",
context_length: Number.POSITIVE_INFINITY,
top_provider: { max_completion_tokens: 0 },
}),
],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeGatewayModel({
id: "some/bad-window",
context_length: -1,
top_provider: { max_completion_tokens: 8192.5 },
}),
makeGatewayModel({
id: "some/bad-output",
context_length: Number.POSITIVE_INFINITY,
top_provider: { max_completion_tokens: 0 },
}),
],
}),
);
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
@@ -275,13 +264,11 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
it("ensures kilo/auto is present even when API doesn't return it", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [makeGatewayModel()],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [makeGatewayModel()],
}),
);
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(requireModelById(models, "kilo/auto").id).toBe("kilo/auto");
@@ -301,10 +288,7 @@ describe("discoverKilocodeModels (fetch path)", () => {
supported_parameters: ["max_tokens", "temperature"],
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [textOnlyModel] }),
});
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [textOnlyModel] }));
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const textModel = requireModelById(models, "some/text-model");
@@ -319,13 +303,11 @@ describe("discoverKilocodeModels (fetch path)", () => {
pricing: undefined,
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()],
}),
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()],
}),
);
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const auto = requireModelById(models, "kilo/auto");

View File

@@ -31,6 +31,21 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
};
});
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
@@ -72,33 +87,26 @@ describe("lmstudio-models", () => {
loadedContextLength?: number;
maxContextLength?: number;
}) =>
vi.fn(async (url: string | URL, init?: RequestInit) => {
vi.fn(async (url: string | URL, _init?: RequestInit) => {
const key = params?.key ?? "qwen3-8b-instruct";
if (String(url).endsWith("/api/v1/models")) {
return {
ok: true,
json: async () => ({
models: [
{
type: "llm",
key,
max_context_length: params?.maxContextLength,
variants: params?.variants,
selected_variant: params?.selectedVariant,
loaded_instances: params?.loadedContextLength
? [{ id: "inst-1", config: { context_length: params.loadedContextLength } }]
: [],
},
],
}),
};
return jsonResponse({
models: [
{
type: "llm",
key,
max_context_length: params?.maxContextLength,
variants: params?.variants,
selected_variant: params?.selectedVariant,
loaded_instances: params?.loadedContextLength
? [{ id: "inst-1", config: { context_length: params.loadedContextLength } }]
: [],
},
],
});
}
if (String(url).endsWith("/api/v1/models/load")) {
return {
ok: true,
json: async () => ({ status: "loaded" }),
requestInit: init,
};
return jsonResponse({ status: "loaded" });
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
@@ -296,9 +304,8 @@ describe("lmstudio-models", () => {
});
it("discovers llm models and maps metadata", async () => {
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({
ok: true,
json: async () => ({
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) =>
jsonResponse({
models: [
{
type: "llm",
@@ -330,7 +337,7 @@ describe("lmstudio-models", () => {
},
],
}),
}));
);
const models = await discoverLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -386,13 +393,7 @@ describe("lmstudio-models", () => {
});
it("reports malformed model list JSON with an owned error", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("bad json");
},
}));
const fetchMock = vi.fn(async () => malformedJsonResponse());
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -405,11 +406,7 @@ describe("lmstudio-models", () => {
it("reports wrong-shaped model list payloads with owned errors", async () => {
for (const payload of [[], { models: {} }, { models: [null] }]) {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => payload,
}));
const fetchMock = vi.fn(async () => jsonResponse(payload));
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -424,12 +421,9 @@ describe("lmstudio-models", () => {
it("caps oversized direct fetch timeouts before discovering models", async () => {
const timeoutController = new AbortController();
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => ({
ok: true,
status: 200,
requestInit: init,
json: async () => ({ models: [] }),
}));
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) =>
jsonResponse({ models: [] }),
);
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -521,20 +515,17 @@ describe("lmstudio-models", () => {
const variantKey = `${canonicalKey}@q4_k_m`;
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return {
ok: true,
json: async () => ({
models: [
{
type: "llm",
key: canonicalKey,
variants: [variantKey],
selected_variant: variantKey,
loaded_instances: [],
},
],
}),
};
return jsonResponse({
models: [
{
type: "llm",
key: canonicalKey,
variants: [variantKey],
selected_variant: variantKey,
loaded_instances: [],
},
],
});
}
if (String(url).endsWith("/api/v1/models/load")) {
return new Response("load failed", { status: 503 });
@@ -575,20 +566,12 @@ describe("lmstudio-models", () => {
it("reports malformed model load JSON with an owned error", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return {
ok: true,
json: async () => ({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
};
return jsonResponse({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
});
}
if (String(url).endsWith("/api/v1/models/load")) {
return {
ok: true,
json: async () => {
throw new SyntaxError("bad json");
},
};
return malformedJsonResponse();
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
@@ -608,12 +591,9 @@ describe("lmstudio-models", () => {
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return {
ok: true,
json: async () => ({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
};
return jsonResponse({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
});
}
if (String(url).endsWith("/api/v1/models/load")) {
return tracked.response;

View File

@@ -59,6 +59,84 @@ describe("mattermost monitor gating", () => {
});
});
it("processes engaged thread follow-ups without a mention", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
threadAlreadyEngaged: true,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: true,
dropReason: null,
});
});
it("engaged threads respond even when onchar is enabled but not triggered", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
threadAlreadyEngaged: true,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: true,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: true,
dropReason: null,
});
});
it("drops non-mentioned channel traffic outside an engaged thread", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
threadAlreadyEngaged: false,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: false,
dropReason: "missing-mention",
});
});
it("bypasses mention for authorized control commands and allows direct chats", () => {
const resolveRequireMention = vi.fn(() => true);

View File

@@ -43,6 +43,9 @@ export type MattermostMentionGateInput = {
requireMentionOverride?: boolean;
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
wasMentioned: boolean;
// Bot has already replied in this thread; treat follow-ups as addressed so the
// user need not re-mention on every turn (parity with Slack thread participation).
threadAlreadyEngaged?: boolean;
isControlCommand: boolean;
commandAuthorized: boolean;
oncharEnabled: boolean;
@@ -75,12 +78,16 @@ export function evaluateMattermostMentionGate(
!params.wasMentioned &&
params.commandAuthorized;
const effectiveWasMentioned =
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
params.wasMentioned ||
shouldBypassMention ||
params.oncharTriggered ||
params.threadAlreadyEngaged === true;
if (
params.oncharEnabled &&
!params.oncharTriggered &&
!params.wasMentioned &&
!params.isControlCommand
!params.isControlCommand &&
params.threadAlreadyEngaged !== true
) {
return {
shouldRequireMention,

View File

@@ -371,6 +371,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
it("suppresses reasoning-prefixed finals before preview finalization", async () => {
const draftStream = createDraftStreamMock();
const deliverFinal = vi.fn(async () => {});
const recordThreadParticipation = vi.fn();
await deliverMattermostReplyWithDraftPreview({
payload: { text: " \n > Reasoning:\n> _hidden_" } as never,
@@ -382,6 +383,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
resolvePreviewFinalText: (text) => text?.trim(),
previewState: { finalizedViaPreviewPost: false },
logVerboseMessage: vi.fn(),
recordThreadParticipation,
deliverPayload: deliverFinal,
});
@@ -390,6 +392,36 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
expect(draftStream.discardPending).not.toHaveBeenCalled();
expect(draftStream.clear).not.toHaveBeenCalled();
expect(updateMattermostPostSpy).not.toHaveBeenCalled();
// No visible reply was sent, so the thread must not be marked as participated.
expect(recordThreadParticipation).not.toHaveBeenCalled();
});
it("records thread participation when a same-thread final finalizes the preview in place", async () => {
const draftStream = createDraftStreamMock();
const deliverFinal = vi.fn(async () => {});
const recordThreadParticipation = vi.fn();
await deliverMattermostReplyWithDraftPreview({
payload: { text: "All good" } as never,
info: { kind: "final" },
kind: "channel",
client: createMattermostClientMock(),
draftStream,
effectiveReplyToId: "thread-root-1",
resolvePreviewFinalText: (text) => text?.trim(),
previewState: { finalizedViaPreviewPost: false },
logVerboseMessage: vi.fn(),
recordThreadParticipation,
deliverPayload: deliverFinal,
});
// Default streaming finalizes by editing the preview post, bypassing deliverPayload —
// participation must still be recorded (regression: PR #95552 review P1).
expect(updateMattermostPostSpy).toHaveBeenCalledWith(expect.anything(), "preview-post-1", {
message: "All good",
});
expect(deliverFinal).not.toHaveBeenCalled();
expect(recordThreadParticipation).toHaveBeenCalledTimes(1);
});
it("deletes the preview after a successful normal final send", async () => {

View File

@@ -116,6 +116,10 @@ import {
import { sendMessageMattermost } from "./send.js";
import { cleanupSlashCommands } from "./slash-commands.js";
import { deactivateSlashCommands, getSlashCommandState } from "./slash-state.js";
import {
hasMattermostThreadParticipationWithPersistence,
recordMattermostThreadParticipation,
} from "./thread-participation.js";
export {
evaluateMattermostMentionGate,
@@ -327,6 +331,10 @@ type MattermostDraftPreviewDeliverParams = {
previewState: MattermostDraftPreviewState;
logVerboseMessage: (message: string) => void;
deliverPayload: (payload: ReplyPayload) => Promise<void>;
// Visible same-thread finals can be delivered by editing the draft preview in
// place (onPreviewFinalized) without ever calling deliverPayload; this lets the
// caller record thread participation on that path too.
recordThreadParticipation?: () => void;
};
export async function deliverMattermostReplyWithDraftPreview(
@@ -374,6 +382,9 @@ export async function deliverMattermostReplyWithDraftPreview(
},
onPreviewFinalized: () => {
params.previewState.finalizedViaPreviewPost = true;
// The visible final reply landed by editing the preview post, so the normal
// deliverPayload record path is skipped; record participation explicitly here.
params.recordThreadParticipation?.();
},
buildSupplementalPayload: (payload) =>
getReplyPayloadTtsSupplement(payload) ? buildTtsSupplementMediaPayload(payload) : undefined,
@@ -1484,6 +1495,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: { triggered: false, stripped: rawText };
const oncharTriggered = oncharResult.triggered;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
// Threads the bot already replied in auto-engage: follow-ups resume without
// a re-mention even under requireMention. Keyed by the thread root id.
const threadAlreadyEngaged =
kind !== "direct" && effectiveReplyToId
? await hasMattermostThreadParticipationWithPersistence({
accountId: account.accountId,
channelId,
threadRootId: effectiveReplyToId,
})
: false;
const mentionDecision = evaluateMattermostMentionGate({
kind,
cfg,
@@ -1493,6 +1514,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
requireMentionOverride: account.requireMention,
resolveRequireMention: core.channel.groups.resolveRequireMention,
wasMentioned,
threadAlreadyEngaged,
isControlCommand,
commandAuthorized,
oncharEnabled,
@@ -1781,6 +1803,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
if (info.kind === "final") {
progressDraft.markFinalReplyStarted();
}
// A visible same-thread final arrives either via a normal send or by editing
// the draft preview in place; record participation on whichever path fires.
const markThreadParticipation = () => {
if (kind !== "direct" && effectiveReplyToId) {
recordMattermostThreadParticipation(
account.accountId,
channelId,
effectiveReplyToId,
{ agentId: route.agentId },
);
}
};
await deliverMattermostReplyWithDraftPreview({
payload: payloadEntry,
info,
@@ -1791,6 +1825,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
resolvePreviewFinalText,
previewState,
logVerboseMessage,
recordThreadParticipation: markThreadParticipation,
deliverPayload: async (payloadToDeliver) => {
const outcome = await deliverMattermostReplyPayload({
core,
@@ -1809,6 +1844,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
// Record only on a visible send so threads we merely observed
// (reasoning-only/empty/suppressed) do not auto-engage later.
if (outcome === "text" || outcome === "media") {
markThreadParticipation();
}
const deliveryLog = formatMattermostFinalDeliveryOutcomeLog({
outcome,
payload: payloadToDeliver,

View File

@@ -0,0 +1,115 @@
// Mattermost tests cover thread participation cache plugin behavior.
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setMattermostRuntime } from "../runtime.js";
import {
clearMattermostThreadParticipationCache,
hasMattermostThreadParticipationWithPersistence,
recordMattermostThreadParticipation,
} from "./thread-participation.js";
// Drain microtasks + the immediate queue so the fire-and-forget persistent write
// in recordMattermostThreadParticipation has settled before we assert on it.
const flush = (): Promise<void> =>
new Promise((resolve) => {
setImmediate(resolve);
});
function setRuntime(openKeyedStore: (options: OpenKeyedStoreOptions) => unknown): void {
setMattermostRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn() {} }) },
} as unknown as PluginRuntime);
}
function setPersistentRuntime(): void {
setRuntime((options) => createPluginStateKeyedStoreForTests("mattermost", options));
}
describe("mattermost thread participation", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
clearMattermostThreadParticipationCache();
setPersistentRuntime();
});
afterEach(() => {
clearMattermostThreadParticipationCache();
resetPluginStateStoreForTests();
});
it("remembers a thread the bot replied in", async () => {
recordMattermostThreadParticipation("acct", "chan", "root-1");
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(true);
});
it("isolates participation by account, channel, and thread", async () => {
recordMattermostThreadParticipation("acct", "chan", "root-1");
await flush();
for (const probe of [
{ accountId: "other", channelId: "chan", threadRootId: "root-1" },
{ accountId: "acct", channelId: "other", threadRootId: "root-1" },
{ accountId: "acct", channelId: "chan", threadRootId: "root-2" },
]) {
await expect(hasMattermostThreadParticipationWithPersistence(probe)).resolves.toBe(false);
}
});
it("ignores empty identifiers", async () => {
recordMattermostThreadParticipation("", "chan", "root-1");
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(false);
});
it("recovers participation from the persistent store after the in-memory cache is lost", async () => {
recordMattermostThreadParticipation("acct", "chan", "root-1");
await flush();
// Simulate a restart: in-memory cache cleared, persistent SQLite store intact.
clearMattermostThreadParticipationCache();
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(true);
});
it("degrades to in-memory only when the persistent store fails", async () => {
setRuntime(() => {
throw new Error("sqlite unavailable");
});
// record + read must not throw; the in-memory cache still answers.
recordMattermostThreadParticipation("acct", "chan", "root-1");
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(true);
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "missing",
}),
).resolves.toBe(false);
});
});

View File

@@ -0,0 +1,151 @@
// Mattermost plugin module implements thread participation cache behavior.
import { resolveGlobalDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
import { getOptionalMattermostRuntime } from "../runtime.js";
/**
* In-memory + persisted cache of Mattermost threads the bot has replied in.
* Lets the bot auto-respond to thread follow-ups without a re-mention after its
* first visible reply. Mirrors the Slack `sent-thread-cache` dual-layer pattern.
*/
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const MAX_ENTRIES = 5000;
const PERSISTENT_MAX_ENTRIES = 1000;
const PERSISTENT_NAMESPACE = "mattermost.thread-participation";
type MattermostThreadParticipationRecord = {
agentId?: string;
repliedAt: number;
};
type MattermostThreadParticipationStore = {
register(
key: string,
value: MattermostThreadParticipationRecord,
opts?: { ttlMs?: number },
): Promise<void>;
lookup(key: string): Promise<MattermostThreadParticipationRecord | undefined>;
};
/**
* Keep thread participation shared across bundled chunks so thread auto-reply
* gating does not diverge between the inbound-gate and reply-dispatch paths.
*/
const MATTERMOST_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.mattermostThreadParticipation");
const threadParticipation = resolveGlobalDedupeCache(MATTERMOST_THREAD_PARTICIPATION_KEY, {
ttlMs: TTL_MS,
maxSize: MAX_ENTRIES,
});
let persistentStore: MattermostThreadParticipationStore | undefined;
let persistentStoreDisabled = false;
function makeKey(accountId: string, channelId: string, threadRootId: string): string {
return `${accountId}:${channelId}:${threadRootId}`;
}
function reportPersistentThreadParticipationError(error: unknown): void {
try {
getOptionalMattermostRuntime()
?.logging.getChildLogger({ plugin: "mattermost", feature: "thread-participation-state" })
.warn("Mattermost persistent thread participation state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break Mattermost message handling.
}
}
function disablePersistentThreadParticipation(error: unknown): void {
persistentStoreDisabled = true;
persistentStore = undefined;
reportPersistentThreadParticipationError(error);
}
function getPersistentThreadParticipationStore(): MattermostThreadParticipationStore | undefined {
if (persistentStoreDisabled) {
return undefined;
}
if (persistentStore) {
return persistentStore;
}
const runtime = getOptionalMattermostRuntime();
if (!runtime) {
return undefined;
}
try {
persistentStore = runtime.state.openKeyedStore<MattermostThreadParticipationRecord>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: TTL_MS,
});
return persistentStore;
} catch (error) {
disablePersistentThreadParticipation(error);
return undefined;
}
}
function rememberPersistentThreadParticipation(params: { key: string; agentId?: string }): void {
const store = getPersistentThreadParticipationStore();
if (!store) {
return;
}
void store
.register(params.key, {
// Stored for future per-agent thread routing; current reads only need presence.
...(params.agentId ? { agentId: params.agentId } : {}),
repliedAt: Date.now(),
})
.catch(disablePersistentThreadParticipation);
}
async function lookupPersistentThreadParticipation(key: string): Promise<boolean> {
const store = getPersistentThreadParticipationStore();
if (!store) {
return false;
}
try {
return Boolean(await store.lookup(key));
} catch (error) {
disablePersistentThreadParticipation(error);
return false;
}
}
export function recordMattermostThreadParticipation(
accountId: string,
channelId: string,
threadRootId: string,
opts?: { agentId?: string },
): void {
if (!accountId || !channelId || !threadRootId) {
return;
}
const key = makeKey(accountId, channelId, threadRootId);
threadParticipation.check(key);
rememberPersistentThreadParticipation({ key, agentId: opts?.agentId });
}
export async function hasMattermostThreadParticipationWithPersistence(params: {
accountId: string;
channelId: string;
threadRootId: string;
}): Promise<boolean> {
if (!params.accountId || !params.channelId || !params.threadRootId) {
return false;
}
const key = makeKey(params.accountId, params.channelId, params.threadRootId);
if (threadParticipation.peek(key)) {
return true;
}
const found = await lookupPersistentThreadParticipation(key);
if (found) {
threadParticipation.check(key);
}
return found;
}
export function clearMattermostThreadParticipationCache(): void {
threadParticipation.clear();
persistentStore = undefined;
persistentStoreDisabled = false;
}

View File

@@ -2,9 +2,12 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "mattermost",
errorMessage: "Mattermost runtime not initialized",
});
export { getMattermostRuntime, setMattermostRuntime };
const {
setRuntime: setMattermostRuntime,
getRuntime: getMattermostRuntime,
tryGetRuntime: getOptionalMattermostRuntime,
} = createPluginRuntimeStore<PluginRuntime>({
pluginId: "mattermost",
errorMessage: "Mattermost runtime not initialized",
});
export { getMattermostRuntime, getOptionalMattermostRuntime, setMattermostRuntime };

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { emitSessionTranscriptUpdate } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
resolveSessionTranscriptsDirForAgent,
type OpenClawConfig,
@@ -33,6 +34,25 @@ type SyncParams = {
progress?: (update: MemorySyncProgressUpdate) => void;
};
type MemorySessionTranscriptUpdate = {
agentId?: string;
sessionFile?: string;
sessionKey?: string;
target?: {
agentId: string;
sessionId: string;
sessionKey: string;
};
};
type MemoryTranscriptUpdateSubscriber = (
listener: (update: MemorySessionTranscriptUpdate) => void,
) => () => void;
const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for(
"openclaw.memoryCore.sessionTranscriptUpdateSubscriber",
);
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
@@ -111,10 +131,27 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
return Array.from(this.sessionsDirtyFiles);
}
getPendingSessionTargets(): MemorySyncParams["sessions"] {
return Array.from(this.sessionPendingTargets.values());
}
getPendingSessionFiles(): string[] {
return Array.from(this.sessionPendingFiles);
}
isSessionsDirty(): boolean {
return this.sessionsDirty;
}
startTranscriptListener(): void {
this.ensureSessionListener();
}
stopTranscriptListener(): void {
this.sessionUnsubscribe?.();
this.sessionUnsubscribe = null;
}
protected computeProviderKey(): string {
return "test";
}
@@ -162,6 +199,8 @@ describe("session startup catch-up", () => {
});
afterEach(async () => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllEnvs();
await fs.rm(stateDir, { recursive: true, force: true });
});
@@ -356,4 +395,84 @@ describe("session startup catch-up", () => {
expect(harness.indexedPaths).toEqual([]);
});
it("queues transcript update identity without requiring a session file", async () => {
vi.useFakeTimers();
const harness = new SessionStartupCatchupHarness([]);
const originalSubscriber = (globalThis as Record<symbol, unknown>)[
MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY
];
let transcriptListener: ((update: MemorySessionTranscriptUpdate) => void) | undefined;
(globalThis as Record<symbol, unknown>)[MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY] = ((
listener,
) => {
transcriptListener = listener;
return () => {
if (transcriptListener === listener) {
transcriptListener = undefined;
}
};
}) satisfies MemoryTranscriptUpdateSubscriber;
harness.startTranscriptListener();
try {
transcriptListener?.({
target: {
agentId: "main",
sessionId: "thread",
sessionKey: "agent:main:thread",
},
});
expect(harness.getPendingSessionTargets()).toEqual([
{ agentId: "main", sessionId: "thread", sessionKey: "agent:main:thread" },
]);
} finally {
harness.stopTranscriptListener();
if (originalSubscriber === undefined) {
delete (globalThis as Record<symbol, unknown>)[
MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY
];
} else {
(globalThis as Record<symbol, unknown>)[MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY] =
originalSubscriber;
}
}
});
it("keeps canonical path transcript update compatibility", async () => {
vi.useFakeTimers();
const session = await writeSessionFile("thread.jsonl");
const harness = new SessionStartupCatchupHarness([]);
harness.startTranscriptListener();
emitSessionTranscriptUpdate({
sessionFile: session.filePath,
sessionKey: "agent:main:thread",
});
expect(harness.getPendingSessionFiles()).toEqual([session.filePath]);
expect(harness.getPendingSessionTargets()).toEqual([]);
harness.stopTranscriptListener();
});
it("prefers transcript update path compatibility before identity", async () => {
vi.useFakeTimers();
const session = await writeSessionFile("thread.jsonl");
const harness = new SessionStartupCatchupHarness([]);
harness.startTranscriptListener();
emitSessionTranscriptUpdate({
sessionFile: session.filePath,
target: {
agentId: "main",
sessionId: "identity-target",
sessionKey: "agent:main:identity-target",
},
});
expect(harness.getPendingSessionFiles()).toEqual([session.filePath]);
expect(harness.getPendingSessionTargets()).toEqual([]);
harness.stopTranscriptListener();
});
});

View File

@@ -170,9 +170,27 @@ const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
]);
const log = createSubsystemLogger("memory");
const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for(
"openclaw.memoryCore.sessionTranscriptUpdateSubscriber",
);
const TEST_MEMORY_WATCH_FACTORY_KEY = Symbol.for("openclaw.test.memoryWatchFactory");
const TEST_MEMORY_NATIVE_WATCH_FACTORY_KEY = Symbol.for("openclaw.test.memoryNativeWatchFactory");
type MemorySessionTranscriptUpdate = {
agentId?: string;
sessionFile?: string;
sessionKey?: string;
target?: {
agentId: string;
sessionId: string;
sessionKey: string;
};
};
type MemoryTranscriptUpdateSubscriber = (
listener: (update: MemorySessionTranscriptUpdate) => void,
) => () => void;
function memoryTableExists(db: DatabaseSync, tableName: string): boolean {
return Boolean(
db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName),
@@ -191,6 +209,18 @@ type LinuxMemoryDirectoryWatcher = {
ino: number;
};
function subscribeMemorySessionTranscriptUpdates(
listener: (update: MemorySessionTranscriptUpdate) => void,
): () => void {
const injected = (globalThis as Record<symbol, unknown>)[
MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY
];
if (typeof injected === "function") {
return (injected as MemoryTranscriptUpdateSubscriber)(listener);
}
return onSessionTranscriptUpdate(listener);
}
function resolveMemoryWatchFactory(): typeof chokidar.watch {
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
const override = (globalThis as Record<PropertyKey, unknown>)[TEST_MEMORY_WATCH_FACTORY_KEY];
@@ -1422,20 +1452,22 @@ export abstract class MemoryManagerSyncOps {
if (!this.sources.has("sessions") || this.sessionUnsubscribe) {
return;
}
this.sessionUnsubscribe = onSessionTranscriptUpdate((update) => {
this.sessionUnsubscribe = subscribeMemorySessionTranscriptUpdates((update) => {
if (this.closed) {
return;
}
const sessionFile = update.sessionFile;
if (!this.isSessionFileForAgent(sessionFile)) {
if (sessionFile && isSessionArchiveArtifactName(path.basename(sessionFile))) {
return;
}
if (sessionFile && this.isSessionFileForAgent(sessionFile)) {
this.scheduleSessionDirty(sessionFile);
return;
}
const target = this.resolveSessionTranscriptUpdateSyncTarget(update);
if (target) {
this.scheduleSessionDirty(target);
return;
}
this.scheduleSessionDirty(sessionFile);
});
}
@@ -1703,13 +1735,30 @@ export abstract class MemoryManagerSyncOps {
return resolvedFile.startsWith(`${resolvedDir}${path.sep}`);
}
private resolveSessionTranscriptUpdateSyncTarget(update: {
agentId?: string;
sessionFile: string;
sessionKey?: string;
}): MemorySessionSyncTarget | null {
private resolveSessionTranscriptUpdateSyncTarget(
update: MemorySessionTranscriptUpdate,
): MemorySessionSyncTarget | null {
if (update.sessionFile && isSessionArchiveArtifactName(path.basename(update.sessionFile))) {
return null;
}
if (update.target) {
const agentId = update.target.agentId.trim();
const sessionId = update.target.sessionId.trim();
const sessionKey = update.target.sessionKey.trim();
if (!agentId || !sessionId || normalizeAgentId(agentId) !== normalizeAgentId(this.agentId)) {
return null;
}
return {
agentId,
sessionId,
...(sessionKey ? { sessionKey } : {}),
};
}
if (!update.sessionFile) {
return null;
}
const parsed = parseCanonicalSessionSyncTargetFromPath(update.sessionFile);
if (!parsed || isSessionArchiveArtifactName(path.basename(update.sessionFile))) {
if (!parsed) {
return null;
}
const agentId = update.agentId?.trim() || parsed.agentId;

View File

@@ -559,6 +559,85 @@ describe("compileMemoryWikiVault", () => {
);
});
it("excludes concept and synthesis pages from stale-pages report", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),
initialize: true,
});
await fs.writeFile(
path.join(rootDir, "entities", "entity-alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha Entity",
sourceIds: ["source.alpha"],
updatedAt: "2025-06-01T00:00:00.000Z",
},
body: "# Alpha Entity\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "source-alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.alpha",
title: "Alpha Source",
updatedAt: "2025-06-01T00:00:00.000Z",
},
body: "# Alpha Source\n",
}),
"utf8",
);
// Concept page with old updatedAt — should be excluded from stale-pages
await fs.writeFile(
path.join(rootDir, "concepts", "concept-beta.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "concept",
id: "concept.beta",
title: "Beta Concept",
sourceIds: ["source.alpha"],
updatedAt: "2025-06-01T00:00:00.000Z",
},
body: "# Beta Concept\n",
}),
"utf8",
);
// Synthesis page with old updatedAt — should be excluded from stale-pages
await fs.writeFile(
path.join(rootDir, "syntheses", "synthesis-gamma.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "synthesis",
id: "synthesis.gamma",
title: "Gamma Synthesis",
sourceIds: ["source.alpha"],
updatedAt: "2025-06-01T00:00:00.000Z",
},
body: "# Gamma Synthesis\n",
}),
"utf8",
);
await compileMemoryWikiVault(config);
const stalePages = await fs.readFile(path.join(rootDir, "reports", "stale-pages.md"), "utf8");
// Entity and source pages still appear in stale-pages
expect(stalePages).toContain("[Alpha Entity](../entities/entity-alpha.md)");
expect(stalePages).toContain("[Alpha Source](../sources/source-alpha.md)");
// Concept and synthesis pages are excluded
expect(stalePages).not.toContain("[Beta Concept](../concepts/concept-beta.md)");
expect(stalePages).not.toContain("[Gamma Synthesis](../syntheses/synthesis-gamma.md)");
});
it("skips dashboard report pages when createDashboards is disabled", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),

View File

@@ -214,6 +214,9 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [
.filter(
(page) =>
page.kind !== "report" &&
// concept/synthesis are intentionally durable references
page.kind !== "concept" &&
page.kind !== "synthesis" &&
!(
isUnmanagedRawSourceSummary(page) &&
!managedImportedSourcePagePaths.has(page.relativePath)

View File

@@ -36,20 +36,25 @@ function requireFirstFetchParams(): {
return fetchParams as { auditContext?: string; url?: string };
}
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
describe("nextcloud talk room info", () => {
it("resolves direct rooms from the room info endpoint", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuard.mockResolvedValue({
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: 1,
},
response: jsonResponse({
ocs: {
data: {
type: 1,
},
}),
},
},
}),
release,
});
@@ -76,16 +81,13 @@ describe("nextcloud talk room info", () => {
it("normalizes signed decimal room type strings through the shared parser", async () => {
fetchWithSsrFGuard.mockResolvedValue({
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: "+01",
},
response: jsonResponse({
ocs: {
data: {
type: "+01",
},
}),
},
},
}),
release: vi.fn(async () => {}),
});
@@ -106,16 +108,13 @@ describe("nextcloud talk room info", () => {
it("does not coerce partial room type strings", async () => {
fetchWithSsrFGuard.mockResolvedValue({
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: "1direct",
},
response: jsonResponse({
ocs: {
data: {
type: "1direct",
},
}),
},
},
}),
release: vi.fn(async () => {}),
});
@@ -136,16 +135,13 @@ describe("nextcloud talk room info", () => {
it("does not classify negative room types as group rooms", async () => {
fetchWithSsrFGuard.mockResolvedValue({
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: -1,
},
response: jsonResponse({
ocs: {
data: {
type: -1,
},
}),
},
},
}),
release: vi.fn(async () => {}),
});

View File

@@ -16,7 +16,7 @@
"@openclaw/plugin-sdk": "workspace:*",
"@openclaw/slack": "workspace:*",
"@openclaw/whatsapp": "workspace:*",
"crabline": "github:openclaw/crabline#b3513f66053788c6a7bd2bc76fbfc7201f647d29",
"@openclaw/crabline": "0.1.0",
"openclaw": "2026.5.28"
},
"peerDependencies": {

View File

@@ -0,0 +1,6 @@
// Qa Lab plugin helper creates collision-resistant artifact run identifiers.
import { randomUUID } from "node:crypto";
export function createQaArtifactRunId(): string {
return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
}

View File

@@ -255,6 +255,39 @@ describe("runQaCharacterEval", () => {
expect(report).not.toContain("Judge Raw Reply");
});
it("creates a unique default output directory under repo artifacts", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({
outputDir: params.outputDir,
model: params.primaryModel,
transcript: "USER Alice: hi\n\nASSISTANT openclaw: default dir reply",
}),
);
const runJudge = makeRunJudge([
{
model: "openai/gpt-5.5",
rank: 1,
score: 8,
summary: "solid",
strengths: ["clear"],
weaknesses: [],
},
]);
const result = await runQaCharacterEval({
repoRoot: tempRoot,
models: ["openai/gpt-5.5"],
runSuite,
runJudge,
});
expect(path.dirname(result.outputDir)).toBe(path.join(tempRoot, ".artifacts", "qa-e2e"));
expect(path.basename(result.outputDir)).toMatch(
/^character-eval-[a-z0-9]+-[a-f0-9]{8}$/u,
);
await expect(fs.stat(result.reportPath).then((stats) => stats.isFile())).resolves.toBe(true);
});
it("can hide candidate model refs from judge prompts and map rankings back", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import { isQaFastModeModelRef, type QaProviderMode } from "./model-selection.js";
import {
QA_FRONTIER_CHARACTER_EVAL_MODELS,
@@ -520,7 +521,7 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `character-eval-${Date.now().toString(36)}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `character-eval-${createQaArtifactRunId()}`);
const runsDir = path.join(outputDir, "runs");
await fs.mkdir(runsDir, { recursive: true });

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import {
OPENCLAW_CRABLINE_DEFAULT_CHANNEL,
resolveOpenClawCrablineChannelDriverSelection,
} from "crabline";
} from "@openclaw/crabline";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -17,6 +17,7 @@ import {
type QaRuntimeParitySuiteSummary,
} from "./agentic-parity-report.js";
import { resolveQaParityPackScenarioIds } from "./agentic-parity.js";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import { runQaCharacterEval, type QaCharacterModelOptions } from "./character-eval.js";
import { resolveRepoRelativeOutputDir } from "./cli-paths.js";
import {
@@ -407,7 +408,7 @@ async function runQaParityPreflight(params: {
".artifacts",
"qa-e2e",
"preflight",
`suite-${Date.now().toString(36)}`,
`suite-${createQaArtifactRunId()}`,
);
const result = await runQaSuiteWithInfraRetry(() =>
runQaFlowSuiteFromRuntime({
@@ -1056,7 +1057,7 @@ export async function runQaParityReportCommand(opts: {
}
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `parity-${Date.now().toString(36)}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `parity-${createQaArtifactRunId()}`);
await fs.mkdir(outputDir, { recursive: true });
if (opts.runtimeAxis === true) {
@@ -1149,7 +1150,7 @@ export async function runQaConfidenceReportCommand(opts: {
const artifactRoot = path.resolve(repoRoot, opts.artifactRoot ?? ".");
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `confidence-${Date.now().toString(36)}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `confidence-${createQaArtifactRunId()}`);
await fs.mkdir(outputDir, { recursive: true });
const manifest = await readQaConfidenceManifestFile(manifestPath);
const reportPayload = await buildQaConfidenceReport({
@@ -1178,7 +1179,7 @@ export async function runQaConfidenceSelfTestCommand(opts: {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `confidence-self-test-${Date.now().toString(36)}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `confidence-self-test-${createQaArtifactRunId()}`);
const result = await writeQaConfidenceSelfTestArtifacts({ outputDir });
process.stdout.write(`QA confidence self-test report: ${result.reportPath}\n`);
process.stdout.write(`QA confidence self-test summary: ${result.summaryPath}\n`);
@@ -1268,7 +1269,7 @@ export async function runQaJsonlReplayCommand(opts: {
const transcriptDir = path.resolve(repoRoot, opts.transcripts ?? "qa/scenarios/jsonl-replay");
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `jsonl-replay-${Date.now().toString(36)}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `jsonl-replay-${createQaArtifactRunId()}`);
await fs.mkdir(outputDir, { recursive: true });
const result = await runJsonlReplay(
{

View File

@@ -800,6 +800,33 @@ describe("qa cli registration", () => {
await expect(invalidProgram.parseAsync(["node", "openclaw", ...args])).rejects.toThrow(message);
});
it.each([
[["qa", "ui", "--port", "65536"], "--port must be a TCP port between 1 and 65535."],
[
["qa", "ui", "--advertise-port", "999999"],
"--advertise-port must be a TCP port between 1 and 65535.",
],
[
["qa", "docker-scaffold", "--output-dir", "/tmp/qa", "--gateway-port", "65536"],
"--gateway-port must be a TCP port between 1 and 65535.",
],
[
["qa", "up", "--qa-lab-port", "65536"],
"--qa-lab-port must be a TCP port between 1 and 65535.",
],
[["qa", "aimock", "--port", "65536"], "--port must be a TCP port between 1 and 65535."],
])("rejects out-of-range QA port option %j", async (args, message) => {
const invalidProgram = new Command();
invalidProgram.exitOverride();
invalidProgram.configureOutput({
writeErr: () => {},
writeOut: () => {},
});
registerQaLabCli(invalidProgram);
await expect(invalidProgram.parseAsync(["node", "openclaw", ...args])).rejects.toThrow(message);
});
it("shows an enable hint when a discovered runner plugin is installed but blocked", async () => {
listQaRunnerCliContributions.mockReset().mockReturnValue([createBlockedQaRunnerContribution()]);
const blockedProgram = new Command();

View File

@@ -62,6 +62,7 @@ const QA_RUN_PROFILE_ONLY_OPTIONS = [
] as const;
const QA_RUN_SELF_CHECK_ONLY_OPTIONS = [{ optionName: "output", flag: "--output" }] as const;
const MAX_QA_CLI_TCP_PORT = 65_535;
type QaSuiteCliOptions = QaScenarioRunCliOptions & {
channelDriver?: QaSuiteCommandOptions["channelDriver"];
@@ -105,6 +106,14 @@ function parseQaCliPositiveIntegerOption(value: string, flag: string): number {
return parsed;
}
function parseQaCliTcpPortOption(value: string, flag: string): number {
const parsed = parseQaCliPositiveIntegerOption(value, flag);
if (parsed > MAX_QA_CLI_TCP_PORT) {
throw invalidQaCliArgument(`${flag} must be a TCP port between 1 and 65535.`);
}
return parsed;
}
function parseQaEvidenceModeOption(value: string): QaProfileCommandOptions["evidenceMode"] {
const evidenceMode = value.trim();
if (evidenceMode === "full" || evidenceMode === "slim") {
@@ -867,11 +876,11 @@ export function registerQaLabCli(program: Command) {
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
.option("--host <host>", "Bind host", "127.0.0.1")
.option("--port <port>", "Bind port", (value: string) =>
parseQaCliPositiveIntegerOption(value, "--port"),
parseQaCliTcpPortOption(value, "--port"),
)
.option("--advertise-host <host>", "Optional public host to advertise in bootstrap payloads")
.option("--advertise-port <port>", "Optional public port to advertise", (value: string) =>
parseQaCliPositiveIntegerOption(value, "--advertise-port"),
parseQaCliTcpPortOption(value, "--advertise-port"),
)
.option("--control-ui-url <url>", "Optional Control UI URL to embed beside the QA panel")
.option(
@@ -909,10 +918,10 @@ export function registerQaLabCli(program: Command) {
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
.requiredOption("--output-dir <path>", "Output directory for docker-compose + state files")
.option("--gateway-port <port>", "Gateway host port", (value: string) =>
parseQaCliPositiveIntegerOption(value, "--gateway-port"),
parseQaCliTcpPortOption(value, "--gateway-port"),
)
.option("--qa-lab-port <port>", "QA lab host port", (value: string) =>
parseQaCliPositiveIntegerOption(value, "--qa-lab-port"),
parseQaCliTcpPortOption(value, "--qa-lab-port"),
)
.option("--provider-base-url <url>", "Provider base URL for the QA gateway")
.option("--image <name>", "Prebaked image name", "openclaw:qa-local-prebaked")
@@ -950,10 +959,10 @@ export function registerQaLabCli(program: Command) {
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
.option("--output-dir <path>", "Output directory for docker-compose + state files")
.option("--gateway-port <port>", "Gateway host port", (value: string) =>
parseQaCliPositiveIntegerOption(value, "--gateway-port"),
parseQaCliTcpPortOption(value, "--gateway-port"),
)
.option("--qa-lab-port <port>", "QA lab host port", (value: string) =>
parseQaCliPositiveIntegerOption(value, "--qa-lab-port"),
parseQaCliTcpPortOption(value, "--qa-lab-port"),
)
.option("--provider-base-url <url>", "Provider base URL for the QA gateway")
.option("--image <name>", "Image tag", "openclaw:qa-local-prebaked")
@@ -985,7 +994,7 @@ export function registerQaLabCli(program: Command) {
.description(providerCommand.description)
.option("--host <host>", "Bind host", "127.0.0.1")
.option("--port <port>", "Bind port", (value: string) =>
parseQaCliPositiveIntegerOption(value, "--port"),
parseQaCliTcpPortOption(value, "--port"),
)
.action(async (opts: { host?: string; port?: number }) => {
await runQaProviderServer(providerCommand.providerMode, opts);

View File

@@ -1,7 +1,7 @@
// Qa Lab tests cover Crabline fake-provider transport integration behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { OPENCLAW_CRABLINE_MANIFEST_PATH } from "crabline";
import { OPENCLAW_CRABLINE_MANIFEST_PATH } from "@openclaw/crabline";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";

View File

@@ -7,7 +7,7 @@ import {
startOpenClawCrablineAdapter,
type OpenClawCrablineChannelDriverSelection,
type StartedOpenClawCrablineAdapter,
} from "crabline";
} from "@openclaw/crabline";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -101,7 +101,10 @@ async function postCrablineInbound(params: {
url: params.adapter.manifest.endpoints.adminInboundUrl,
init: {
body: JSON.stringify(params.providerBody),
headers: { "content-type": "application/json" },
headers: {
authorization: `Bearer ${params.adapter.manifest.adminToken}`,
"content-type": "application/json",
},
method: "POST",
},
policy: { allowPrivateNetwork: true },

View File

@@ -1231,6 +1231,9 @@ describe("buildQaRuntimeEnv", () => {
await expect(readFile(path.join(artifactDir, "README.txt"), "utf8")).resolves.toContain(
"was not copied because it may contain credentials or auth tokens",
);
await expect(readFile(path.join(artifactDir, "README.txt"), "utf8")).resolves.not.toContain(
tempRoot,
);
});
it("rejects preserved gateway artifacts outside the repo root", async () => {

View File

@@ -162,7 +162,7 @@ async function preserveQaGatewayDebugArtifacts(params: {
[
"Only sanitized gateway debug artifacts are preserved here.",
"The full QA gateway runtime was not copied because it may contain credentials or auth tokens.",
`Original runtime temp root: ${params.tempRoot}`,
"Original runtime temp root omitted because local temp paths can identify the runner.",
"",
].join("\n"),
"utf8",

View File

@@ -657,7 +657,8 @@ describe("qa-lab server", () => {
});
const result = await lab.runSelfCheck();
expect(result.outputPath).toBe(path.join(repoRoot, ".artifacts", "qa-e2e", "self-check.md"));
expect(path.dirname(result.outputPath)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
expect(path.basename(result.outputPath)).toMatch(/^self-check-[a-z0-9]+-[a-f0-9]{8}\.md$/u);
expect(await readFile(result.outputPath, "utf8")).toContain("Synthetic Slack-class roundtrip");
});

View File

@@ -15,6 +15,7 @@ import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtim
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { chromium } from "playwright-core";
import { z } from "zod";
import { createQaArtifactRunId } from "../../artifact-run-id.js";
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
import { startQaGatewayChild } from "../../gateway-child.js";
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
@@ -1527,7 +1528,7 @@ export async function runDiscordQaLive(params: {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `discord-${Date.now().toString(36)}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `discord-${createQaArtifactRunId()}`);
await fs.mkdir(outputDir, { recursive: true });
const providerMode = normalizeQaProviderMode(

View File

@@ -9,6 +9,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import { createQaArtifactRunId } from "../../artifact-run-id.js";
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
import { startQaGatewayChild } from "../../gateway-child.js";
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
@@ -1711,7 +1712,7 @@ export async function runSlackQaLive(params: {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `slack-${Date.now().toString(36)}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `slack-${createQaArtifactRunId()}`);
await fs.mkdir(outputDir, { recursive: true });
const providerMode = normalizeQaProviderMode(

View File

@@ -11,6 +11,7 @@ import {
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import { createQaArtifactRunId } from "../../artifact-run-id.js";
import {
QA_EVIDENCE_FILENAME,
buildLiveTransportEvidenceSummary,
@@ -1805,7 +1806,7 @@ export async function runTelegramQaLive(params: {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `telegram-${Date.now().toString(36)}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `telegram-${createQaArtifactRunId()}`);
await fs.mkdir(outputDir, { recursive: true });
const providerMode = normalizeQaProviderMode(

View File

@@ -15,6 +15,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { z } from "zod";
import { createQaArtifactRunId } from "../../artifact-run-id.js";
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
import { startQaGatewayChild } from "../../gateway-child.js";
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
@@ -3033,7 +3034,7 @@ export async function runWhatsAppQaLive(params: {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `whatsapp-${Date.now().toString(36)}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `whatsapp-${createQaArtifactRunId()}`);
await fs.mkdir(outputDir, { recursive: true });
const providerMode = normalizeQaProviderMode(

View File

@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { OpenClawCrablineChannelDriverSelection } from "crabline";
import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";

View File

@@ -3314,7 +3314,7 @@ describe("qa mock openai server", () => {
const toolPlanOutput = outputItem(await response.json());
expect(toolPlanOutput.type).toBe("function_call");
expect(toolPlanOutput.name).toBe("web_search");
expect(String(toolPlanOutput.arguments)).toContain("denied-input");
expect(String(toolPlanOutput.arguments)).toContain("OPENCLAW_QA_WEB_SEARCH_DENIED_INPUT");
});
it("plans QA subagent handoff calls even when Codex dynamic tools are not in body.tools", async () => {

View File

@@ -5,6 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress";
import { closeQaHttpServer } from "../../bus-server.js";
import { QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY } from "../../qa-web-search-provider.js";
import { writeJson } from "../shared/http-json.js";
type ResponsesInputItem = Record<string, unknown>;
@@ -860,6 +861,9 @@ function extractToolSearchTarget(text: string): string | null {
}
function buildQaToolSearchArgs(targetTool: string, failureMode: boolean): Record<string, unknown> {
if (failureMode && targetTool === "web_search") {
return { query: QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY };
}
if (failureMode) {
return { __qaFailureMode: "denied-input" };
}
@@ -1535,49 +1539,57 @@ function buildToolCallEvents(prompt: string): StreamEvent[] {
function buildReleaseAuditJson() {
return `${JSON.stringify(
{
verified: true,
verified: false,
findings: [
{
id: "REL-GATEWAY-417",
source: "src/gateway/reconnect.ts",
status: "retry jitter verified, resume token fallback still needs manual spot check",
verified: true,
},
{
id: "REL-CHANNEL-238",
source: "src/channels/delivery.ts",
status: "thread replies preserve ordering, root-channel fallback needs handoff note",
verified: true,
},
{
id: "REL-CRON-904",
source: "src/scheduling/cron.ts",
status: "single-run lock verified for restart wakeups",
verified: true,
},
{
id: "REL-MEMORY-552",
source: "src/memory/recall.ts",
status:
"fallback summary survives empty memory search; ranking sample needs second reviewer",
verified: true,
},
{
id: "REL-PLUGIN-319",
source: "src/plugins/runtime.ts",
status: "bundled runtime manifest loads cleanly after restart",
verified: true,
},
{
id: "REL-INSTALL-846",
source: "install/update.ts",
status: "update smoke passed from previous stable tag",
verified: true,
},
{
id: "REL-DOCS-611",
source: "docs/operator-notes.md",
status:
"docs mention reconnect, cron, memory, plugin, and installer checks; channel ordering and UI notes need maintainer handoff",
verified: true,
},
{
id: "REL-UI-BLOCKED",
source: "ui/control-panel.ts",
status: "blocked: source file was referenced by checklist but missing from the fixture",
verified: false,
},
],
},

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { createQaLabWebSearchProvider as createQaLabWebSearchContractProvider } from "../web-search-contract-api.js";
import {
createQaLabWebSearchProvider,
QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY,
QA_LAB_WEB_SEARCH_PROVIDER_ID,
} from "./qa-web-search-provider.js";
@@ -55,4 +56,16 @@ describe("qa-lab web search provider", () => {
await expect(tool.execute({ __qaFailureMode: "denied-input" })).rejects.toThrow(/query/i);
});
it("keeps the QA failure sentinel as a deterministic tool failure", async () => {
const provider = createQaLabWebSearchProvider();
const tool = provider.createTool({});
if (!tool) {
throw new Error("expected QA Lab web search tool");
}
await expect(tool.execute({ query: QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY })).rejects.toThrow(
/denied input sentinel/i,
);
});
});

View File

@@ -9,6 +9,7 @@ import {
} from "openclaw/plugin-sdk/provider-web-search";
export const QA_LAB_WEB_SEARCH_PROVIDER_ID = "qa-lab-search";
export const QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY = "OPENCLAW_QA_WEB_SEARCH_DENIED_INPUT";
const QaLabWebSearchSchema = {
type: "object",
@@ -64,6 +65,9 @@ export function createQaLabWebSearchProvider(): WebSearchProviderPlugin {
parameters: QaLabWebSearchSchema,
execute: async (args) => {
const query = readStringParam(args, "query", { required: true });
if (query === QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY) {
throw new Error("QA Lab web_search denied input sentinel");
}
const count =
readPositiveIntegerParam(args, "count", {
max: MAX_SEARCH_COUNT,

View File

@@ -161,6 +161,22 @@ describe("qa run config", () => {
expect(outputDir.startsWith(path.join(repoRoot, ".artifacts", "qa-e2e", "lab-"))).toBe(true);
});
it("keeps generated run output dirs unique within the same millisecond", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-06-23T07:30:00.000Z"));
const repoRoot = path.resolve("/tmp/openclaw-repo");
const first = createQaRunOutputDir(repoRoot);
const second = createQaRunOutputDir(repoRoot);
expect(first).not.toBe(second);
expect(path.basename(first)).toMatch(/^lab-2026-06-23-073000000Z-[0-9a-f]{8}$/u);
expect(path.basename(second)).toMatch(/^lab-2026-06-23-073000000Z-[0-9a-f]{8}$/u);
} finally {
vi.useRealTimers();
}
});
it("prefers the Codex OAuth default when the runtime resolver says it is available", () => {
defaultQaRuntimeModelForMode.mockImplementation((mode, options) =>
mode === "live-frontier"

View File

@@ -1,4 +1,5 @@
// Qa Lab helper module supports run config behavior.
import { randomUUID } from "node:crypto";
import path from "node:path";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { defaultQaModelForMode as defaultStaticQaModelForMode } from "./model-selection.js";
@@ -132,5 +133,5 @@ export function createIdleQaRunnerSnapshot(scenarios: QaSeedScenario[]): QaLabRu
export function createQaRunOutputDir(baseDir = process.cwd()) {
const stamp = new Date().toISOString().replaceAll(":", "").replaceAll(".", "").replace("T", "-");
return path.join(baseDir, ".artifacts", "qa-e2e", `lab-${stamp}`);
return path.join(baseDir, ".artifacts", "qa-e2e", `lab-${stamp}-${randomUUID().slice(0, 8)}`);
}

View File

@@ -10,7 +10,7 @@ export const QA_MATURITY_TAXONOMY_PATH = "taxonomy.yaml";
export const QA_MATURITY_SCORES_PATH = "qa/maturity-scores.yaml";
export const QA_MATURITY_SCORE_KEYS = ["quality", "completeness"] as const;
export const QA_MATURITY_SCORE_LABELS = [
"Lovable",
"Clawesome",
"Stable",
"Beta",
"Alpha",

View File

@@ -53,10 +53,13 @@ describe("resolveQaSelfCheckOutputPath", () => {
).toBe("/tmp/custom/self-check.md");
});
it("anchors default self-check reports under the provided repo root", () => {
it("anchors default self-check reports under unique files in the provided repo root", () => {
const repoRoot = path.resolve("/tmp/openclaw-repo");
expect(resolveQaSelfCheckOutputPath({ repoRoot })).toBe(
path.join(repoRoot, ".artifacts", "qa-e2e", "self-check.md"),
);
const firstPath = resolveQaSelfCheckOutputPath({ repoRoot });
const secondPath = resolveQaSelfCheckOutputPath({ repoRoot });
expect(path.dirname(firstPath)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
expect(path.basename(firstPath)).toMatch(/^self-check-[a-z0-9]+-[a-f0-9]{8}\.md$/u);
expect(secondPath).not.toBe(firstPath);
});
});

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { renderQaMarkdownReport } from "openclaw/plugin-sdk/qa-runtime";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import type { QaBusState } from "./bus-state.js";
import { createQaTransportAdapter, type QaTransportId } from "./qa-transport-registry.js";
import { runQaScenario, type QaScenarioResult } from "./scenario.js";
@@ -27,7 +28,7 @@ export function resolveQaSelfCheckOutputPath(params?: { outputPath?: string; rep
return params.outputPath;
}
const repoRoot = path.resolve(params?.repoRoot ?? process.cwd());
return path.join(repoRoot, ".artifacts", "qa-e2e", "self-check.md");
return path.join(repoRoot, ".artifacts", "qa-e2e", `self-check-${createQaArtifactRunId()}.md`);
}
export async function runQaSelfCheckAgainstState(params: {

View File

@@ -28,23 +28,19 @@ async function makeTempRepo(prefix: string) {
return repoRoot;
}
async function writeEvidence(pathLocal: string) {
await fs.mkdir(path.dirname(pathLocal), { recursive: true });
await fs.writeFile(
pathLocal,
`${JSON.stringify(
{
kind: "openclaw.qa.evidence-summary",
schemaVersion: 2,
generatedAt: "2026-06-14T00:00:00.000Z",
evidenceMode: "full",
entries: [],
},
null,
2,
)}\n`,
"utf8",
);
async function writeEvidence(pathLocal: string, writeFile = true) {
const evidence = {
kind: "openclaw.qa.evidence-summary",
schemaVersion: 2,
generatedAt: "2026-06-14T00:00:00.000Z",
evidenceMode: "full",
entries: [],
};
if (writeFile) {
await fs.mkdir(path.dirname(pathLocal), { recursive: true });
await fs.writeFile(pathLocal, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
}
return evidence;
}
describe("qa suite runtime launcher", () => {
@@ -52,12 +48,17 @@ describe("qa suite runtime launcher", () => {
runQaFlowSuite.mockReset();
runQaTestFileScenarios.mockReset();
runQaFlowSuite.mockImplementation(
async (params: { outputDir?: string; scenarioIds?: string[] } | undefined) => {
async (
params:
| { outputDir?: string; scenarioIds?: string[]; writeEvidenceFile?: boolean }
| undefined,
) => {
const outputDir = params?.outputDir ?? "/tmp/qa-flow";
const evidencePath = path.join(outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
const evidence = await writeEvidence(evidencePath, params?.writeEvidenceFile);
const scenarioIds = params?.scenarioIds ?? ["channel-chat-baseline"];
return {
evidence,
outputDir,
evidencePath,
reportPath: path.join(outputDir, "qa-suite-report.md"),
@@ -76,14 +77,16 @@ describe("qa suite runtime launcher", () => {
async (params: {
outputDir: string;
scenarios: Array<{ id: string; execution: { kind: "script" | "vitest" | "playwright" } }>;
writeEvidenceFile?: boolean;
}) => {
const [scenario] = params.scenarios;
if (!scenario) {
throw new Error("expected scenario");
}
const evidencePath = path.join(params.outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
const evidence = await writeEvidence(evidencePath, params.writeEvidenceFile);
return {
evidence,
outputDir: params.outputDir,
executionKind: scenario.execution.kind,
evidencePath,
@@ -247,15 +250,27 @@ describe("qa suite runtime launcher", () => {
expect.objectContaining({
outputDir: path.join(outputDir, "flow"),
scenarioIds: ["channel-chat-baseline"],
writeEvidenceFile: false,
}),
);
expect(runQaTestFileScenarios).toHaveBeenCalledWith(
expect.objectContaining({
outputDir: path.join(outputDir, "playwright"),
writeEvidenceFile: false,
}),
);
await expect(fs.access(path.join(outputDir, "qa-suite-summary.json"))).resolves.toBeUndefined();
await expect(fs.access(path.join(outputDir, "qa-evidence.json"))).resolves.toBeUndefined();
await expect(fs.access(path.join(outputDir, "flow", "qa-evidence.json"))).rejects.toMatchObject(
{
code: "ENOENT",
},
);
await expect(
fs.access(path.join(outputDir, "playwright", "qa-evidence.json")),
).rejects.toMatchObject({
code: "ENOENT",
});
const summary = JSON.parse(
await fs.readFile(path.join(outputDir, "qa-suite-summary.json"), "utf8"),
) as {

View File

@@ -175,6 +175,7 @@ async function runQaTestFileSuiteFromRuntime(params: {
providerMode,
primaryModel,
scenarios: params.scenarios,
writeEvidenceFile: runParams?.writeEvidenceFile,
});
}
@@ -292,6 +293,13 @@ async function readQaSuiteEvidenceSummary(evidencePath: string) {
return validateQaEvidenceSummaryJson(JSON.parse(await fs.readFile(evidencePath, "utf8")));
}
async function resolveQaSuiteResultEvidenceSummary(result: {
evidence?: QaEvidenceSummaryJson;
evidencePath: string;
}) {
return result.evidence ?? (await readQaSuiteEvidenceSummary(result.evidencePath));
}
function mergeQaEvidenceSummaries(params: {
evidenceSummaries: readonly QaEvidenceSummaryJson[];
generatedAt: string;
@@ -489,6 +497,7 @@ async function runUnifiedQaSuite(params: {
flowPartitions.length === 1
? suitePartitionOutputDir(outputDir, "flow")
: flowSuitePartitionOutputDir(outputDir, partition.kind),
writeEvidenceFile: false,
providerMode,
primaryModel,
alternateModel,
@@ -512,7 +521,7 @@ async function runUnifiedQaSuite(params: {
}
}
return {
evidenceSummaries: [await readQaSuiteEvidenceSummary(result.evidencePath)],
evidenceSummaries: [await resolveQaSuiteResultEvidenceSummary(result)],
scenarioResults,
};
},
@@ -530,13 +539,14 @@ async function runUnifiedQaSuite(params: {
runParams: {
...params.runParams,
outputDir: suitePartitionOutputDir(outputDir, kind),
writeEvidenceFile: false,
providerMode,
primaryModel,
scenarioIds: testFileScenarios.map((scenario) => scenario.id),
},
scenarios: testFileScenarios,
});
testFileEvidenceSummaries.push(await readQaSuiteEvidenceSummary(result.evidencePath));
testFileEvidenceSummaries.push(await resolveQaSuiteResultEvidenceSummary(result));
testFileScenarioResults.push(
...result.results.map((scenarioResult) => ({
scenarioId: scenarioResult.scenario.id,

View File

@@ -86,6 +86,22 @@ describe("qa suite planning helpers", () => {
}
});
it("creates unique default suite output dirs inside the repo root", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-suite-default-root-"));
try {
const firstDir = await resolveQaSuiteOutputDir(repoRoot);
const secondDir = await resolveQaSuiteOutputDir(repoRoot);
expect(path.dirname(firstDir)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
expect(path.basename(firstDir)).toMatch(/^suite-[a-z0-9]+-[a-f0-9]{8}$/u);
expect(secondDir).not.toBe(firstDir);
await expect(lstat(firstDir).then((stats) => stats.isDirectory())).resolves.toBe(true);
await expect(lstat(secondDir).then((stats) => stats.isDirectory())).resolves.toBe(true);
} finally {
await rm(repoRoot, { recursive: true, force: true });
}
});
it("rejects symlinked suite output dirs that escape the repo root", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-suite-root-"));
const outsideRoot = await mkdtemp(path.join(os.tmpdir(), "qa-suite-outside-"));

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