Compare commits

..

512 Commits

Author SHA1 Message Date
Peter Steinberger
c535f6d3f3 docs: document daemon shared helper contracts 2026-06-01 13:15:52 -04:00
Peter Steinberger
baaf6f8d1c docs: document CLI startup runtime helpers 2026-06-01 13:13:41 -04:00
Peter Steinberger
01e7733deb docs: document config set helper contracts 2026-06-01 13:12:12 -04:00
Peter Steinberger
66425c3406 docs: document CLI utility helper contracts 2026-06-01 13:10:43 -04:00
Peter Steinberger
f6d05d604b docs: document CLI banner contracts 2026-06-01 13:09:39 -04:00
Peter Steinberger
92a676fc71 docs: document CLI completion contracts 2026-06-01 13:08:22 -04:00
Peter Steinberger
4e5835c038 docs: document CLI command policy contracts 2026-06-01 13:07:15 -04:00
Peter Steinberger
c4686e50c2 docs: document CLI invocation display helpers 2026-06-01 13:05:49 -04:00
Peter Steinberger
3764ff6b84 docs: document CLI argv helpers 2026-06-01 13:04:38 -04:00
Peter Steinberger
242995a3af docs: document CLI root option helpers 2026-06-01 13:03:06 -04:00
Peter Steinberger
4ce258ae9b docs: document approval handler runtime contracts 2026-06-01 13:01:14 -04:00
Peter Steinberger
b132ca0183 docs: document command carrier exports 2026-06-01 12:59:09 -04:00
Peter Steinberger
4c556fc09f docs: document offsetless datetime parsing 2026-06-01 12:57:30 -04:00
Peter Steinberger
98e05f8754 docs: document duration formatter contract 2026-06-01 12:55:42 -04:00
Peter Steinberger
05df67dd70 docs: document lsof resolver contracts 2026-06-01 12:53:09 -04:00
Peter Steinberger
30d6a53681 docs: document port diagnostics contracts 2026-06-01 12:51:27 -04:00
Peter Steinberger
3c25345fd5 docs: document binary detection contract 2026-06-01 12:49:41 -04:00
Peter Steinberger
11d7a51844 docs: document git root contracts 2026-06-01 12:48:01 -04:00
Peter Steinberger
38da14ac55 docs: document package json contracts 2026-06-01 12:46:27 -04:00
Peter Steinberger
eb7ec0e620 docs: document runtime status contract 2026-06-01 12:44:36 -04:00
Peter Steinberger
9fe0862e4b docs: document WebSocket raw data contract 2026-06-01 12:43:10 -04:00
Peter Steinberger
4667b7cca2 docs: document secure random contracts 2026-06-01 12:41:28 -04:00
Peter Steinberger
a8b695a944 docs: document JSON byte contracts 2026-06-01 12:40:06 -04:00
Peter Steinberger
ba6af56f48 docs: document path prepend contracts 2026-06-01 12:38:42 -04:00
Peter Steinberger
412fb4b32e docs: document port probe contract 2026-06-01 12:37:14 -04:00
Peter Steinberger
6fa07e83bd docs: document install target contracts 2026-06-01 12:35:45 -04:00
Peter Steinberger
889fc5fa91 docs: document update channel contracts 2026-06-01 12:34:00 -04:00
Peter Steinberger
9bfb81d64e docs: document update check contracts 2026-06-01 12:31:39 -04:00
Peter Steinberger
08b953d111 docs: document update restart sentinel handoff 2026-06-01 12:28:52 -04:00
Peter Steinberger
d10427f45c docs: document restart sentinel continuations 2026-06-01 12:27:22 -04:00
Peter Steinberger
c6a49588aa docs: document entrypoint detection 2026-06-01 12:25:37 -04:00
Peter Steinberger
187cfdf385 docs: document npm registry spec contracts 2026-06-01 12:24:09 -04:00
Peter Steinberger
724bdbb1bd docs: document secret input normalization 2026-06-01 12:22:48 -04:00
Peter Steinberger
5a6a6db65d docs: document inline directive helpers 2026-06-01 12:21:36 -04:00
Peter Steinberger
daf2b631e0 docs: document delivery context helpers 2026-06-01 12:20:18 -04:00
Peter Steinberger
ba9993229f docs: document message channel helpers 2026-06-01 12:18:51 -04:00
Peter Steinberger
021252e214 docs: document shared utils contracts 2026-06-01 12:17:31 -04:00
Peter Steinberger
5c00de15f5 docs: document ssrf contracts 2026-06-01 12:16:19 -04:00
Peter Steinberger
ba97b0484d docs: document local-origin bypass contracts 2026-06-01 12:15:13 -04:00
Peter Steinberger
e2c5e19876 docs: document proxy fetch contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
b3d9bf8f55 docs: document runtime fetch contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
59e8c8a166 docs: document fetch compatibility contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
a8019540bd docs: document web push contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
55a44bb7ae docs: document Homebrew resolution contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
2821654f38 docs: document gateway discovery targets 2026-06-01 12:13:49 -04:00
Peter Steinberger
22855ab94e docs: document gateway process contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
10eec59169 docs: document host env security contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
affa47c13b docs: document restart sentinel contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
d39becd739 docs: document port diagnostics contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
eb8a1b6877 docs: document heartbeat cooldown contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
f3608d08b4 docs: document OpenAI tool choice contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
e6dec97e75 docs: document temp directory contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
80e2bfbd16 docs: document executable path contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
a5c8558689 docs: document MCP loopback contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
aca296e92b docs: document infra utility contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
dba817386a docs: document npm registry spec contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
f8d93befac docs: document gateway shared auth contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
887ebc95fa docs: document gateway method scope contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
54ebb9d08f docs: document agent stream safety contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
ce4f471206 docs: document CLI session reuse contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
ae606118b4 docs: document node pairing surface contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
30c0c1352f docs: document node pairing persistence contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
94a92a78f4 docs: document gateway security helper contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
e4539d2756 docs: document gateway update mutation contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
42f1c9e3d4 docs: document node wake rpc contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
ade91600bc docs: document node approval pairing contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
5fe49e3f9d docs: document gateway node registry contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
518eff785e docs: document gateway startup runtime contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
f1635142d8 docs: document gateway startup config contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
ee39f5d282 docs: document gateway talk session registry contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
17b35107a9 docs: document gateway talk relay contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
3d798f4e8e docs: document gateway session history contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
81dcefe261 docs: document gateway subagent lineage contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
c847c89dbb docs: document session lifecycle event contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
690d79b32a docs: document gateway active session shutdown contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
593d97b9ca docs: document gateway transcript ownership contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
5e88b8b5af docs: document gateway session patch contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
c57c27016a docs: document gateway auth resolution contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
1f0c7847a6 docs: document gateway auth secret materialization 2026-06-01 12:13:48 -04:00
Peter Steinberger
8a475b6631 docs: document gateway explicit connection policy 2026-06-01 12:13:48 -04:00
Peter Steinberger
d48a8e53bb docs: document control ui routing contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
845851cc78 docs: document gateway utility contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
03abdfea2c docs: document gateway run session key contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
eb16425492 docs: document node invoke sanitizer contract 2026-06-01 12:13:48 -04:00
Peter Steinberger
64fcdba480 docs: document gateway rate limit contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
ca0e791b4a docs: document gateway request context contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
76b69cfecb docs: document gateway shared auth generation 2026-06-01 12:13:48 -04:00
Peter Steinberger
080b453592 docs: document gateway session key contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
fa4f5044f5 docs: document gateway credential secret contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
e144252720 docs: document gateway method contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
646522aaa3 docs: document plugin main api contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
10c39f6da5 docs: document plugin node service contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
c07ae4e067 docs: document plugin command gateway contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
db0d6d750f docs: document speech realtime provider contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
e51e9c327c docs: document provider plugin tail contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
c057a31564 docs: document provider plugin identity contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
59cb1a2be3 docs: document provider setup prompt contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
ece65f4e24 docs: document provider runtime hook contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
266380f6c0 docs: document provider catalog type contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
84914e4dc8 docs: document provider auth method contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
5f51677454 docs: document provider auth type contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
39fa88a1e4 docs: document migration provider type contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
8a42725d38 docs: document migrate prompt option APIs 2026-06-01 12:13:47 -04:00
Peter Steinberger
ed4f308d28 docs: document migrate command entrypoint 2026-06-01 12:13:47 -04:00
Peter Steinberger
e58cba2797 docs: document migrate provider context helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
088d228e71 docs: document migrate output helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
cb55aa2ab1 docs: document migrate selection helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
1705b12dea docs: document plugin SDK migration runtime 2026-06-01 12:13:47 -04:00
Peter Steinberger
a605c11b6f docs: document plugin SDK migration helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
d31966726b docs: document provider tool schema compatibility 2026-06-01 12:13:47 -04:00
Peter Steinberger
9e45e0c9b6 docs: document outbound text chunking 2026-06-01 12:13:47 -04:00
Peter Steinberger
c9f51ad18d docs: document channel route identity helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
a5b42a7500 docs: document inbound debounce policy 2026-06-01 12:13:47 -04:00
Peter Steinberger
0cff3edb56 docs: document channel config matching helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
fb756242be docs: document plugin host cleanup timeout 2026-06-01 12:13:47 -04:00
Peter Steinberger
fe384065fe docs: document provider catalog helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
a8bbff2f9e docs: document channel model override resolution 2026-06-01 12:13:47 -04:00
Peter Steinberger
cceb080869 docs: document channel config helper contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
e87e873017 docs: document account snapshot field projection 2026-06-01 12:13:47 -04:00
Peter Steinberger
7764e91417 docs: document thread binding policy helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
535616c292 docs: document channel target parsing contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
62eb7259c3 docs: document direct dm access bridge 2026-06-01 12:13:47 -04:00
Peter Steinberger
e5d7cf2efc docs: document provider catalog text projection 2026-06-01 12:13:47 -04:00
Peter Steinberger
ed43f9090d docs: document gateway startup plugin scopes 2026-06-01 12:13:47 -04:00
Peter Steinberger
e634c7459e docs: document embedding provider runtime lookup 2026-06-01 12:13:47 -04:00
Peter Steinberger
2ad26392c5 docs: document memory embedding provider runtime 2026-06-01 12:13:47 -04:00
Peter Steinberger
378146c9bc docs: document memory runtime helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
d67ff3e041 docs: document bundle config helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
db2df9dd79 docs: document plugin config state 2026-06-01 12:13:47 -04:00
Peter Steinberger
d43ba91710 docs: document plugin activation context 2026-06-01 12:13:47 -04:00
Peter Steinberger
d6145ad4c2 docs: document trusted tool policy flow 2026-06-01 12:13:47 -04:00
Peter Steinberger
0c3b71ba23 docs: document provider install catalog 2026-06-01 12:13:47 -04:00
Peter Steinberger
d58a649a33 docs: document web search provider helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
c41b710ae9 docs: document web fetch provider helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
8c3e7eddfd docs: document web provider resolution 2026-06-01 12:13:47 -04:00
Peter Steinberger
09d8eae1e2 docs: document manifest model suppression 2026-06-01 12:13:47 -04:00
Peter Steinberger
786d5c1042 docs: document manifest owner policy 2026-06-01 12:13:47 -04:00
Peter Steinberger
fd88ce0039 docs: document manifest tool availability 2026-06-01 12:13:47 -04:00
Peter Steinberger
b487a2dfbb docs: document plugin scheduled turn contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
1802ed180a docs: document uninstall helper contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
0c790251e1 docs: document effective plugin id resolution 2026-06-01 12:13:47 -04:00
Peter Steinberger
52ad1b26ef docs: document optional plugin manifest rule 2026-06-01 12:13:46 -04:00
Peter Steinberger
88cefa4d3f docs: document web search credential detection 2026-06-01 12:13:46 -04:00
Peter Steinberger
ef6e5aa961 docs: document hook agent channel context 2026-06-01 12:13:46 -04:00
Peter Steinberger
2c04aea604 docs: document host hook cleanup timeout 2026-06-01 12:13:46 -04:00
Peter Steinberger
1cb93fee3e docs: document agent tool result middleware helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
247a67320a docs: document plugin source display 2026-06-01 12:13:46 -04:00
Peter Steinberger
02aee615de docs: document plugin control plane context 2026-06-01 12:13:46 -04:00
Peter Steinberger
ba7e68b271 docs: document plugin scope helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
f3b723fd9a docs: document provider auth input mode 2026-06-01 12:13:46 -04:00
Peter Steinberger
58a5c1e512 docs: document installed plugin index store paths 2026-06-01 12:13:46 -04:00
Peter Steinberger
43212e574c docs: document plugin HTTP path normalization 2026-06-01 12:13:46 -04:00
Peter Steinberger
95bd60001d docs: document provider config owner hints 2026-06-01 12:13:46 -04:00
Peter Steinberger
ac206252fa docs: document plugin conversation binding runtime 2026-06-01 12:13:46 -04:00
Peter Steinberger
7ad843234f docs: document interactive registry helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
e52c366a07 docs: document plugin tool descriptor cache 2026-06-01 12:13:46 -04:00
Peter Steinberger
681a0863f1 docs: document plugin cache primitives 2026-06-01 12:13:46 -04:00
Peter Steinberger
ee23d27ce2 docs: document plugin root cache inputs 2026-06-01 12:13:46 -04:00
Peter Steinberger
175db3e84d docs: document package entrypoint candidates 2026-06-01 12:13:46 -04:00
Peter Steinberger
07693abbca docs: document bundled public surface resolution 2026-06-01 12:13:46 -04:00
Peter Steinberger
27359abe70 docs: document host hook JSON boundary 2026-06-01 12:13:46 -04:00
Peter Steinberger
41ea42f864 docs: document setup wizard flow 2026-06-01 12:13:46 -04:00
Peter Steinberger
30f3fd75b1 docs: clarify configured binding matching 2026-06-01 12:13:46 -04:00
Peter Steinberger
4518c7f673 docs: document stateful target driver contract 2026-06-01 12:13:46 -04:00
Peter Steinberger
39b93679b5 docs: document configured binding consumer contract 2026-06-01 12:13:46 -04:00
Peter Steinberger
8eb8eef88e docs: document configured binding skips 2026-06-01 12:13:46 -04:00
Peter Steinberger
8b02c78f46 docs: document chat target prefix helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
a181224d0a docs: document status approval utilities 2026-06-01 12:13:46 -04:00
Peter Steinberger
ef8f96aeca docs: document channel module loader 2026-06-01 12:13:46 -04:00
Peter Steinberger
5f143b6361 docs: document config write ambiguity 2026-06-01 12:13:46 -04:00
Peter Steinberger
db4fb64e2f docs: document group policy warning helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
23426e4d26 docs: document channel utility entrypoints 2026-06-01 12:13:46 -04:00
Peter Steinberger
6fd7ffd4c4 docs: document status issue helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
962cae0bf9 docs: document setup wizard type contracts 2026-06-01 12:13:46 -04:00
Peter Steinberger
c2364779e0 docs: document channel adapter type contracts 2026-06-01 12:13:46 -04:00
Peter Steinberger
a17b95e2dc docs: document channel plugin type contracts 2026-06-01 12:13:46 -04:00
Peter Steinberger
2987e9bc82 docs: document outbound config types 2026-06-01 12:13:46 -04:00
Peter Steinberger
9011a31d56 docs: document setup wizard helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
55f124ed01 docs: document setup promotion helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
2a42a0e2fe docs: document bundled read helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
6a8090b7d8 docs: document approval config schema helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
88c1abb9b5 docs: document dm pairing helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
a9f3e35813 docs: document channel runtime helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
39b3364ae5 docs: document media helper APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
a9176b3e3c docs: document threading target helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
df4512571f docs: document channel state helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
ba95ba46da docs: document channel catalog helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
30678b2812 docs: document bundled channel loader 2026-06-01 12:13:45 -04:00
Peter Steinberger
6477e3c75a docs: document channel registry helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
69763d0d0e docs: document outbound loader APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
bd3683052d docs: document presentation limit invariants 2026-06-01 12:13:45 -04:00
Peter Steinberger
061cddc829 docs: document outbound helper APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
112a78b070 docs: document message action helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
c665944276 docs: document message tool APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
fd883d2eb4 docs: document message action discovery 2026-06-01 12:13:45 -04:00
Peter Steinberger
69583e9f15 docs: document stateful target builtins 2026-06-01 12:13:45 -04:00
Peter Steinberger
6234092a66 docs: document configured binding consumers 2026-06-01 12:13:45 -04:00
Peter Steinberger
d69a72f98e docs: document binding public APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
bfaaac79b6 docs: document stateful target drivers 2026-06-01 12:13:45 -04:00
Peter Steinberger
bc36755609 docs: document configured binding registry 2026-06-01 12:13:45 -04:00
Peter Steinberger
a1223825a2 docs: document binding routing helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
c05687aa34 docs: document directory adapter helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
d032288a77 docs: document directory config helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
7e71a0b4a4 docs: document setup wizard prompt helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
187dd18674 docs: document setup wizard policy helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
f2e6163788 docs: document setup wizard helper parsing 2026-06-01 12:13:45 -04:00
Peter Steinberger
f54ee04c05 docs: document setup wizard delegation 2026-06-01 12:13:45 -04:00
Peter Steinberger
d2b8293236 docs: document setup promotion keys 2026-06-01 12:13:45 -04:00
Peter Steinberger
10c99178c6 docs: document setup promotion helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
9fdc022ad0 docs: document plugin config helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
bbbb3ad27b docs: document config write wrappers 2026-06-01 12:13:45 -04:00
Peter Steinberger
824abf5fa1 docs: document config write policy helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
3de4e9e00f docs: document channel account helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
bda364fb74 docs: document account action gate 2026-06-01 12:13:45 -04:00
Peter Steinberger
0785082b8d docs: document target resolver helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
80dd8c390e docs: document channel route helper invariants 2026-06-01 12:13:45 -04:00
Peter Steinberger
df637ed2f8 docs: document channel target parser contracts 2026-06-01 12:13:45 -04:00
Peter Steinberger
69b1b3fdd3 docs: document chat target prefix helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
6ae61ffaef docs: document allowlist matcher invariants 2026-06-01 12:13:45 -04:00
Peter Steinberger
8b9b4ce082 docs: document allowlist resolution helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
75223a869d docs: document plugin allow-from helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
e3514e8d71 docs: document channel gating helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
4088a58674 docs: document channel ingress sender gates 2026-06-01 12:13:44 -04:00
Peter Steinberger
ae3f41f6c3 docs: document channel ingress allowlist helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
52e1d14e94 docs: document channel ingress identity helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
0b41911c70 docs: document channel ingress runtime invariants 2026-06-01 12:13:44 -04:00
Peter Steinberger
c3fa7f2148 docs: document channel turn helper invariants 2026-06-01 12:13:44 -04:00
Peter Steinberger
e20d87cfc3 docs: document delivery result compatibility 2026-06-01 12:13:44 -04:00
Peter Steinberger
fb94dac19d docs: document durable reply delivery 2026-06-01 12:13:44 -04:00
Peter Steinberger
c959f82d5c docs: document channel turn kernel invariants 2026-06-01 12:13:44 -04:00
Peter Steinberger
8502427352 docs: document inbound reply dispatch delivery 2026-06-01 12:13:44 -04:00
Peter Steinberger
853e32fef3 docs: document reply prefix context 2026-06-01 12:13:44 -04:00
Peter Steinberger
bbee5e456c docs: document channel typing internals 2026-06-01 12:13:44 -04:00
Peter Steinberger
e4e3a8dbc4 docs: document channel typing lifecycle 2026-06-01 12:13:44 -04:00
Peter Steinberger
596ee3c2a8 docs: document channel reply pipeline contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
e836cd8b71 docs: document channel inbound compatibility 2026-06-01 12:13:44 -04:00
Peter Steinberger
3f313b0ca9 docs: document channel config helper contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
7759b44638 docs: document channel policy helper contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
fa3e1067a6 docs: document channel send result helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
95eaf32b61 docs: document channel route helper contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
902c2d685c docs: document status helper contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
e5d1ce4f84 docs: document webhook target helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
1f6058f495 docs: document webhook memory guards 2026-06-01 12:13:44 -04:00
Peter Steinberger
95a84d98e4 docs: document webhook request guards 2026-06-01 12:13:44 -04:00
Peter Steinberger
3534f68068 docs: document plugin runtime store 2026-06-01 12:13:44 -04:00
Peter Steinberger
9d71225d39 docs: document agent harness task runtime 2026-06-01 12:13:44 -04:00
Peter Steinberger
02043fe89b docs: document agent harness task runtime scope 2026-06-01 12:13:44 -04:00
Peter Steinberger
32abf56791 docs: document detached task runtime state 2026-06-01 12:13:44 -04:00
Peter Steinberger
750bbdf09f docs: document detached task runtime dispatch 2026-06-01 12:13:44 -04:00
Peter Steinberger
afe95da1f7 docs: document detached task runtime contract 2026-06-01 12:13:44 -04:00
Peter Steinberger
da42fb0a81 docs: document task registry types 2026-06-01 12:13:44 -04:00
Peter Steinberger
ba65ce48a0 docs: document task registry summaries 2026-06-01 12:13:44 -04:00
Peter Steinberger
d7a35e7079 docs: document task domain view mappers 2026-06-01 12:13:44 -04:00
Peter Steinberger
8016ce9999 docs: document task status helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
1f9a80ca61 docs: document required completion contract 2026-06-01 12:13:44 -04:00
Peter Steinberger
a21a7ee883 docs: document task executor policy helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
1d0f43a709 docs: document blocked taskflow retry helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
f1ecfbe08f docs: document taskflow executor helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
301c84204d docs: document task owner access helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
ca78f99c96 docs: document taskflow owner access helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
1d4f70a8cd docs: document managed taskflow runtime guards 2026-06-01 12:13:44 -04:00
Peter Steinberger
a8113c72f6 docs: document managed taskflow runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
4f48cd1413 docs: document plugin runtime task DTOs 2026-06-01 12:13:43 -04:00
Peter Steinberger
843dfafaa8 docs: document plugin runtime task types 2026-06-01 12:13:43 -04:00
Peter Steinberger
0db557a6dc docs: document plugin runtime llm types 2026-06-01 12:13:43 -04:00
Peter Steinberger
d598a239ca docs: document plugin runtime core types 2026-06-01 12:13:43 -04:00
Peter Steinberger
037cf3ed86 docs: document plugin runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
89203a47dd docs: document runtime context registry 2026-06-01 12:13:43 -04:00
Peter Steinberger
0160c650e6 docs: document channel runtime surface types 2026-06-01 12:13:43 -04:00
Peter Steinberger
d92e91373c docs: document channel runtime contexts 2026-06-01 12:13:43 -04:00
Peter Steinberger
0f4eedd32a docs: document approval handler bootstrap 2026-06-01 12:13:43 -04:00
Peter Steinberger
a1a836f2bb docs: document exec approval channel runtime 2026-06-01 12:13:43 -04:00
Peter Steinberger
c4a8e1be9b docs: document exec approval runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
7a070e6ca2 docs: document approval native runtime helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
904f84df05 docs: document approval route coordinator 2026-06-01 12:13:43 -04:00
Peter Steinberger
fbb050028d docs: document exec approval surface states 2026-06-01 12:13:43 -04:00
Peter Steinberger
fb78550cbb docs: document approval request filters 2026-06-01 12:13:43 -04:00
Peter Steinberger
96e9d73a64 docs: document exec approval display sanitizers 2026-06-01 12:13:43 -04:00
Peter Steinberger
365b63de19 docs: document exec approval session targets 2026-06-01 12:13:43 -04:00
Peter Steinberger
410bf91087 docs: document approval account binding helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
4d9d9d3e42 docs: document approval gateway resolver 2026-06-01 12:13:43 -04:00
Peter Steinberger
c1d56cb9b3 docs: document approval error detection 2026-06-01 12:13:43 -04:00
Peter Steinberger
6b8fd7a3cd docs: document approval turn-source routing 2026-06-01 12:13:43 -04:00
Peter Steinberger
5f9926b7fd docs: document approval view model builders 2026-06-01 12:13:43 -04:00
Peter Steinberger
4becd8dbfe docs: document approval view model unions 2026-06-01 12:13:43 -04:00
Peter Steinberger
a8a2be4f33 docs: document approval handler adapters 2026-06-01 12:13:43 -04:00
Peter Steinberger
d688f72752 docs: document lazy approval runtime adapter 2026-06-01 12:13:43 -04:00
Peter Steinberger
19d0073e5f docs: document approval handler runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
74eacd9742 docs: document approval native runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
22518f9820 docs: document approval native target keys 2026-06-01 12:13:43 -04:00
Peter Steinberger
3f04d320ad docs: document approval native delivery planner 2026-06-01 12:13:43 -04:00
Peter Steinberger
31420c16e1 docs: document approval native route notices 2026-06-01 12:13:43 -04:00
Peter Steinberger
4276ba3b60 docs: document approval display path helper 2026-06-01 12:13:43 -04:00
Peter Steinberger
a7b2cd5be2 docs: document exec approval surface helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
1cf7ea66e5 docs: document safe-bin runtime policy 2026-06-01 12:13:43 -04:00
Peter Steinberger
97026eab56 docs: document safe-bin argv validator 2026-06-01 12:13:43 -04:00
Peter Steinberger
6fa4e7ceb0 docs: document safe-bin semantic helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
1fe2d34e01 docs: document safe-bin profile helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
2751480168 docs: document safe-bin trust helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
930b1fc082 docs: document exec allowlist pattern matching 2026-06-01 12:13:43 -04:00
Peter Steinberger
ba9825795b docs: document shell wrapper APIs 2026-06-01 12:13:43 -04:00
Peter Steinberger
3f5bf3ac35 docs: document executable path helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
8197cdcac4 docs: document exec command resolution APIs 2026-06-01 12:13:43 -04:00
Peter Steinberger
38306a7695 docs: document exec command analysis APIs 2026-06-01 12:13:43 -04:00
Peter Steinberger
fd7b7a09d8 docs: document exec allowlist result APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
1752e50eb1 docs: document system-run approval match result 2026-06-01 12:13:42 -04:00
Peter Steinberger
92138702fb docs: document exec approval request config APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
934bf883c1 docs: document exec approval prompt policy 2026-06-01 12:13:42 -04:00
Peter Steinberger
42b0b53efa docs: document exec approval allowlist APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
6d478c61cf docs: document exec approval store APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
4a48b7efe7 docs: document exec approval decision APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
b51b9cbbf4 docs: document dispatch wrapper resolution APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
fa568259e4 docs: document exec approval reply APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
1e26fa770d docs: document dotenv loader contracts 2026-06-01 12:13:42 -04:00
Peter Steinberger
3693916c0c docs: document update channel contracts 2026-06-01 12:13:42 -04:00
Peter Steinberger
5b313c819a docs: document home directory helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
cafea5c3ef docs: document exec approval policy combinators 2026-06-01 12:13:42 -04:00
Peter Steinberger
ab6bc8d109 docs: document exec approval policy APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
db0470aece docs: document error kind contract 2026-06-01 12:13:42 -04:00
Peter Steinberger
cf49d56b74 docs: document exec approval session target 2026-06-01 12:13:42 -04:00
Peter Steinberger
2eeabc4e12 docs: document exec approval surface APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
f0af33a0ff docs: document event session routing APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
20133a58a9 docs: document approval request filters 2026-06-01 12:13:42 -04:00
Peter Steinberger
2a69d62245 docs: document container environment helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
14440032bd docs: document clipboard helper 2026-06-01 12:13:42 -04:00
Peter Steinberger
ea516f648b docs: document clawhub spec parser 2026-06-01 12:13:42 -04:00
Peter Steinberger
b71792767e docs: document channel summary APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
7939c408cf docs: document channel runtime context APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
d017bacc5a docs: document bonjour discovery APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
86ad6d9772 docs: document diagnostic event APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
0dcb3ce86b docs: document channel activity helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
a864715dd0 docs: document agent event contracts 2026-06-01 12:13:42 -04:00
Peter Steinberger
474cdce26c docs: document source reply mirror 2026-06-01 12:13:42 -04:00
Peter Steinberger
5917d8ba45 docs: document outbound session context 2026-06-01 12:13:42 -04:00
Peter Steinberger
1bc4ba9908 docs: document outbound delivery substrate 2026-06-01 12:13:42 -04:00
Peter Steinberger
6f16ee9266 docs: document session binding service 2026-06-01 12:13:42 -04:00
Peter Steinberger
2c7c7bf7f9 docs: document delivery queue storage 2026-06-01 12:13:42 -04:00
Peter Steinberger
7580daf705 docs: document delivery recovery helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
e6c50fd771 docs: document outbound message API 2026-06-01 12:13:42 -04:00
Peter Steinberger
f3aae8a380 docs: document message action runner API 2026-06-01 12:13:42 -04:00
Peter Steinberger
34c5d059aa docs: document agent delivery helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
eab3b1a6a2 docs: document npm install env helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
0edb913c13 docs: document OpenClaw exec env marker 2026-06-01 12:13:41 -04:00
Peter Steinberger
74d98e1fd7 docs: document shell env fallback 2026-06-01 12:13:41 -04:00
Peter Steinberger
082c443015 docs: document binary prerequisite helper 2026-06-01 12:13:41 -04:00
Peter Steinberger
8340b1151c docs: document control UI asset helpers 2026-06-01 12:13:41 -04:00
Peter Steinberger
89daadd478 docs: document channel status issues 2026-06-01 12:13:41 -04:00
Peter Steinberger
3d335e402a docs: document brew resolution 2026-06-01 12:13:41 -04:00
Peter Steinberger
8b5a6bda51 docs: document CLI root option parsing 2026-06-01 12:13:41 -04:00
Peter Steinberger
3ac62666ed docs: document diagnostics timeline 2026-06-01 12:13:41 -04:00
Peter Steinberger
8d5a2f5fa9 docs: document diagnostic LLM content policy 2026-06-01 12:13:41 -04:00
Peter Steinberger
0b5ead9f37 docs: document diagnostic trace context 2026-06-01 12:13:41 -04:00
Peter Steinberger
67e6f9aaba docs: document disk space helpers 2026-06-01 12:13:41 -04:00
Peter Steinberger
983c5a664c docs: document diagnostic flags 2026-06-01 12:13:41 -04:00
Peter Steinberger
b5ee774d68 docs: document embedded mode flag 2026-06-01 12:13:41 -04:00
Peter Steinberger
9676536668 docs: document package manager detection 2026-06-01 12:13:41 -04:00
Peter Steinberger
441a7cf792 docs: document browser open helpers 2026-06-01 12:13:41 -04:00
Peter Steinberger
3a35c1e806 docs: document map size pruning 2026-06-01 12:13:41 -04:00
Peter Steinberger
fbeaf41dc2 docs: document abort signal helper 2026-06-01 12:13:41 -04:00
Peter Steinberger
5590a45e7e docs: document channel activity 2026-06-01 12:13:41 -04:00
Peter Steinberger
9a551d49f3 docs: document approval gateway runtime 2026-06-01 12:13:41 -04:00
Peter Steinberger
fdae22dfea docs: document approval turn source auth 2026-06-01 12:13:41 -04:00
Peter Steinberger
552fa03822 docs: document approval display errors 2026-06-01 12:13:41 -04:00
Peter Steinberger
7e97b42a95 docs: document approval view model 2026-06-01 12:13:41 -04:00
Peter Steinberger
d3b9c5aa3e docs: document approval handler runtime 2026-06-01 12:13:41 -04:00
Peter Steinberger
78172b720b docs: document native approval runtime 2026-06-01 12:13:41 -04:00
Peter Steinberger
9ea00cf73a docs: document native approval delivery 2026-06-01 12:13:41 -04:00
Peter Steinberger
a5013c5574 docs: document native approval route notices 2026-06-01 12:13:41 -04:00
Peter Steinberger
ec7ae4fc9a docs: document approval request binding filters 2026-06-01 12:13:41 -04:00
Peter Steinberger
a9e6e4c5e3 docs: document exec approval channel runtime 2026-06-01 12:13:41 -04:00
Peter Steinberger
92e6368860 docs: document exec approval forwarder 2026-06-01 12:13:41 -04:00
Peter Steinberger
6757a52944 docs: document exec approval reply routing 2026-06-01 12:13:41 -04:00
Peter Steinberger
e011559750 docs: document system-run approval binding 2026-06-01 12:13:41 -04:00
Peter Steinberger
4d63f1ea8c docs: document system-run approval context 2026-06-01 12:13:41 -04:00
Peter Steinberger
36d1080d83 docs: document system-run command contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
d3e8a89959 docs: document exec allowlist entry contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
8b3c5d898a docs: document exec allowlist contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
0c5b962a29 docs: document safe-bin profile contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
1a43a00def docs: document safe-bin trust contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
439904eef4 docs: document safe-bin runtime policy contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
9bd5808fda docs: document exec approval policy snapshots 2026-06-01 12:13:41 -04:00
Peter Steinberger
32bf8712e9 docs: document approval display contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
2466798a08 docs: document executable path contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
797e503fe8 docs: document exec command resolution contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
c7e238a862 docs: document shell inline command scanners 2026-06-01 12:13:40 -04:00
Peter Steinberger
0097a6fb46 docs: document shell wrapper trust contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
8a3bda61e1 docs: document dispatch wrapper trust contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
fd715e0eee docs: document command carrier helper contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
f83ff78bb8 docs: document command explainer span contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
9094429658 docs: document command policy analysis contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
909d521602 docs: document command explanation summary contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
44cef2a792 docs: document command risk carrier contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
0d4dec734d docs: document inline eval detector contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
41aee0429c docs: document TCP port parser contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
e1f1045d46 docs: document port diagnostics contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
a98b9ceb37 docs: document package tag input contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
b6064d1cf5 docs: document update channel contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
fbbf2e6237 docs: document inline option token contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
2f2c77e192 docs: document prototype key guard contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
d64c80daae docs: document environment helper contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
db9ced7b9d docs: document PATH bootstrap contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
11576303ab docs: document fetch header normalization contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
c2f5594555 docs: document secret file compatibility contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
df1e4177e4 docs: document JSON file helper contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
a7d11dd3c7 docs: document HTTP body guard contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
493e4ab2f9 docs: document fixed-window rate limiter contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
0f4fa29d78 docs: document backoff helper contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
f25cbad91b docs: document retry engine contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
713d4cd355 docs: document retry policy contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
2ed5feffef docs: document number coercion thresholds 2026-06-01 12:13:40 -04:00
Peter Steinberger
99d6f0f8c1 docs: document map and numeric option helpers 2026-06-01 12:13:40 -04:00
Peter Steinberger
dcbe7e30d9 docs: document dedupe cache contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
917d24f5c9 docs: document diagnostic error metadata helpers 2026-06-01 12:13:40 -04:00
Peter Steinberger
be922af1e6 docs: document shared error helpers 2026-06-01 12:13:40 -04:00
Peter Steinberger
bc3165647f docs: document reasoning tag partitioner contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
9f36c0f00c docs: document trajectory path helpers 2026-06-01 12:13:40 -04:00
Peter Steinberger
35ee75ec6b docs: document trajectory runtime writer contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
6a14ad3189 docs: document trajectory cleanup guards 2026-06-01 12:13:40 -04:00
Peter Steinberger
3451c03366 docs: document trajectory export contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
5b3d73bc90 docs: document trajectory metadata contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
97620910ef docs: document support bundle writer contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
e58cb30c44 docs: document diagnostic support export contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
158dd20e24 docs: document support log sanitizer invariants 2026-06-01 12:13:40 -04:00
Peter Steinberger
16d872f02d docs: document support redaction contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
3edc65d397 docs: document logging redaction API contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
86260867ad docs: document bounded concurrency helper contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
dd062c655c docs: document fetch timeout abort contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
b8244deddb docs: document gateway client public contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
b1fa7f0e16 docs: document device auth payload contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
eed3735edd docs: document gateway client readiness start rules 2026-06-01 12:13:39 -04:00
Peter Steinberger
9d1edb4c00 docs: document event loop readiness probe fields 2026-06-01 12:13:39 -04:00
Peter Steinberger
d39c2051d0 docs: document gateway timeout clamp rules 2026-06-01 12:13:39 -04:00
Peter Steinberger
d008a425c2 docs: document live plugin config fallback 2026-06-01 12:13:39 -04:00
Peter Steinberger
7fc4dd9d14 docs: document node presence reasons 2026-06-01 12:13:39 -04:00
Peter Steinberger
c3697c2ac1 docs: document avatar policy constants 2026-06-01 12:13:39 -04:00
Peter Steinberger
de85fcd978 docs: document OpenAI Codex auth helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
8fb987c565 docs: document plugin command runner 2026-06-01 12:13:39 -04:00
Peter Steinberger
8cacdce95e docs: document tool send target fallback 2026-06-01 12:13:39 -04:00
Peter Steinberger
fd12d434ba docs: document telegram account facade 2026-06-01 12:13:39 -04:00
Peter Steinberger
a70b17e5cb docs: document runtime store helper 2026-06-01 12:13:39 -04:00
Peter Steinberger
283dff0c19 docs: document webhook request guard helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
e2878dcf33 docs: document keyed async queue helper 2026-06-01 12:13:39 -04:00
Peter Steinberger
af9f15074f docs: document tool payload helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
e7e7e4f2f1 docs: document plugin SDK runtime helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
0ba732cf5e docs: document browser maintenance helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
a9120d2df6 docs: document safe record helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
d3106d2209 docs: document approval reaction helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
e174ddaaeb docs: document provider catalog helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
8b3a9a5617 docs: document provider onboard helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
c6fed61806 docs: document allowlist config helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
602a8e2d10 docs: document provider tool compat helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
1c31afac81 docs: document persistent dedupe helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
5ceb45d38e docs: document account setup helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
ff326e9ca5 docs: document auth and chunk helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
2991ae6fc9 docs: document assistant text helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
4f12fa1d70 docs: document command status runtime 2026-06-01 12:13:39 -04:00
Peter Steinberger
a73f42096e docs: document provider model helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
c11a3a0d78 docs: document browser config helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
c4520714c8 docs: document QA scenario helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
157e893b77 docs: document migration helper contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
f260f1bc06 docs: document status helper payloads 2026-06-01 12:13:39 -04:00
Peter Steinberger
8cba61f985 docs: document extension shared helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
48f2eef53b docs: document session policy payloads 2026-06-01 12:13:39 -04:00
Peter Steinberger
692dbb7b3f docs: document webhook guard helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
fc2e6ab07e docs: document channel diagnostics helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
00465096ce docs: document thread binding label helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
7ee37a45c4 docs: document reply prefix typing guard helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
102f1427e9 docs: document inbound debounce stream helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
bf14891ff3 docs: document direct dm allow-from helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
fc84fd8f26 docs: document channel match allowlist helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
6a10a55114 docs: document channel snapshot presence helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
6e1e89cbe9 docs: document channel config helper contracts 2026-06-01 12:13:38 -04:00
Peter Steinberger
df725c5b4e docs: document channel turn adapter contracts 2026-06-01 12:13:38 -04:00
Peter Steinberger
0c7f9ea6be docs: document channel turn delivery helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
216a2daf23 docs: document inbound reply bridge helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
a48823f18b docs: document message receive capability helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
c4d88ffc3e docs: document durable ingress queue helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
02e12555bb docs: document message outbound bridge helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
9888974144 docs: document durable message state fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
c78717b229 docs: document live message contracts 2026-06-01 12:13:38 -04:00
Peter Steinberger
d5d0090865 docs: document message receipt fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
f407e71101 docs: document channel turn helper fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
5d713e20ec docs: document inbound event context helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
c291eb6c6c docs: document conversation resolution helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
36c53e66ef docs: document route projection helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
6810dfd575 docs: document direct dm guard policy 2026-06-01 12:13:38 -04:00
Peter Steinberger
9b40fcd056 docs: document ack reaction helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
f529df5b97 docs: document channel target policy helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
f9c86a65a6 docs: document thread binding policy fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
e47f45e322 docs: document command gating fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
7ef4d676c9 docs: document mention gating fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
0c2dc54eae docs: document entry status fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
b21b889017 docs: document usage timeseries fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
6991205bd8 docs: document usage payload fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
dfdcd2aa97 docs: document node list payload fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
dc0cc1b7c1 docs: document thread binding lifecycle fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
25ce9fbb31 docs: document runtime requirement fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
ca6fd41b95 docs: document requirement metadata fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
6a6930983b docs: document store writer queue fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
a1ff03b634 docs: document scoped id cache helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
4d8686c24e docs: document custom command config helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
5802610280 docs: document final tag parser helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
51c0ca2aa6 docs: document device auth store helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
f3a313bfd1 docs: document node matching helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
afa810271a docs: document shared runtime policy helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
11d9b2780b docs: document shared utility helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
4a2ce15e59 docs: document assistant error format helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
c1e7449b28 docs: document shared json schema helpers 2026-06-01 12:13:37 -04:00
Peter Steinberger
49156048f0 docs: document markdown table chunk helpers 2026-06-01 12:13:37 -04:00
Peter Steinberger
1c212ee73f docs: document markdown ir helpers 2026-06-01 12:13:37 -04:00
Peter Steinberger
63625093a1 docs: document markdown code span helpers 2026-06-01 12:13:37 -04:00
Peter Steinberger
fb59ac217c docs: document markdown render helpers 2026-06-01 12:13:37 -04:00
1298 changed files with 19840 additions and 38814 deletions

View File

@@ -16,10 +16,6 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
- Full Release Validation parent monitors fail fast: once a required child job
fails, the parent cancels the remaining child matrix and prints the failed
job summary. Inspect that first red job instead of waiting for unrelated
matrix tails.
## Preflight
@@ -77,9 +73,6 @@ gh workflow run full-release-validation.yml \
```
Use `release_profile=stable` unless the operator explicitly asks for the broad advisory provider/media matrix. Use narrow `rerun_group` after focused fixes.
Publish with `openclaw-release-publish.yml` using `release_profile=from-validation`
unless a maintainer intentionally wants to cross-check a specific profile; the
publish workflow reads the effective profile from the full-validation manifest.
## Watch

View File

@@ -49,21 +49,17 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
the next beta number until the matching npm package has actually published.
If a published beta needs a fix, commit the fix on the release branch and
increment to the next `-beta.N`.
- For a beta release train, keep Full Release Validation as a pre-publish gate
unless the operator explicitly waives it. Run the fast local preflight, npm
preflight, full release validation, and performance in parallel where safe.
If anything fails before npm publish, fix it on the release branch,
forward-port the fix to `main`, move the unpublished beta tag/prerelease to
the fixed commit, and rerun the affected pre-publish gates. If anything fails
after npm publish, fix it, forward-port to `main`, increment beta number, and
repeat. After each beta publish, run the published-package roster focused on
install/update/Docker/Parallels/NPM Telegram. For later beta attempts, rerun
only lanes whose evidence changed unless the fix touches broad release,
install/update, plugin, Docker, Parallels, or live QA behavior. After each
beta is live, scan current `main` once for critical fixes that landed after
the release branch cut and backport only important low-risk fixes. Operators
may authorize up to 4 autonomous beta attempts; after 4 failed beta attempts,
stop and report.
- For a beta release train, run the fast local preflight first, publish the
beta to npm `beta`, then run the expensive published-package roster focused
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
the release branch, commit/push/pull, increment beta number, and repeat. Run
the full expensive roster at least once before stable/latest promotion; for
later beta attempts, rerun only lanes whose evidence changed unless the fix
touches broad release, install/update, plugin, Docker, Parallels, or live QA
behavior. After each beta is published, scan current `main` once for critical
fixes that landed after the release branch cut and backport only important
low-risk fixes. Operators may authorize up to 4 autonomous beta attempts;
after 4 failed beta attempts, stop and report.
- As soon as the release candidate SHA exists, dispatch `OpenClaw Performance`
with `target_ref=<release-sha>` in parallel with the other release work. Do
not wait for full release validation to start the performance signal.
@@ -472,10 +468,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- The npm workflow and the private mac publish workflow accept
`preflight_only=true` to run validation/build/package steps without uploading
public release assets.
- Real npm publish requires a prior successful npm preflight run id and the
successful Full Release Validation run id for the same tag/SHA so the publish
job promotes the prepared tarball instead of rebuilding it and attaches the
correct release evidence.
- Real npm publish requires a prior successful npm preflight run id so the
publish job promotes the prepared tarball instead of rebuilding it.
- Real private mac publish requires a prior successful private mac preflight
run id so the publish job promotes the prepared artifacts instead of
rebuilding or renotarizing them again.
@@ -505,12 +499,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
instead of uploading public GitHub release assets.
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
workflow artifacts and intentionally skip stable `appcast.xml` generation.
- For stable releases, npm preflight, Full Release Validation, public mac
validation, private mac validation, and private mac preflight must all pass
before any real publish run starts. For beta releases, npm preflight and Full
Release Validation must pass before npm publish unless the operator explicitly
waives the full gate; mac beta validation is still only required when
requested.
- For stable releases, npm preflight, public mac validation, private mac
validation, and private mac preflight must all pass before any real publish
run starts. For beta releases, npm preflight plus the selected Docker,
install/update, Parallels, and release-check lanes are sufficient unless mac
beta validation was explicitly requested.
- Real publish runs may be dispatched from `main` or from a
`release/YYYY.M.D` branch. For release-branch runs, the tag must be contained
in that release branch, and the real publish must reuse a successful preflight

View File

@@ -605,19 +605,7 @@ jobs:
restore-keys: |
${{ runner.os }}-build-all-v3-
- name: Restore dist build cache
id: dist_build_cache
uses: actions/cache/restore@v5
with:
path: |
dist/
dist-runtime/
extensions/*/src/host/**/.bundle.hash
extensions/*/src/host/**/*.bundle.js
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
- name: Build dist
if: steps.dist_build_cache.outputs.cache-hit != 'true'
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build:ci-artifacts
@@ -626,6 +614,14 @@ jobs:
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
run: pnpm ui:i18n:check
- name: Cache dist build
uses: actions/cache@v5
with:
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
@@ -755,18 +751,6 @@ jobs:
done
exit "$failures"
- name: Save dist build cache
if: steps.dist_build_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
continue-on-error: true
with:
path: |
dist/
dist-runtime/
extensions/*/src/host/**/.bundle.hash
extensions/*/src/host/**/*.bundle.js
key: ${{ steps.dist_build_cache.outputs.cache-primary-key }}
- name: Upload gateway watch regression artifacts
if: always() && needs.preflight.outputs.run_check_additional == 'true'
uses: actions/upload-artifact@v7
@@ -1167,8 +1151,7 @@ jobs:
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
OPENCLAW_VITEST_NO_OUTPUT_RETRY: "1"
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "900000"
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
shell: bash
run: |

View File

@@ -32,11 +32,11 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_CHILD_CONCURRENCY: "1"
PNPM_CONFIG_MODULES_DIR: "/var/tmp/openclaw-pnpm-node-modules"
PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
PNPM_CONFIG_NETWORK_CONCURRENCY: "1"
PNPM_CONFIG_STORE_DIR: "/var/tmp/openclaw-pnpm-store"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/var/tmp/openclaw-pnpm-virtual-store"
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
jobs:
hydrate:

View File

@@ -229,7 +229,7 @@ jobs:
needs: [resolve_target]
if: inputs.rerun_group == 'all'
runs-on: ubuntu-24.04
timeout-minutes: 20
timeout-minutes: 45
permissions:
contents: read
steps:
@@ -245,11 +245,54 @@ jobs:
DOCKER_BUILDKIT: "1"
run: |
set -euo pipefail
timeout --kill-after=30s 15m docker build \
timeout --kill-after=30s 35m docker build \
--target runtime-assets \
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
.
- name: Build and smoke test final Docker runtime image
env:
DOCKER_BUILDKIT: "1"
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
run: |
set -euo pipefail
image_ref="openclaw-release-runtime-smoke:${TARGET_SHA}"
timeout --kill-after=30s 35m docker build \
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
-t "${image_ref}" \
.
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
set -eu
test -f /app/src/agents/templates/HEARTBEAT.md
temp_root="$(mktemp -d)"
trap "rm -rf \"${temp_root}\"" EXIT
mkdir -p "${temp_root}/home" "${temp_root}/cwd"
cd "${temp_root}/cwd"
set +e
HOME="${temp_root}/home" \
USERPROFILE="${temp_root}/home" \
OPENCLAW_HOME="${temp_root}/home" \
OPENCLAW_NO_ONBOARD=1 \
OPENCLAW_SUPPRESS_NOTES=1 \
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \
AWS_EC2_METADATA_DISABLED=true \
AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \
AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \
node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \
>"${temp_root}/out.log" 2>&1
status="$?"
set -e
if grep -F "Missing workspace template:" "${temp_root}/out.log"; then
cat "${temp_root}/out.log"
exit 1
fi
test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md"
if [ "${status}" -ne 0 ]; then
cat "${temp_root}/out.log"
fi
'
normal_ci:
name: Run normal full CI
needs: [resolve_target, docker_runtime_assets_preflight]
@@ -337,21 +380,6 @@ jobs:
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]'
}
fail_fast_failed_jobs() {
local failed_jobs_json
failed_jobs_json="$(
fetch_child_jobs |
jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
)"
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix."
jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json"
cancel_child
trap - EXIT INT TERM
exit 1
fi
}
cancel_child() {
if [[ -n "${run_id:-}" ]]; then
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
@@ -367,9 +395,6 @@ jobs:
break
fi
poll_count=$((poll_count + 1))
if (( poll_count % 2 == 0 )); then
fail_fast_failed_jobs
fi
if (( poll_count % 10 == 0 )); then
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
@@ -485,21 +510,6 @@ jobs:
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]'
}
fail_fast_failed_jobs() {
local failed_jobs_json
failed_jobs_json="$(
fetch_child_jobs |
jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
)"
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix."
jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json"
cancel_child
trap - EXIT INT TERM
exit 1
fi
}
cancel_child() {
if [[ -n "${run_id:-}" ]]; then
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
@@ -515,9 +525,6 @@ jobs:
break
fi
poll_count=$((poll_count + 1))
if (( poll_count % 2 == 0 )); then
fail_fast_failed_jobs
fi
if (( poll_count % 10 == 0 )); then
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
@@ -683,24 +690,6 @@ jobs:
[[ "$saw_advisory" == "1" && "$failed" == "0" ]]
}
fail_fast_failed_jobs() {
local failed_jobs_json
if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
return 0
fi
failed_jobs_json="$(
fetch_child_jobs |
jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
)"
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix."
jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json"
cancel_child
trap - EXIT INT TERM
exit 1
fi
}
cancel_child() {
if [[ -n "${run_id:-}" ]]; then
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
@@ -716,9 +705,6 @@ jobs:
break
fi
poll_count=$((poll_count + 1))
if (( poll_count % 2 == 0 )); then
fail_fast_failed_jobs
fi
if (( poll_count % 10 == 0 )); then
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
@@ -976,21 +962,6 @@ jobs:
}
trap cancel_child EXIT INT TERM
fail_fast_failed_jobs() {
local failed_jobs_json
failed_jobs_json="$(
gh_with_retry run view "$run_id" --json jobs \
--jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
)"
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run."
jq '.[] | {name, conclusion, url}' <<< "$failed_jobs_json"
cancel_child
trap - EXIT INT TERM
exit 1
fi
}
poll_count=0
while true; do
status="$(gh_with_retry run view "$run_id" --json status --jq '.status')"
@@ -998,9 +969,6 @@ jobs:
break
fi
poll_count=$((poll_count + 1))
if (( poll_count % 2 == 0 )); then
fail_fast_failed_jobs
fi
if (( poll_count % 10 == 0 )); then
echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true

View File

@@ -46,12 +46,11 @@ on:
default: true
type: boolean
release_profile:
description: Release coverage profile used for release evidence summaries; default reads it from the validation manifest
description: Release coverage profile used for release evidence summaries
required: false
default: from-validation
default: beta
type: choice
options:
- from-validation
- beta
- stable
- full
@@ -136,9 +135,9 @@ jobs:
exit 1
fi
case "$RELEASE_PROFILE" in
from-validation|beta|stable|full) ;;
beta|stable|full) ;;
*)
echo "release_profile must be one of: from-validation, beta, stable, full" >&2
echo "release_profile must be one of: beta, stable, full" >&2
exit 1
;;
esac
@@ -260,7 +259,6 @@ jobs:
echo "sha=$release_sha" >> "$GITHUB_OUTPUT"
- name: Validate full release validation manifest
id: full_manifest
if: ${{ inputs.publish_openclaw_npm }}
env:
GH_TOKEN: ${{ github.token }}
@@ -291,7 +289,7 @@ jobs:
echo "Full release validation target SHA mismatch: expected $EXPECTED_SHA, got $target_sha" >&2
exit 1
fi
if [[ "$EXPECTED_RELEASE_PROFILE" != "from-validation" && "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then
if [[ "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then
echo "Full release validation profile mismatch: expected $EXPECTED_RELEASE_PROFILE, got $release_profile" >&2
exit 1
fi
@@ -299,7 +297,6 @@ jobs:
echo "Full release validation must run rerun_group=all before npm publish; got $rerun_group" >&2
exit 1
fi
echo "release_profile=$release_profile" >> "$GITHUB_OUTPUT"
- name: Validate release tag is reachable from a trusted release branch
env:
@@ -335,7 +332,7 @@ jobs:
env:
RELEASE_TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
RELEASE_PROFILE: ${{ steps.full_manifest.outputs.release_profile || inputs.release_profile }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
run: |
{
@@ -504,7 +501,7 @@ jobs:
wait_for_run() {
local workflow="$1"
local run_id="$2"
local status conclusion url updated_at created_at duration_seconds duration_label last_state failed_json
local status conclusion url updated_at created_at duration_seconds duration_label last_state
last_state=""
while true; do
@@ -513,14 +510,6 @@ jobs:
if [[ "$status" == "completed" ]]; then
break
fi
failed_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs \
--jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' || true)"
if [[ -n "${failed_json}" ]] && jq -e 'length > 0' <<< "$failed_json" >/dev/null; then
echo "${workflow} has failed jobs before the workflow completed: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" >&2
jq '.[] | {name, conclusion, url}' <<< "$failed_json" >&2 || true
print_failed_run_summary "${run_id}"
return 1
fi
url="$(printf '%s' "$run_json" | jq -r '.url')"
updated_at="$(printf '%s' "$run_json" | jq -r '.updatedAt')"
state="${status}:${updated_at}"

View File

@@ -818,7 +818,6 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_SLACK_CAPTURE_CONTENT: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.slack_scenario || '' }}
run: |
set -euo pipefail

View File

@@ -9,18 +9,18 @@ Docs: https://docs.openclaw.ai
- Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.
- Skills, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes do less repeated work on hot paths while keeping config, dispatch, and Linux file-watch behavior stable. (#89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
- Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, prioritize first connect, and expose calmer composer controls. (#88772, #88825, #88998, #89030, #89106) Thanks @vincentkoc and @sallyom.
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, trace first-output latency, and expose calmer composer controls. (#88772, #88825, #88998) Thanks @vincentkoc.
- Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)
- iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)
- Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, and rollback snapshots so failures report bounded proof instead of stalling. (#88966) Thanks @RomneyDa.
- Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, and rollback snapshots so failures report bounded proof instead of stalling.
### Changes
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery, and refresh the ClawHub showcase cards. (#88734) Thanks @shakkernerd and @vyctorbrzezowski.
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery. Thanks @shakkernerd.
- Skills: let the `skill_workshop` agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.
- Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
- Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
@@ -45,19 +45,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
- Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.
- Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.
- Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.
- Release/CI/E2E: normalize inherited Linux `C.UTF-8` locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.
- Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.
- Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.
- Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as `null` or arrays.
- Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.
- Talk: preserve explicit `null` payloads on controller-created turn and output-audio lifecycle events.
- Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
@@ -65,22 +52,19 @@ Docs: https://docs.openclaw.ai
- Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex `lastGood` auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
- Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.
- Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#89181) Thanks @RomneyDa.
- Agents/auth: write auth profiles atomically, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state.
- Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill `apiKey` SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.
- Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.
- CLI: avoid live catalog validation during `openclaw agents add`, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.
- CLI/desktop: bridge WSL clipboard operations through the shell and recognize manual-update launchd jobs. (#88764)
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
- Plugins: preserve npm plugin roots after blocked installs, skip plugin-local `openclaw` peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
- Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.
- Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.
- Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.
- Memory: serialize QMD update/embed writes per store, preserve phase signals on read errors, harden envelope metadata sanitization, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931) Thanks @openperf and @amittell.
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, strip Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #76612) Thanks @coder999999999, @BryanTegomoh, and @vliuyt.
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512)
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
@@ -88,7 +72,7 @@ Docs: https://docs.openclaw.ai
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, docker package cleanup, and mainline test flakes. (#88127, #88137, #88155, #88160, #88966) Thanks @RomneyDa.
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
- Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
@@ -96,11 +80,9 @@ Docs: https://docs.openclaw.ai
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.
- Channels: stop schema-padded poll modifiers from turning normal `send` actions into invalid poll sends. (#89601) Thanks @codezz.
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.
- Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.
- OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
@@ -675,7 +657,6 @@ Docs: https://docs.openclaw.ai
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.
- TUI/streaming watchdog: dismiss the `This response is taking longer than expected` notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.
- Agents/auth profiles: replace the bare `No available auth profile for <provider> (all in cooldown or unavailable)` TUI error with plain-language copy that explains what happened in user terms (sign-in expired, provider asking us to slow down, billing issue on the account, etc.) and suggests the matching `openclaw models auth login --provider <provider>` recovery command for sign-in and billing causes, while falling back to the underlying provider error for cases without a clear recovery path. Thanks @romneyda.
- Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.
## 2026.5.20

View File

@@ -218,7 +218,6 @@ Current OpenClaw Android implication:
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
- The repo now ships this split as Android product flavors:
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities.
- Installed-app listing is user controlled. `device.apps` is advertised only after the user enables **Settings > Phone Capabilities > Installed Apps**. The command defaults to launcher-visible apps and does not require `QUERY_ALL_PACKAGES`.
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
Policy links:

View File

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

View File

@@ -148,7 +148,6 @@ class MainViewModel(
val gatewayBootstrapToken: StateFlow<String> = prefs.gatewayBootstrapToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
@@ -300,10 +299,6 @@ class MainViewModel(
prefs.setCanvasDebugStatusEnabled(value)
}
fun setInstalledAppsSharingEnabled(value: Boolean) {
ensureRuntime().setInstalledAppsSharingEnabled(value)
}
fun setNotificationForwardingEnabled(value: Boolean) {
ensureRuntime().setNotificationForwardingEnabled(value)
}

View File

@@ -207,7 +207,6 @@ class NodeRuntime(
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
hasRecordAudioPermission = { hasRecordAudioPermission() },
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
manualTls = { manualTls.value },
)
@@ -246,7 +245,6 @@ class NodeRuntime(
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
debugBuild = { BuildConfig.DEBUG },
onCanvasA2uiPush = {
_canvasA2uiHydrated.value = true
@@ -868,7 +866,6 @@ class NodeRuntime(
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
prefs.notificationForwardingMode
@@ -1080,12 +1077,6 @@ class NodeRuntime(
prefs.setCanvasDebugStatusEnabled(value)
}
fun setInstalledAppsSharingEnabled(value: Boolean) {
if (prefs.installedAppsSharingEnabled.value == value) return
prefs.setInstalledAppsSharingEnabled(value)
refreshNodeSurfaceAfterSharingChange()
}
fun setNotificationForwardingEnabled(value: Boolean) {
prefs.setNotificationForwardingEnabled(value)
}
@@ -1423,11 +1414,6 @@ class NodeRuntime(
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
}
private fun refreshNodeSurfaceAfterSharingChange() {
val endpoint = connectedEndpoint ?: return
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
}
private fun connectWithAuth(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,

View File

@@ -40,13 +40,11 @@ class SecurePrefs(
private const val notificationsForwardingMaxEventsPerMinuteKey =
"notifications.forwarding.maxEventsPerMinute"
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
private const val voiceMicEnabledKey = "voice.micEnabled"
}
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
// Non-secret UI/runtime preferences stay readable for migration and backup behavior.
private val plainPrefs: SharedPreferences =
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
@@ -116,10 +114,6 @@ class SecurePrefs(
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _installedAppsSharingEnabled =
MutableStateFlow(plainPrefs.getBoolean(installedAppsSharingEnabledKey, false))
val installedAppsSharingEnabled: StateFlow<Boolean> = _installedAppsSharingEnabled
private val _notificationForwardingEnabled =
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
@@ -258,11 +252,6 @@ class SecurePrefs(
_canvasDebugStatusEnabled.value = value
}
fun setInstalledAppsSharingEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(installedAppsSharingEnabledKey, value) }
_installedAppsSharingEnabled.value = value
}
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)

View File

@@ -28,7 +28,6 @@ class ConnectionManager(
private val callLogAvailable: () -> Boolean,
private val photosAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val installedAppsSharingEnabled: () -> Boolean,
private val manualTls: () -> Boolean,
) {
companion object {
@@ -116,7 +115,6 @@ class ConnectionManager(
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),
installedAppsSharingEnabled = installedAppsSharingEnabled(),
debugBuild = BuildConfig.DEBUG,
)

View File

@@ -8,7 +8,6 @@ import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
@@ -25,121 +24,16 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.Locale
private const val DEFAULT_DEVICE_APPS_LIMIT = 100
private const val MAX_DEVICE_APPS_LIMIT = 200
private const val DEVICE_APPS_SYSTEM_FLAGS =
ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean =
(appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0
internal data class DeviceAppEntry(
val label: String,
val packageName: String,
val system: Boolean,
val enabled: Boolean,
val launchable: Boolean,
)
internal interface DeviceAppSource {
fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry>
}
private class AndroidDeviceAppSource(
private val appContext: Context,
) : DeviceAppSource {
override fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry> {
val packageManager = appContext.packageManager
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
val launchablePackages =
packageManager
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
.asSequence()
.mapNotNull {
it.activityInfo
?.packageName
?.trim()
?.takeIf(String::isNotEmpty)
}.toSet()
val appInfos =
if (includeNonLaunchable) {
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
} else {
launchablePackages.mapNotNull { packageName ->
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
}
}
return appInfos
.asSequence()
.mapNotNull { appInfo ->
appInfo.packageName
?.trim()
?.takeIf(String::isNotEmpty)
?.let { packageName ->
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
DeviceAppEntry(
label = label.ifEmpty { packageName },
packageName = packageName,
system = isSystemDeviceApp(appInfo),
enabled = appInfo.enabled,
launchable = packageName in launchablePackages,
)
}
}.distinctBy { it.packageName }
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
}
private data class DeviceAppsRequest(
val includeSystem: Boolean,
val includeDisabled: Boolean,
val includeNonLaunchable: Boolean,
val query: String?,
val limit: Int,
)
/**
* Gateway device command adapter for Android status, info, permission, and health snapshots.
*/
class DeviceHandler private constructor(
class DeviceHandler(
private val appContext: Context,
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
private val appSource: DeviceAppSource = AndroidDeviceAppSource(appContext),
) {
constructor(
appContext: Context,
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
) : this(
appContext = appContext,
smsEnabled = smsEnabled,
callLogEnabled = callLogEnabled,
photosEnabled = photosEnabled,
appSource = AndroidDeviceAppSource(appContext),
)
companion object {
internal fun forTesting(
appContext: Context,
appSource: DeviceAppSource,
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
): DeviceHandler =
DeviceHandler(
appContext = appContext,
smsEnabled = smsEnabled,
callLogEnabled = callLogEnabled,
photosEnabled = photosEnabled,
appSource = appSource,
)
/**
* SMS is available only when the feature flag, telephony hardware, and at least one SMS permission align.
*/
@@ -180,48 +74,6 @@ class DeviceHandler private constructor(
/** Returns coarse device health for memory, power, thermal, battery, and security patch state. */
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson())
fun handleDeviceApps(paramsJson: String?): GatewaySession.InvokeResult {
val request = parseDeviceAppsRequest(paramsJson)
val matchingApps =
appSource
.listApps(includeNonLaunchable = request.includeNonLaunchable)
.asSequence()
.filter { request.includeSystem || !it.system }
.filter { request.includeDisabled || it.enabled }
.filter { app ->
val query = request.query ?: return@filter true
app.label.contains(query, ignoreCase = true) || app.packageName.contains(query, ignoreCase = true)
}.toList()
val limitedApps = matchingApps.take(request.limit)
return GatewaySession.InvokeResult.ok(
buildJsonObject {
put("count", JsonPrimitive(limitedApps.size))
put("totalMatched", JsonPrimitive(matchingApps.size))
put("truncated", JsonPrimitive(matchingApps.size > limitedApps.size))
put("visibility", JsonPrimitive(if (request.includeNonLaunchable) "android-visible" else "launcher"))
put("includeSystem", JsonPrimitive(request.includeSystem))
put("includeDisabled", JsonPrimitive(request.includeDisabled))
put(
"apps",
buildJsonArray {
for (app in limitedApps) {
add(
buildJsonObject {
put("label", JsonPrimitive(app.label))
put("packageName", JsonPrimitive(app.packageName))
put("system", JsonPrimitive(app.system))
put("enabled", JsonPrimitive(app.enabled))
put("launchable", JsonPrimitive(app.launchable))
},
)
}
},
)
}.toString(),
)
}
private fun statusPayloadJson(): String {
val battery = readBatterySnapshot()
val powerManager = appContext.getSystemService(PowerManager::class.java)
@@ -513,24 +365,6 @@ class DeviceHandler private constructor(
}.toString()
}
private fun parseDeviceAppsRequest(paramsJson: String?): DeviceAppsRequest {
val params = parseJsonParamsObject(paramsJson)
val includeSystem = parseJsonBooleanFlag(params, "includeSystem") ?: false
val includeDisabled = parseJsonBooleanFlag(params, "includeDisabled") ?: false
val includeNonLaunchable = parseJsonBooleanFlag(params, "includeNonLaunchable") ?: false
val query = parseJsonString(params, "query")?.trim()?.takeIf { it.isNotEmpty() }
val limit =
(parseJsonInt(params, "limit") ?: DEFAULT_DEVICE_APPS_LIMIT)
.coerceIn(1, MAX_DEVICE_APPS_LIMIT)
return DeviceAppsRequest(
includeSystem = includeSystem,
includeDisabled = includeDisabled,
includeNonLaunchable = includeNonLaunchable,
query = query,
limit = limit,
)
}
private fun readBatterySnapshot(): BatterySnapshot {
// ACTION_BATTERY_CHANGED is sticky; registerReceiver(null, ...) reads the last system snapshot.
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))

View File

@@ -28,7 +28,6 @@ data class NodeRuntimeFlags(
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
val installedAppsSharingEnabled: Boolean,
val debugBuild: Boolean,
)
@@ -44,7 +43,6 @@ enum class InvokeCommandAvailability {
PhotosAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
InstalledAppsSharingEnabled,
DebugBuild,
}
@@ -195,10 +193,6 @@ object InvokeCommandRegistry {
InvokeCommandSpec(
name = OpenClawDeviceCommand.Health.rawValue,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Apps.rawValue,
availability = InvokeCommandAvailability.InstalledAppsSharingEnabled,
),
InvokeCommandSpec(
name = OpenClawNotificationsCommand.List.rawValue,
),
@@ -287,7 +281,6 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.InstalledAppsSharingEnabled -> flags.installedAppsSharingEnabled
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
}
}.map { it.name }

View File

@@ -85,7 +85,6 @@ class InvokeDispatcher(
private val smsTelephonyAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val photosAvailable: () -> Boolean,
private val installedAppsSharingEnabled: () -> Boolean,
private val debugBuild: () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
private val onCanvasA2uiReset: () -> Unit,
@@ -194,7 +193,6 @@ class InvokeDispatcher(
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson)
OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson)
OpenClawDeviceCommand.Apps.rawValue -> deviceHandler.handleDeviceApps(paramsJson)
// Notifications command
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
@@ -350,15 +348,6 @@ class InvokeDispatcher(
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
)
}
InvokeCommandAvailability.InstalledAppsSharingEnabled ->
if (installedAppsSharingEnabled()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "INSTALLED_APPS_SHARING_DISABLED",
message = "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings",
)
}
InvokeCommandAvailability.DebugBuild ->
if (debugBuild()) {
null

View File

@@ -112,7 +112,6 @@ enum class OpenClawDeviceCommand(
Info("device.info"),
Permissions("device.permissions"),
Health("device.health"),
Apps("device.apps"),
;
companion object {

View File

@@ -714,7 +714,6 @@ private fun PhoneCapabilitiesScreen(
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val installedAppsSharingEnabled by viewModel.installedAppsSharingEnabled.collectAsState()
val cameraPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
viewModel.setCameraEnabled(granted)
@@ -769,13 +768,6 @@ private fun PhoneCapabilitiesScreen(
listOf(
SettingsToggleRow("Camera", "Allow camera tools when requested.", Icons.Default.CameraAlt, cameraEnabled, ::setCameraAccess),
SettingsToggleRow("Precise Location", "Share precise location while location is enabled.", Icons.Default.LocationOn, locationPreciseEnabled, ::setPreciseLocation),
SettingsToggleRow(
"Installed Apps",
if (installedAppsSharingEnabled) "OpenClaw can list launcher-visible apps." else "App list stays on this phone.",
Icons.Default.Storage,
installedAppsSharingEnabled,
viewModel::setInstalledAppsSharingEnabled,
),
SettingsToggleRow("Keep Awake", "Keep the node available during active work.", Icons.Default.Bolt, preventSleep, viewModel::setPreventSleep),
SettingsToggleRow("Canvas Status", "Show screen-sharing debug state.", Icons.AutoMirrored.Filled.ScreenShare, canvasDebugStatusEnabled, viewModel::setCanvasDebugStatusEnabled),
),
@@ -1253,7 +1245,6 @@ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus {
}
}
/** Applies query/system visibility rules while always preserving selected packages. */
internal fun filterNotificationAppsForPicker(
apps: List<InstalledApp>,
selectedPackages: Set<String>,
@@ -1272,7 +1263,6 @@ internal fun filterNotificationAppsForPicker(
}
}
/** Summarizes allowlist/blocklist mode with an empty-state warning when needed. */
private fun notificationPackageSelectionSummary(
mode: NotificationPackageFilterMode,
selectedCount: Int,
@@ -1292,7 +1282,6 @@ private fun notificationPackageSelectionSummary(
}
}
/** Builds compact two-letter app badges from package-picker labels. */
private fun notificationAppBadge(label: String): String {
val initials =
label

View File

@@ -62,21 +62,6 @@ class SecurePrefsTest {
assertFalse(plainPrefs.getBoolean("talk.enabled", false))
}
@Test
fun installedAppsSharing_defaultsOffAndPersistsOptIn() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertFalse(prefs.installedAppsSharingEnabled.value)
prefs.setInstalledAppsSharingEnabled(true)
assertTrue(prefs.installedAppsSharingEnabled.value)
assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false))
}
@Test
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
val context = RuntimeEnvironment.getApplication()

View File

@@ -9,7 +9,6 @@ import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawPhotosCommand
@@ -476,15 +475,6 @@ class ConnectionManagerTest {
assertTrue(options.caps.contains(OpenClawCapability.VoiceWake.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesDeviceAppsOnlyWhenUserOptedIn() {
val disabled = newManager(installedAppsSharingEnabled = false).buildNodeConnectOptions()
val enabled = newManager(installedAppsSharingEnabled = true).buildNodeConnectOptions()
assertFalse(disabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue))
assertTrue(enabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue))
}
@Test
fun buildNodeConnectOptions_omitsVoiceWakeWithoutMicrophonePermission() {
val options =
@@ -556,7 +546,6 @@ class ConnectionManagerTest {
callLogAvailable: Boolean = false,
photosAvailable: Boolean = false,
hasRecordAudioPermission: Boolean = false,
installedAppsSharingEnabled: Boolean = false,
): ConnectionManager {
val context = RuntimeEnvironment.getApplication()
val prefs =
@@ -578,7 +567,6 @@ class ConnectionManagerTest {
callLogAvailable = { callLogAvailable },
photosAvailable = { photosAvailable },
hasRecordAudioPermission = { hasRecordAudioPermission },
installedAppsSharingEnabled = { installedAppsSharingEnabled },
manualTls = { false },
)
}

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.node
import android.content.Context
import android.content.pm.ApplicationInfo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
@@ -321,108 +320,6 @@ class DeviceHandlerTest {
system["securityPatchLevel"]?.jsonPrimitive?.content
}
@Test
fun handleDeviceApps_filtersAndLimitsVisibleApps() {
val handler =
DeviceHandler.forTesting(
appContext = appContext(),
appSource =
FakeDeviceAppSource(
listOf(
DeviceAppEntry(
label = "Calendar",
packageName = "com.google.android.calendar",
system = false,
enabled = true,
launchable = true,
),
DeviceAppEntry(
label = "Android System",
packageName = "android",
system = true,
enabled = true,
launchable = false,
),
DeviceAppEntry(
label = "Disabled App",
packageName = "com.example.disabled",
system = false,
enabled = false,
launchable = true,
),
DeviceAppEntry(
label = "Gmail",
packageName = "com.google.android.gm",
system = false,
enabled = true,
launchable = true,
),
),
),
)
val result = handler.handleDeviceApps("""{"query":"google","limit":1}""")
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
assertEquals("1", payload.getValue("count").jsonPrimitive.content)
assertEquals("2", payload.getValue("totalMatched").jsonPrimitive.content)
assertTrue(payload.getValue("truncated").jsonPrimitive.boolean)
assertEquals("launcher", payload.getValue("visibility").jsonPrimitive.content)
val apps = payload.getValue("apps").jsonArray
assertEquals(1, apps.size)
val app = apps.first().jsonObject
assertEquals("Calendar", app.getValue("label").jsonPrimitive.content)
assertEquals("com.google.android.calendar", app.getValue("packageName").jsonPrimitive.content)
assertTrue(!app.getValue("system").jsonPrimitive.boolean)
assertTrue(app.getValue("enabled").jsonPrimitive.boolean)
assertTrue(app.getValue("launchable").jsonPrimitive.boolean)
}
@Test
fun handleDeviceApps_canIncludeSystemAndNonLaunchableApps() {
val source =
FakeDeviceAppSource(
listOf(
DeviceAppEntry(
label = "Android System",
packageName = "android",
system = true,
enabled = true,
launchable = false,
),
),
)
val handler = DeviceHandler.forTesting(appContext = appContext(), appSource = source)
val result = handler.handleDeviceApps("""{"includeSystem":true,"includeNonLaunchable":true}""")
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
assertEquals("android-visible", payload.getValue("visibility").jsonPrimitive.content)
assertTrue(payload.getValue("includeSystem").jsonPrimitive.boolean)
val app =
payload
.getValue("apps")
.jsonArray
.first()
.jsonObject
assertEquals("android", app.getValue("packageName").jsonPrimitive.content)
assertTrue(app.getValue("system").jsonPrimitive.boolean)
assertTrue(!app.getValue("launchable").jsonPrimitive.boolean)
assertTrue(source.includeNonLaunchableRequests.single())
}
@Test
fun isSystemDeviceApp_treatsUpdatedBuiltInsAsSystemApps() {
val appInfo =
ApplicationInfo().apply {
flags = ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
}
assertTrue(isSystemDeviceApp(appInfo))
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
private fun parsePayload(payloadJson: String?): JsonObject {
@@ -430,14 +327,3 @@ class DeviceHandlerTest {
return Json.parseToJsonElement(jsonString).jsonObject
}
}
private class FakeDeviceAppSource(
private val apps: List<DeviceAppEntry>,
) : DeviceAppSource {
val includeNonLaunchableRequests = mutableListOf<Boolean>()
override fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry> {
includeNonLaunchableRequests += includeNonLaunchable
return apps
}
}

View File

@@ -115,15 +115,6 @@ class InvokeCommandRegistryTest {
assertMissingAll(commands, optionalCommands + debugCommands)
}
@Test
fun advertisedCommands_includesDeviceAppsOnlyWhenUserOptedIn() {
val disabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = false))
val enabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = true))
assertFalse(disabled.contains(OpenClawDeviceCommand.Apps.rawValue))
assertTrue(enabled.contains(OpenClawDeviceCommand.Apps.rawValue))
}
@Test
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
val commands =
@@ -160,7 +151,6 @@ class InvokeCommandRegistryTest {
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = false,
installedAppsSharingEnabled = false,
debugBuild = false,
),
)
@@ -272,7 +262,6 @@ class InvokeCommandRegistryTest {
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
installedAppsSharingEnabled: Boolean = false,
debugBuild: Boolean = false,
): NodeRuntimeFlags =
NodeRuntimeFlags(
@@ -286,7 +275,6 @@ class InvokeCommandRegistryTest {
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,
installedAppsSharingEnabled = installedAppsSharingEnabled,
debugBuild = debugBuild,
)

View File

@@ -4,7 +4,6 @@ import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawPhotosCommand
@@ -171,20 +170,6 @@ class InvokeDispatcherTest {
assertEquals("LOCATION_DISABLED: enable Location in Settings", result.error?.message)
}
@Test
fun handleInvoke_blocksDeviceAppsWhenSharingDisabled() =
runTest {
val result =
newDispatcher(installedAppsSharingEnabled = false)
.handleInvoke(OpenClawDeviceCommand.Apps.rawValue, """{"limit":1}""")
assertEquals("INSTALLED_APPS_SHARING_DISABLED", result.error?.code)
assertEquals(
"INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings",
result.error?.message,
)
}
@Test
fun handleInvoke_blocksMotionActivityWhenUnavailable() =
runTest {
@@ -265,7 +250,6 @@ class InvokeDispatcherTest {
smsTelephonyAvailable: Boolean = true,
callLogAvailable: Boolean = false,
photosAvailable: Boolean = true,
installedAppsSharingEnabled: Boolean = true,
debugBuild: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
@@ -313,7 +297,6 @@ class InvokeDispatcherTest {
smsTelephonyAvailable = { smsTelephonyAvailable },
callLogAvailable = { callLogAvailable },
photosAvailable = { photosAvailable },
installedAppsSharingEnabled = { installedAppsSharingEnabled },
debugBuild = { debugBuild },
onCanvasA2uiPush = {},
onCanvasA2uiReset = {},

View File

@@ -57,7 +57,6 @@ class OpenClawProtocolConstantsTest {
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
assertEquals("device.apps", OpenClawDeviceCommand.Apps.rawValue)
}
@Test

View File

@@ -1,9 +1,5 @@
# OpenClaw iOS Changelog
## 2026.6.2 - 2026-06-02
Maintenance update for the current OpenClaw release.
## 2026.6.1 - 2026-06-01
Maintenance update for the current OpenClaw release.

View File

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

View File

@@ -1 +1,5 @@
Maintenance update for the current OpenClaw release.
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.

View File

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

View File

@@ -514,16 +514,12 @@ extension GatewayConnection {
var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed),
"sessionKey": AnyCodable(sessionKey),
"thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
"channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let thinking = invocation.thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
!thinking.isEmpty
{
params["thinking"] = AnyCodable(thinking)
}
if let timeout = invocation.timeoutSeconds {
params["timeout"] = AnyCodable(timeout)
}
@@ -668,7 +664,7 @@ extension GatewayConnection {
func chatSend(
sessionKey: String,
message: String,
thinking: String?,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> OpenClawChatSendResponse
@@ -677,14 +673,10 @@ extension GatewayConnection {
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(resolvedKey),
"message": AnyCodable(message),
"thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey),
"timeoutMs": AnyCodable(timeoutMs),
]
if let thinking = thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
!thinking.isEmpty
{
params["thinking"] = AnyCodable(thinking)
}
if !attachments.isEmpty {
let encoded = attachments.map { att in

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.6.2</string>
<string>2026.6.1</string>
<key>CFBundleVersion</key>
<string>2026060200</string>
<string>2026053100</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -387,7 +387,7 @@ actor TalkModeRuntime {
let response = try await GatewayConnection.shared.chatSend(
sessionKey: sessionKey,
message: prompt,
thinking: nil,
thinking: "low",
idempotencyKey: runId,
attachments: [])
guard self.isCurrent(gen) else { return }

View File

@@ -34,7 +34,7 @@ enum VoiceWakeForwarder {
struct ForwardOptions {
var sessionKey: String = "main"
var thinking: String?
var thinking: String = "low"
var deliver: Bool = true
var to: String?
var channel: GatewayAgentChannel = .webchat
@@ -97,6 +97,7 @@ enum VoiceWakeForwarder {
return ForwardOptions(
sessionKey: sessionKey,
thinking: "low",
deliver: true,
to: to,
channel: channel,

View File

@@ -173,57 +173,9 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
let params = json?["params"] as? [String: Any]
#expect(params?["thinking"] == nil)
#expect(params?["voiceWakeTrigger"] as? String == "")
}
@Test func `chat send omits thinking when inheriting session default`() async throws {
let recorder = WebSocketMessageRecorder()
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
recorder.append(message)
guard sendIndex > 0,
let data = Self.messageData(message),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? String
else { return }
task.emitReceiveSuccess(.data(Self.chatSendOkResponseData(id: id)))
})
})
let connection = GatewayConnection(
configProvider: {
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
},
sessionBox: WebSocketSessionBox(session: session))
_ = try await connection.chatSend(
sessionKey: "main",
message: "hello",
thinking: nil,
idempotencyKey: "chat-1",
attachments: [])
await connection.shutdown()
guard let chatMessage = recorder.snapshot().reversed().first(where: { message in
guard let data = Self.messageData(message),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return false }
return json["method"] as? String == "chat.send"
}) else {
Issue.record("expected chat.send websocket payload")
return
}
guard let payloadData = Self.messageData(chatMessage) else {
Issue.record("unexpected chat.send websocket message type")
return
}
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
let params = json?["params"] as? [String: Any]
#expect(params?["thinking"] == nil)
}
private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? {
switch message {
case let .string(text):
@@ -234,15 +186,4 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
nil
}
}
private static func chatSendOkResponseData(id: String) -> Data {
Data("""
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "runId": "chat-1", "status": "ok" }
}
""".utf8)
}
}

View File

@@ -14,7 +14,7 @@ import Testing
@Test func `forward options defaults`() {
let opts = VoiceWakeForwarder.ForwardOptions()
#expect(opts.sessionKey == "main")
#expect(opts.thinking == nil)
#expect(opts.thinking == "low")
#expect(opts.deliver == true)
#expect(opts.to == nil)
#expect(opts.channel == .webchat)
@@ -38,7 +38,6 @@ import Testing
#expect(opts.channel == .telegram)
#expect(opts.to == "telegram:6812765697")
#expect(opts.voiceWakeTrigger == "open claw")
#expect(opts.thinking == nil)
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
}

View File

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

View File

@@ -1,2 +1,2 @@
12083a1dec4d5fd603a28a3b7d70e4967d5d2d7075047abaf1bc7e1713cbceb9 plugin-sdk-api-baseline.json
980f8c517889a3c7680ba73cf0efeac5850bf3e9c8bb0e758e542dd79a76a178 plugin-sdk-api-baseline.jsonl
bdcf661ec680f79819096950295bdb04805aac9639477058d8855f294f6d8034 plugin-sdk-api-baseline.json
6b8c92cc5a9277f90973370102fa31efb23ffd93008c3ed961d38e4a8a3073b0 plugin-sdk-api-baseline.jsonl

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,82 +0,0 @@
---
summary: "ClawHub CLI entry points for discovering, installing, publishing, and verifying OpenClaw skills and plugins."
read_when:
- You want to use ClawHub from the command line
- You want to install ClawHub skills or plugins through OpenClaw
- You want to publish ClawHub packages
title: "ClawHub CLI"
---
# ClawHub CLI
OpenClaw has two command-line entry points for ClawHub:
- `openclaw skills` and `openclaw plugins` install and manage ClawHub packages
inside OpenClaw.
- The standalone `clawhub` CLI handles publisher workflows such as login,
publish, transfer, and sync.
## Discover and install
Use OpenClaw commands when you want to install or update packages for a local
OpenClaw agent or Gateway.
```bash
openclaw skills search "calendar"
openclaw skills install <slug>
openclaw skills update <slug>
openclaw skills verify <slug>
openclaw plugins search "calendar"
openclaw plugins install clawhub:<package>
openclaw plugins update <id-or-npm-spec>
```
Skill installs target the active workspace `skills/` directory by default. Add
`--global` to install into the shared managed skills directory.
Plugin installs use the `clawhub:` prefix when you want ClawHub resolution
instead of npm or another install source.
## Publish and maintain
Install the standalone ClawHub CLI for publisher workflows:
```bash
npm i -g clawhub
clawhub login
```
Publish plugin packages with `clawhub package publish`:
```bash
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
clawhub package publish your-org/your-plugin@v1.0.0
```
Publish skill folders with `clawhub skill publish`:
```bash
clawhub skill publish ./skills/review-helper
clawhub skill publish ./skills/review-helper --version 1.0.0
```
When local skill scan state or package ownership needs maintenance, use the
relevant standalone command:
```bash
clawhub sync --all
clawhub package transfer @old-owner/package --to new-owner
```
## Related
- [`openclaw skills`](/cli/skills) - local skill search, install, update, and
verification
- [`openclaw plugins`](/cli/plugins) - plugin search, install, update, and
inspection
- [ClawHub publishing](/clawhub/publishing) - owner scope, release validation,
and review flow
- [Creating skills](/tools/creating-skills) - skill authoring and publish flow
- [Building plugins](/plugins/building-plugins) - plugin package authoring

View File

@@ -93,7 +93,6 @@ openclaw onboard --non-interactive \
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
OpenClaw marks common vision model IDs as image-capable automatically. Pass `--custom-image-input` for unknown custom vision IDs, or `--custom-text-input` to force text-only metadata.
Use `--custom-compatibility openai-responses` for OpenAI-compatible endpoints that support `/v1/responses` but not `/v1/chat/completions`.
LM Studio also supports a provider-specific key flag in non-interactive mode:

View File

@@ -32,8 +32,9 @@ This is for cooperative/shared inbox hardening. A single Gateway shared by mutua
It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example open DM/group policy, configured group targets, or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default.
For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
For webhook ingress, startup logs a non-fatal security warning and audit flags `hooks.token` reuse of active Gateway shared-secret auth values, including `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` and `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`. It also warns when:
For webhook ingress, it warns when:
- `hooks.token` reuses an active Gateway shared-secret auth value (`gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` or `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`)
- `hooks.token` is short
- `hooks.path="/"`
- `hooks.defaultSessionKey` is unset
@@ -42,7 +43,7 @@ For webhook ingress, startup logs a non-fatal security warning and audit flags `
- overrides are enabled without `hooks.allowedSessionKeyPrefixes`
If Gateway password auth is supplied only at startup, pass the same value to `openclaw security audit --auth password --password <password>` so the audit can check it against `hooks.token`.
Run `openclaw doctor --fix` to rotate a persisted reused `hooks.token`, then update external hook senders to use the new hook token.
Password-mode reuse is an audit finding for compatibility; rotate one of the secrets instead of expecting Gateway startup to reject that configuration.
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records).

View File

@@ -372,30 +372,6 @@ its own control markers and channel delivery.
For CLIs that emit Claude Code stream-json compatible JSONL, set
`jsonlDialect: "claude-stream-json"` on that backend's config.
## Native compaction ownership
Some CLI backends run an agent that compacts its **own** transcript, so OpenClaw must
not run its safeguard summarizer against them - doing so fights the backend's own
compaction and can hard-fail the turn.
`claude-cli` has no harness endpoint - Claude Code compacts internally - so it declares
`ownsNativeCompaction: true`, and OpenClaw returns a no-op from the compaction path.
Native-harness sessions such as Codex keep routing to their harness compaction endpoint
instead.
Because the backend owns compaction, the old stopgap of setting
`contextTokens: 1_000_000` purely to keep OpenClaw's safeguard from firing on a
claude-cli session is **no longer needed** - the opt-out replaces it.
```typescript
api.registerCliBackend({ id: "my-cli", ownsNativeCompaction: true /* ... */ });
```
Only declare `ownsNativeCompaction` for a backend that genuinely owns its compaction: it
must reliably bound its own transcript as it nears its context window and persist a
resumable session (e.g. `--resume` / `--session-id`); otherwise a deferred session can
stay over budget. Matching `agentHarnessId` sessions still route to the harness endpoint.
## Bundle MCP overlays
CLI backends do **not** receive OpenClaw tool calls directly, but a backend can

View File

@@ -469,7 +469,7 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto
- `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model.
- `allowAgents`: default allowlist of configured target agent ids for `sessions_spawn` when the requester agent does not set its own `subagents.allowAgents` (`["*"]` = any configured target; default: same agent only). Stale entries whose agent config was deleted are rejected by `sessions_spawn` and omitted from `agents_list`; run `openclaw doctor --fix` to clean them up.
- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn`. `0` means no timeout.
- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout.
- `announceTimeoutMs`: per-call timeout (milliseconds) for gateway `agent` announce delivery attempts. Default: `120000`. Transient retries can make the total announce wait longer than one configured timeout.
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`.

View File

@@ -730,8 +730,8 @@ Query-string hook tokens are rejected.
Validation and safety notes:
- `hooks.enabled=true` requires a non-empty `hooks.token`.
- `hooks.token` should be distinct from active Gateway shared-secret auth (`gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` or `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`); startup logs a non-fatal security warning when it detects reuse.
- `openclaw security audit` flags hook/Gateway auth reuse as a critical finding, including Gateway password auth supplied only at audit time (`--auth password --password <password>`). Run `openclaw doctor --fix` to rotate a persisted reused `hooks.token`, then update external hook senders to use the new hook token.
- `hooks.token` must be distinct from `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`; reusing the Gateway token fails startup validation.
- `openclaw security audit` also flags `hooks.token` reuse of active Gateway password auth (`gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`, or `--auth password --password <password>`) as a critical finding; password-mode reuse stays startup-compatible and should be repaired by rotating one of the secrets.
- `hooks.path` cannot be `/`; use a dedicated subpath such as `/hooks`.
- If `hooks.allowRequestSessionKey=true`, constrain `hooks.allowedSessionKeyPrefixes` (for example `["hook:"]`).
- If a mapping or preset uses a templated `sessionKey`, set `hooks.allowedSessionKeyPrefixes` and `hooks.allowRequestSessionKey=true`. Static mapping keys do not require that opt-in.

View File

@@ -329,7 +329,6 @@ Android nodes can advertise additional command families when the corresponding c
Available families:
- `device.status`, `device.info`, `device.permissions`, `device.health`
- `device.apps` when Installed Apps sharing is enabled in Android Settings
- `notifications.list`, `notifications.actions`
- `photos.latest`
- `contacts.search`, `contacts.add`
@@ -342,14 +341,12 @@ Example invokes:
```bash
openclaw nodes invoke --node <idOrNameOrIp> --command device.status --params '{}'
openclaw nodes invoke --node <idOrNameOrIp> --command device.apps --params '{"limit":10}'
openclaw nodes invoke --node <idOrNameOrIp> --command notifications.list --params '{}'
openclaw nodes invoke --node <idOrNameOrIp> --command photos.latest --params '{"limit":1}'
```
Notes:
- `device.apps` is opt-in and returns launcher-visible apps by default.
- Motion commands are capability-gated by available sensors.
## System commands (node host / mac node)

View File

@@ -219,9 +219,8 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- By default, Android Talk uses native speech recognition, Gateway chat, and `talk.speak` through the configured gateway Talk provider. Local system TTS is used only when `talk.speak` is unavailable.
- Android Talk uses realtime Gateway relay only when `talk.realtime.mode` is `realtime` and `talk.realtime.transport` is `gateway-relay`.
- Voice wake remains disabled in the Android UX/runtime.
- Additional Android command families (availability depends on device, permissions, and user settings):
- Additional Android command families (availability depends on device + permissions):
- `device.status`, `device.info`, `device.permissions`, `device.health`
- `device.apps` only when **Settings > Phone Capabilities > Installed Apps** is enabled; it lists launcher-visible apps by default.
- `notifications.list`, `notifications.actions` (see [Notification forwarding](#notification-forwarding) below)
- `photos.latest`
- `contacts.search`, `contacts.add`

View File

@@ -208,28 +208,10 @@ only for behavior that really belongs to the backend.
| `authEpochMode` | Decide how auth changes invalidate stored CLI sessions |
| `nativeToolMode` | Declare whether the CLI has always-on native tools |
| `bundleMcp` / `bundleMcpMode` | Opt into OpenClaw's loopback MCP tool bridge |
| `ownsNativeCompaction` | Backend owns its own compaction - OpenClaw defers |
Keep these hooks provider-owned. Do not add CLI-specific branches to core when a
backend hook can express the behavior.
### `ownsNativeCompaction`: opting out of OpenClaw compaction
If your backend runs an agent that compacts its **own** transcript, set
`ownsNativeCompaction: true` so OpenClaw's safeguard summarizer never runs against its
sessions - the CLI compaction lifecycle returns a no-op and the turn proceeds. `claude-cli`
declares it because Claude Code compacts internally with no harness endpoint. Native-harness
sessions such as Codex keep routing to their harness compaction endpoint instead.
**Only declare it when all of the following hold**, or a deferred over-budget session can
stay over budget / go stale (OpenClaw no longer rescues it):
- the backend reliably compacts or bounds its own transcript as it nears its window;
- it persists a resumable session so the compacted state survives turns
(e.g. `--resume` / `--session-id`);
- it is not a native-harness compaction session - matching `agentHarnessId` sessions
route to the harness endpoint instead.
## MCP tool bridge
CLI backends do not receive OpenClaw tools by default. If the CLI can consume an

View File

@@ -120,7 +120,6 @@ observation-only.
- **`before_tool_call`** - rewrite tool params, block execution, or require approval
- `after_tool_call` - observe tool results, errors, and duration
- `resolve_exec_env` - contribute plugin-owned environment variables to `exec`
- **`tool_result_persist`** - rewrite the assistant message produced from a tool result
- **`before_message_write`** - inspect or block an in-progress message write (rare)
@@ -234,28 +233,6 @@ for host-trusted gates such as workspace policy, budget enforcement, or
reserved workflow safety. External plugins should use normal `before_tool_call`
hooks.
### Exec environment hook
`resolve_exec_env` lets plugins contribute environment variables to `exec`
tool invocations after the base exec environment is built and before the
command runs. It receives:
- `event.sessionKey`
- `event.toolName`, currently always `"exec"`
- `event.host`, one of `"gateway"`, `"sandbox"`, or `"node"`
- context fields such as `ctx.agentId`, `ctx.sessionKey`,
`ctx.messageProvider`, and `ctx.channelId`
Return a `Record<string, string>` to merge into the exec environment. Handlers
run in priority order, and later hook results override earlier hook results for
the same key.
Hook output is filtered through the host exec environment key policy before it
is merged. Invalid keys, `PATH`, and dangerous host override keys such as
`LD_*`, `DYLD_*`, `NODE_OPTIONS`, proxy variables, and TLS override variables
are dropped. The filtered plugin env is included in gateway approval/audit
metadata and forwarded to node-host execution requests.
### Tool result persistence
Tool results can include structured `details` for UI rendering, diagnostics,

View File

@@ -353,7 +353,7 @@ API key auth, and dynamic model resolution.
| --- | --- | --- |
| `openai-compatible` | Shared OpenAI-style replay policy for OpenAI-compatible transports, including tool-call-id sanitation, assistant-first ordering fixes, and generic Gemini-turn validation where the transport needs it | `moonshot`, `ollama`, `xai`, `zai` |
| `anthropic-by-model` | Claude-aware replay policy chosen by `modelId`, so Anthropic-message transports only get Claude-specific thinking-block cleanup when the resolved model is actually a Claude id | `amazon-bedrock`, `anthropic-vertex` |
| `google-gemini` | Native Gemini replay policy plus bootstrap replay sanitation. The shared family keeps the text-output Gemini CLI on tagged reasoning; the direct `google` provider overrides `resolveReasoningOutputMode` to `native` because Gemini API thinking arrives as native thought parts. | `google`, `google-gemini-cli` |
| `google-gemini` | Native Gemini replay policy plus bootstrap replay sanitation and tagged reasoning-output mode | `google`, `google-gemini-cli` |
| `passthrough-gemini` | Gemini thought-signature sanitation for Gemini models running through OpenAI-compatible proxy transports; does not enable native Gemini replay validation or bootstrap rewrites | `openrouter`, `kilocode`, `opencode`, `opencode-go` |
| `hybrid-anthropic-openai` | Hybrid policy for providers that mix Anthropic-message and OpenAI-compatible model surfaces in one plugin; optional Claude-only thinking-block dropping stays scoped to the Anthropic side | `minimax` |
@@ -376,13 +376,6 @@ API key auth, and dynamic model resolution.
- `openclaw/plugin-sdk/provider-stream` - `ProviderStreamFamily`, `buildProviderStreamFamilyHooks(...)`, `composeProviderStreamWrappers(...)`, plus the shared OpenAI/Codex wrappers (`createOpenAIAttributionHeadersWrapper`, `createOpenAIFastModeWrapper`, `createOpenAIServiceTierWrapper`, `createOpenAIResponsesContextManagementWrapper`, `createCodexNativeWebSearchWrapper`), DeepSeek V4 OpenAI-compatible wrapper (`createDeepSeekV4OpenAICompatibleThinkingWrapper`), Anthropic Messages thinking prefill cleanup (`createAnthropicThinkingPrefillPayloadWrapper`), plain-text tool-call compat (`createPlainTextToolCallCompatWrapper`), and shared proxy/provider wrappers (`createOpenRouterWrapper`, `createToolStreamWrapper`, `createMinimaxFastModeWrapper`).
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai")`, and underlying provider schema helpers.
For Gemini-family providers, keep the reasoning-output mode aligned with
the transport. Direct Google Gemini API providers should use `native`
reasoning output so OpenClaw consumes native thought parts without adding
`<think>` / `<final>` prompt directives. Text-only Gemini CLI-style
backends that parse a final JSON/text response can keep the shared
`google-gemini` tagged contract.
Some stream helpers stay provider-local on purpose. `@openclaw/anthropic-provider` keeps `wrapAnthropicProviderStream`, `resolveAnthropicBetas`, `resolveAnthropicFastMode`, `resolveAnthropicServiceTier`, and the lower-level Anthropic wrapper builders in its own public `api.ts` / `contract-api.ts` seam because they encode Claude OAuth beta handling and `context1m` gating. The xAI plugin similarly keeps native xAI Responses shaping in its own `wrapStreamFn` (`/fast` aliases, default `tool_stream`, unsupported strict-tool cleanup, xAI-specific reasoning-payload removal).
The same package-root pattern also backs `@openclaw/openai-provider` (provider builders, default-model helpers, realtime provider builders) and `@openclaw/openrouter-provider` (provider builder plus onboarding/config helpers).

View File

@@ -58,15 +58,6 @@ explicitly to use Gemini, Voyage, Mistral, DeepInfra, Bedrock, GitHub Copilot,
Ollama, a local GGUF model, or an OpenAI-compatible `/v1/embeddings` endpoint.
Legacy configs that still say `provider: "auto"` resolve to `openai`.
<Warning>
Changing the embedding provider, model, provider settings, sources, scope,
chunking, or tokenizer can make the existing SQLite vector index incompatible.
OpenClaw pauses vector search and reports an index identity warning instead of
automatically re-embedding everything. Rebuild when you are ready with
`openclaw memory status --index --agent <id>` or
`openclaw memory index --force --agent <id>`.
</Warning>
If OpenAI embeddings are unreachable from your network, memory recall fails open
instead of blocking the turn. Set the existing `memorySearch.provider` field to a
reachable local, Ollama, regional, or OpenAI-compatible provider to restore
@@ -164,8 +155,7 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
| `outputDimensionality` | `number` | `3072` | For Embedding 2: 768, 1536, or 3072 |
<Warning>
Changing model or `outputDimensionality` changes the index identity. OpenClaw
pauses vector search until you explicitly rebuild the memory index.
Changing model or `outputDimensionality` triggers an automatic full reindex.
</Warning>
</Accordion>

View File

@@ -105,7 +105,7 @@ Per-agent heartbeat is supported at `agents.list[].heartbeat`.
- Prompt caching is automatic on supported recent models. OpenClaw does not need to inject block-level cache markers.
- OpenClaw uses `prompt_cache_key` to keep cache routing stable across turns. Direct OpenAI hosts use `prompt_cache_retention: "24h"` when `cacheRetention: "long"` is selected.
- OpenAI-compatible Completions providers receive `prompt_cache_key` only when their model config explicitly sets `compat.supportsPromptCacheKey: true`. Long-retention forwarding is a separate capability: explicit `cacheRetention: "long"` sends `prompt_cache_retention: "24h"` only when that compat entry also supports long cache retention. Providers such as Mistral can opt into cache keys while setting `compat.supportsLongCacheRetention: false` to suppress the long-retention field. `cacheRetention: "none"` suppresses both fields.
- OpenAI-compatible Completions providers receive `prompt_cache_key` only when their model config explicitly sets `compat.supportsPromptCacheKey: true`; with that same opt-in, explicit `cacheRetention: "long"` also forwards `prompt_cache_retention: "24h"`, and `cacheRetention: "none"` suppresses both fields.
- OpenAI responses expose cached prompt tokens via `usage.prompt_tokens_details.cached_tokens` (or `input_tokens_details.cached_tokens` on Responses API events). OpenClaw maps that to `cacheRead`.
- OpenAI does not expose a separate cache-write token counter, so `cacheWrite` stays `0` on OpenAI paths even when the provider is warming a cache.
- OpenAI returns useful tracing and rate-limit headers such as `x-request-id`, `openai-processing-ms`, and `x-ratelimit-*`, but cache-hit accounting should come from the usage payload, not from headers.

View File

@@ -101,8 +101,6 @@ Automated UK school meal booking via ParentPay. Uses mouse coordinates for relia
**@julianengel** • `files` `r2` `presigned-urls`
Upload to Cloudflare R2/S3 and generate secure presigned download links. Useful for remote OpenClaw instances.
<img src="/assets/showcase/r2-upload.png" alt="R2 upload skill on ClawHub" />
</Card>
<Card title="iOS app via Telegram" icon="mobile">
@@ -271,8 +269,6 @@ Vapi voice assistant to OpenClaw HTTP bridge. Near real-time phone calls with yo
**@obviyus** • `transcription` `multilingual` `skill`
Multi-lingual audio transcription via OpenRouter (Gemini, and more). Available on ClawHub.
<img src="/assets/showcase/openrouter-transcribe.png" alt="OpenRouter transcription skill on ClawHub" />
</Card>
</CardGroup>
@@ -293,8 +289,6 @@ OpenClaw gateway running on Home Assistant OS with SSH tunnel support and persis
**ClawHub**`homeassistant` `skill` `automation`
Control and automate Home Assistant devices via natural language.
<img src="/assets/showcase/homeassistant.png" alt="Home Assistant skill on ClawHub" />
</Card>
<Card title="Nix packaging" icon="snowflake" href="https://github.com/openclaw/nix-openclaw">
@@ -307,8 +301,6 @@ Batteries-included nixified OpenClaw configuration for reproducible deployments.
**ClawHub**`calendar` `caldav` `skill`
Calendar skill using khal and vdirsyncer. Self-hosted calendar integration.
<img src="/assets/showcase/caldav-calendar.png" alt="CalDAV calendar skill on ClawHub" />
</Card>
</CardGroup>

View File

@@ -219,7 +219,7 @@ What you set:
- `--custom-model-id`
- `--custom-api-key` (optional; falls back to `CUSTOM_API_KEY`)
- `--custom-provider-id` (optional)
- `--custom-compatibility <openai|openai-responses|anthropic>` (optional; default `openai`)
- `--custom-compatibility <openai|anthropic>` (optional; default `openai`)
- `--custom-image-input` / `--custom-text-input` (optional; override inferred model input capability)
</Accordion>

View File

@@ -286,9 +286,8 @@ different operation limit:
openclaw config set plugins.entries.acpx.config.timeoutSeconds 180
```
Runtime turns use OpenClaw agent/run timeouts, including `/acp timeout`.
`sessions_spawn` does not accept per-call timeout overrides. Restart the
gateway after changing this value.
Runtime turns use OpenClaw agent/run timeouts, including `/acp timeout` and
`sessions_spawn.timeoutSeconds`. Restart the gateway after changing this value.
### Health probe agent configuration

View File

@@ -549,11 +549,12 @@ Two ways to start an ACP session:
`streamLogPath` pointing to a session-scoped JSONL log
(`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
</ParamField>
ACP `sessions_spawn` runs use `agents.defaults.subagents.runTimeoutSeconds` for
their default child turn limit. The tool does not accept per-call timeout
overrides.
<ParamField path="runTimeoutSeconds" type="number">
Aborts the ACP child turn after N seconds. `0` keeps the turn on the
gateway's no-timeout path. The same value is applied to the Gateway
run and ACP runtime so stalled/quota-exhausted harnesses do not
occupy the parent agent lane indefinitely.
</ParamField>
<ParamField path="model" type="string">
Explicit model override for the ACP child session. Codex ACP spawns
normalize OpenAI refs such as `openai/gpt-5.4` to Codex ACP startup

View File

@@ -141,7 +141,7 @@ session to confirm the effective tool list.
- **Model:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`). ACP runtime spawns use the same configured subagent model when present; otherwise the ACP harness keeps its own default. An explicit `sessions_spawn.model` still wins.
- **Thinking:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`). ACP runtime spawns also apply `agents.defaults.models["provider/model"].params.thinking` for the selected model. An explicit `sessions_spawn.thinking` still wins.
- **Run timeout:** OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). `sessions_spawn` does not accept per-call timeout overrides.
- **Run timeout:** if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout).
- **Task delivery:** native sub-agents receive the delegated task in their first visible `[Subagent Task]` message. The sub-agent system prompt carries runtime rules and routing context, not a hidden duplicate of the task.
Accepted native sub-agent spawns include the resolved child model metadata in
@@ -208,6 +208,9 @@ Per-agent overrides use `agents.list[].subagents.delegationMode`.
<ParamField path="thinking" type="string">
Override thinking level for the sub-agent run.
</ParamField>
<ParamField path="runTimeoutSeconds" type="number">
Defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`. When set, the sub-agent run is aborted after N seconds.
</ParamField>
<ParamField path="thread" type="boolean" default="false">
When `true`, requests channel thread binding for this sub-agent session.
</ParamField>
@@ -372,7 +375,7 @@ remain spawnable while inheriting defaults.
- Archive uses `sessions.delete` and renames the transcript to `*.deleted.<timestamp>` (same folder).
- `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename).
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
- Configured run timeouts do **not** auto-archive; they only stop the run. The session remains until auto-archive.
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
- Auto-archive applies equally to depth-1 and depth-2 sessions.
- Browser cleanup is separate from archive cleanup: tracked browser tabs/processes are best-effort closed when the run finishes, even if the transcript/session record is kept.
@@ -391,7 +394,7 @@ worker sub-sub-agents.
maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1)
maxChildrenPerAgent: 5, // max active children per agent session (default: 5)
maxConcurrent: 8, // global concurrency lane cap (default: 8)
runTimeoutSeconds: 900, // default timeout for sessions_spawn (0 = no timeout)
runTimeoutSeconds: 900, // default timeout for sessions_spawn when omitted (0 = no timeout)
announceTimeoutMs: 120000, // per-call gateway announce timeout
},
},

View File

@@ -215,7 +215,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
agent: "codex",
mode: "persistent",
model: "gpt-5.4",
sessionOptions: { model: "gpt-5.4" },
});
});
@@ -620,7 +619,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
);
});
it("passes model startup through sessionOptions for non-Codex ACP agents", async () => {
it("does not normalize model startup for non-Codex ACP agents", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
@@ -649,7 +648,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
agent: "main",
mode: "persistent",
model: "openai/gpt-5.5",
sessionOptions: { model: "openai/gpt-5.5" },
});
});
@@ -696,7 +694,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
agent: "codex",
mode: "persistent",
model: "gpt-5.5",
sessionOptions: { model: "gpt-5.5" },
});
});
@@ -731,7 +728,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
mode: "persistent",
model: "gpt-5.4/xhigh",
thinking: "x-high",
sessionOptions: { model: "gpt-5.4/xhigh" },
});
});

View File

@@ -17,7 +17,6 @@ import {
type AcpRuntimeStatus,
type AcpRuntimeTurn,
type AcpRuntimeTurnResult,
type SessionAgentOptions,
} from "acpx/runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime";
@@ -50,8 +49,6 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
openclawProcessCleanup?: AcpxProcessCleanupDeps;
};
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
type ResetAwareSessionStore = AcpSessionStore & {
markFresh: (sessionKey: string) => void;
@@ -550,16 +547,6 @@ function codexAcpSessionModelId(override: CodexAcpModelOverride): string {
: override.model;
}
function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegateEnsureInput {
const existingOptions = (input as { sessionOptions?: SessionAgentOptions }).sessionOptions;
const model = input.model?.trim() || existingOptions?.model;
const sessionOptions = model ? { ...existingOptions, model } : existingOptions;
return {
...input,
...(sessionOptions ? { sessionOptions } : {}),
} as AcpxDelegateEnsureInput;
}
function quoteShellArg(value: string): string {
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value;
@@ -955,7 +942,7 @@ export class AcpxRuntime implements AcpRuntime {
this.withCodexWrapperDiagnostics({
command: stableLaunchCommand,
fallbackCode: "ACP_SESSION_INIT_FAILED",
run: () => delegate.ensureSession(withAcpxSessionOptions(input)),
run: () => delegate.ensureSession(input),
}),
});
}
@@ -975,7 +962,7 @@ export class AcpxRuntime implements AcpRuntime {
this.withCodexWrapperDiagnostics({
command: stableLaunchCommand,
fallbackCode: "ACP_SESSION_INIT_FAILED",
run: () => delegate.ensureSession(withAcpxSessionOptions(normalizedInput)),
run: () => delegate.ensureSession(normalizedInput),
}),
),
});

View File

@@ -29,7 +29,6 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "always-on",
ownsNativeCompaction: true,
config: {
command: "claude",
args: [

View File

@@ -266,7 +266,6 @@ export async function startCodexAttemptThread(params: {
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
environmentSelection: startupEnvironmentSelection,
contextEngineProjection: params.contextEngineProjection,
signal: params.signal,
pluginThreadConfig: pluginThreadConfigRequired
? {
enabled: true,

View File

@@ -18,8 +18,6 @@ import {
import {
filterCodexDynamicTools,
resolveCodexDynamicToolsLoading,
resolveCodexDynamicToolsLoadingForModel,
shouldUseDirectCodexDynamicToolsForModel,
} from "./dynamic-tool-profile.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { createCodexTestModel } from "./test-support.js";
@@ -181,22 +179,6 @@ describe("Codex app-server dynamic tool build", () => {
expect(resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
});
it("uses direct dynamic tools for OpenAI nano models without tool_search support", () => {
const tools = [createRuntimeDynamicTool("message"), createRuntimeDynamicTool("web_search")];
const toolBridge = createCodexDynamicToolBridge({
tools,
signal: new AbortController().signal,
loading: resolveCodexDynamicToolsLoadingForModel({}, "openai/gpt-5.4-nano"),
});
expect(shouldUseDirectCodexDynamicToolsForModel("gpt-5.4-nano")).toBe(true);
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.4-nano")).toBe("direct");
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.5")).toBe("searchable");
const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search");
expect(webSearch).not.toHaveProperty("deferLoading");
expect(webSearch).not.toHaveProperty("namespace");
});
it("quarantines unreadable tool entries before Codex-specific filtering", async () => {
const messageTool = createRuntimeDynamicTool("message");
const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], {

View File

@@ -47,33 +47,6 @@ export function resolveCodexDynamicToolsLoading(
: (config.codexDynamicToolsLoading ?? "searchable");
}
function normalizeCodexModelId(modelId: string | undefined): string {
const normalized = modelId?.trim().toLowerCase();
if (!normalized) {
return "";
}
return normalized.includes("/") ? normalized.split("/").at(-1)! : normalized;
}
export function shouldUseDirectCodexDynamicToolsForModel(modelId: string | undefined): boolean {
return shouldDisableCodexToolSearchForModel(modelId);
}
export function shouldDisableCodexToolSearchForModel(modelId: string | undefined): boolean {
return normalizeCodexModelId(modelId) === "gpt-5.4-nano";
}
export function resolveCodexDynamicToolsLoadingForModel(
config: Pick<CodexPluginConfig, "codexDynamicToolsLoading">,
modelId: string | undefined,
env: CodexDynamicToolProfileEnv = process.env,
): CodexDynamicToolsLoading {
const loading = resolveCodexDynamicToolsLoading(config, env);
return loading === "searchable" && shouldUseDirectCodexDynamicToolsForModel(modelId)
? "direct"
: loading;
}
export function filterCodexDynamicTools<T extends { name: string }>(
tools: T[],
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,

View File

@@ -1652,81 +1652,6 @@ describe("CodexAppServerEventProjector", () => {
});
});
it("fails closed when a native tool call finishes without a matching result", async () => {
const trajectoryRecorder = {
filePath: "trajectory.jsonl",
recordEvent: vi.fn(),
flush: vi.fn(async () => undefined),
};
const projector = await createProjector(await createParams(), { trajectoryRecorder });
await projector.handleNotification(
forCurrentTurn("item/started", {
item: {
type: "commandExecution",
id: "cmd-denied",
command: "node scripts/report.js --publish",
cwd: "/workspace",
processId: null,
source: "agent",
status: "inProgress",
commandActions: [],
aggregatedOutput: null,
exitCode: null,
durationMs: null,
},
}),
);
await projector.handleNotification(
turnCompleted([
{
type: "agentMessage",
id: "msg-denied",
text: "The requested publish command was denied before execution.",
},
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(String(result.promptError)).toContain("without a matching tool.result");
expect(result.promptErrorSource).toBe("prompt");
expect(result.messagesSnapshot.map((message) => message.role)).toEqual([
"user",
"assistant",
"toolResult",
"assistant",
]);
const toolResultMessage = requireRecord(result.messagesSnapshot[2], "tool result message");
expect(toolResultMessage.toolCallId).toBe("cmd-denied");
expect(toolResultMessage.toolName).toBe("bash");
expect(toolResultMessage.isError).toBe(true);
const toolResultContent = requireArray(toolResultMessage.content, "tool result content");
expect(JSON.stringify(toolResultContent)).toContain("matching tool.result");
expect(trajectoryRecorder.recordEvent).toHaveBeenCalledWith("tool.call", {
threadId: THREAD_ID,
turnId: TURN_ID,
itemId: "cmd-denied",
toolCallId: "cmd-denied",
name: "bash",
arguments: {
command: "node scripts/report.js --publish",
cwd: "/workspace",
},
});
expect(trajectoryRecorder.recordEvent).toHaveBeenCalledWith("tool.result", {
threadId: THREAD_ID,
turnId: TURN_ID,
itemId: "cmd-denied",
toolCallId: "cmd-denied",
name: "bash",
status: "failed",
isError: true,
result: { status: "failed", reason: "missing_tool_result" },
output: expect.stringContaining("without a matching tool.result"),
});
});
it("uses streamed command output when final command snapshots omit aggregated output", async () => {
const onAgentEvent = vi.fn();
const trajectoryRecorder = {

View File

@@ -109,8 +109,6 @@ const CODEX_PROMPT_TOTAL_INPUT_KEYS = [
const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20;
const TOOL_TRANSCRIPT_OUTPUT_MAX_CHARS = 12_000;
const MISSING_TOOL_RESULT_ERROR =
"OpenClaw recorded a native Codex tool.call without a matching tool.result before the turn completed.";
const GENERATED_IMAGE_MEDIA_SUBDIR = "tool-image-generation";
const BYTES_PER_MB = 1024 * 1024;
// Match OpenClaw's default image media cap for generated image tool outputs.
@@ -174,10 +172,6 @@ export class CodexAppServerEventProjector {
private readonly toolTranscriptMessages: AgentMessage[] = [];
private readonly toolTranscriptCallIds = new Set<string>();
private readonly toolTranscriptResultIds = new Set<string>();
private readonly toolTranscriptNamesById = new Map<string, string>();
private readonly toolTrajectoryCallIds = new Set<string>();
private readonly toolTrajectoryResultIds = new Set<string>();
private readonly toolTrajectoryNamesById = new Map<string, string>();
private readonly transcriptToolProgressCallIds = new Set<string>();
private lastNativeToolError: EmbeddedRunAttemptResult["lastToolError"];
private readonly nativeGeneratedMediaUrls = new Set<string>();
@@ -191,7 +185,6 @@ export class CodexAppServerEventProjector {
private completedTurn: CodexTurn | undefined;
private promptError: unknown;
private promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
private synthesizedMissingToolResultError: string | null = null;
private aborted = false;
private tokenUsage: ReturnType<typeof normalizeUsage>;
private guardianReviewCount = 0;
@@ -292,12 +285,6 @@ export class CodexAppServerEventProjector {
this.reasoningItemOrder,
).join("\n\n");
const planText = collectTextValues(this.planTextByItem).join("\n\n");
this.synthesizeMissingToolResults({
failClosed:
!this.completedTurn ||
this.completedTurn.status !== "completed" ||
assistantTexts.length > 0,
});
const lastAssistant =
assistantTexts.length > 0
? this.createAssistantMessage(assistantTexts.join("\n\n"))
@@ -341,7 +328,6 @@ export class CodexAppServerEventProjector {
const turnFailed = this.completedTurn?.status === "failed";
const promptError =
this.promptError ??
this.synthesizedMissingToolResultError ??
(turnFailed ? (this.completedTurn?.error?.message ?? "codex app-server turn failed") : null);
const agentHarnessResultClassification = classifyAgentHarnessTerminalOutcome({
assistantTexts,
@@ -1139,8 +1125,6 @@ export class CodexAppServerEventProjector {
status: ReturnType<typeof itemStatus>;
}): void {
if (params.phase === "start") {
this.toolTrajectoryCallIds.add(params.item.id);
this.toolTrajectoryNamesById.set(params.item.id, params.name);
this.options.trajectoryRecorder?.recordEvent("tool.call", {
threadId: this.threadId,
turnId: this.turnId,
@@ -1151,7 +1135,6 @@ export class CodexAppServerEventProjector {
});
return;
}
this.toolTrajectoryResultIds.add(params.item.id);
const toolResult = itemToolResult(params.item).result;
const output = itemOutputText(params.item, this.toolResultOutputTextByItem);
this.options.trajectoryRecorder?.recordEvent("tool.result", {
@@ -1413,7 +1396,6 @@ export class CodexAppServerEventProjector {
return;
}
this.toolTranscriptCallIds.add(params.id);
this.toolTranscriptNamesById.set(params.id, params.name);
this.toolTranscriptArgumentsById.set(params.id, params.arguments);
if (!shouldEmitTranscriptToolProgress(params.name, params.arguments)) {
this.transcriptToolProgressSuppressedIds.add(params.id);
@@ -1443,61 +1425,6 @@ export class CodexAppServerEventProjector {
);
}
private synthesizeMissingToolResults(params: { failClosed: boolean }): void {
if (!params.failClosed) {
return;
}
const missingTranscriptIds = [...this.toolTranscriptCallIds].filter(
(id) => !this.toolTranscriptResultIds.has(id),
);
const missingTrajectoryIds = [...this.toolTrajectoryCallIds].filter(
(id) => !this.toolTrajectoryResultIds.has(id),
);
if (missingTranscriptIds.length === 0 && missingTrajectoryIds.length === 0) {
return;
}
for (const id of missingTranscriptIds) {
const name = this.toolTranscriptNamesById.get(id) ?? this.toolTrajectoryNamesById.get(id);
if (!name) {
continue;
}
this.recordToolTranscriptResult({
id,
name,
text: formatMissingToolResultError({ id, name }),
isError: true,
});
}
for (const id of missingTrajectoryIds) {
const name = this.toolTrajectoryNamesById.get(id) ?? this.toolTranscriptNamesById.get(id);
if (!name) {
continue;
}
this.toolTrajectoryResultIds.add(id);
const text = formatMissingToolResultError({ id, name });
this.options.trajectoryRecorder?.recordEvent("tool.result", {
threadId: this.threadId,
turnId: this.turnId,
itemId: id,
toolCallId: id,
name,
status: "failed",
isError: true,
result: { status: "failed", reason: "missing_tool_result" },
output: text,
});
}
const missingCount = new Set([...missingTranscriptIds, ...missingTrajectoryIds]).size;
this.synthesizedMissingToolResultError =
missingCount === 1
? MISSING_TOOL_RESULT_ERROR
: `${MISSING_TOOL_RESULT_ERROR} missingToolResultCount=${missingCount}`;
this.promptErrorSource = this.promptErrorSource ?? "prompt";
}
private emitTranscriptToolCallProgress(params: ToolTranscriptCallInput): void {
if (!shouldEmitTranscriptToolProgress(params.name, params.arguments)) {
return;
@@ -2027,10 +1954,6 @@ function itemStatus(item: CodexThreadItem): "completed" | "failed" | "running" |
return "completed";
}
function formatMissingToolResultError(params: { id: string; name: string }): string {
return `${MISSING_TOOL_RESULT_ERROR} toolCallId=${params.id}; toolName=${params.name}`;
}
function isNonSuccessItemStatus(status: ReturnType<typeof itemStatus>): boolean {
return status === "failed" || status === "blocked";
}

View File

@@ -1281,7 +1281,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-fresh");
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
expect(savedBinding?.contextEngine?.projection).toBeUndefined();
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-before");
});
it("preserves a newer context-engine binding when a stale resumed thread overflows", async () => {

View File

@@ -165,7 +165,7 @@ import {
} from "./dynamic-tool-execution.js";
import {
filterCodexDynamicTools,
resolveCodexDynamicToolsLoadingForModel,
resolveCodexDynamicToolsLoading,
} from "./dynamic-tool-profile.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js";
@@ -595,7 +595,7 @@ export async function runCodexAppServerAttempt(
tools,
registeredTools,
signal: runAbortController.signal,
loading: resolveCodexDynamicToolsLoadingForModel(pluginConfig, params.modelId),
loading: resolveCodexDynamicToolsLoading(pluginConfig),
directToolNames: shouldForceMessageTool(params) ? ["message"] : [],
hookContext: {
agentId: sessionAgentId,
@@ -1924,38 +1924,6 @@ export async function runCodexAppServerAttempt(
);
} else {
thread = await restartContextEngineCodexThread();
// The fresh retry thread was not bootstrapped with the
// context-engine projection. Clear the stale projection from
// the saved binding so the next run will re-project instead
// of assuming the old epoch is still in the thread.
{
const retryBinding = await readCodexAppServerBinding(activeSessionFile);
if (
retryBinding &&
retryBinding.threadId === thread.threadId &&
retryBinding.contextEngine?.projection
) {
const {
schemaVersion: _schemaVersion,
sessionFile: _boundSessionFile,
updatedAt: _updatedAt,
...bindingForWrite
} = retryBinding;
await writeCodexAppServerBinding(activeSessionFile, {
...bindingForWrite,
contextEngine: bindingForWrite.contextEngine
? { ...bindingForWrite.contextEngine, projection: undefined }
: undefined,
});
embeddedAgentLog.info(
"codex app-server cleared stale context-engine projection after overflow retry",
{
threadId: thread.threadId,
previousEpoch: retryBinding.contextEngine.projection.epoch,
},
);
}
}
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "thread_ready_retry", threadId: thread.threadId },
@@ -2672,7 +2640,7 @@ export const testing = {
buildDynamicTools,
filterCodexDynamicToolsForAllowlist,
includeForcedCodexDynamicToolAllow,
resolveCodexDynamicToolsLoadingForModel,
resolveCodexDynamicToolsLoading,
resolveCodexAppServerHookChannelId,
buildCodexAppServerPromptTimeoutOutcome,
resolveOpenClawCodingToolsSessionKeys,

View File

@@ -1,31 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
import { sendResult } from "./sandbox-exec-server/json-rpc.js";
function createSocket() {
return {
send: vi.fn(),
} as unknown as WebSocket & { send: ReturnType<typeof vi.fn> };
}
function sentJson(socket: ReturnType<typeof createSocket>) {
return JSON.parse(String(socket.send.mock.calls[0]?.[0])) as unknown;
}
describe("sandbox exec-server JSON-RPC helpers", () => {
it("preserves explicit null results", () => {
const socket = createSocket();
sendResult(socket, 1, null);
expect(sentJson(socket)).toEqual({ jsonrpc: "2.0", id: 1, result: null });
});
it("keeps undefined results as empty objects for methods without bodies", () => {
const socket = createSocket();
sendResult(socket, 2, undefined);
expect(sentJson(socket)).toEqual({ jsonrpc: "2.0", id: 2, result: {} });
});
});

View File

@@ -80,9 +80,7 @@ export function sendResult(
id: string | number,
result: JsonValue | undefined,
): void {
socket.send(
JSON.stringify({ jsonrpc: "2.0", id, result: result === undefined ? {} : result }),
);
socket.send(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }));
}
export function sendError(

View File

@@ -40,7 +40,6 @@ export type CodexAppServerThreadBinding = {
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
dynamicToolsFingerprint?: string;
dynamicToolsContainDeferred?: boolean;
userMcpServersFingerprint?: string;
mcpServersFingerprint?: string;
nativeHookRelayGeneration?: string;
@@ -112,10 +111,6 @@ export async function readCodexAppServerBinding(
typeof parsed.dynamicToolsFingerprint === "string"
? parsed.dynamicToolsFingerprint
: undefined,
dynamicToolsContainDeferred:
typeof parsed.dynamicToolsContainDeferred === "boolean"
? parsed.dynamicToolsContainDeferred
: undefined,
userMcpServersFingerprint:
typeof parsed.userMcpServersFingerprint === "string"
? parsed.userMcpServersFingerprint
@@ -175,7 +170,6 @@ export async function writeCodexAppServerBinding(
sandbox: binding.sandbox,
serviceTier: binding.serviceTier,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
userMcpServersFingerprint: binding.userMcpServersFingerprint,
mcpServersFingerprint: binding.mcpServersFingerprint,
nativeHookRelayGeneration: binding.nativeHookRelayGeneration,

View File

@@ -63,16 +63,6 @@ function createNamedDynamicTool(
};
}
function createDeferredNamedDynamicTool(
name: string,
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
return {
...createNamedDynamicTool(name),
namespace: "openclaw",
deferLoading: true,
};
}
function createPluginAppConfigPatch() {
return {
apps: {
@@ -179,42 +169,6 @@ function createTwoCalendarAppPolicyContext() {
setupRunAttemptTestHooks();
describe("Codex app-server thread lifecycle bindings", () => {
it("does not write a binding when thread start resolves after abort", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const abortController = new AbortController();
let resolveStart: ((value: ReturnType<typeof threadStartResult>) => void) | undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return await new Promise<ReturnType<typeof threadStartResult>>((resolve) => {
resolveStart = resolve;
});
}
throw new Error(`unexpected method: ${method}`);
});
const run = startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
signal: abortController.signal,
});
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("thread/start", expect.any(Object), {
signal: abortController.signal,
}),
);
abortController.abort("test_abort");
resolveStart?.(threadStartResult("thread-after-abort"));
await expect(run).rejects.toThrow("test_abort");
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
});
it("resumes a bound Codex thread when only dynamic tool descriptions change", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -253,42 +207,6 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it("starts a fresh Codex thread when dynamic tools switch from deferred to direct", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
appServer,
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("web_search")],
appServer,
});
expect(binding.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
});
it("resumes a bound Codex thread when dynamic tools are reordered", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -535,7 +453,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("message")],
dynamicTools: [createMessageDynamicTool("Send and manage messages.")],
appServer,
});
const fingerprint = (await readCodexAppServerBinding(sessionFile))?.dynamicToolsFingerprint;
@@ -550,13 +468,12 @@ describe("Codex app-server thread lifecycle bindings", () => {
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("message")],
dynamicTools: [createMessageDynamicTool("Send and manage messages.")],
appServer,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.dynamicToolsFingerprint).toBe(fingerprint);
expect(binding?.dynamicToolsContainDeferred).toBe(true);
expect(binding?.threadId).toBe("thread-1");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",

View File

@@ -1,8 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
import {
buildDeveloperInstructions,
@@ -11,15 +8,8 @@ import {
buildThreadResumeParams,
buildThreadStartParams,
codexDynamicToolsFingerprint,
formatCodexThreadLifecycleTimingSummary,
resolveReasoningEffort,
shouldWarnCodexThreadLifecycleTimingSummary,
startOrResumeThread,
type CodexThreadLifecycleTimingLogger,
} from "./thread-lifecycle.js";
import { createCodexTestModel } from "./test-support.js";
let tempDir: string;
function createAttemptParams(params: {
provider: string;
@@ -31,7 +21,6 @@ function createAttemptParams(params: {
bootstrapContextMode?: "full" | "lightweight";
bootstrapContextRunKind?: "default" | "heartbeat" | "cron";
images?: EmbeddedRunAttemptParams["images"];
modelId?: string;
}): EmbeddedRunAttemptParams {
const authProfileProviders =
params.authProfileProviders ??
@@ -41,7 +30,7 @@ function createAttemptParams(params: {
const authProfileType = params.authProfileType ?? "oauth";
return {
provider: params.provider,
modelId: params.modelId ?? "gpt-5.4",
modelId: "gpt-5.4",
prompt: "test prompt",
authProfileId: params.authProfileId,
...(params.bootstrapContextMode ? { bootstrapContextMode: params.bootstrapContextMode } : {}),
@@ -84,102 +73,6 @@ function createAppServerOptions() {
} as const;
}
function createThreadLifecycleParams(
sessionFile: string,
workspaceDir: string,
): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir,
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4-codex",
model: createCodexTestModel("codex"),
thinkLevel: "medium",
disableTools: true,
timeoutMs: 5_000,
authStorage: {} as never,
authProfileStore: { version: 1, profiles: {} },
modelRegistry: {} as never,
} as EmbeddedRunAttemptParams;
}
function createThreadLifecycleAppServerOptions(): Parameters<
typeof startOrResumeThread
>[0]["appServer"] {
return {
start: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
},
codeModeOnly: false,
requestTimeoutMs: 60_000,
turnCompletionIdleTimeoutMs: 60_000,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
};
}
function threadStartResult(threadId = "thread-1") {
return {
thread: {
id: threadId,
sessionId: "session-1",
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: tempDir,
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: "gpt-5.4-codex",
modelProvider: "openai",
serviceTier: null,
cwd: tempDir,
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
function createTimingLogger(traceEnabled: boolean): CodexThreadLifecycleTimingLogger {
return {
isEnabled: vi.fn((level: "trace") => level === "trace" && traceEnabled),
trace: vi.fn(),
warn: vi.fn(),
};
}
function expectSingleLogMessage(
log: CodexThreadLifecycleTimingLogger,
level: "trace" | "warn",
): string {
const mock = log[level] as ReturnType<typeof vi.fn>;
expect(mock).toHaveBeenCalledTimes(1);
const message = mock.mock.calls[0]?.[0];
expect(typeof message).toBe("string");
return message as string;
}
describe("Codex app-server native code mode config", () => {
it("keeps Codex-native subagents primary while limiting OpenClaw spawn to OpenClaw delegation", () => {
const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" }));
@@ -258,7 +151,7 @@ describe("Codex app-server native code mode config", () => {
expect(instructions).not.toContain("Deferred searchable OpenClaw dynamic tools available");
});
it("keeps durable dynamic tool fingerprints scoped to loading mode", () => {
it("keeps durable dynamic tool fingerprints independent from presentation mode", () => {
const inputSchema = {
type: "object",
additionalProperties: false,
@@ -284,7 +177,7 @@ describe("Codex app-server native code mode config", () => {
},
]);
expect(searchableFingerprint).not.toBe(directFingerprint);
expect(searchableFingerprint).toBe(directFingerprint);
});
it("keeps OpenClaw skill catalogs out of developer instructions", () => {
@@ -321,25 +214,6 @@ describe("Codex app-server native code mode config", () => {
expect(request.personality).toBe("none");
});
it("disables Codex tool-search features for nano models", () => {
const request = buildThreadStartParams(
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
{
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
},
);
expect(request.config).toEqual({
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.multi_agent": false,
});
});
it("removes Codex model personality on thread/resume", () => {
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
@@ -800,176 +674,6 @@ describe("Codex app-server model provider selection", () => {
});
});
describe("Codex app-server thread lifecycle timing", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-thread-lifecycle-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("formats stage summaries with run, session, action, and elapsed timing", () => {
const message = formatCodexThreadLifecycleTimingSummary({
runId: "run-a",
sessionId: "session-a",
sessionKey: "agent:main:session-a",
action: "started",
summary: {
totalMs: 12,
spans: [
{ name: "read-binding", durationMs: 4, elapsedMs: 4 },
{ name: "thread-start-request", durationMs: 8, elapsedMs: 12 },
],
},
});
expect(message).toBe(
"[trace:codex-app-server] thread lifecycle: runId=run-a sessionId=session-a " +
"sessionKey=agent:main:session-a action=started totalMs=12 " +
"stages=read-binding:4ms@4ms,thread-start-request:8ms@12ms",
);
});
it("warns when the total or a single stage crosses the lifecycle threshold", () => {
expect(
shouldWarnCodexThreadLifecycleTimingSummary(
{
totalMs: 9,
spans: [{ name: "thread-start-request", durationMs: 10, elapsedMs: 10 }],
},
{ totalThresholdMs: 50, stageThresholdMs: 10 },
),
).toBe(true);
expect(
shouldWarnCodexThreadLifecycleTimingSummary(
{
totalMs: 50,
spans: [{ name: "thread-start-request", durationMs: 1, elapsedMs: 1 }],
},
{ totalThresholdMs: 50, stageThresholdMs: 10 },
),
).toBe(true);
});
it("emits a trace stage summary when starting a new thread with trace enabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
let nowMs = 0;
const log = createTimingLogger(true);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
nowMs += 17;
return threadStartResult("thread-started");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createThreadLifecycleParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
timing: {
enabled: true,
now: () => nowMs,
log,
totalThresholdMs: 1_000,
stageThresholdMs: 1_000,
},
});
const message = expectSingleLogMessage(log, "trace");
expect(log.warn).not.toHaveBeenCalled();
expect(message).toContain("action=started");
expect(message).toContain("thread-start-request:17ms@17ms");
expect(message).toContain("thread-ready:0ms@17ms");
});
it("emits a trace stage summary when resuming an existing thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
let nowMs = 0;
const log = createTimingLogger(true);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-existing");
}
if (method === "thread/resume") {
nowMs += 9;
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
const commonParams = {
client: { request } as never,
params: createThreadLifecycleParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
};
await startOrResumeThread({
...commonParams,
timing: {
enabled: true,
now: () => nowMs,
log: createTimingLogger(false),
},
});
await startOrResumeThread({
...commonParams,
timing: {
enabled: true,
now: () => nowMs,
log,
totalThresholdMs: 1_000,
stageThresholdMs: 1_000,
},
});
const message = expectSingleLogMessage(log, "trace");
expect(message).toContain("action=resumed");
expect(message).toContain("thread-resume-request:9ms@9ms");
});
it("warns on slow start even when trace logging is disabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
let nowMs = 0;
const log = createTimingLogger(false);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
nowMs += 25;
return threadStartResult("thread-slow");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createThreadLifecycleParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
timing: {
enabled: true,
now: () => nowMs,
log,
totalThresholdMs: 10,
stageThresholdMs: 10,
},
});
const message = expectSingleLogMessage(log, "warn");
expect(log.trace).not.toHaveBeenCalled();
expect(message).toContain("action=started");
expect(message).toContain("thread-start-request:25ms@25ms");
});
});
describe("resolveReasoningEffort (#71946)", () => {
describe("modern Codex models (none/low/medium/high/xhigh enum)", () => {
it.each(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"] as const)(

View File

@@ -20,7 +20,6 @@ import {
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
} from "./context-engine-projection.js";
import { shouldDisableCodexToolSearchForModel } from "./dynamic-tool-profile.js";
import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js";
import {
isCodexPluginThreadBindingStale,
@@ -115,88 +114,32 @@ const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = {
project_doc_max_bytes: 0,
};
const CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG: JsonObject = {
"features.multi_agent": false,
};
export type CodexThreadLifecycleTimingSpan = {
type CodexThreadLifecycleTimingSpan = {
name: string;
durationMs: number;
elapsedMs: number;
};
export type CodexThreadLifecycleTimingSummary = {
type CodexThreadLifecycleTimingSummary = {
totalMs: number;
spans: CodexThreadLifecycleTimingSpan[];
};
export type CodexThreadLifecycleTimingLogger = {
isEnabled?: (level: "trace") => boolean;
trace: (message: string, meta?: Record<string, unknown>) => void;
warn: (message: string, meta?: Record<string, unknown>) => void;
};
export type CodexThreadLifecycleTimingAction = "started" | "resumed" | "rotated";
export type CodexThreadLifecycleTimingOptions = {
enabled?: boolean;
now?: () => number;
log?: CodexThreadLifecycleTimingLogger;
totalThresholdMs?: number;
stageThresholdMs?: number;
};
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS = 1_000;
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS = 500;
export function shouldWarnCodexThreadLifecycleTimingSummary(
summary: CodexThreadLifecycleTimingSummary,
options: CodexThreadLifecycleTimingOptions = {},
): boolean {
const totalThresholdMs =
options.totalThresholdMs ?? CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS;
const stageThresholdMs =
options.stageThresholdMs ?? CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS;
return (
summary.totalMs >= totalThresholdMs ||
summary.spans.some((span) => span.durationMs >= stageThresholdMs)
);
}
export function formatCodexThreadLifecycleTimingSummary(params: {
runId: string;
sessionId: string;
sessionKey?: string;
action: CodexThreadLifecycleTimingAction;
summary: CodexThreadLifecycleTimingSummary;
}): string {
const spans =
params.summary.spans.length > 0
? params.summary.spans
.map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
.join(",")
: "none";
return (
`[trace:codex-app-server] thread lifecycle: runId=${params.runId} ` +
`sessionId=${params.sessionId} sessionKey=${params.sessionKey ?? "unknown"} ` +
`action=${params.action} totalMs=${params.summary.totalMs} stages=${spans}`
);
}
function createCodexThreadLifecycleTimingTracker(options: CodexThreadLifecycleTimingOptions = {}): {
function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean } = {}): {
measure: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
measureSync: <T>(name: string, run: () => T) => T;
mark: (name: string) => void;
logSummary: (params: {
logIfSlow: (params: {
runId: string;
sessionId: string;
sessionKey?: string;
action: CodexThreadLifecycleTimingAction;
action: "started" | "resumed" | "rotated";
threadId?: string;
}) => void;
} {
const log = options.log ?? embeddedAgentLog;
if (!options.enabled && log.isEnabled?.("trace") !== true) {
if (!options.enabled) {
return {
async measure(_name, run) {
return await run();
@@ -204,31 +147,37 @@ function createCodexThreadLifecycleTimingTracker(options: CodexThreadLifecycleTi
measureSync(_name, run) {
return run();
},
mark() {},
logSummary() {},
logIfSlow() {},
};
}
const now = options.now ?? Date.now;
const startedAt = now();
const startedAt = Date.now();
let didLog = false;
const spans: CodexThreadLifecycleTimingSpan[] = [];
const toMs = (value: number) => Math.max(0, Math.round(value));
const record = (name: string, spanStartedAt: number) => {
const currentAt = now();
spans.push({
name,
durationMs: toMs(currentAt - spanStartedAt),
elapsedMs: toMs(currentAt - startedAt),
durationMs: toMs(Date.now() - spanStartedAt),
elapsedMs: toMs(Date.now() - startedAt),
});
};
const snapshot = (): CodexThreadLifecycleTimingSummary => ({
totalMs: toMs(now() - startedAt),
totalMs: toMs(Date.now() - startedAt),
spans: spans.slice(),
});
const shouldLog = (summary: CodexThreadLifecycleTimingSummary) =>
summary.totalMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS ||
summary.spans.some((span) => span.durationMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS);
const formatSpans = (summary: CodexThreadLifecycleTimingSummary) =>
summary.spans.length > 0
? summary.spans
.map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
.join(",")
: "none";
return {
async measure(name, run) {
const spanStartedAt = now();
const spanStartedAt = Date.now();
try {
return await run();
} finally {
@@ -236,47 +185,38 @@ function createCodexThreadLifecycleTimingTracker(options: CodexThreadLifecycleTi
}
},
measureSync(name, run) {
const spanStartedAt = now();
const spanStartedAt = Date.now();
try {
return run();
} finally {
record(name, spanStartedAt);
}
},
mark(name) {
record(name, now());
},
logSummary(params) {
logIfSlow(params) {
if (didLog) {
return;
}
const summary = snapshot();
const shouldWarn = shouldWarnCodexThreadLifecycleTimingSummary(summary, options);
if (!shouldWarn && !log.isEnabled?.("trace")) {
if (!shouldLog(summary)) {
return;
}
didLog = true;
const message = formatCodexThreadLifecycleTimingSummary({
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
action: params.action,
summary,
});
const meta = {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
action: params.action,
threadId: params.threadId,
totalMs: summary.totalMs,
spans: summary.spans,
};
if (shouldWarn) {
log.warn(message, meta);
} else {
log.trace(message, meta);
}
embeddedAgentLog.warn(
`codex app-server thread lifecycle timings runId=${params.runId} sessionId=${
params.sessionId
} sessionKey=${params.sessionKey ?? "unknown"} action=${params.action} totalMs=${
summary.totalMs
} stages=${formatSpans(summary)}`,
{
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
action: params.action,
threadId: params.threadId,
totalMs: summary.totalMs,
spans: summary.spans,
},
);
},
};
}
@@ -303,23 +243,16 @@ export async function startOrResumeThread(params: {
environmentSelection?: CodexTurnEnvironmentParams[];
pluginThreadConfig?: CodexPluginThreadConfigProvider;
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
signal?: AbortSignal;
timing?: CodexThreadLifecycleTimingOptions;
}): Promise<CodexAppServerThreadLifecycleBinding> {
// Thread lifecycle spans are useful when profiling startup churn, but normal
// turns should not pay Date.now/span-array overhead while resuming threads.
const lifecycleTiming = createCodexThreadLifecycleTimingTracker({
...params.timing,
enabled:
params.timing?.enabled ?? isCodexAppServerProfilerEnabled(params.params.config),
enabled: isCodexAppServerProfilerEnabled(params.params.config),
});
const dynamicToolsFingerprint = lifecycleTiming.measureSync("dynamic-tools-fingerprint", () =>
const dynamicToolsFingerprint = lifecycleTiming.measureSync("fingerprint_dynamic_tools", () =>
fingerprintDynamicTools(params.dynamicTools),
);
const dynamicToolsContainDeferred = params.dynamicTools.some(
(tool) => tool.deferLoading === true,
);
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
const contextEngineBinding = lifecycleTiming.measureSync("context_engine_binding", () =>
buildContextEngineBinding(params.params, params.contextEngineProjection),
);
const userMcpServersConfigPatch =
@@ -332,7 +265,7 @@ export async function startOrResumeThread(params: {
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
params.environmentSelection,
);
let binding = await lifecycleTiming.measure("read-binding", () =>
let binding = await lifecycleTiming.measure("read_binding", () =>
readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
@@ -342,22 +275,6 @@ export async function startOrResumeThread(params: {
let preserveExistingBinding = false;
let rotatedContextEngineBinding = false;
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
const throwIfAborted = () => {
if (!params.signal?.aborted) {
return;
}
const reason = params.signal.reason;
if (reason instanceof Error) {
throw reason;
}
const error = new Error(
typeof reason === "string" && reason.length > 0
? reason
: "codex app-server thread lifecycle aborted",
);
error.name = "AbortError";
throw error;
};
if (binding?.threadId && params.nativeCodeModeEnabled === false) {
embeddedAgentLog.debug(
"codex app-server native tool surface disabled for turn; starting transient thread",
@@ -439,7 +356,7 @@ export async function startOrResumeThread(params: {
})
) {
try {
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin-config-recovery", () =>
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin_config_recovery", () =>
params.pluginThreadConfig?.build(),
);
pluginBindingStale =
@@ -470,23 +387,6 @@ export async function startOrResumeThread(params: {
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (binding?.threadId) {
if (
binding.dynamicToolsFingerprint &&
params.dynamicTools.length > 0 &&
binding.dynamicToolsContainDeferred !== dynamicToolsContainDeferred &&
(binding.dynamicToolsContainDeferred !== undefined || !dynamicToolsContainDeferred)
) {
embeddedAgentLog.debug(
"codex app-server dynamic tool loading changed; starting a new thread",
{
threadId: binding.threadId,
},
);
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
}
if (binding?.threadId) {
// `/codex resume <thread>` writes a binding before the next turn can know
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
@@ -532,7 +432,7 @@ export async function startOrResumeThread(params: {
userMcpServersConfigPatch,
finalConfigPatch.configPatch,
);
const resumeParams = lifecycleTiming.measureSync("thread-resume-params", () =>
const resumeParams = lifecycleTiming.measureSync("thread_resume_params", () =>
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
authProfileId,
@@ -545,11 +445,10 @@ export async function startOrResumeThread(params: {
}),
);
const response = assertCodexThreadResumeResponse(
await lifecycleTiming.measure("thread-resume-request", () =>
params.client.request("thread/resume", resumeParams, { signal: params.signal }),
await lifecycleTiming.measure("thread_resume_request", () =>
params.client.request("thread/resume", resumeParams),
),
);
throwIfAborted();
const boundAuthProfileId = authProfileId;
const fallbackModelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
@@ -562,7 +461,7 @@ export async function startOrResumeThread(params: {
params.mcpServersFingerprintEvaluated === true
? params.mcpServersFingerprint
: binding.mcpServersFingerprint;
await lifecycleTiming.measure("thread-resume-write-binding", () =>
await lifecycleTiming.measure("thread_resume_write_binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
{
@@ -572,7 +471,6 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration:
@@ -602,8 +500,7 @@ export async function startOrResumeThread(params: {
action: "resumed",
});
}
lifecycleTiming.mark("thread-ready");
lifecycleTiming.logSummary({
lifecycleTiming.logIfSlow({
runId: params.params.runId,
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
@@ -618,7 +515,6 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration:
@@ -644,7 +540,7 @@ export async function startOrResumeThread(params: {
const pluginThreadConfig = params.pluginThreadConfig?.enabled
? (prebuiltPluginThreadConfig ??
(await lifecycleTiming.measure("plugin-config-build", () =>
(await lifecycleTiming.measure("plugin_config_build", () =>
params.pluginThreadConfig?.build(),
)))
: undefined;
@@ -652,7 +548,7 @@ export async function startOrResumeThread(params: {
configPatch: params.finalConfigPatch,
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
};
const config = lifecycleTiming.measureSync("merge-thread-config", () =>
const config = lifecycleTiming.measureSync("merge_thread_config", () =>
mergeCodexThreadConfigs(
params.config,
userMcpServersConfigPatch,
@@ -660,7 +556,7 @@ export async function startOrResumeThread(params: {
finalConfigPatch.configPatch,
),
);
const startParams = lifecycleTiming.measureSync("thread-start-params", () =>
const startParams = lifecycleTiming.measureSync("thread_start_params", () =>
buildThreadStartParams(params.params, {
cwd: params.cwd,
dynamicTools: params.dynamicTools,
@@ -672,9 +568,9 @@ export async function startOrResumeThread(params: {
environmentSelection: params.environmentSelection,
}),
);
const threadStartResponse = await lifecycleTiming.measure("thread-start-request", async () => {
const threadStartResponse = await lifecycleTiming.measure("thread_start_request", async () => {
try {
return await params.client.request("thread/start", startParams, { signal: params.signal });
return await params.client.request("thread/start", startParams);
} catch (error) {
if (error instanceof CodexAppServerRpcError) {
throw new CodexThreadStartRequestError(error);
@@ -683,7 +579,6 @@ export async function startOrResumeThread(params: {
}
});
const response = assertCodexThreadStartResponse(threadStartResponse);
throwIfAborted();
const modelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
authProfileId: params.params.authProfileId,
@@ -695,7 +590,7 @@ export async function startOrResumeThread(params: {
const nextMcpServersFingerprint =
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
if (!preserveExistingBinding) {
await lifecycleTiming.measure("thread-start-write-binding", () =>
await lifecycleTiming.measure("thread_start_write_binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
{
@@ -705,7 +600,6 @@ export async function startOrResumeThread(params: {
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
@@ -735,8 +629,7 @@ export async function startOrResumeThread(params: {
});
}
}
lifecycleTiming.mark("thread-ready");
lifecycleTiming.logSummary({
lifecycleTiming.logIfSlow({
runId: params.params.runId,
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
@@ -752,7 +645,6 @@ export async function startOrResumeThread(params: {
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
@@ -1013,14 +905,7 @@ function buildCodexRuntimeThreadConfigForRun(
config: JsonObject | undefined,
options: { nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean } = {},
): JsonObject {
const baseConfig = buildCodexRuntimeThreadConfig(config, options);
const runtimeConfig =
mergeCodexThreadConfigs(
baseConfig,
shouldDisableCodexToolSearchForModel(params.modelId)
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
: undefined,
) ?? baseConfig;
const runtimeConfig = buildCodexRuntimeThreadConfig(config, options);
if (params.bootstrapContextMode !== "lightweight") {
return runtimeConfig;
}
@@ -1210,7 +1095,9 @@ function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
for (const [key, child] of Object.entries(tool).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (key === "description") {
// Tool-search presentation can change per turn without changing the
// durable app-server execution contract for an existing thread.
if (key === "description" || key === "deferLoading" || key === "namespace") {
continue;
}
stable[key] = stabilizeJsonValue(child);

View File

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

View File

@@ -1,11 +1,6 @@
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const telemetryState = vi.hoisted(() => {
type TestSpanContext = {
traceId: string;
spanId: string;
traceFlags: number;
};
const counters = new Map<string, { add: ReturnType<typeof vi.fn> }>();
const histograms = new Map<string, { record: ReturnType<typeof vi.fn> }>();
const spans: Array<{
@@ -14,7 +9,7 @@ const telemetryState = vi.hoisted(() => {
end: ReturnType<typeof vi.fn>;
setAttributes: ReturnType<typeof vi.fn>;
setStatus: ReturnType<typeof vi.fn>;
spanContext: ReturnType<typeof vi.fn<() => TestSpanContext>>;
spanContext: ReturnType<typeof vi.fn>;
}> = [];
const tracer = {
startSpan: vi.fn((name: string, _opts?: unknown, _ctx?: unknown) => {
@@ -25,7 +20,7 @@ const telemetryState = vi.hoisted(() => {
end: vi.fn(),
setAttributes: vi.fn(),
setStatus: vi.fn(),
spanContext: vi.fn<() => TestSpanContext>(() => ({
spanContext: vi.fn(() => ({
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
spanId,
traceFlags: 1,
@@ -161,21 +156,13 @@ vi.mock("@opentelemetry/semantic-conventions", () => ({
}));
import {
createDiagnosticTraceContext,
emitTrustedDiagnosticEvent,
emitTrustedDiagnosticEventWithPrivateData,
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
waitForDiagnosticEventsDrained,
type DiagnosticEventPrivateData,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import {
emitInternalDiagnosticEventForTest,
logMessageDispatchStarted,
logMessageProcessed,
onTrustedInternalDiagnosticEvent,
runWithDiagnosticTraceContext,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { onTrustedInternalDiagnosticEvent } from "openclaw/plugin-sdk/plugin-test-runtime";
import type { OpenClawPluginServiceContext } from "../api.js";
import { emitDiagnosticEvent } from "../api.js";
import { createDiagnosticsOtelService } from "./service.js";
@@ -188,12 +175,6 @@ const SPAN_ID = "00f067aa0ba902b7";
const CHILD_SPAN_ID = "1111111111111111";
const GRANDCHILD_SPAN_ID = "2222222222222222";
const TOOL_SPAN_ID = "3333333333333333";
const MODEL_CALL_SPAN_ID = "4444444444444444";
const MODEL_USAGE_SPAN_ID = "5555555555555555";
function numberedSpanId(index: number) {
return (index + 0x1000).toString(16).padStart(16, "0");
}
const PROTO_KEY = "__proto__";
const MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS = 128 * 1024;
const OTEL_TRUNCATED_SUFFIX_MAX_CHARS = 20;
@@ -268,27 +249,6 @@ function startedSpanOptions(name: string) {
return startedSpanCall(name)?.[1];
}
function startedSpanParentContexts(name: string) {
return telemetryState.tracer.startSpan.mock.calls
.filter((call) => call[0] === name)
.map(
(call) =>
(call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined)
?.spanContext,
);
}
function startedSpanParentContextsByName(name: string) {
return telemetryState.tracer.startSpan.mock.calls
.filter((call) => call[0] === name)
.map((call) => ({
attributes: (call[1] as { attributes?: Record<string, unknown> } | undefined)?.attributes,
parentContext: (
call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined
)?.spanContext,
}));
}
function mockCall(mock: { mock: { calls: unknown[][] } }, callIndex = 0): unknown[] {
const call = mock.mock.calls.at(callIndex);
if (!call) {
@@ -2603,479 +2563,7 @@ describe("diagnostics-otel service", () => {
await service.stop?.(ctx);
});
test("correlates one channel message waterfall across message, harness, usage, and model spans", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "message.dispatch.started",
channel: "slack",
source: "replyResolver",
sessionKey: "agent:main:slack:channel:c1",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "harness.run.started",
runId: "run-1",
harnessId: "codex",
pluginId: "codex",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
trace: {
traceId: TRACE_ID,
spanId: TOOL_SPAN_ID,
parentSpanId: GRANDCHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.call.started",
runId: "run-1",
callId: "call-1",
provider: "openai",
model: "gpt-5.5",
api: "openai-codex-responses",
transport: "stdio",
trace: {
traceId: TRACE_ID,
spanId: MODEL_CALL_SPAN_ID,
parentSpanId: TOOL_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: "call-1",
provider: "openai",
model: "gpt-5.5",
api: "openai-codex-responses",
transport: "stdio",
durationMs: 80,
trace: {
traceId: TRACE_ID,
spanId: MODEL_CALL_SPAN_ID,
parentSpanId: TOOL_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "harness.run.completed",
runId: "run-1",
harnessId: "codex",
pluginId: "codex",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
durationMs: 100,
outcome: "completed",
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.usage",
sessionKey: "agent:main:slack:channel:c1",
channel: "slack",
agentId: "main",
provider: "openai",
model: "gpt-5.5",
usage: { input: 3, output: 2, total: 5 },
durationMs: 10,
trace: {
traceId: TRACE_ID,
spanId: MODEL_USAGE_SPAN_ID,
parentSpanId: GRANDCHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "message.processed",
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
await flushDiagnosticEvents();
const messageSpan = spanByName("openclaw.message.processed");
const harnessSpan = spanByName("openclaw.harness.run");
const runSpan = spanByName("openclaw.run");
const usageSpan = spanByName("openclaw.model.usage");
const modelCallSpan = spanByName("openclaw.model.call");
const messageSpanContext = messageSpan.spanContext();
const harnessSpanContext = harnessSpan.spanContext();
const runSpanContext = runSpan.spanContext();
const usageSpanContext = usageSpan.spanContext();
const modelCallSpanContext = modelCallSpan.spanContext();
const parentBySpanName = Object.fromEntries(
telemetryState.tracer.startSpan.mock.calls.map((call) => [
call[0],
(call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined)
?.spanContext,
]),
);
expect(messageSpanContext.traceId).toBe(TRACE_ID);
expect(harnessSpanContext.traceId).toBe(TRACE_ID);
expect(usageSpanContext.traceId).toBe(TRACE_ID);
expect(modelCallSpanContext.traceId).toBe(TRACE_ID);
expect(parentBySpanName["openclaw.message.processed"]?.spanId).toBe(SPAN_ID);
expect(parentBySpanName["openclaw.harness.run"]?.spanId).toBe(messageSpanContext.spanId);
expect(parentBySpanName["openclaw.run"]?.spanId).toBe(harnessSpanContext.spanId);
expect(parentBySpanName["openclaw.model.usage"]?.spanId).toBe(harnessSpanContext.spanId);
expect(parentBySpanName["openclaw.model.call"]?.spanId).toBe(runSpanContext.spanId);
await service.stop?.(ctx);
});
test("uses production message lifecycle helpers as the message span anchor", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageDispatchStarted({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
source: "replyResolver",
});
emitTrustedDiagnosticEvent({
type: "harness.run.started",
runId: "run-1",
harnessId: "codex",
pluginId: "codex",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.usage",
sessionKey: "agent:main:slack:channel:c1",
channel: "slack",
agentId: "main",
provider: "openai",
model: "gpt-5.5",
usage: { input: 3, output: 2, total: 5 },
durationMs: 10,
trace: {
traceId: TRACE_ID,
spanId: MODEL_USAGE_SPAN_ID,
parentSpanId: GRANDCHILD_SPAN_ID,
traceFlags: "01",
},
});
logMessageProcessed({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
});
});
await flushDiagnosticEvents();
const messageSpan = spanByName("openclaw.message.processed");
const harnessSpan = spanByName("openclaw.harness.run");
const messageSpanContext = messageSpan.spanContext();
const harnessSpanContext = harnessSpan.spanContext();
const parentBySpanName = Object.fromEntries(
telemetryState.tracer.startSpan.mock.calls.map((call) => [
call[0],
(call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined)
?.spanContext,
]),
);
expect(parentBySpanName["openclaw.message.processed"]?.spanId).toBe(SPAN_ID);
expect(parentBySpanName["openclaw.harness.run"]?.spanId).toBe(messageSpanContext.spanId);
expect(parentBySpanName["openclaw.model.usage"]?.spanId).toBe(harnessSpanContext.spanId);
expect(messageSpanContext.traceId).toBe(TRACE_ID);
expect(harnessSpanContext.traceId).toBe(TRACE_ID);
await service.stop?.(ctx);
});
test("does not force a remote parent for root message lifecycle helpers", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageDispatchStarted({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
source: "replyResolver",
});
logMessageProcessed({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
});
});
await flushDiagnosticEvents();
expect(spanByName("openclaw.message.processed").spanContext().traceId).toBe(TRACE_ID);
expect(startedSpanParentContexts("openclaw.message.processed")[0]).toBeUndefined();
await service.stop?.(ctx);
});
test("parents outbound delivery spans under the active message lifecycle span", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageDispatchStarted({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
source: "replyResolver",
});
emitInternalDiagnosticEventForTest({
type: "message.delivery.completed",
channel: "slack",
deliveryKind: "text",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 15,
resultCount: 1,
});
emitInternalDiagnosticEventForTest({
type: "message.delivery.error",
channel: "slack",
deliveryKind: "media",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 25,
errorCategory: "network",
});
logMessageProcessed({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
});
});
await flushDiagnosticEvents();
const messageSpanContext = spanByName("openclaw.message.processed").spanContext();
const deliveryParentContexts = startedSpanParentContexts("openclaw.message.delivery");
expect(deliveryParentContexts).toHaveLength(2);
expect(deliveryParentContexts[0]?.traceId).toBe(TRACE_ID);
expect(deliveryParentContexts[0]?.spanId).toBe(messageSpanContext.spanId);
expect(deliveryParentContexts[1]?.traceId).toBe(TRACE_ID);
expect(deliveryParentContexts[1]?.spanId).toBe(messageSpanContext.spanId);
await service.stop?.(ctx);
});
test("parents multi-batch late delivery spans from the retained message context", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageDispatchStarted({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
source: "replyResolver",
});
for (let index = 0; index < 125; index += 1) {
emitInternalDiagnosticEventForTest({
type: "message.delivery.completed",
channel: "slack",
deliveryKind: "text",
sessionKey: `agent:main:slack:channel:c${index}`,
durationMs: 15,
resultCount: 1,
});
}
logMessageProcessed({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
});
});
const messageSpan = spanByName("openclaw.message.processed");
const messageSpanContext = messageSpan.spanContext();
expect(messageSpan.end).toHaveBeenCalledTimes(1);
await waitForDiagnosticEventsDrained();
const deliveryParentContexts = startedSpanParentContexts("openclaw.message.delivery");
expect(deliveryParentContexts).toHaveLength(125);
expect(deliveryParentContexts.every((parent) => parent?.traceId === TRACE_ID)).toBe(true);
expect(
deliveryParentContexts.every((parent) => parent?.spanId === messageSpanContext.spanId),
).toBe(true);
await service.stop?.(ctx);
});
test("correlates skipped duplicate message lifecycle helpers to the active inbound trace", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageProcessed({
channel: "slack",
messageId: "msg-duplicate",
chatId: "c1",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 5,
outcome: "skipped",
reason: "duplicate",
});
});
await flushDiagnosticEvents();
const messageSpan = spanByName("openclaw.message.processed");
const messageSpanContext = messageSpan.spanContext();
const parentContext = startedSpanParentContexts("openclaw.message.processed")[0];
expect(messageSpanContext.traceId).toBe(TRACE_ID);
expect(parentContext?.traceId).toBe(TRACE_ID);
expect(parentContext?.spanId).toBe(SPAN_ID);
expect(firstSpanAttributes("openclaw.message.processed")["openclaw.reason"]).toBe("duplicate");
expect(messageSpan.end).toHaveBeenCalledTimes(1);
await service.stop?.(ctx);
});
test("does not force a remote parent for fallback root message processed spans", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "message.processed",
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 25,
outcome: "skipped",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
await flushDiagnosticEvents();
expect(spanByName("openclaw.message.processed").spanContext().traceId).toBe(TRACE_ID);
expect(startedSpanParentContexts("openclaw.message.processed")[0]).toBeUndefined();
await service.stop?.(ctx);
});
test("does not retain fallback message processed spans as active parents", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "message.processed",
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 25,
outcome: "skipped",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
expect(spanByName("openclaw.message.processed").end).toHaveBeenCalledTimes(1);
telemetryState.tracer.setSpanContext.mockClear();
emitTrustedDiagnosticEvent({
type: "harness.run.started",
runId: "run-1",
harnessId: "codex",
pluginId: "codex",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled();
expect(startedSpanCall("openclaw.harness.run")?.[2]).toBeUndefined();
await service.stop?.(ctx);
});
test("retains trusted run context long enough for exact post-completion usage parenting", async () => {
test("keeps trusted run spans alive long enough for post-completion usage parenting", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
@@ -3106,7 +2594,6 @@ describe("diagnostics-otel service", () => {
traceFlags: "01",
},
});
await Promise.resolve();
emitTrustedDiagnosticEvent({
type: "model.usage",
provider: "openai",
@@ -3116,7 +2603,7 @@ describe("diagnostics-otel service", () => {
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
@@ -3139,345 +2626,6 @@ describe("diagnostics-otel service", () => {
await service.stop?.(ctx);
});
test("does not parent sibling active runs through shared upstream aliases", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-2",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
const runContexts = startedSpanParentContextsByName("openclaw.run");
expect(runContexts).toHaveLength(2);
expect(runContexts[0]?.parentContext).toBeUndefined();
expect(runContexts[1]?.parentContext).toBeUndefined();
await service.stop?.(ctx);
});
test("does not parent sibling runs through retained upstream aliases", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-2",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
const runContexts = startedSpanParentContextsByName("openclaw.run");
expect(runContexts).toHaveLength(2);
expect(runContexts[0]?.parentContext).toBeUndefined();
expect(runContexts[1]?.parentContext).toBeUndefined();
await service.stop?.(ctx);
});
test("parents retained upstream alias events only when the owner matches", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: "call-1",
provider: "openai",
model: "gpt-5.4",
durationMs: 80,
trace: {
traceId: TRACE_ID,
spanId: MODEL_CALL_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
await flushDiagnosticEvents();
const runSpanContext = spanByName("openclaw.run").spanContext();
const modelParentContext = startedSpanParentContexts("openclaw.model.call")[0];
expect(modelParentContext?.traceId).toBe(TRACE_ID);
expect(modelParentContext?.spanId).toBe(runSpanContext.spanId);
await service.stop?.(ctx);
});
test("parents multi-batch late model spans from the retained run context", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
for (let index = 0; index < 125; index += 1) {
emitTrustedDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: `call-${index}`,
provider: "openai",
model: "gpt-5.4",
durationMs: 80,
trace: {
traceId: TRACE_ID,
spanId: numberedSpanId(index),
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
}
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
const runSpan = spanByName("openclaw.run");
const runSpanContext = runSpan.spanContext();
expect(runSpan.end).toHaveBeenCalledTimes(1);
await waitForDiagnosticEventsDrained();
const modelParentContexts = startedSpanParentContexts("openclaw.model.call");
expect(modelParentContexts).toHaveLength(125);
expect(modelParentContexts.every((parent) => parent?.traceId === TRACE_ID)).toBe(true);
expect(modelParentContexts.every((parent) => parent?.spanId === runSpanContext.spanId)).toBe(
true,
);
await service.stop?.(ctx);
});
test("removes retained run contexts after queued diagnostics drain", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
for (let index = 0; index < 125; index += 1) {
emitTrustedDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: `call-${index}`,
provider: "openai",
model: "gpt-5.4",
durationMs: 80,
trace: {
traceId: TRACE_ID,
spanId: numberedSpanId(index),
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
}
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
await waitForDiagnosticEventsDrained();
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
await waitForDiagnosticEventsDrained();
await Promise.resolve();
telemetryState.tracer.setSpanContext.mockClear();
telemetryState.tracer.startSpan.mockClear();
emitTrustedDiagnosticEvent({
type: "model.usage",
provider: "openai",
model: "gpt-5.4",
usage: { input: 3, output: 2, total: 5 },
durationMs: 10,
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled();
expect(startedSpanCall("openclaw.model.usage")?.[2]).toBeUndefined();
await service.stop?.(ctx);
});
test("clears retained run contexts when the service stops", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
await service.stop?.(ctx);
await service.start(ctx);
telemetryState.tracer.setSpanContext.mockClear();
telemetryState.tracer.startSpan.mockClear();
emitTrustedDiagnosticEvent({
type: "model.usage",
provider: "openai",
model: "gpt-5.4",
usage: { input: 3, output: 2, total: 5 },
durationMs: 10,
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled();
expect(startedSpanCall("openclaw.model.usage")?.[2]).toBeUndefined();
await service.stop?.(ctx);
});
test("does not force remote parents for completed-only trusted lifecycle spans", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });

View File

@@ -6,7 +6,6 @@ import {
SpanStatusCode,
TraceFlags,
} from "@opentelemetry/api";
import type { SpanContext } from "@opentelemetry/api";
import type { LogRecord, SeverityNumber } from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
@@ -27,7 +26,6 @@ import {
ATTR_GEN_AI_SYSTEM_INSTRUCTIONS,
ATTR_GEN_AI_TOOL_DEFINITIONS,
} from "@opentelemetry/semantic-conventions/incubating";
import { waitForDiagnosticEventsDrained } from "openclaw/plugin-sdk/diagnostic-runtime";
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
import type {
DiagnosticEventMetadata,
@@ -88,8 +86,6 @@ const GEN_AI_TOKEN_USAGE_BUCKETS = [
const GEN_AI_OPERATION_DURATION_BUCKETS = [
0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92,
];
const MAX_RETAINED_TRUSTED_SPAN_CONTEXTS = 1024;
const RETAINED_TRUSTED_SPAN_CONTEXT_TIMEOUT_MS = 5_000;
type OtelContentCapturePolicy = {
inputMessages: boolean;
@@ -132,7 +128,6 @@ type SessionRecoveryDiagnosticEvent = Extract<
{ type: "session.recovery.requested" | "session.recovery.completed" }
>;
type TalkDiagnosticEvent = Extract<DiagnosticEventPayload, { type: "talk.event" }>;
type TrustedSpanAliasOwner = { kind: "run"; id: string };
const NO_CONTENT_CAPTURE: OtelContentCapturePolicy = {
inputMessages: false,
@@ -1245,25 +1240,17 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const meter = metrics.getMeter("openclaw");
const tracer = trace.getTracer("openclaw");
const activeTrustedSpans = new Map<string, ReturnType<typeof tracer.startSpan>>();
const activeTrustedSpanAliases = new Map<
string,
{ span: ReturnType<typeof tracer.startSpan>; spanId: string; owner: TrustedSpanAliasOwner }
>();
const retainedTrustedSpanContexts = new Map<
string,
{ spanContext: SpanContext; token: symbol; owner?: TrustedSpanAliasOwner }
>();
const retainedTrustedSpanContextCleanupTimers = new Set<ReturnType<typeof setTimeout>>();
const activeTrustedSpanAliases = new Map<string, ReturnType<typeof tracer.startSpan>>();
const pendingTrustedRunFinalizers = new Map<string, ReturnType<typeof setImmediate>>();
stopActiveTrustedSpans = () => {
const stopAt = Date.now();
for (const handle of retainedTrustedSpanContextCleanupTimers) {
clearTimeout(handle);
for (const handle of pendingTrustedRunFinalizers.values()) {
clearImmediate(handle);
}
retainedTrustedSpanContextCleanupTimers.clear();
retainedTrustedSpanContexts.clear();
pendingTrustedRunFinalizers.clear();
for (const span of new Set([
...activeTrustedSpans.values(),
...Array.from(activeTrustedSpanAliases.values(), (entry) => entry.span),
...activeTrustedSpanAliases.values(),
])) {
span.end(stopAt);
}
@@ -1692,139 +1679,20 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => (metadata.trusted ? normalizeTraceContext(evt.trace) : undefined);
const internalOrTrustedTraceContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => (metadata.trusted || metadata.internal ? normalizeTraceContext(evt.trace) : undefined);
const trustedSpanAliasOwner = (
evt: DiagnosticEventPayload,
): TrustedSpanAliasOwner | undefined => {
if ("runId" in evt && evt.runId) {
return { kind: "run", id: evt.runId };
}
return undefined;
};
const sameTrustedSpanAliasOwner = (
left: TrustedSpanAliasOwner | undefined,
right: TrustedSpanAliasOwner | undefined,
) => Boolean(left && right && left.kind === right.kind && left.id === right.id);
const trustedSpanAliasKey = (spanId: string, owner: TrustedSpanAliasOwner) =>
`${spanId}:${owner.kind}:${owner.id}`;
const retainedTrustedSpanContextKey = (
traceId: string,
spanId: string,
owner?: TrustedSpanAliasOwner,
) => `${traceId}:${owner ? trustedSpanAliasKey(spanId, owner) : spanId}`;
const retainedTrustedSpanContext = (
traceContext: DiagnosticTraceContext | undefined,
spanId: string | undefined,
owner?: TrustedSpanAliasOwner,
) => {
if (!traceContext?.traceId || !spanId) {
return undefined;
}
const retained =
(owner
? retainedTrustedSpanContexts.get(
retainedTrustedSpanContextKey(traceContext.traceId, spanId, owner),
)
: undefined) ??
retainedTrustedSpanContexts.get(
retainedTrustedSpanContextKey(traceContext.traceId, spanId),
);
if (retained?.spanContext.traceId !== traceContext.traceId) {
return undefined;
}
if (retained.owner && !sameTrustedSpanAliasOwner(retained.owner, owner)) {
return undefined;
}
return retained.spanContext;
};
const activeTrustedSpanAlias = (spanId: string, owner: TrustedSpanAliasOwner | undefined) => {
if (!owner) {
return undefined;
}
const alias = activeTrustedSpanAliases.get(trustedSpanAliasKey(spanId, owner));
if (!alias || !sameTrustedSpanAliasOwner(alias.owner, owner)) {
return undefined;
}
return alias.span;
};
const internalOrTrustedParentContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const traceContext = internalOrTrustedTraceContext(evt, metadata);
const parentSpanId = traceContext?.parentSpanId ?? traceContext?.spanId;
if (!traceContext || !parentSpanId) {
return undefined;
}
return contextForTraceContext({
...traceContext,
spanId: parentSpanId,
});
};
const internalOrTrustedExplicitParentContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const traceContext = internalOrTrustedTraceContext(evt, metadata);
if (!traceContext?.parentSpanId) {
return undefined;
}
return contextForTraceContext({
...traceContext,
spanId: traceContext.parentSpanId,
});
};
const activeTrustedParentContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const traceContext = trustedTraceContext(evt, metadata);
const parentSpanId = traceContext?.parentSpanId;
const parentSpanId = trustedTraceContext(evt, metadata)?.parentSpanId;
if (!parentSpanId) {
return undefined;
}
const owner = trustedSpanAliasOwner(evt);
const activeParentSpan =
activeTrustedSpans.get(parentSpanId) ?? activeTrustedSpanAlias(parentSpanId, owner);
const spanContext =
activeParentSpan?.spanContext() ??
retainedTrustedSpanContext(traceContext, parentSpanId, owner);
if (!spanContext) {
activeTrustedSpans.get(parentSpanId) ?? activeTrustedSpanAliases.get(parentSpanId);
if (!activeParentSpan) {
return undefined;
}
return trace.setSpanContext(otelContextApi.active(), spanContext);
};
const activeInternalOrTrustedContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const traceContext = internalOrTrustedTraceContext(evt, metadata);
if (!traceContext) {
return undefined;
}
const owner = trustedSpanAliasOwner(evt);
const activeSpan =
(traceContext.spanId
? (activeTrustedSpans.get(traceContext.spanId) ??
activeTrustedSpanAlias(traceContext.spanId, owner))
: undefined) ??
(traceContext.parentSpanId
? (activeTrustedSpans.get(traceContext.parentSpanId) ??
activeTrustedSpanAlias(traceContext.parentSpanId, owner))
: undefined);
if (activeSpan) {
return trace.setSpanContext(otelContextApi.active(), activeSpan.spanContext());
}
const retainedSpanContext =
retainedTrustedSpanContext(traceContext, traceContext.spanId, owner) ??
retainedTrustedSpanContext(traceContext, traceContext.parentSpanId, owner);
if (retainedSpanContext) {
return trace.setSpanContext(otelContextApi.active(), retainedSpanContext);
}
return internalOrTrustedParentContext(evt, metadata);
return trace.setSpanContext(otelContextApi.active(), activeParentSpan.spanContext());
};
const trackTrustedSpan = (
evt: DiagnosticEventPayload,
@@ -1837,17 +1705,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}
return span;
};
const trackInternalOrTrustedSpan = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
span: ReturnType<typeof tracer.startSpan>,
) => {
const spanId = internalOrTrustedTraceContext(evt, metadata)?.spanId;
if (spanId) {
activeTrustedSpans.set(spanId, span);
}
return span;
};
const takeTrackedTrustedSpan = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
@@ -1862,109 +1719,33 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}
return span;
};
const getTrackedInternalOrTrustedSpan = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const spanId = internalOrTrustedTraceContext(evt, metadata)?.spanId;
if (!spanId) {
return undefined;
}
return activeTrustedSpans.get(spanId);
};
const setSpanAttrs = (
span: ReturnType<typeof tracer.startSpan>,
attributes: Record<string, string | number | boolean>,
) => {
span.setAttributes?.(redactOtelAttributes(attributes));
};
const retainTrustedSpanContext = (
traceId: string,
spanId: string,
spanContext: SpanContext,
token: symbol,
owner?: TrustedSpanAliasOwner,
) => {
retainedTrustedSpanContexts.set(retainedTrustedSpanContextKey(traceId, spanId, owner), {
spanContext,
token,
...(owner ? { owner } : {}),
});
while (retainedTrustedSpanContexts.size > MAX_RETAINED_TRUSTED_SPAN_CONTEXTS) {
const oldestKey = retainedTrustedSpanContexts.keys().next().value;
if (!oldestKey) {
break;
}
retainedTrustedSpanContexts.delete(oldestKey);
}
};
const scheduleRetainedTrustedSpanContextCleanup = (token: symbol) => {
let drainHandle: ReturnType<typeof setTimeout> | undefined;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (drainHandle) {
clearTimeout(drainHandle);
retainedTrustedSpanContextCleanupTimers.delete(drainHandle);
drainHandle = undefined;
}
if (timeoutHandle) {
clearTimeout(timeoutHandle);
retainedTrustedSpanContextCleanupTimers.delete(timeoutHandle);
timeoutHandle = undefined;
}
for (const [key, retained] of retainedTrustedSpanContexts) {
if (retained.token === token) {
retainedTrustedSpanContexts.delete(key);
}
}
};
drainHandle = setTimeout(() => {
if (drainHandle) {
retainedTrustedSpanContextCleanupTimers.delete(drainHandle);
drainHandle = undefined;
}
void waitForDiagnosticEventsDrained().then(cleanup, cleanup);
}, 0);
(drainHandle as { unref?: () => void }).unref?.();
retainedTrustedSpanContextCleanupTimers.add(drainHandle);
timeoutHandle = setTimeout(cleanup, RETAINED_TRUSTED_SPAN_CONTEXT_TIMEOUT_MS);
(timeoutHandle as { unref?: () => void }).unref?.();
retainedTrustedSpanContextCleanupTimers.add(timeoutHandle);
};
const completeTrackedLifecycleSpan = (
const scheduleTrackedRunSpanFinalize = (
spanId: string,
parentSpanId: string | undefined,
span: ReturnType<typeof tracer.startSpan>,
endTimeMs: number,
) => {
const spanContext = span.spanContext();
const retainedKeys: Array<{ spanId: string; owner?: TrustedSpanAliasOwner }> = [{ spanId }];
const retainedAliasKeys: string[] = [];
for (const [aliasKey, alias] of activeTrustedSpanAliases) {
if (alias.span === span) {
retainedKeys.push({ spanId: alias.spanId, owner: alias.owner });
retainedAliasKeys.push(aliasKey);
const existingHandle = pendingTrustedRunFinalizers.get(spanId);
if (existingHandle) {
clearImmediate(existingHandle);
}
const handle = setImmediate(() => {
pendingTrustedRunFinalizers.delete(spanId);
if (activeTrustedSpans.get(spanId) === span) {
activeTrustedSpans.delete(spanId);
}
}
if (activeTrustedSpans.get(spanId) === span) {
activeTrustedSpans.delete(spanId);
}
for (const aliasKey of retainedAliasKeys) {
if (activeTrustedSpanAliases.get(aliasKey)?.span === span) {
activeTrustedSpanAliases.delete(aliasKey);
if (parentSpanId && activeTrustedSpanAliases.get(parentSpanId) === span) {
activeTrustedSpanAliases.delete(parentSpanId);
}
}
span.end(endTimeMs);
const token = Symbol("retainedTrustedSpanContext");
for (const retainedKey of retainedKeys) {
retainTrustedSpanContext(
spanContext.traceId,
retainedKey.spanId,
spanContext,
token,
retainedKey.owner,
);
}
scheduleRetainedTrustedSpanContextCleanup(token);
span.end(endTimeMs);
});
pendingTrustedRunFinalizers.set(spanId, handle);
};
const addRunAttrs = (
@@ -2181,28 +1962,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const recordMessageDispatchStarted = (
evt: Extract<DiagnosticEventPayload, { type: "message.dispatch.started" }>,
metadata: DiagnosticEventMetadata,
) => {
const attrs = {
messageDispatchStartedCounter.add(1, {
"openclaw.channel": lowCardinalityAttr(evt.channel),
"openclaw.source": lowCardinalityAttr(evt.source),
};
messageDispatchStartedCounter.add(1, attrs);
if (!tracesEnabled) {
return;
}
const traceContext = internalOrTrustedTraceContext(evt, metadata);
if (!traceContext?.spanId || activeTrustedSpans.has(traceContext.spanId)) {
return;
}
trackInternalOrTrustedSpan(
evt,
metadata,
spanWithDuration("openclaw.message.processed", attrs, undefined, {
parentContext: internalOrTrustedExplicitParentContext(evt, metadata),
startTimeMs: evt.ts,
}),
);
});
};
const recordMessageDispatchCompleted = (
@@ -2220,7 +1984,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const recordMessageProcessed = (
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
metadata: DiagnosticEventMetadata,
) => {
const attrs = {
"openclaw.channel": lowCardinalityAttr(evt.channel),
@@ -2237,23 +2000,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.reason) {
spanAttrs["openclaw.reason"] = lowCardinalityAttr(evt.reason, "unknown");
}
const trackedSpan = getTrackedInternalOrTrustedSpan(evt, metadata);
const span =
trackedSpan ??
spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs, {
parentContext: internalOrTrustedExplicitParentContext(evt, metadata),
endTimeMs: evt.ts,
});
setSpanAttrs(span, spanAttrs);
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
if (evt.outcome === "error" && evt.error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) });
}
const traceContext = internalOrTrustedTraceContext(evt, metadata);
if (trackedSpan && traceContext?.spanId) {
completeTrackedLifecycleSpan(traceContext.spanId, trackedSpan, evt.ts);
return;
}
span.end(evt.ts);
span.end();
};
const messageDeliveryAttrs = (
@@ -2271,7 +2022,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const recordMessageDeliveryCompleted = (
evt: Extract<DiagnosticEventPayload, { type: "message.delivery.completed" }>,
metadata: DiagnosticEventMetadata,
) => {
const attrs = {
...messageDeliveryAttrs(evt),
@@ -2288,14 +2038,13 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
"openclaw.delivery.result_count": evt.resultCount,
},
evt.durationMs,
{ parentContext: activeInternalOrTrustedContext(evt, metadata), endTimeMs: evt.ts },
{ endTimeMs: evt.ts },
);
span.end(evt.ts);
};
const recordMessageDeliveryError = (
evt: Extract<DiagnosticEventPayload, { type: "message.delivery.error" }>,
metadata: DiagnosticEventMetadata,
) => {
const attrs = {
...messageDeliveryAttrs(evt),
@@ -2307,7 +2056,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
return;
}
const span = spanWithDuration("openclaw.message.delivery", attrs, evt.durationMs, {
parentContext: activeInternalOrTrustedContext(evt, metadata),
endTimeMs: evt.ts,
});
span.setStatus({
@@ -2336,12 +2084,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
);
const parentSpanId = trustedTraceContext(evt, metadata)?.parentSpanId;
if (parentSpanId && !activeTrustedSpans.has(parentSpanId)) {
const owner: TrustedSpanAliasOwner = { kind: "run", id: evt.runId };
activeTrustedSpanAliases.set(trustedSpanAliasKey(parentSpanId, owner), {
span,
spanId: parentSpanId,
owner,
});
activeTrustedSpanAliases.set(parentSpanId, span);
}
};
@@ -2620,7 +2363,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
});
}
if (trackedSpan && trustedTrace?.spanId) {
completeTrackedLifecycleSpan(trustedTrace.spanId, trackedSpan, evt.ts);
scheduleTrackedRunSpanFinalize(
trustedTrace.spanId,
trustedTrace.parentSpanId,
trackedSpan,
evt.ts,
);
return;
}
span.end(evt.ts);
@@ -2680,12 +2428,8 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
spanAttrs["openclaw.harness.items.completed"] = evt.itemLifecycle.completedCount;
spanAttrs["openclaw.harness.items.active"] = evt.itemLifecycle.activeCount;
}
const trustedTrace = trustedTraceContext(evt, metadata);
const trackedSpan = trustedTrace?.spanId
? activeTrustedSpans.get(trustedTrace.spanId)
: undefined;
const span =
trackedSpan ??
takeTrackedTrustedSpan(evt, metadata) ??
spanWithDuration("openclaw.harness.run", spanAttrs, evt.durationMs, {
parentContext: activeTrustedParentContext(evt, metadata),
endTimeMs: evt.ts,
@@ -2697,10 +2441,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
message: "error",
});
}
if (trackedSpan && trustedTrace?.spanId) {
completeTrackedLifecycleSpan(trustedTrace.spanId, trackedSpan, evt.ts);
return;
}
span.end(evt.ts);
};
@@ -3336,22 +3076,22 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
recordMessageReceived(evt);
return;
case "message.dispatch.started":
recordMessageDispatchStarted(evt, metadata);
recordMessageDispatchStarted(evt);
return;
case "message.dispatch.completed":
recordMessageDispatchCompleted(evt);
return;
case "message.processed":
recordMessageProcessed(evt, metadata);
recordMessageProcessed(evt);
return;
case "message.delivery.started":
recordMessageDeliveryStarted(evt);
return;
case "message.delivery.completed":
recordMessageDeliveryCompleted(evt, metadata);
recordMessageDeliveryCompleted(evt);
return;
case "message.delivery.error":
recordMessageDeliveryError(evt, metadata);
recordMessageDeliveryError(evt);
return;
case "talk.event":
recordTalkEvent(evt, metadata);

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@discordjs/voice": "0.19.2",
"discord-api-types": "0.38.48",
"libopus-wasm": "0.2.0",
"libopus-wasm": "0.1.0",
"typebox": "1.1.39",
"undici": "8.3.0",
"ws": "8.21.0"
@@ -352,9 +352,9 @@
]
},
"node_modules/libopus-wasm": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/libopus-wasm/-/libopus-wasm-0.2.0.tgz",
"integrity": "sha512-x/2Gu1/C6L3IICY09zyfp984AWiOYjn53u4WfdY3yh+3KTzMN8Xkm77q3lenWMVIk5SnSzjGEkQT+VQMFHLBHQ==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/libopus-wasm/-/libopus-wasm-0.1.0.tgz",
"integrity": "sha512-/aurGcAVgy0GcBEUzFaX9pm9qv7zYcy8W5hBXFiK+cyqOXAX4lOS6rlFogkY9CcSIajhjnuXyixsbmziSHCDMQ==",
"license": "MIT",
"engines": {
"node": ">=20"

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@discordjs/voice": "0.19.2",
"discord-api-types": "0.38.48",
"libopus-wasm": "0.2.0",
"libopus-wasm": "0.1.0",
"typebox": "1.1.39",
"undici": "8.3.0",
"ws": "8.21.0"

View File

@@ -1,4 +1,4 @@
import { EmbeddedBlockChunker, formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
import {
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
@@ -6,7 +6,6 @@ import {
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingProgressCommentary,
@@ -282,13 +281,6 @@ export function createDiscordDraftPreviewController(params: {
if (!normalized) {
return;
}
const displayLine = formatReasoningProgressDisplayLine(
normalized,
resolveChannelProgressDraftMaxLineChars(params.discordConfig),
);
if (!displayLine) {
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
const priorIndex =
lastReasoningProgressLine === undefined
@@ -296,13 +288,13 @@ export function createDiscordDraftPreviewController(params: {
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
if (priorIndex >= 0) {
previewToolProgressLines = [...previewToolProgressLines];
previewToolProgressLines[priorIndex] = displayLine;
previewToolProgressLines[priorIndex] = normalized;
} else {
previewToolProgressLines = [...previewToolProgressLines, displayLine].slice(
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
}
lastReasoningProgressLine = displayLine;
lastReasoningProgressLine = normalized;
}
const progressActive = await progressDraftGate.noteWork();
if (progressActive && progressDraftGate.hasStarted) {
@@ -473,57 +465,11 @@ export function createDiscordDraftPreviewController(params: {
function normalizeReasoningProgressLine(text: string): string {
return text
.replace(
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
"",
)
.replace(/^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i, "")
.replace(/\s+/g, " ")
.trim();
}
function normalizeReasoningProgressInput(text: string): string {
const normalized = normalizeReasoningProgressLine(text);
const italic = normalized.match(/^_(.*)_$/u);
return (italic?.[1] ?? normalized).trim();
}
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalizedText = normalizeReasoningProgressInput(text);
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
if (!formatted) {
return "";
}
if (Array.from(formatted).length <= maxChars) {
return formatted;
}
const italic = formatted.match(/^_(.*)_$/u);
if (!italic) {
return compactReasoningProgressDisplayLine(formatted, maxChars);
}
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
return body ? `_${body}_` : "";
}
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalized = text.replace(/\s+/g, " ").trim();
const chars = Array.from(normalized);
if (chars.length <= maxChars) {
return normalized;
}
if (maxChars <= 1) {
return "…";
}
const head = chars
.slice(0, maxChars - 1)
.join("")
.trimEnd();
const boundary = head.search(/\s+\S*$/u);
if (boundary > Math.floor(maxChars * 0.6)) {
return `${head.slice(0, boundary).trimEnd()}`;
}
return `${head}`;
}
function normalizeCommentaryProgressText(text: string): string {
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
@@ -566,9 +512,7 @@ function mergeReasoningProgressText(
}
function isReasoningSnapshotText(text: string): boolean {
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
text,
);
return /^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i.test(text);
}
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {

View File

@@ -3123,320 +3123,6 @@ describe("processDiscordMessage draft streaming", () => {
expect(updates.join("\n")).not.toContain("Thinking\n");
});
it("accumulates reasoning deltas in Discord progress drafts", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
for (const text of ["Considering", " plugin", " installation", "!"]) {
await params?.replyOptions?.onReasoningStream?.({ text });
}
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Considering plugin installation!_",
);
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates.join("\n")).not.toContain("• _!_");
});
it("preserves raw reasoning content that starts with Thinking", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking" });
await params?.replyOptions?.onReasoningStream?.({ text: " through the install plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Thinking through the install plan_",
);
});
it("preserves raw reasoning content that starts with Thinking colon", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking: compare install paths" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Thinking: compare install paths_",
);
});
it("preserves raw reasoning content that starts with Reasoning colon", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning: compare install paths" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Reasoning: compare install paths_",
);
});
it("strips legacy Reasoning newline wrappers from progress snapshots", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Reasoning:\ncompare install paths",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _compare install paths_",
);
});
it("strips legacy Thinking ellipsis display wrappers from progress snapshots", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Thinking...\n\n_compare install paths_",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _compare install paths_",
);
});
it("preserves raw reasoning content that starts with a Thinking line", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking\nthrough the plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Thinking through the plan_",
);
});
it("appends raw reasoning chunks that start with Thinking", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking about the plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _I was Thinking about the plan_",
);
});
it("appends raw reasoning chunks that start with Thinking ellipsis", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking... through the plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _I was Thinking... through the plan_",
);
});
it("appends raw reasoning chunks that start with Reasoning colon", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning: through edge cases" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _I was Reasoning: through edge cases_",
);
});
it("keeps reasoning italics balanced when progress lines truncate", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Thinking through a very detailed installation plan with many steps",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
maxLineChars: 36,
},
},
},
});
await runProcessDiscordMessage(ctx);
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
const reasoningLine = lastUpdate?.split("\n").at(-1);
expect(reasoningLine).toMatch(/^ _.*_$/u);
expect(reasoningLine?.match(/_/gu)).toHaveLength(2);
});
it("replaces reasoning snapshots instead of appending duplicates", async () => {
const draftStream = createMockDraftStreamForTest();
@@ -3466,7 +3152,9 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update.mock.calls.at(-1)?.[0]).toContain("_Reading Checking_");
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Reading _ _Checking_",
);
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates.join("\n")).not.toContain("_Checking Reading");
});

View File

@@ -1,6 +1,10 @@
import path from "node:path";
import { MessageFlags } from "discord-api-types/v10";
import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import {
formatReasoningMessage,
resolveAckReaction,
resolveHumanDelayConfig,
} from "openclaw/plugin-sdk/agent-runtime";
import {
createStatusReactionController,
DEFAULT_TIMING,
@@ -983,7 +987,8 @@ async function processDiscordMessageInner(
: undefined,
onReasoningStream: async (payload) => {
await statusReactions.setThinking();
await draftPreview.pushReasoningProgress(payload?.text, {
const formattedText = payload?.text ? formatReasoningMessage(payload.text) : undefined;
await draftPreview.pushReasoningProgress(formattedText, {
snapshot: payload?.isReasoningSnapshot === true,
});
},

View File

@@ -1774,10 +1774,6 @@ export class DiscordVoiceManager {
logVoiceVerbose(`receive stream ended: ${analysis.message}`);
return;
}
if (analysis.isDecodeCorruption && !analysis.countsAsDecryptFailure) {
logVoiceVerbose(`receive decode skipped: ${analysis.message}`);
return;
}
logger.warn(`discord voice: receive error: ${analysis.message}`);
if (analysis.shouldAttemptPassthrough) {
this.enableDaveReceivePassthrough(

View File

@@ -1,4 +1,3 @@
import { OpusError, OpusErrorCode } from "libopus-wasm";
import { describe, expect, it, vi } from "vitest";
import {
analyzeVoiceReceiveError,
@@ -16,7 +15,6 @@ describe("voice receive recovery", () => {
).toEqual({
message: "Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)",
isAbortLike: false,
isDecodeCorruption: false,
shouldAttemptPassthrough: true,
countsAsDecryptFailure: true,
});
@@ -26,60 +24,15 @@ describe("voice receive recovery", () => {
expect(analyzeVoiceReceiveError(new Error("memory access out of bounds"))).toEqual({
message: "memory access out of bounds",
isAbortLike: false,
isDecodeCorruption: false,
shouldAttemptPassthrough: false,
countsAsDecryptFailure: true,
});
});
it("treats corrupt Opus packets as non-recoverable decode noise", () => {
expect(
analyzeVoiceReceiveError(
new OpusError(OpusErrorCode.InvalidPacket, "not inspected", "decode"),
),
).toEqual({
message: "not inspected",
isAbortLike: false,
isDecodeCorruption: true,
shouldAttemptPassthrough: false,
countsAsDecryptFailure: false,
});
});
it("treats structurally equivalent Opus errors as decode corruption", () => {
const analysis = analyzeVoiceReceiveError({
name: "OpusError",
message: "libopus decode failed (-4): corrupted stream",
code: OpusErrorCode.InvalidPacket,
codeName: "InvalidPacket",
operation: "decode",
});
expect(analysis).toMatchObject({
isAbortLike: false,
isDecodeCorruption: true,
shouldAttemptPassthrough: false,
countsAsDecryptFailure: false,
});
});
it("does not classify corrupt Opus packet text without the Opus error contract", () => {
expect(
analyzeVoiceReceiveError(new Error("libopus decode failed (-4): corrupted stream")),
).toEqual({
message: "libopus decode failed (-4): corrupted stream",
isAbortLike: false,
isDecodeCorruption: false,
shouldAttemptPassthrough: false,
countsAsDecryptFailure: false,
});
});
it("treats premature stream close as an expected receive end", () => {
expect(analyzeVoiceReceiveError(new Error("Premature close"))).toEqual({
message: "Premature close",
isAbortLike: true,
isDecodeCorruption: false,
shouldAttemptPassthrough: false,
countsAsDecryptFailure: false,
});

View File

@@ -1,4 +1,3 @@
import { OpusErrorCode, isOpusError } from "libopus-wasm";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
const DECRYPT_FAILURE_WINDOW_MS = 30_000;
@@ -19,7 +18,6 @@ export type VoiceReceiveRecoveryState = {
type VoiceReceiveErrorAnalysis = {
message: string;
isAbortLike: boolean;
isDecodeCorruption: boolean;
shouldAttemptPassthrough: boolean;
countsAsDecryptFailure: boolean;
};
@@ -82,23 +80,13 @@ function isAbortLikeReceiveError(err: unknown): boolean {
);
}
function isOpusDecodeInvalidPacketError(err: unknown): boolean {
return (
isOpusError(err) &&
err.code === OpusErrorCode.InvalidPacket &&
(err.operation === "decode" || err.operation === "decodeFloat")
);
}
export function analyzeVoiceReceiveError(err: unknown): VoiceReceiveErrorAnalysis {
const message = formatErrorMessage(err);
const normalizedMessage = message.toLowerCase();
const shouldAttemptPassthrough = message.includes(DAVE_PASSTHROUGH_DISABLED_MARKER);
const isWasmMemoryAccessFailure = normalizedMessage.includes(WASM_MEMORY_ACCESS_MARKER);
const isWasmMemoryAccessFailure = message.toLowerCase().includes(WASM_MEMORY_ACCESS_MARKER);
return {
message,
isAbortLike: isAbortLikeReceiveError(err),
isDecodeCorruption: isOpusDecodeInvalidPacketError(err),
shouldAttemptPassthrough,
countsAsDecryptFailure:
message.includes(DECRYPT_FAILURE_MARKER) ||

View File

@@ -1575,7 +1575,7 @@ export async function handleFeishuMessage(params: {
turnResult.dispatched &&
shouldSendNoVisibleReplyFallback({
...turnResult.dispatchResult,
failedCounts: dispatcher.getFailedCounts?.() ?? { tool: 0, block: 0, final: 0 },
failedCounts: dispatcher.getFailedCounts(),
})
) {
await ensureNoVisibleReplyFallback("broadcast-dispatch-complete-no-visible-reply");
@@ -1771,7 +1771,7 @@ export async function handleFeishuMessage(params: {
if (
shouldSendNoVisibleReplyFallback({
...dispatchResult,
failedCounts: dispatcher.getFailedCounts?.() ?? { tool: 0, block: 0, final: 0 },
failedCounts: dispatcher.getFailedCounts(),
})
) {
await ensureNoVisibleReplyFallback("dispatch-complete-no-visible-reply");

View File

@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveThinkingProfile } from "./provider-policy-api.js";
describe("github-copilot provider-policy-api", () => {
it("returns the base level set for non-xhigh GitHub Copilot models", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-opus-4.6",
})?.levels.map((level) => level.id),
).toEqual(["off", "minimal", "low", "medium", "high"]);
});
it("appends xhigh for current static GPT Copilot xhigh ids", () => {
for (const modelId of ["gpt-5.4", "gpt-5.3-codex"]) {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId,
})?.levels.map((level) => level.id),
`model=${modelId}`,
).toContain("xhigh");
}
});
it("appends xhigh when catalog compat advertises it", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "future-copilot-model",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
})?.levels.map((level) => level.id),
).toContain("xhigh");
});
it("appends xhigh for static Copilot metadata overrides", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-opus-4.7-1m-internal",
})?.levels.map((level) => level.id),
).toContain("xhigh");
});
it("normalizes the model id casing before xhigh membership checks", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "GPT-5.4",
})?.levels.map((level) => level.id),
).toContain("xhigh");
});
it("returns null for non-GitHub Copilot providers", () => {
expect(
resolveThinkingProfile({
provider: "openai",
modelId: "gpt-5.4",
}),
).toBeNull();
});
});

View File

@@ -1,39 +0,0 @@
import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/core";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveStaticCopilotModelOverride } from "./model-metadata.js";
const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.3-codex"] as const;
function compatSupportsXHigh(
compat: { supportedReasoningEfforts?: readonly string[] | null } | null | undefined,
) {
return (
Array.isArray(compat?.supportedReasoningEfforts) &&
compat.supportedReasoningEfforts.some(
(effort) => normalizeOptionalLowercaseString(effort) === "xhigh",
)
);
}
export function resolveThinkingProfile(context: ProviderDefaultThinkingPolicyContext) {
if (context.provider.trim().toLowerCase() !== "github-copilot") {
return null;
}
const normalizedModelId = normalizeOptionalLowercaseString(context.modelId) ?? "";
const staticCompat = resolveStaticCopilotModelOverride(normalizedModelId)?.compat;
const modelSupportsXHigh =
COPILOT_XHIGH_MODEL_IDS.includes(normalizedModelId as never) ||
compatSupportsXHigh(context.compat) ||
compatSupportsXHigh(staticCompat);
return {
levels: [
{ id: "off" as const },
{ id: "minimal" as const },
{ id: "low" as const },
{ id: "medium" as const },
{ id: "high" as const },
...(modelSupportsXHigh ? [{ id: "xhigh" as const }] : []),
],
};
}

View File

@@ -1,8 +1,6 @@
import { describe, expect, it } from "vitest";
import {
isGoogleGenerativeAiApi,
isGoogleVertexBaseUrl,
isGoogleVertexHostname,
normalizeGoogleApiBaseUrl,
normalizeGoogleGenerativeAiBaseUrl,
normalizeGoogleProviderConfig,
@@ -85,23 +83,6 @@ describe("google generative ai helpers", () => {
models: [{ api: "openai-completions" }],
}),
).toBe(false);
expect(
shouldNormalizeGoogleGenerativeAiProviderConfig("google-vertex", {
baseUrl: "https://aiplatform.googleapis.com",
}),
).toBe(false);
});
it("detects native Google Vertex hosts by hostname only", () => {
expect(isGoogleVertexHostname("aiplatform.googleapis.com")).toBe(true);
expect(isGoogleVertexHostname("us-central1-aiplatform.googleapis.com")).toBe(true);
expect(isGoogleVertexHostname("generativelanguage.googleapis.com")).toBe(false);
expect(isGoogleVertexHostname("evil-aiplatform.googleapis.com.attacker.com")).toBe(false);
expect(
isGoogleVertexBaseUrl(
"https://generativelanguage.googleapis.com/v1beta/proxy/aiplatform.googleapis.com",
),
).toBe(false);
});
it("normalizes transport baseUrls only for Google Generative AI", () => {
@@ -133,28 +114,6 @@ describe("google generative ai helpers", () => {
api: "openai-completions",
baseUrl: "https://generativelanguage.googleapis.com",
});
expect(
resolveGoogleGenerativeAiTransport({
provider: "google-vertex",
api: undefined,
baseUrl: "https://us-central1-aiplatform.googleapis.com",
}),
).toEqual({
api: "google-vertex",
baseUrl: "https://us-central1-aiplatform.googleapis.com",
});
expect(
resolveGoogleGenerativeAiTransport({
provider: "google-vertex",
api: "openai-completions",
baseUrl:
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
}),
).toEqual({
api: "openai-completions",
baseUrl:
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
});
});
it("normalizes google-vertex model ids without rewriting the OpenAI-compatible baseUrl", () => {

View File

@@ -30,8 +30,6 @@ export {
export {
DEFAULT_GOOGLE_API_BASE_URL,
isGoogleGenerativeAiApi,
isGoogleVertexBaseUrl,
isGoogleVertexHostname,
normalizeGoogleApiBaseUrl,
normalizeGoogleGenerativeAiBaseUrl,
normalizeGoogleProviderConfig,

View File

@@ -65,13 +65,7 @@ describe("google provider plugin hooks", () => {
modelApi: "google-generative-ai",
modelId: "gemini-3.1-pro-preview",
} as never),
).toBe("native");
expect(
provider.resolveReasoningOutputMode?.({
provider: "google",
modelId: "gemini-3.1-pro-preview",
} as never),
).toBe("native");
).toBe("tagged");
const sanitized = await Promise.resolve(
provider.sanitizeReplayHistory?.({
@@ -108,60 +102,6 @@ describe("google provider plugin hooks", () => {
expect(customEntries[0]?.customType).toBe("google-turn-ordering-bootstrap");
});
it("keeps google-gemini-cli on tagged reasoning mode", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const cliProvider = requireRegisteredProvider(providers, "google-gemini-cli");
expect(
cliProvider.resolveReasoningOutputMode?.({
provider: "google-gemini-cli",
modelApi: "google-gemini-cli",
modelId: "gemini-2.5-pro",
} as never),
).toBe("tagged");
});
it("keeps google-antigravity hook aliases on tagged reasoning mode", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const provider = requireRegisteredProvider(providers, "google-antigravity");
expect(
provider.resolveReasoningOutputMode?.({
provider: "google-antigravity",
modelApi: "openai-completions",
modelId: "gemini-3-pro-low",
} as never),
).toBe("tagged");
});
it("keeps google-vertex hook aliases on native reasoning mode", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const provider = requireRegisteredProvider(providers, "google-vertex");
expect(
provider.resolveReasoningOutputMode?.({
provider: "google-vertex",
modelApi: "google-vertex",
modelId: "gemini-3.1-pro-preview",
} as never),
).toBe("native");
expect(
provider.resolveReasoningOutputMode?.({
provider: "google-vertex",
modelId: "gemini-3.1-pro-preview",
} as never),
).toBe("native");
});
it("owns Gemini tool schema normalization for direct and CLI providers", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,

View File

@@ -40,9 +40,4 @@ describe("google model id helpers", () => {
expect(normalizeGoogleModelId("gemini-3.1-flash-lite")).toBe("gemini-3.1-flash-lite");
expect(normalizeGoogleModelId("gemini-3.1-flash-lite-preview")).toBe("gemini-3.1-flash-lite");
});
it("maps the old Gemma 4 26B shorthand to Google's canonical API id", () => {
expect(normalizeGoogleModelId("gemma-4-26b")).toBe("gemma-4-26b-a4b-it");
expect(normalizeGoogleModelId("google/gemma-4-26b")).toBe("google/gemma-4-26b-a4b-it");
});
});

View File

@@ -27,9 +27,6 @@ export function normalizeGoogleModelId(id: string): string {
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
return "gemini-3-flash-preview";
}
if (id === "gemma-4-26b") {
return "gemma-4-26b-a4b-it";
}
return id;
}

View File

@@ -11,13 +11,8 @@ describe("google provider catalog", () => {
expect(provider.api).toBe("google-vertex");
expect(provider.baseUrl).toBe("https://{location}-aiplatform.googleapis.com");
expect(provider.models.map((model) => model.id)).toEqual(
expect.arrayContaining(["gemini-2.5-pro", "gemini-3.1-pro-preview", "gemini-3.1-flash-lite"]),
expect.arrayContaining(["gemini-2.5-pro", "gemini-3.1-pro-preview"]),
);
expect(provider.models.find((model) => model.id === "gemini-3.1-flash-lite")).toMatchObject({
contextWindow: 1_048_576,
maxTokens: 65_536,
reasoning: true,
});
});
it("keeps Google AI Studio and Vertex model ids aligned", () => {

View File

@@ -43,15 +43,6 @@ const GOOGLE_GEMINI_TEXT_MODELS: ModelDefinitionConfig[] = [
contextWindow: 1_048_576,
maxTokens: 65_536,
},
{
id: "gemini-3.1-flash-lite",
name: "Gemini 3.1 Flash Lite",
reasoning: true,
input: ["text", "image"],
cost: GOOGLE_GEMINI_COST,
contextWindow: 1_048_576,
maxTokens: 65_536,
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash Preview",

View File

@@ -494,24 +494,6 @@ describe("resolveGoogleGeminiForwardCompatModel", () => {
});
});
it("canonicalizes Gemma 4 26B shorthand before cloning templates", () => {
const model = resolveGoogleGeminiForwardCompatModel({
providerId: "google",
ctx: createContext({
provider: "google",
modelId: "gemma-4-26b",
models: [createTemplateModel("google", "gemini-3-flash-preview", { reasoning: false })],
}),
});
expectModelFields(model, {
provider: "google",
id: "gemma-4-26b-a4b-it",
api: "google-generative-ai",
reasoning: true,
});
});
it("preserves template reasoning for non-Gemma 4 gemma models", () => {
const model = resolveGoogleGeminiForwardCompatModel({
providerId: "google",

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