Compare commits

..

837 Commits

Author SHA1 Message Date
Peter Steinberger
542baa3b43 docs: document DM policy compatibility contracts 2026-06-01 22:43:57 -04:00
Peter Steinberger
72402b51c5 docs: document channel ingress SDK contracts 2026-06-01 22:40:58 -04:00
Peter Steinberger
df90aac6e2 docs: document ingress state contracts 2026-06-01 22:39:21 -04:00
Peter Steinberger
09796c6991 docs: document ingress decision contracts 2026-06-01 22:37:09 -04:00
Peter Steinberger
86ef2324a3 docs: document ingress sender gate contracts 2026-06-01 22:35:11 -04:00
Peter Steinberger
f1326d71ae docs: document ingress allowlist contracts 2026-06-01 22:33:46 -04:00
Peter Steinberger
cf6700486c docs: document ingress identity contracts 2026-06-01 22:32:06 -04:00
Peter Steinberger
537636b926 docs: document ingress access group contracts 2026-06-01 22:30:55 -04:00
Peter Steinberger
21648cf844 docs: document direct DM guard policy 2026-06-01 22:29:10 -04:00
Peter Steinberger
0841fe7d67 docs: document direct DM dispatch contracts 2026-06-01 22:27:53 -04:00
Peter Steinberger
7ecc9551ff docs: document channel DM access helpers 2026-06-01 22:26:12 -04:00
Peter Steinberger
5d423e5f1a docs: document direct DM access contracts 2026-06-01 22:25:03 -04:00
Peter Steinberger
fc459ad376 docs: document read-only channel command defaults 2026-06-01 22:22:42 -04:00
Peter Steinberger
abd52441c5 docs: document native command session targets 2026-06-01 22:21:24 -04:00
Peter Steinberger
33711a477b docs: document typing start skip semantics 2026-06-01 22:19:08 -04:00
Peter Steinberger
5edcff17c8 docs: document conversation label contracts 2026-06-01 22:17:17 -04:00
Peter Steinberger
a652a0529b docs: document account snapshot credential contracts 2026-06-01 22:15:47 -04:00
Peter Steinberger
d92f990126 docs: document outbound attachment staging contract 2026-06-01 22:13:21 -04:00
Peter Steinberger
5577442e84 docs: document base64 mime sniffing contract 2026-06-01 22:12:06 -04:00
Peter Steinberger
fe01495f8e docs: document channel inbound media root contracts 2026-06-01 22:10:13 -04:00
Peter Steinberger
c8d313f742 docs: document local media access contracts 2026-06-01 22:09:01 -04:00
Peter Steinberger
82466b33c5 docs: document media load option contracts 2026-06-01 22:07:02 -04:00
Peter Steinberger
480091b9ee docs: document media reference contracts 2026-06-01 22:05:32 -04:00
Peter Steinberger
5ea7e9d071 docs: document media read access contracts 2026-06-01 22:03:28 -04:00
Peter Steinberger
6c4626eca1 docs: document realtime voice provider resolver contracts 2026-06-01 22:01:37 -04:00
Peter Steinberger
b3f7436307 docs: document realtime voice activation contracts 2026-06-01 21:59:56 -04:00
Peter Steinberger
e327700c7d docs: document tool call repair contracts 2026-06-01 21:58:24 -04:00
Peter Steinberger
988b2e12a6 docs: document channel allowlist resolution contracts 2026-06-01 21:56:11 -04:00
Peter Steinberger
4cf2a2dd6f docs: document system run allowlist contracts 2026-06-01 21:54:58 -04:00
Peter Steinberger
b65bd56a79 docs: document media understanding output contracts 2026-06-01 21:52:15 -04:00
Peter Steinberger
bed2f620dd docs: document media generation catalog contracts 2026-06-01 21:50:36 -04:00
Peter Steinberger
eb1d0a3588 docs: document memory batch contracts 2026-06-01 21:48:10 -04:00
Peter Steinberger
fbdbbe9e97 docs: document llm event stream contracts 2026-06-01 21:46:22 -04:00
Peter Steinberger
782a360580 docs: document shell capture contracts 2026-06-01 21:44:31 -04:00
Peter Steinberger
0e9b65889b docs: document media boundary helpers 2026-06-01 21:42:55 -04:00
Peter Steinberger
914f0f9315 docs: document markdown chunking invariants 2026-06-01 21:40:42 -04:00
Peter Steinberger
1666686eba docs: document gateway connect error contracts 2026-06-01 21:39:25 -04:00
Peter Steinberger
1cfc23afb4 docs: document voice webhook ingress contracts 2026-06-01 21:37:35 -04:00
Peter Steinberger
5acb805f37 docs: document voice runtime boundary contracts 2026-06-01 21:36:02 -04:00
Peter Steinberger
8361b69ff6 docs: document voice response extraction contracts 2026-06-01 21:33:55 -04:00
Peter Steinberger
66c588e28f docs: document plivo callback flow contracts 2026-06-01 21:32:08 -04:00
Peter Steinberger
3d5c8b25a5 docs: document telnyx call setup contracts 2026-06-01 21:29:25 -04:00
Peter Steinberger
0ff9e3a88f docs: document twilio stream bridge contracts 2026-06-01 21:27:27 -04:00
Peter Steinberger
4774d26cec docs: document voice webhook security contracts 2026-06-01 21:25:10 -04:00
Peter Steinberger
0bff438190 docs: document voice call lifecycle helper contracts 2026-06-01 21:23:01 -04:00
Peter Steinberger
2fbddd65e6 docs: document realtime audio pacer contracts 2026-06-01 21:20:55 -04:00
Peter Steinberger
08ff5f7235 docs: document media stream helper contracts 2026-06-01 21:19:06 -04:00
Peter Steinberger
ca10f65280 docs: document voice stream frame contracts 2026-06-01 21:17:17 -04:00
Peter Steinberger
79627a02a5 docs: document voice model selection contracts 2026-06-01 21:15:27 -04:00
Peter Steinberger
b8cd4513a6 docs: document realtime voice context contracts 2026-06-01 21:13:37 -04:00
Peter Steinberger
cd003a688e docs: clarify voice mapping contracts 2026-06-01 21:11:54 -04:00
Peter Steinberger
7321e70b6f docs: document mock voice provider contracts 2026-06-01 21:09:57 -04:00
Peter Steinberger
691baa3054 docs: document voice test support contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
782de561c8 docs: document voice config compatibility contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
56c2ee2a77 docs: document voice exposure tunnel contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
7e59c8a48a docs: document twilio helper contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
2cb6f013ad docs: document voice shared type contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
c9417590c4 docs: document voice runtime lifecycle contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
dae769e4d1 docs: document voice response generator contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
fd36d510ac docs: document voice lifecycle timer contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
1b7da5d000 docs: document voice event processor contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
4730f05e78 docs: document voice outbound helper contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
859d01c919 docs: document telephony persistence contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
3549150d17 docs: document voice utility helper contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
bb7339fe24 docs: document realtime default instructions 2026-06-01 21:07:14 -04:00
Peter Steinberger
7033becd07 docs: document voice config helpers 2026-06-01 21:07:14 -04:00
Peter Steinberger
9326519c8d docs: document twilio provider state 2026-06-01 21:07:14 -04:00
Peter Steinberger
ace9d4c842 docs: document stale call reaper options 2026-06-01 21:07:14 -04:00
Peter Steinberger
8ccb15f813 docs: document realtime handler APIs 2026-06-01 21:07:14 -04:00
Peter Steinberger
85aa7cca7a docs: document realtime audio pacer contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
fd84a67e22 docs: document voice webhook server contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
c4b7e5ebd7 docs: tighten voice webhook security comments 2026-06-01 21:07:14 -04:00
Peter Steinberger
6878fb25f9 docs: document shared provider helpers 2026-06-01 21:07:14 -04:00
Peter Steinberger
da6e410690 docs: document plivo provider contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
7d013c1353 docs: document telnyx provider contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
f733a37db3 docs: document twilio provider helpers 2026-06-01 21:07:14 -04:00
Peter Steinberger
42fae37d9f docs: tighten voice provider API docs 2026-06-01 21:07:14 -04:00
Peter Steinberger
940e4e64ff docs: document voice manager context 2026-06-01 21:07:14 -04:00
Peter Steinberger
30bf7310a5 docs: document voice event processor 2026-06-01 21:07:14 -04:00
Peter Steinberger
f4952f3c42 docs: document voice initial message flow 2026-06-01 21:07:14 -04:00
Peter Steinberger
a0590e113a docs: document voice call snapshot persistence 2026-06-01 21:07:14 -04:00
Peter Steinberger
645f3025a7 docs: document voice restore invariants 2026-06-01 21:07:14 -04:00
Peter Steinberger
84398e8509 docs: document voice call type contracts 2026-06-01 21:07:14 -04:00
Peter Steinberger
89d694b33a docs: tighten voice tunnel comments 2026-06-01 21:07:14 -04:00
Peter Steinberger
2b411b0298 docs: document telephony voice selection 2026-06-01 21:07:14 -04:00
Peter Steinberger
f5c2e455c7 docs: tighten media stream comments 2026-06-01 21:07:14 -04:00
Peter Steinberger
6495eb8355 docs: document telephony tts adapter 2026-06-01 21:07:14 -04:00
Peter Steinberger
f3dccaa707 docs: document voice runtime transcript handling 2026-06-01 21:07:14 -04:00
Peter Steinberger
3830ae5f86 docs: tighten voice response comments 2026-06-01 21:07:14 -04:00
Peter Steinberger
955cc4a0fa docs: document realtime voice context budget 2026-06-01 21:07:14 -04:00
Peter Steinberger
e6049f5560 docs: document voice realtime path defaults 2026-06-01 21:07:14 -04:00
Peter Steinberger
b949cd8a63 docs: document realtime consult coordination 2026-06-01 21:07:14 -04:00
Peter Steinberger
eb68d9e8e7 docs: document realtime bridge lookup 2026-06-01 21:07:13 -04:00
Peter Steinberger
ee6b5eb51a docs: document voice webhook realtime guards 2026-06-01 21:07:13 -04:00
Peter Steinberger
57930933ce docs: document twilio twiml policy 2026-06-01 21:07:13 -04:00
Peter Steinberger
a9865297f9 docs: document plivo call control invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
8f952a1819 docs: document telnyx call control invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
ef7f54e1db docs: document twilio stream cleanup 2026-06-01 21:07:13 -04:00
Peter Steinberger
1bb275b4af docs: document voice provider contracts 2026-06-01 21:07:13 -04:00
Peter Steinberger
8f8fba66e3 docs: document voice lifecycle cleanup 2026-06-01 21:07:13 -04:00
Peter Steinberger
3663b216ea docs: document voice outbound invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
082e0e1e74 docs: document voice twiml helpers 2026-06-01 21:07:13 -04:00
Peter Steinberger
029eae8d4d docs: document voice event idempotency 2026-06-01 21:07:13 -04:00
Peter Steinberger
7e91337292 docs: document voice call store ordering 2026-06-01 21:07:13 -04:00
Peter Steinberger
5cddc8617b docs: document tts voice lookup 2026-06-01 21:07:13 -04:00
Peter Steinberger
b29bc49452 docs: document telephony audio exports 2026-06-01 21:07:13 -04:00
Peter Steinberger
ab0c86079c docs: document telephony tts policy 2026-06-01 21:07:13 -04:00
Peter Steinberger
7cc4b178da docs: document realtime audio pacer invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
8e21b7b791 docs: document stream frame adapter invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
70c180de5c docs: document webhook replay invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
3ab4ff1970 docs: document voice webhook lifecycle 2026-06-01 21:07:13 -04:00
Peter Steinberger
b80b736bec docs: document voice config invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
902a7f2e40 docs: document realtime consult invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
fd66568e9c docs: document media stream invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
cb50517168 docs: document twilio provider invariants 2026-06-01 21:07:13 -04:00
Peter Steinberger
461d582bf0 docs: document voice cli helpers 2026-06-01 21:07:13 -04:00
Peter Steinberger
df403be1a6 docs: document voice response sanitizing 2026-06-01 21:07:13 -04:00
Peter Steinberger
ddec7f7583 docs: document voice runtime setup 2026-06-01 21:07:13 -04:00
Peter Steinberger
f675c85e97 docs: document voice core bridge 2026-06-01 21:07:13 -04:00
Peter Steinberger
935e31e1f7 docs: document voice webhook security guards 2026-06-01 21:07:13 -04:00
Peter Steinberger
7a2312ed3b docs: document stale call reaper 2026-06-01 21:07:13 -04:00
Peter Steinberger
22408ff4ca docs: document voice mock provider 2026-06-01 21:07:13 -04:00
Peter Steinberger
a293e4ea36 docs: document voice outbound helpers 2026-06-01 21:07:13 -04:00
Peter Steinberger
d1d363f02c docs: document voice runtime utilities 2026-06-01 21:07:13 -04:00
Peter Steinberger
181937aa79 docs: document voice twiml helpers 2026-06-01 21:07:13 -04:00
Peter Steinberger
b05a9e64e7 docs: document voice continue operations 2026-06-01 21:07:13 -04:00
Peter Steinberger
052b9caa4c docs: document realtime voice context helpers 2026-06-01 21:07:13 -04:00
Peter Steinberger
b8cd038b53 docs: document telephony tts helpers 2026-06-01 21:07:13 -04:00
Peter Steinberger
807a78d729 docs: document voice response helpers 2026-06-01 21:07:13 -04:00
Peter Steinberger
3ba3706e7b docs: document voice config compatibility 2026-06-01 21:07:13 -04:00
Peter Steinberger
f8fbeca3b0 docs: document webhook exposure checks 2026-06-01 21:07:13 -04:00
Peter Steinberger
ecfdc422ff docs: document telnyx provider flow 2026-06-01 21:07:13 -04:00
Peter Steinberger
b2d4015559 docs: document plivo provider flow 2026-06-01 21:07:13 -04:00
Peter Steinberger
38b3f872ec docs: document twilio provider helpers 2026-06-01 21:07:13 -04:00
Peter Steinberger
30342d1ff1 docs: document voice provider helpers 2026-06-01 21:07:13 -04:00
Peter Steinberger
5772ce0bd2 docs: document voice event state flow 2026-06-01 21:07:12 -04:00
Peter Steinberger
8b615e7bdd docs: document voice call timers 2026-06-01 21:07:12 -04:00
Peter Steinberger
74a6828e65 docs: document voice call record store 2026-06-01 21:07:12 -04:00
Peter Steinberger
6f885c9e69 docs: document voice manager helpers 2026-06-01 21:07:12 -04:00
Peter Steinberger
606f914786 docs: document voice helper boundaries 2026-06-01 21:07:12 -04:00
Peter Steinberger
0a37307b9e docs: document tailscale exposure helpers 2026-06-01 21:07:12 -04:00
Peter Steinberger
a45cf4aa3d docs: document stream frame adapters 2026-06-01 21:07:12 -04:00
Peter Steinberger
627f937126 docs: document realtime audio pacing 2026-06-01 21:07:12 -04:00
Peter Steinberger
87a51de824 docs: document voice config merge guard 2026-06-01 21:07:12 -04:00
Peter Steinberger
cf7aa53974 docs: document voice mapping passthrough 2026-06-01 21:07:12 -04:00
Peter Steinberger
1dd7dcbb8b docs: document telephony audio chunking 2026-06-01 21:07:12 -04:00
Peter Steinberger
1c7bc0a70c docs: document pair loop guard invariants 2026-06-01 21:07:12 -04:00
Peter Steinberger
2eef5e64ea docs: document string entry coercion 2026-06-01 21:07:12 -04:00
Peter Steinberger
44030e6a70 docs: document async lock contract 2026-06-01 21:07:12 -04:00
Peter Steinberger
b4e3680c15 docs: document slack external menu tokens 2026-06-01 21:07:12 -04:00
Peter Steinberger
f5fed728d7 docs: document thread binding contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
7e1c1293d2 docs: document ssrf pinned lookup contract 2026-06-01 21:07:12 -04:00
Peter Steinberger
2ba9dcc4d1 docs: document local media access guardrails 2026-06-01 21:07:12 -04:00
Peter Steinberger
c4b2e5ede1 docs: document media mime helper contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
9b9481466b docs: document tcp port config parsing 2026-06-01 21:07:12 -04:00
Peter Steinberger
e887319d03 docs: document fetch runtime proxy preset 2026-06-01 21:07:12 -04:00
Peter Steinberger
19cd359980 docs: document scp host normalization contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
6d6f800b71 docs: document provider operation retry contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
89e289bebf docs: document image asset helper contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
887da616a3 docs: document delivery queue runtime contract 2026-06-01 21:07:12 -04:00
Peter Steinberger
6f7111af77 docs: document provider auth runtime contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
fb184b23d8 docs: document provider catalog projection contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
3da99c9c5d docs: document provider stream family contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
4a5250bbd0 docs: document provider stream wrapper contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
a01dad0467 docs: document provider replay family contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
3e0f205e21 docs: remove provider web facade header comments 2026-06-01 21:07:12 -04:00
Peter Steinberger
7e89d1549c docs: document web search provider contract fields 2026-06-01 21:07:12 -04:00
Peter Steinberger
7fe48606d9 docs: document provider selection contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
c9dba69584 docs: document provider enable result contract 2026-06-01 21:07:12 -04:00
Peter Steinberger
04e960542d docs: document oauth provider auth result helper 2026-06-01 21:07:12 -04:00
Peter Steinberger
37d68a2c26 docs: clarify outbound text chunking contract 2026-06-01 21:07:12 -04:00
Peter Steinberger
4f75d03f98 docs: document reply payload delivery helpers 2026-06-01 21:07:12 -04:00
Peter Steinberger
c56f0ad6e8 docs: document outbound media load options 2026-06-01 21:07:12 -04:00
Peter Steinberger
e7685a3442 docs: document channel policy helper contracts 2026-06-01 21:07:12 -04:00
Peter Steinberger
0915a43ae3 docs: document browser profile facade contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
40bc655224 docs: document agent media payload contract 2026-06-01 21:07:11 -04:00
Peter Steinberger
c4c3649a69 docs: document browser control auth facade 2026-06-01 21:07:11 -04:00
Peter Steinberger
982d81f613 docs: document browser cdp url helpers 2026-06-01 21:07:11 -04:00
Peter Steinberger
cd01bd00fc docs: document allow-from helper contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
133a0a3d1b docs: document approval approver resolution 2026-06-01 21:07:11 -04:00
Peter Steinberger
542c2a667c docs: document approval auth helper contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
eaa9da2d81 docs: document approval client helper contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
7c7c52640c docs: document approval delivery capability contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
7106593349 docs: document native approval routing contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
284c316fde docs: document channel lifecycle contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
f4a049d571 docs: document approval reaction runtime contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
c7d3d09345 docs: document plugin config runtime helpers 2026-06-01 21:07:11 -04:00
Peter Steinberger
f43e8eac30 docs: document provider onboard merge contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
daa6405784 docs: document provider tool schema compat 2026-06-01 21:07:11 -04:00
Peter Steinberger
63d1572d40 docs: document channel route shape contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
6d3d1b4449 docs: document channel ingress SDK contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
5198edc051 docs: document group access decision contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
776121bf27 docs: document session transcript hit contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
321bd8734d docs: document allowlist config edit contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
f5c3fc2033 docs: document session visibility contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
eb925afda2 docs: document channel config adapter options 2026-06-01 21:07:11 -04:00
Peter Steinberger
66dccf2111 docs: document channel config write policy shapes 2026-06-01 21:07:11 -04:00
Peter Steinberger
fe976b19f5 docs: document gateway config write contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
c306bf9986 docs: document model auth status payloads 2026-06-01 21:07:11 -04:00
Peter Steinberger
c9c71965d2 docs: document talk realtime helper contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
7e3832cb72 docs: document webchat reply media normalization 2026-06-01 21:07:11 -04:00
Peter Steinberger
a71d83f1ea docs: document webchat media helper contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
d14c004124 docs: document managed image attachment limits 2026-06-01 21:07:11 -04:00
Peter Steinberger
87881bb3f8 docs: document session history state payloads 2026-06-01 21:07:11 -04:00
Peter Steinberger
f529019f71 docs: document session kill http contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
ad230f0072 docs: document openai embeddings endpoint contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
57c15073bd docs: document openai models endpoint contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
3d4a170acd docs: document openai compat error contracts 2026-06-01 21:07:11 -04:00
Peter Steinberger
70954c5ef1 docs: document responses prompt conversion 2026-06-01 21:07:11 -04:00
Peter Steinberger
bc1ceb11f5 docs: document responses output item helpers 2026-06-01 21:07:11 -04:00
Peter Steinberger
5c5ead97f2 docs: document openai tool choice constraints 2026-06-01 21:07:11 -04:00
Peter Steinberger
5a451e4b29 docs: document openai http request context helpers 2026-06-01 21:07:11 -04:00
Peter Steinberger
74b7668ad7 docs: document gateway http auth contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
fd820654f6 docs: document gateway post json endpoint options 2026-06-01 21:07:10 -04:00
Peter Steinberger
91bc6d2f75 docs: document shared gateway http responses 2026-06-01 21:07:10 -04:00
Peter Steinberger
c8f2b9864a docs: document gateway client readiness adapter 2026-06-01 21:07:10 -04:00
Peter Steinberger
845ae136e2 docs: document control ui link resolution 2026-06-01 21:07:10 -04:00
Peter Steinberger
8bad7e3c5f docs: document control ui shared url helpers 2026-06-01 21:07:10 -04:00
Peter Steinberger
5d916a47e0 docs: document gateway lane concurrency contract 2026-06-01 21:07:10 -04:00
Peter Steinberger
ce6443d6c2 docs: document gateway client bootstrap contract 2026-06-01 21:07:10 -04:00
Peter Steinberger
a4f270e960 docs: document gateway auth mode validation message 2026-06-01 21:07:10 -04:00
Peter Steinberger
25c19e98d9 docs: document gateway connection auth options 2026-06-01 21:07:10 -04:00
Peter Steinberger
be1d0283f7 docs: document gateway probe target contract 2026-06-01 21:07:10 -04:00
Peter Steinberger
6ea9de0ba9 docs: document gateway probe auth source contract 2026-06-01 21:07:10 -04:00
Peter Steinberger
9f9b233262 docs: document gateway dedupe entry contract 2026-06-01 21:07:10 -04:00
Peter Steinberger
befc96d445 docs: document startup task result contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
9de16d960e docs: document hook allowlist policy contract 2026-06-01 21:07:10 -04:00
Peter Steinberger
ac3fed0b90 docs: document channel health policy contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
8856a3e63f docs: document control UI bootstrap contract 2026-06-01 21:07:10 -04:00
Peter Steinberger
8348c97336 docs: document plugin reload target contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
a1e7b5c2af docs: document MCP loopback runtime cache contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
93d27fd090 docs: document MCP schema export contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
ae1d58e2e2 docs: document MCP loopback protocol contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
73d7448920 docs: document websocket close reason contract 2026-06-01 21:07:10 -04:00
Peter Steinberger
35e8f4aeb5 docs: document gateway method helper contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
9e8e5f8b8e docs: document node wake state contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
f5ee1d71a0 docs: document agent helper API contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
a1ac0e892c docs: document RPC attachment normalization contract 2026-06-01 21:07:10 -04:00
Peter Steinberger
5554d29db7 docs: document gateway request type contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
ba9f3be82b docs: document plugin route context contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
c4618bd859 docs: document hook request replay contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
47c68db395 docs: document gateway event loop health contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
14d9a9d184 docs: document gateway health state refresh contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
ad3e74f433 docs: document gateway chat state registry contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
5869473dc3 docs: document gateway model pricing cache state contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
005da3bfc0 docs: document gateway active session shutdown tracker contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
c3042c8a53 docs: document gateway run session key lookup contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
595df6e4fc docs: document gateway session event broadcast contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
80a9f9171d docs: document gateway transcript key ownership contracts 2026-06-01 21:07:10 -04:00
Peter Steinberger
a85df5a2fe docs: document gateway session kill HTTP contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
3d39143851 docs: document gateway session mutation cleanup contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
578258775e docs: document gateway transcript reader contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
9cbe85f2e6 docs: document gateway transcript index contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
035ca4106d docs: document gateway session compaction checkpoint contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
ace66d9276 docs: document gateway session lineage helper contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
2e856ecf6d docs: document gateway session lifecycle state contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
bb5a2a6c4b docs: document gateway session reset lifecycle contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
e3652a0541 docs: document gateway sessions patch contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
9ac9c4014e docs: document gateway session transcript file contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
a059c5e359 docs: document gateway session store key contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
d1cc90f991 docs: document gateway session transcript key contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
bbf74df187 docs: document gateway live chat projection contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
9f56655cba docs: document gateway chat abort contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
32cf26edb9 docs: document gateway chat attachment contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
38219de4a8 docs: document gateway chat sanitize contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
74de22592f docs: document gateway chat display projection contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
49d563823e docs: document gateway openai chat helper contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
55c26f453a docs: document gateway openai compatibility contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
39dcc60cf3 docs: document gateway openresponses helper contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
74b77e746c docs: document gateway openresponses continuity contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
b1562cf30e docs: document gateway openai embeddings contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
f46921dbc1 docs: document gateway openai models contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
2873917a67 docs: document gateway http compatibility utility contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
45e30ed8cb docs: document gateway http auth trust contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
343901eed2 docs: document gateway http endpoint helper contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
39987341ef docs: document gateway http common contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
e968912c0a docs: document gateway maintenance timer contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
a8946ceaa2 docs: document gateway runtime startup service contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
a38f8a7727 docs: document gateway runtime service contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
937c81d269 docs: document gateway runtime state contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
f741019d47 docs: document gateway reload handler contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
f3a66be5db docs: document gateway runtime config contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
1b3d42a5bf docs: document gateway server hook facade contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
6920c31b59 docs: document gateway hook request handler contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
bff56270f7 docs: document gateway hook mapping contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
16a2e9797c docs: document gateway hook helper contracts 2026-06-01 21:07:09 -04:00
Peter Steinberger
3580dcc2c5 docs: document gateway control reply suppression 2026-06-01 21:07:09 -04:00
Peter Steinberger
77cbf0bbe7 docs: document gateway local request context 2026-06-01 21:07:08 -04:00
Peter Steinberger
be604a74cc docs: document gateway control ui routing contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
d3645e9a09 docs: document gateway connection helpers 2026-06-01 21:07:08 -04:00
Peter Steinberger
f430f7b35f docs: document gateway shutdown env helpers 2026-06-01 21:07:08 -04:00
Peter Steinberger
5c8ad36c96 docs: document gateway boot echo guard contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
9cf089add3 docs: document gateway config reload contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
2f42e28822 docs: document gateway agent list contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
0dfecf5d38 docs: document gateway cli session history contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
0cf207ff69 docs: document gateway chat display projection 2026-06-01 21:07:08 -04:00
Peter Steinberger
34f5d18646 docs: document gateway chat attachment contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
894f76f9b2 docs: document gateway chat sanitize contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
cf2f010c11 docs: document gateway agent prompt helpers 2026-06-01 21:07:08 -04:00
Peter Steinberger
9dec94077c docs: document gateway channel health contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
46fdc874ff docs: document gateway install auth policy 2026-06-01 21:07:08 -04:00
Peter Steinberger
07cfeb8825 docs: document gateway auth token resolution 2026-06-01 21:07:08 -04:00
Peter Steinberger
748d15a7e8 docs: document gateway auth rate limiter contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
44a41c983d docs: document gateway chat state registries 2026-06-01 21:07:08 -04:00
Peter Steinberger
5991581624 docs: document gateway health state contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
6462d5711f docs: document gateway plugin http path contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
da00d620c8 docs: document gateway plugin http auth contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
5e2913b8f2 docs: document gateway http auth contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
35efd98a8d docs: document websocket auth policy contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
a74d094a92 docs: document gateway auth probe contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
8501e1ab49 docs: document hosted plugin url contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
1b35fd6042 docs: document live chat projection contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
f199a3ec4a docs: document gateway cli backend live helpers 2026-06-01 21:07:08 -04:00
Peter Steinberger
8b445c0b1c docs: document gateway run lifecycle contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
31cb21dc80 docs: document gateway cli registration contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
9141dac9ff docs: document gateway cli rpc contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
641c8d3e8f docs: document cli route parser contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
48ef13f3f9 docs: document message cli subcommand contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
f002c11263 docs: document message cli helper contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
ed98cf4072 docs: document program runtime helper contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
06bbffa56b docs: document program command descriptor contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
b9dd6e2176 docs: document nodes cli command contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
84941d8079 docs: document nodes cli rpc contracts 2026-06-01 21:07:08 -04:00
Peter Steinberger
7c71652b97 docs: document update command restore contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
68d189aee2 docs: document update cli helper contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
de62123e4d docs: document daemon shared helper contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
ffbfcf7ede docs: document CLI startup runtime helpers 2026-06-01 21:07:07 -04:00
Peter Steinberger
af78281011 docs: document config set helper contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
ea0411257d docs: document CLI utility helper contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
bcd4e91a26 docs: document CLI banner contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
03ccc1860d docs: document CLI completion contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
1a7ff3c75c docs: document CLI command policy contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
657355d2b0 docs: document CLI invocation display helpers 2026-06-01 21:07:07 -04:00
Peter Steinberger
2b444e9b43 docs: document CLI argv helpers 2026-06-01 21:07:07 -04:00
Peter Steinberger
c603b71d40 docs: document CLI root option helpers 2026-06-01 21:07:07 -04:00
Peter Steinberger
ac33c605cc docs: document approval handler runtime contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
df13f8aa6d docs: document command carrier exports 2026-06-01 21:07:07 -04:00
Peter Steinberger
590b653d8d docs: document offsetless datetime parsing 2026-06-01 21:07:07 -04:00
Peter Steinberger
f126a99773 docs: document duration formatter contract 2026-06-01 21:07:07 -04:00
Peter Steinberger
d44e59b737 docs: document lsof resolver contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
dee8f41d99 docs: document port diagnostics contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
3242949658 docs: document binary detection contract 2026-06-01 21:07:07 -04:00
Peter Steinberger
b1375ef40c docs: document git root contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
0f3ef7d6e7 docs: document package json contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
0ed2a3f6f4 docs: document runtime status contract 2026-06-01 21:07:07 -04:00
Peter Steinberger
1404b0e87e docs: document WebSocket raw data contract 2026-06-01 21:07:07 -04:00
Peter Steinberger
738bcde966 docs: document secure random contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
93f04f1edd docs: document JSON byte contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
a47f3b240d docs: document path prepend contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
e90dea78a8 docs: document port probe contract 2026-06-01 21:07:07 -04:00
Peter Steinberger
03b1d06980 docs: document install target contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
78638ba4bb docs: document update channel contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
c4fcafcf8e docs: document update check contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
e4c1182789 docs: document update restart sentinel handoff 2026-06-01 21:07:07 -04:00
Peter Steinberger
1cba4300a8 docs: document restart sentinel continuations 2026-06-01 21:07:07 -04:00
Peter Steinberger
93084f6073 docs: document entrypoint detection 2026-06-01 21:07:07 -04:00
Peter Steinberger
f4d53265da docs: document npm registry spec contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
c77e69b27b docs: document secret input normalization 2026-06-01 21:07:07 -04:00
Peter Steinberger
b9fd6d96cc docs: document inline directive helpers 2026-06-01 21:07:07 -04:00
Peter Steinberger
1fd4e90463 docs: document delivery context helpers 2026-06-01 21:07:07 -04:00
Peter Steinberger
ae62e30ae7 docs: document message channel helpers 2026-06-01 21:07:07 -04:00
Peter Steinberger
7b11b3f782 docs: document shared utils contracts 2026-06-01 21:07:07 -04:00
Peter Steinberger
0531beaf52 docs: document ssrf contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
355c1354e9 docs: document local-origin bypass contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
064ac94744 docs: document proxy fetch contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
b5f9cb6151 docs: document runtime fetch contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
5e03331d19 docs: document fetch compatibility contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
75b6ebc524 docs: document web push contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
f3a35fb09b docs: document Homebrew resolution contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
7634b15b81 docs: document gateway discovery targets 2026-06-01 21:07:06 -04:00
Peter Steinberger
5122e14c6b docs: document gateway process contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
2efa068f0b docs: document host env security contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
0a59b1319d docs: document restart sentinel contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
222e6f5c60 docs: document port diagnostics contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
cdd8bc862b docs: document heartbeat cooldown contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
762ad43b26 docs: document OpenAI tool choice contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
87c1417dab docs: document temp directory contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
71c473a539 docs: document executable path contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
5fa93a09d6 docs: document MCP loopback contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
e5b9d3c66b docs: document infra utility contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
4c12cc9da1 docs: document npm registry spec contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
0df70f2f9a docs: document gateway shared auth contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
ccbfcd3337 docs: document gateway method scope contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
a564c7dd82 docs: document agent stream safety contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
79c2c69ef1 docs: document CLI session reuse contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
c76863ec8a docs: document node pairing surface contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
297d95b94c docs: document node pairing persistence contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
751eabc9c4 docs: document gateway security helper contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
89d868733a docs: document gateway update mutation contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
1ea0f55fd6 docs: document node wake rpc contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
7f2ab82410 docs: document node approval pairing contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
2fc6ef9cd0 docs: document gateway node registry contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
e90fb1feba docs: document gateway startup runtime contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
7398020b1f docs: document gateway startup config contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
3ce0abff1a docs: document gateway talk session registry contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
71f9d68616 docs: document gateway talk relay contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
eae814770c docs: document gateway session history contracts 2026-06-01 21:07:06 -04:00
Peter Steinberger
9660aab819 docs: document gateway subagent lineage contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
a1f602765e docs: document session lifecycle event contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
243094a9e2 docs: document gateway active session shutdown contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
aa63357a88 docs: document gateway transcript ownership contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
7aff176ead docs: document gateway session patch contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
c7ac8c0b58 docs: document gateway auth resolution contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
ac29cbccc1 docs: document gateway auth secret materialization 2026-06-01 21:07:05 -04:00
Peter Steinberger
ab4ff72e05 docs: document gateway explicit connection policy 2026-06-01 21:07:05 -04:00
Peter Steinberger
1fd3e8a536 docs: document control ui routing contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
b57eb93646 docs: document gateway utility contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
188dbfbbbd docs: document gateway run session key contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
279a3a00bb docs: document node invoke sanitizer contract 2026-06-01 21:07:05 -04:00
Peter Steinberger
51ae46319a docs: document gateway rate limit contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
d9ef964c42 docs: document gateway request context contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
d47eeda8f9 docs: document gateway shared auth generation 2026-06-01 21:07:05 -04:00
Peter Steinberger
5eaba4ce10 docs: document gateway session key contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
ff4a7f7e50 docs: document gateway credential secret contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
8a6472b4b0 docs: document gateway method contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
9091d44ad2 docs: document plugin main api contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
5f2a996550 docs: document plugin node service contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
d096e788aa docs: document plugin command gateway contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
67a8225f3b docs: document speech realtime provider contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
3932238405 docs: document provider plugin tail contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
8dd47022bc docs: document provider plugin identity contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
429082e106 docs: document provider setup prompt contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
97c9ef2bad docs: document provider runtime hook contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
14b88e5193 docs: document provider catalog type contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
55d0eebf38 docs: document provider auth method contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
32fe56d9b5 docs: document provider auth type contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
fb2e814383 docs: document migration provider type contracts 2026-06-01 21:07:05 -04:00
Peter Steinberger
6d4d2d662a docs: document migrate prompt option APIs 2026-06-01 21:07:05 -04:00
Peter Steinberger
3c1d353e33 docs: document migrate command entrypoint 2026-06-01 21:07:05 -04:00
Peter Steinberger
85d0bd8c75 docs: document migrate provider context helpers 2026-06-01 21:07:05 -04:00
Peter Steinberger
ba37ac552c docs: document migrate output helpers 2026-06-01 21:07:05 -04:00
Peter Steinberger
ea4b3fd235 docs: document migrate selection helpers 2026-06-01 21:07:05 -04:00
Peter Steinberger
233a68e820 docs: document plugin SDK migration runtime 2026-06-01 21:07:05 -04:00
Peter Steinberger
6b4d308045 docs: document plugin SDK migration helpers 2026-06-01 21:07:05 -04:00
Peter Steinberger
c7befdc0e0 docs: document provider tool schema compatibility 2026-06-01 21:07:05 -04:00
Peter Steinberger
06e70c8ea5 docs: document outbound text chunking 2026-06-01 21:07:04 -04:00
Peter Steinberger
d7dedeb427 docs: document channel route identity helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
6e53296c56 docs: document inbound debounce policy 2026-06-01 21:07:04 -04:00
Peter Steinberger
5bd5cbcc3e docs: document channel config matching helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
e3647f0c03 docs: document plugin host cleanup timeout 2026-06-01 21:07:04 -04:00
Peter Steinberger
8ed427971d docs: document provider catalog helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
1cbf3a9114 docs: document channel model override resolution 2026-06-01 21:07:04 -04:00
Peter Steinberger
37b3dd4008 docs: document channel config helper contracts 2026-06-01 21:07:04 -04:00
Peter Steinberger
4712707798 docs: document account snapshot field projection 2026-06-01 21:07:04 -04:00
Peter Steinberger
041d699c13 docs: document thread binding policy helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
090d549a17 docs: document channel target parsing contracts 2026-06-01 21:07:04 -04:00
Peter Steinberger
ce00659782 docs: document direct dm access bridge 2026-06-01 21:07:04 -04:00
Peter Steinberger
fc35ea8283 docs: document provider catalog text projection 2026-06-01 21:07:04 -04:00
Peter Steinberger
7b3803a4a6 docs: document gateway startup plugin scopes 2026-06-01 21:07:04 -04:00
Peter Steinberger
68ce3a2d38 docs: document embedding provider runtime lookup 2026-06-01 21:07:04 -04:00
Peter Steinberger
b9910b87a0 docs: document memory embedding provider runtime 2026-06-01 21:07:04 -04:00
Peter Steinberger
6c67c766ce docs: document memory runtime helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
4b2ccbf421 docs: document bundle config helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
05c5d5a23d docs: document plugin config state 2026-06-01 21:07:04 -04:00
Peter Steinberger
39daf6e335 docs: document plugin activation context 2026-06-01 21:07:04 -04:00
Peter Steinberger
b4cce6da21 docs: document trusted tool policy flow 2026-06-01 21:07:04 -04:00
Peter Steinberger
458d49e8e4 docs: document provider install catalog 2026-06-01 21:07:04 -04:00
Peter Steinberger
36dd1f902e docs: document web search provider helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
6d88c9416d docs: document web fetch provider helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
ae3f999856 docs: document web provider resolution 2026-06-01 21:07:04 -04:00
Peter Steinberger
eac2c3db00 docs: document manifest model suppression 2026-06-01 21:07:04 -04:00
Peter Steinberger
e60a8bac79 docs: document manifest owner policy 2026-06-01 21:07:04 -04:00
Peter Steinberger
30a5337315 docs: document manifest tool availability 2026-06-01 21:07:04 -04:00
Peter Steinberger
8382859716 docs: document plugin scheduled turn contracts 2026-06-01 21:07:04 -04:00
Peter Steinberger
4e004384e0 docs: document uninstall helper contracts 2026-06-01 21:07:04 -04:00
Peter Steinberger
79074b7ee9 docs: document effective plugin id resolution 2026-06-01 21:07:04 -04:00
Peter Steinberger
ab1415b62d docs: document optional plugin manifest rule 2026-06-01 21:07:04 -04:00
Peter Steinberger
8359e618ed docs: document web search credential detection 2026-06-01 21:07:04 -04:00
Peter Steinberger
86c3de42cf docs: document hook agent channel context 2026-06-01 21:07:04 -04:00
Peter Steinberger
44413914a2 docs: document host hook cleanup timeout 2026-06-01 21:07:04 -04:00
Peter Steinberger
84d2aff5fb docs: document agent tool result middleware helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
4354045ce1 docs: document plugin source display 2026-06-01 21:07:04 -04:00
Peter Steinberger
fa305ad2e7 docs: document plugin control plane context 2026-06-01 21:07:04 -04:00
Peter Steinberger
81d30ae3c8 docs: document plugin scope helpers 2026-06-01 21:07:04 -04:00
Peter Steinberger
b460cae176 docs: document provider auth input mode 2026-06-01 21:07:04 -04:00
Peter Steinberger
ab3b585601 docs: document installed plugin index store paths 2026-06-01 21:07:04 -04:00
Peter Steinberger
8061edd972 docs: document plugin HTTP path normalization 2026-06-01 21:07:04 -04:00
Peter Steinberger
88b853cf7b docs: document provider config owner hints 2026-06-01 21:07:03 -04:00
Peter Steinberger
b8b85fb402 docs: document plugin conversation binding runtime 2026-06-01 21:07:03 -04:00
Peter Steinberger
a074ac6382 docs: document interactive registry helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
1a8e1f25ae docs: document plugin tool descriptor cache 2026-06-01 21:07:03 -04:00
Peter Steinberger
26bde4dcbd docs: document plugin cache primitives 2026-06-01 21:07:03 -04:00
Peter Steinberger
f97c5946b7 docs: document plugin root cache inputs 2026-06-01 21:07:03 -04:00
Peter Steinberger
3fb6b22133 docs: document package entrypoint candidates 2026-06-01 21:07:03 -04:00
Peter Steinberger
8ea2dc7075 docs: document bundled public surface resolution 2026-06-01 21:07:03 -04:00
Peter Steinberger
393ac2a110 docs: document host hook JSON boundary 2026-06-01 21:07:03 -04:00
Peter Steinberger
ce908ef258 docs: document setup wizard flow 2026-06-01 21:07:03 -04:00
Peter Steinberger
bd549a1a02 docs: clarify configured binding matching 2026-06-01 21:07:03 -04:00
Peter Steinberger
251d1a3c33 docs: document stateful target driver contract 2026-06-01 21:07:03 -04:00
Peter Steinberger
fb5c0da417 docs: document configured binding consumer contract 2026-06-01 21:07:03 -04:00
Peter Steinberger
700003d25c docs: document configured binding skips 2026-06-01 21:07:03 -04:00
Peter Steinberger
5f4fbb1639 docs: document chat target prefix helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
4c0a838b34 docs: document status approval utilities 2026-06-01 21:07:03 -04:00
Peter Steinberger
281e503a18 docs: document channel module loader 2026-06-01 21:07:03 -04:00
Peter Steinberger
091df1fddc docs: document config write ambiguity 2026-06-01 21:07:03 -04:00
Peter Steinberger
0826b75e9b docs: document group policy warning helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
79fae8a163 docs: document channel utility entrypoints 2026-06-01 21:07:03 -04:00
Peter Steinberger
521861192b docs: document status issue helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
c94710b5f4 docs: document setup wizard type contracts 2026-06-01 21:07:03 -04:00
Peter Steinberger
ccc4053def docs: document channel adapter type contracts 2026-06-01 21:07:03 -04:00
Peter Steinberger
cb72a1ce2d docs: document channel plugin type contracts 2026-06-01 21:07:03 -04:00
Peter Steinberger
ca23a63de1 docs: document outbound config types 2026-06-01 21:07:03 -04:00
Peter Steinberger
b6288593c2 docs: document setup wizard helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
817f220aaa docs: document setup promotion helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
06502bc9ad docs: document bundled read helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
3e74cc4d1a docs: document approval config schema helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
ffa248a523 docs: document dm pairing helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
7a4a814a3d docs: document channel runtime helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
5948160245 docs: document media helper APIs 2026-06-01 21:07:03 -04:00
Peter Steinberger
2656a8feca docs: document threading target helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
1d87ef5a86 docs: document channel state helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
ba8abd1357 docs: document channel catalog helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
4fabaea49b docs: document bundled channel loader 2026-06-01 21:07:03 -04:00
Peter Steinberger
64b684e187 docs: document channel registry helpers 2026-06-01 21:07:03 -04:00
Peter Steinberger
3cbf0d1faa docs: document outbound loader APIs 2026-06-01 21:07:03 -04:00
Peter Steinberger
e088d2cbbe docs: document presentation limit invariants 2026-06-01 21:07:02 -04:00
Peter Steinberger
75ba474c7d docs: document outbound helper APIs 2026-06-01 21:07:02 -04:00
Peter Steinberger
93ff68940d docs: document message action helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
0d676cfd48 docs: document message tool APIs 2026-06-01 21:07:02 -04:00
Peter Steinberger
c0026f1811 docs: document message action discovery 2026-06-01 21:07:02 -04:00
Peter Steinberger
112ce219fb docs: document stateful target builtins 2026-06-01 21:07:02 -04:00
Peter Steinberger
937a5a1ee1 docs: document configured binding consumers 2026-06-01 21:07:02 -04:00
Peter Steinberger
72edfa235e docs: document binding public APIs 2026-06-01 21:07:02 -04:00
Peter Steinberger
58ba60e14e docs: document stateful target drivers 2026-06-01 21:07:02 -04:00
Peter Steinberger
5782a24b97 docs: document configured binding registry 2026-06-01 21:07:02 -04:00
Peter Steinberger
b03998ae37 docs: document binding routing helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
18bf52fc94 docs: document directory adapter helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
026ec61336 docs: document directory config helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
c01cd303b2 docs: document setup wizard prompt helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
be1009ea34 docs: document setup wizard policy helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
eaa1af3e56 docs: document setup wizard helper parsing 2026-06-01 21:07:02 -04:00
Peter Steinberger
f4833592b3 docs: document setup wizard delegation 2026-06-01 21:07:02 -04:00
Peter Steinberger
577636d728 docs: document setup promotion keys 2026-06-01 21:07:02 -04:00
Peter Steinberger
a827663a5b docs: document setup promotion helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
07ca2b6871 docs: document plugin config helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
5f431f4fcd docs: document config write wrappers 2026-06-01 21:07:02 -04:00
Peter Steinberger
da2d32c5f8 docs: document config write policy helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
9ab59b4953 docs: document channel account helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
9e5dace9d3 docs: document account action gate 2026-06-01 21:07:02 -04:00
Peter Steinberger
544245826c docs: document target resolver helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
d275f33bd5 docs: document channel route helper invariants 2026-06-01 21:07:02 -04:00
Peter Steinberger
9df20de599 docs: document channel target parser contracts 2026-06-01 21:07:02 -04:00
Peter Steinberger
4184e9833b docs: document chat target prefix helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
92405fb43a docs: document allowlist matcher invariants 2026-06-01 21:07:02 -04:00
Peter Steinberger
e6232d218f docs: document allowlist resolution helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
8044db357f docs: document plugin allow-from helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
219ff4f299 docs: document channel gating helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
2646058c9b docs: document channel ingress sender gates 2026-06-01 21:07:02 -04:00
Peter Steinberger
5ed4298fb3 docs: document channel ingress allowlist helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
66c359839a docs: document channel ingress identity helpers 2026-06-01 21:07:02 -04:00
Peter Steinberger
48dc4444ae docs: document channel ingress runtime invariants 2026-06-01 21:07:02 -04:00
Peter Steinberger
903612ab64 docs: document channel turn helper invariants 2026-06-01 21:07:02 -04:00
Peter Steinberger
a4d7a8e3d9 docs: document delivery result compatibility 2026-06-01 21:07:02 -04:00
Peter Steinberger
1edf373908 docs: document durable reply delivery 2026-06-01 21:07:02 -04:00
Peter Steinberger
e36f9bcb89 docs: document channel turn kernel invariants 2026-06-01 21:07:02 -04:00
Peter Steinberger
2aec8684a0 docs: document inbound reply dispatch delivery 2026-06-01 21:07:02 -04:00
Peter Steinberger
b32d6f48ca docs: document reply prefix context 2026-06-01 21:07:01 -04:00
Peter Steinberger
a69a86775b docs: document channel typing internals 2026-06-01 21:07:01 -04:00
Peter Steinberger
a116a0567e docs: document channel typing lifecycle 2026-06-01 21:07:01 -04:00
Peter Steinberger
0e16019ead docs: document channel reply pipeline contracts 2026-06-01 21:07:01 -04:00
Peter Steinberger
f56e36d828 docs: document channel inbound compatibility 2026-06-01 21:07:01 -04:00
Peter Steinberger
7b18277681 docs: document channel config helper contracts 2026-06-01 21:07:01 -04:00
Peter Steinberger
e2990c76df docs: document channel policy helper contracts 2026-06-01 21:07:01 -04:00
Peter Steinberger
7b8ff148af docs: document channel send result helpers 2026-06-01 21:07:01 -04:00
Peter Steinberger
bfc66fb505 docs: document channel route helper contracts 2026-06-01 21:07:01 -04:00
Peter Steinberger
ecba8fb765 docs: document status helper contracts 2026-06-01 21:07:01 -04:00
Peter Steinberger
4bb06ec498 docs: document webhook target helpers 2026-06-01 21:07:01 -04:00
Peter Steinberger
04f2a05a95 docs: document webhook memory guards 2026-06-01 21:07:01 -04:00
Peter Steinberger
783a709a94 docs: document webhook request guards 2026-06-01 21:07:01 -04:00
Peter Steinberger
c7240c46a7 docs: document plugin runtime store 2026-06-01 21:07:01 -04:00
Peter Steinberger
ec2f8ca948 docs: document agent harness task runtime 2026-06-01 21:07:01 -04:00
Peter Steinberger
5d489d45e8 docs: document agent harness task runtime scope 2026-06-01 21:07:01 -04:00
Peter Steinberger
bf3f207175 docs: document detached task runtime state 2026-06-01 21:07:01 -04:00
Peter Steinberger
3c65961276 docs: document detached task runtime dispatch 2026-06-01 21:07:01 -04:00
Peter Steinberger
f31c30fece docs: document detached task runtime contract 2026-06-01 21:07:01 -04:00
Peter Steinberger
124bb53ea9 docs: document task registry types 2026-06-01 21:07:01 -04:00
Peter Steinberger
fe7fcc9091 docs: document task registry summaries 2026-06-01 21:07:01 -04:00
Peter Steinberger
adf128510b docs: document task domain view mappers 2026-06-01 21:07:01 -04:00
Peter Steinberger
dcbf2dde4c docs: document task status helpers 2026-06-01 21:07:01 -04:00
Peter Steinberger
32a5c3848a docs: document required completion contract 2026-06-01 21:07:01 -04:00
Peter Steinberger
e1509529bf docs: document task executor policy helpers 2026-06-01 21:07:01 -04:00
Peter Steinberger
73b434f25b docs: document blocked taskflow retry helpers 2026-06-01 21:07:01 -04:00
Peter Steinberger
792976b76f docs: document taskflow executor helpers 2026-06-01 21:07:01 -04:00
Peter Steinberger
8aaf6d9a84 docs: document task owner access helpers 2026-06-01 21:07:01 -04:00
Peter Steinberger
30a4478c10 docs: document taskflow owner access helpers 2026-06-01 21:07:01 -04:00
Peter Steinberger
5d07ee772e docs: document managed taskflow runtime guards 2026-06-01 21:07:01 -04:00
Peter Steinberger
fa9ef924a2 docs: document managed taskflow runtime types 2026-06-01 21:07:01 -04:00
Peter Steinberger
f066d1c87e docs: document plugin runtime task DTOs 2026-06-01 21:07:01 -04:00
Peter Steinberger
05f2113302 docs: document plugin runtime task types 2026-06-01 21:07:01 -04:00
Peter Steinberger
3597ff0547 docs: document plugin runtime llm types 2026-06-01 21:07:01 -04:00
Peter Steinberger
dd90fd0255 docs: document plugin runtime core types 2026-06-01 21:07:01 -04:00
Peter Steinberger
cb04dd3028 docs: document plugin runtime types 2026-06-01 21:07:01 -04:00
Peter Steinberger
04505f86eb docs: document runtime context registry 2026-06-01 21:07:01 -04:00
Peter Steinberger
f0101337bb docs: document channel runtime surface types 2026-06-01 21:07:01 -04:00
Peter Steinberger
797777c813 docs: document channel runtime contexts 2026-06-01 21:07:01 -04:00
Peter Steinberger
c79b89173d docs: document approval handler bootstrap 2026-06-01 21:07:01 -04:00
Peter Steinberger
2a2228e496 docs: document exec approval channel runtime 2026-06-01 21:07:00 -04:00
Peter Steinberger
157fddee51 docs: document exec approval runtime types 2026-06-01 21:07:00 -04:00
Peter Steinberger
5ea6857491 docs: document approval native runtime helpers 2026-06-01 21:07:00 -04:00
Peter Steinberger
59eb39e39a docs: document approval route coordinator 2026-06-01 21:07:00 -04:00
Peter Steinberger
a2b0002d3f docs: document exec approval surface states 2026-06-01 21:07:00 -04:00
Peter Steinberger
0308347fa7 docs: document approval request filters 2026-06-01 21:07:00 -04:00
Peter Steinberger
cf6875e633 docs: document exec approval display sanitizers 2026-06-01 21:07:00 -04:00
Peter Steinberger
9cf1c116ff docs: document exec approval session targets 2026-06-01 21:07:00 -04:00
Peter Steinberger
3030a4973e docs: document approval account binding helpers 2026-06-01 21:07:00 -04:00
Peter Steinberger
8c59fbbe92 docs: document approval gateway resolver 2026-06-01 21:07:00 -04:00
Peter Steinberger
443791ef52 docs: document approval error detection 2026-06-01 21:07:00 -04:00
Peter Steinberger
9ee71023c2 docs: document approval turn-source routing 2026-06-01 21:07:00 -04:00
Peter Steinberger
731cfb6ff5 docs: document approval view model builders 2026-06-01 21:07:00 -04:00
Peter Steinberger
9e7f9915a0 docs: document approval view model unions 2026-06-01 21:07:00 -04:00
Peter Steinberger
ef20dc5f2f docs: document approval handler adapters 2026-06-01 21:07:00 -04:00
Peter Steinberger
f58a38b522 docs: document lazy approval runtime adapter 2026-06-01 21:07:00 -04:00
Peter Steinberger
05a13da12c docs: document approval handler runtime types 2026-06-01 21:07:00 -04:00
Peter Steinberger
e0cfcc3151 docs: document approval native runtime types 2026-06-01 21:07:00 -04:00
Peter Steinberger
ac61833b62 docs: document approval native target keys 2026-06-01 21:07:00 -04:00
Peter Steinberger
1683b809c1 docs: document approval native delivery planner 2026-06-01 21:07:00 -04:00
Peter Steinberger
78d012ece4 docs: document approval native route notices 2026-06-01 21:07:00 -04:00
Peter Steinberger
67d008d00e docs: document approval display path helper 2026-06-01 21:07:00 -04:00
Peter Steinberger
adeafcee18 docs: document exec approval surface helpers 2026-06-01 21:07:00 -04:00
Peter Steinberger
d9099828a4 docs: document safe-bin runtime policy 2026-06-01 21:07:00 -04:00
Peter Steinberger
0b66e2cd01 docs: document safe-bin argv validator 2026-06-01 21:07:00 -04:00
Peter Steinberger
beabbe9219 docs: document safe-bin semantic helpers 2026-06-01 21:07:00 -04:00
Peter Steinberger
62a27e1be5 docs: document safe-bin profile helpers 2026-06-01 21:07:00 -04:00
Peter Steinberger
cc31cddf54 docs: document safe-bin trust helpers 2026-06-01 21:07:00 -04:00
Peter Steinberger
d6fe20c350 docs: document exec allowlist pattern matching 2026-06-01 21:07:00 -04:00
Peter Steinberger
a483a2cbc5 docs: document shell wrapper APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
7fff122060 docs: document executable path helpers 2026-06-01 21:07:00 -04:00
Peter Steinberger
6b9185c6ec docs: document exec command resolution APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
473188bd1f docs: document exec command analysis APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
b734ccfa3c docs: document exec allowlist result APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
b8f1843909 docs: document system-run approval match result 2026-06-01 21:07:00 -04:00
Peter Steinberger
4048b087c3 docs: document exec approval request config APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
2a7e41b27b docs: document exec approval prompt policy 2026-06-01 21:07:00 -04:00
Peter Steinberger
2f5f5307ef docs: document exec approval allowlist APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
2439e2450a docs: document exec approval store APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
cec3fbae45 docs: document exec approval decision APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
7881649f7e docs: document dispatch wrapper resolution APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
355a411e2a docs: document exec approval reply APIs 2026-06-01 21:07:00 -04:00
Peter Steinberger
883f4cbf25 docs: document dotenv loader contracts 2026-06-01 21:06:59 -04:00
Peter Steinberger
eaf86695b3 docs: document update channel contracts 2026-06-01 21:06:59 -04:00
Peter Steinberger
0b3465e9a3 docs: document home directory helpers 2026-06-01 21:06:59 -04:00
Peter Steinberger
2478bd2db4 docs: document exec approval policy combinators 2026-06-01 21:06:59 -04:00
Peter Steinberger
c949857684 docs: document exec approval policy APIs 2026-06-01 21:06:59 -04:00
Peter Steinberger
37218ccd2b docs: document error kind contract 2026-06-01 21:06:59 -04:00
Peter Steinberger
298fcebd96 docs: document exec approval session target 2026-06-01 21:06:59 -04:00
Peter Steinberger
b62ab78f03 docs: document exec approval surface APIs 2026-06-01 21:06:59 -04:00
Peter Steinberger
e46cb79e93 docs: document event session routing APIs 2026-06-01 21:06:59 -04:00
Peter Steinberger
7f45dc815f docs: document approval request filters 2026-06-01 21:06:59 -04:00
Peter Steinberger
f5556b500e docs: document container environment helpers 2026-06-01 21:06:59 -04:00
Peter Steinberger
19821c958d docs: document clipboard helper 2026-06-01 21:06:59 -04:00
Peter Steinberger
c94964e3a0 docs: document clawhub spec parser 2026-06-01 21:06:59 -04:00
Peter Steinberger
e6ecffc7fb docs: document channel summary APIs 2026-06-01 21:06:59 -04:00
Peter Steinberger
fc5c22a238 docs: document channel runtime context APIs 2026-06-01 21:06:59 -04:00
Peter Steinberger
a4c1d64a33 docs: document bonjour discovery APIs 2026-06-01 21:06:59 -04:00
Peter Steinberger
9d96e542de docs: document diagnostic event APIs 2026-06-01 21:06:59 -04:00
Peter Steinberger
0ebc68745f docs: document channel activity helpers 2026-06-01 21:06:59 -04:00
Peter Steinberger
dd42bb9e4c docs: document agent event contracts 2026-06-01 21:06:59 -04:00
Peter Steinberger
59ab73f417 docs: document source reply mirror 2026-06-01 21:06:59 -04:00
Peter Steinberger
cb2ec869ac docs: document outbound session context 2026-06-01 21:06:59 -04:00
Peter Steinberger
1ca4396825 docs: document outbound delivery substrate 2026-06-01 21:06:59 -04:00
Peter Steinberger
98b2385585 docs: document session binding service 2026-06-01 21:06:59 -04:00
Peter Steinberger
0a1adb9290 docs: document delivery queue storage 2026-06-01 21:06:59 -04:00
Peter Steinberger
21662d3ee8 docs: document delivery recovery helpers 2026-06-01 21:06:59 -04:00
Peter Steinberger
a33ec61daa docs: document outbound message API 2026-06-01 21:06:59 -04:00
Peter Steinberger
1eb4a2a837 docs: document message action runner API 2026-06-01 21:06:59 -04:00
Peter Steinberger
1e7c7caba5 docs: document agent delivery helpers 2026-06-01 21:06:59 -04:00
Peter Steinberger
d97ce8e7c1 docs: document npm install env helpers 2026-06-01 21:06:59 -04:00
Peter Steinberger
8ef5d37f84 docs: document OpenClaw exec env marker 2026-06-01 21:06:59 -04:00
Peter Steinberger
2858ced19f docs: document shell env fallback 2026-06-01 21:06:59 -04:00
Peter Steinberger
19e4a47ba5 docs: document binary prerequisite helper 2026-06-01 21:06:59 -04:00
Peter Steinberger
4ab1f899c8 docs: document control UI asset helpers 2026-06-01 21:06:59 -04:00
Peter Steinberger
3159b1840b docs: document channel status issues 2026-06-01 21:06:59 -04:00
Peter Steinberger
d44507dd58 docs: document brew resolution 2026-06-01 21:06:59 -04:00
Peter Steinberger
1988f443dd docs: document CLI root option parsing 2026-06-01 21:06:59 -04:00
Peter Steinberger
a4e811a063 docs: document diagnostics timeline 2026-06-01 21:06:59 -04:00
Peter Steinberger
1e8b669bdc docs: document diagnostic LLM content policy 2026-06-01 21:06:59 -04:00
Peter Steinberger
76412b9e76 docs: document diagnostic trace context 2026-06-01 21:06:59 -04:00
Peter Steinberger
515acdb6b7 docs: document disk space helpers 2026-06-01 21:06:59 -04:00
Peter Steinberger
64598efd21 docs: document diagnostic flags 2026-06-01 21:06:58 -04:00
Peter Steinberger
e819d5718b docs: document embedded mode flag 2026-06-01 21:06:58 -04:00
Peter Steinberger
51cf923f7e docs: document package manager detection 2026-06-01 21:06:58 -04:00
Peter Steinberger
bf2628fd09 docs: document browser open helpers 2026-06-01 21:06:58 -04:00
Peter Steinberger
bc6ddea004 docs: document map size pruning 2026-06-01 21:06:58 -04:00
Peter Steinberger
cf6f086114 docs: document abort signal helper 2026-06-01 21:06:58 -04:00
Peter Steinberger
85f262ad3b docs: document channel activity 2026-06-01 21:06:58 -04:00
Peter Steinberger
07642fd3ac docs: document approval gateway runtime 2026-06-01 21:06:58 -04:00
Peter Steinberger
e9eb6a5a6e docs: document approval turn source auth 2026-06-01 21:06:58 -04:00
Peter Steinberger
058cf763b4 docs: document approval display errors 2026-06-01 21:06:58 -04:00
Peter Steinberger
e3439e2019 docs: document approval view model 2026-06-01 21:06:58 -04:00
Peter Steinberger
7e5a7eff15 docs: document approval handler runtime 2026-06-01 21:06:58 -04:00
Peter Steinberger
5d4b2081b5 docs: document native approval runtime 2026-06-01 21:06:58 -04:00
Peter Steinberger
b60e95ac50 docs: document native approval delivery 2026-06-01 21:06:58 -04:00
Peter Steinberger
9fbf3ab3f5 docs: document native approval route notices 2026-06-01 21:06:58 -04:00
Peter Steinberger
0a4ef8b44c docs: document approval request binding filters 2026-06-01 21:06:58 -04:00
Peter Steinberger
9d27524aae docs: document exec approval channel runtime 2026-06-01 21:06:58 -04:00
Peter Steinberger
37ee88c43a docs: document exec approval forwarder 2026-06-01 21:06:58 -04:00
Peter Steinberger
8c40322f6d docs: document exec approval reply routing 2026-06-01 21:06:58 -04:00
Peter Steinberger
9621d02c3b docs: document system-run approval binding 2026-06-01 21:06:58 -04:00
Peter Steinberger
db9524334d docs: document system-run approval context 2026-06-01 21:06:58 -04:00
Peter Steinberger
023d1c1346 docs: document system-run command contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
02257c6145 docs: document exec allowlist entry contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
63a085603d docs: document exec allowlist contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
6c1acbb51d docs: document safe-bin profile contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
3962e794a3 docs: document safe-bin trust contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
e93debe38a docs: document safe-bin runtime policy contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
2aa74b8be8 docs: document exec approval policy snapshots 2026-06-01 21:06:58 -04:00
Peter Steinberger
641329157f docs: document approval display contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
069e616b40 docs: document executable path contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
b72867c4ef docs: document exec command resolution contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
e7029418b2 docs: document shell inline command scanners 2026-06-01 21:06:58 -04:00
Peter Steinberger
0094f36bb9 docs: document shell wrapper trust contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
6a96f5701a docs: document dispatch wrapper trust contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
4d335bccae docs: document command carrier helper contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
018a5dccf1 docs: document command explainer span contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
3cb4554fe8 docs: document command policy analysis contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
d1d6900c6d docs: document command explanation summary contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
bd46b791e9 docs: document command risk carrier contracts 2026-06-01 21:06:58 -04:00
Peter Steinberger
3c781401ad docs: document inline eval detector contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
86581bd139 docs: document TCP port parser contract 2026-06-01 21:06:57 -04:00
Peter Steinberger
c5fda5eb9a docs: document port diagnostics contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
390434673e docs: document package tag input contract 2026-06-01 21:06:57 -04:00
Peter Steinberger
f27fdcbdb0 docs: document update channel contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
303141be85 docs: document inline option token contract 2026-06-01 21:06:57 -04:00
Peter Steinberger
580bc23dcc docs: document prototype key guard contract 2026-06-01 21:06:57 -04:00
Peter Steinberger
9e5f601c61 docs: document environment helper contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
d22b8d1cdb docs: document PATH bootstrap contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
01c5513c41 docs: document fetch header normalization contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
4dde1e9b54 docs: document secret file compatibility contract 2026-06-01 21:06:57 -04:00
Peter Steinberger
5efffc9184 docs: document JSON file helper contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
301aae5cd7 docs: document HTTP body guard contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
bd3f2929c0 docs: document fixed-window rate limiter contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
6a540d945c docs: document backoff helper contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
68a780bb3c docs: document retry engine contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
bd654bf5be docs: document retry policy contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
aa5996ff28 docs: document number coercion thresholds 2026-06-01 21:06:57 -04:00
Peter Steinberger
4404474a99 docs: document map and numeric option helpers 2026-06-01 21:06:57 -04:00
Peter Steinberger
edad8bd695 docs: document dedupe cache contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
f1fcb4763c docs: document diagnostic error metadata helpers 2026-06-01 21:06:57 -04:00
Peter Steinberger
c996011b0c docs: document shared error helpers 2026-06-01 21:06:57 -04:00
Peter Steinberger
b7659b414e docs: document reasoning tag partitioner contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
0c22351b0e docs: document trajectory path helpers 2026-06-01 21:06:57 -04:00
Peter Steinberger
a3c068ab46 docs: document trajectory runtime writer contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
34e60c7613 docs: document trajectory cleanup guards 2026-06-01 21:06:57 -04:00
Peter Steinberger
ae048ac2dc docs: document trajectory export contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
b97bc433ff docs: document trajectory metadata contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
82b69dceb8 docs: document support bundle writer contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
2ae9055e8d docs: document diagnostic support export contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
b1ec36802c docs: document support log sanitizer invariants 2026-06-01 21:06:57 -04:00
Peter Steinberger
98b8eb02d2 docs: document support redaction contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
9a6c1eb13f docs: document logging redaction API contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
10d44e6e2a docs: document bounded concurrency helper contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
7c3bf80220 docs: document fetch timeout abort contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
279f14f3fc docs: document gateway client public contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
9d50d2beb6 docs: document device auth payload contracts 2026-06-01 21:06:57 -04:00
Peter Steinberger
e9b481bbf6 docs: document gateway client readiness start rules 2026-06-01 21:06:57 -04:00
Peter Steinberger
2d2a4da093 docs: document event loop readiness probe fields 2026-06-01 21:06:57 -04:00
Peter Steinberger
aaebe74428 docs: document gateway timeout clamp rules 2026-06-01 21:06:56 -04:00
Peter Steinberger
ac68783d81 docs: document live plugin config fallback 2026-06-01 21:06:56 -04:00
Peter Steinberger
11169b5c6a docs: document node presence reasons 2026-06-01 21:06:56 -04:00
Peter Steinberger
a190b16ced docs: document avatar policy constants 2026-06-01 21:06:56 -04:00
Peter Steinberger
8be6591675 docs: document OpenAI Codex auth helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
e9982ad288 docs: document plugin command runner 2026-06-01 21:06:56 -04:00
Peter Steinberger
ef68275a6d docs: document tool send target fallback 2026-06-01 21:06:56 -04:00
Peter Steinberger
92aeda817d docs: document telegram account facade 2026-06-01 21:06:56 -04:00
Peter Steinberger
67a08ebadb docs: document runtime store helper 2026-06-01 21:06:56 -04:00
Peter Steinberger
e68c5861ac docs: document webhook request guard helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
0109afd7fc docs: document keyed async queue helper 2026-06-01 21:06:56 -04:00
Peter Steinberger
3bdd36b718 docs: document tool payload helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
0a88da285b docs: document plugin SDK runtime helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
3bab9e07d4 docs: document browser maintenance helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
77b22b4e22 docs: document safe record helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
30f28516d7 docs: document approval reaction helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
ecea9a3d8c docs: document provider catalog helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
02565857e8 docs: document provider onboard helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
32f1e0e3ac docs: document allowlist config helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
ddfe936ebe docs: document provider tool compat helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
2810c181ea docs: document persistent dedupe helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
911ebfa7fb docs: document account setup helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
8675ae253b docs: document auth and chunk helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
20c0ef5341 docs: document assistant text helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
ef1f870335 docs: document command status runtime 2026-06-01 21:06:56 -04:00
Peter Steinberger
74dfd528cc docs: document provider model helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
624f279b6c docs: document browser config helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
48e0fb965d docs: document QA scenario helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
22ccabe92e docs: document migration helper contracts 2026-06-01 21:06:56 -04:00
Peter Steinberger
bd3eea8a24 docs: document status helper payloads 2026-06-01 21:06:56 -04:00
Peter Steinberger
cb3ea96414 docs: document extension shared helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
cf2f6e8902 docs: document session policy payloads 2026-06-01 21:06:56 -04:00
Peter Steinberger
c11eb54ff3 docs: document webhook guard helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
b25cc2cb97 docs: document channel diagnostics helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
9693b72e87 docs: document thread binding label helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
b7f733f828 docs: document reply prefix typing guard helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
8f5c762f9b docs: document inbound debounce stream helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
170f7ac81b docs: document direct dm allow-from helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
bb70e68f82 docs: document channel match allowlist helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
fbeb08967c docs: document channel snapshot presence helpers 2026-06-01 21:06:56 -04:00
Peter Steinberger
69df840dd1 docs: document channel config helper contracts 2026-06-01 21:06:56 -04:00
Peter Steinberger
ea70737204 docs: document channel turn adapter contracts 2026-06-01 21:06:55 -04:00
Peter Steinberger
6950c25a89 docs: document channel turn delivery helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
45823c5f88 docs: document inbound reply bridge helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
4d4b9a76ce docs: document message receive capability helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
e08890f356 docs: document durable ingress queue helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
f63647a799 docs: document message outbound bridge helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
d49ce1011b docs: document durable message state fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
10f1f19a55 docs: document live message contracts 2026-06-01 21:06:55 -04:00
Peter Steinberger
eb45f7506e docs: document message receipt fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
72ddb522b4 docs: document channel turn helper fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
1a45a6d112 docs: document inbound event context helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
a227ce9cd5 docs: document conversation resolution helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
871ba88159 docs: document route projection helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
6588d77fa0 docs: document direct dm guard policy 2026-06-01 21:06:55 -04:00
Peter Steinberger
edbbbddf96 docs: document ack reaction helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
deddd60a50 docs: document channel target policy helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
8d5352fdf9 docs: document thread binding policy fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
b19e8edd45 docs: document command gating fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
9b3f19377d docs: document mention gating fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
035f50f0b3 docs: document entry status fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
8917f5fcd5 docs: document usage timeseries fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
7f8ae918e3 docs: document usage payload fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
a43dafe15d docs: document node list payload fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
0f883cb654 docs: document thread binding lifecycle fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
d2c55f660f docs: document runtime requirement fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
93efa868b9 docs: document requirement metadata fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
0c15c69e8f docs: document store writer queue fields 2026-06-01 21:06:55 -04:00
Peter Steinberger
40326fcd4f docs: document scoped id cache helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
d0c99db71b docs: document custom command config helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
333c28efe1 docs: document final tag parser helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
062c82ef82 docs: document device auth store helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
732f2e5375 docs: document node matching helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
db1246e1bd docs: document shared runtime policy helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
fb614861e0 docs: document shared utility helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
d887a39c28 docs: document assistant error format helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
f0e5fd6037 docs: document shared json schema helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
343c56a64c docs: document markdown table chunk helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
386044015b docs: document markdown ir helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
62edb0ccab docs: document markdown code span helpers 2026-06-01 21:06:55 -04:00
Peter Steinberger
8554efb754 docs: document markdown render helpers 2026-06-01 21:06:54 -04:00
1277 changed files with 16099 additions and 20186 deletions

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]

View File

@@ -45,17 +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.
- 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.

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

@@ -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),
),

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

@@ -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

@@ -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,2 +1,2 @@
f3e0379cbe0e584a8c9658253d4a808356fe80fb5ec775bbee9e968e8d815380 plugin-sdk-api-baseline.json
601b55acafbd1e00b850c9b0c15d587029050906960071d448d37538b223e226 plugin-sdk-api-baseline.jsonl
63d49032a9b4dc4874a0ca17be73ecc97a2df5d1f47b4e72db34868423370558 plugin-sdk-api-baseline.json
af79f7d711afa0a8563782b8f5cdd7e46b9aea245f5e7ebc464327a8969ed65e plugin-sdk-api-baseline.jsonl

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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,
@@ -2640,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: {
@@ -253,42 +243,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 +489,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 +504,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,
},
);
},
};
}
@@ -304,22 +244,16 @@ export async function startOrResumeThread(params: {
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 +266,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,
@@ -439,7 +373,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 +404,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 +449,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,7 +462,7 @@ export async function startOrResumeThread(params: {
}),
);
const response = assertCodexThreadResumeResponse(
await lifecycleTiming.measure("thread-resume-request", () =>
await lifecycleTiming.measure("thread_resume_request", () =>
params.client.request("thread/resume", resumeParams, { signal: params.signal }),
),
);
@@ -562,7 +479,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 +489,6 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration:
@@ -602,8 +518,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 +533,6 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration:
@@ -644,7 +558,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 +566,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 +574,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,7 +586,7 @@ 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 });
} catch (error) {
@@ -695,7 +609,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 +619,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 +648,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 +664,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 +924,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 +1114,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

@@ -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

@@ -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

@@ -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

@@ -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",

View File

@@ -4,7 +4,6 @@ import type {
} from "openclaw/plugin-sdk/plugin-entry";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeGoogleModelId } from "./model-id.js";
const GOOGLE_GEMINI_CLI_PROVIDER_ID = "google-gemini-cli";
const GOOGLE_ANTIGRAVITY_PROVIDER_ID = "google-antigravity";
@@ -42,9 +41,6 @@ function normalizeGeminiProRequestId(id: string): string {
if (id === "gemini-3-pro" || id === "gemini-3-pro-preview" || id === "gemini-3.1-pro") {
return "gemini-3.1-pro-preview";
}
if (id === "gemma-4-26b") {
return normalizeGoogleModelId(id);
}
return id;
}

View File

@@ -12,7 +12,6 @@ type GoogleApiCarrier = {
};
type GoogleProviderConfigLike = GoogleApiCarrier & {
baseUrl?: string | null;
models?: ReadonlyArray<GoogleApiCarrier | null | undefined> | null;
};
@@ -38,28 +37,6 @@ function stripUrlUserInfo(url: URL): void {
url.password = "";
}
const GOOGLE_VERTEX_HOST = "aiplatform.googleapis.com";
const GOOGLE_VERTEX_REGION_HOST_SUFFIX = "-aiplatform.googleapis.com";
export function isGoogleVertexHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase();
return (
normalized === GOOGLE_VERTEX_HOST || normalized.endsWith(GOOGLE_VERTEX_REGION_HOST_SUFFIX)
);
}
export function isGoogleVertexBaseUrl(baseUrl?: string | null): boolean {
const raw = normalizeOptionalString(baseUrl);
if (!raw) {
return false;
}
try {
return isGoogleVertexHostname(new URL(raw).hostname);
} catch {
return false;
}
}
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL);
try {
@@ -108,12 +85,9 @@ export function resolveGoogleGenerativeAiTransport<TApi extends string | null |
provider?: string;
api: TApi;
baseUrl?: string;
}): { api: TApi | "google-generative-ai" | "google-vertex"; baseUrl?: string } {
}): { api: TApi | "google-generative-ai"; baseUrl?: string } {
const api =
params.api ??
(params.provider === "google-vertex" && isGoogleVertexBaseUrl(params.baseUrl)
? "google-vertex"
: undefined) ??
(params.provider === "google" && params.baseUrl ? "google-generative-ai" : params.api);
return {
api,
@@ -133,9 +107,6 @@ export function shouldNormalizeGoogleGenerativeAiProviderConfig(
providerKey: string,
provider: GoogleProviderConfigLike,
): boolean {
if (providerKey === "google-vertex" && isGoogleVertexBaseUrl(provider.baseUrl)) {
return false;
}
if (isGoogleGenerativeAiApi(provider.api)) {
return true;
}

View File

@@ -1,67 +0,0 @@
import type { Model } from "openclaw/plugin-sdk/llm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildGoogleProvider } from "./provider-registration.js";
const streamFns = vi.hoisted(() => ({
createGenerativeAi: vi.fn(() => vi.fn()),
createVertex: vi.fn(() => vi.fn()),
}));
vi.mock("./transport-stream.js", () => ({
createGoogleGenerativeAiTransportStreamFn: streamFns.createGenerativeAi,
createGoogleVertexTransportStreamFn: streamFns.createVertex,
}));
function model(overrides: Partial<Model> = {}): Model {
return {
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash",
provider: "google-vertex",
api: "google-generative-ai",
baseUrl: "https://aiplatform.googleapis.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 65_536,
...overrides,
} as Model;
}
describe("buildGoogleProvider createStreamFn", () => {
beforeEach(() => {
streamFns.createGenerativeAi.mockClear();
streamFns.createVertex.mockClear();
});
it("routes native Vertex hosts through the Vertex transport", () => {
const provider = buildGoogleProvider();
provider.createStreamFn?.({
provider: "google-vertex",
modelId: "gemini-2.5-flash",
model: model(),
} as never);
expect(streamFns.createVertex).toHaveBeenCalledTimes(1);
expect(streamFns.createGenerativeAi).not.toHaveBeenCalled();
});
it("preserves explicit OpenAI-compatible Vertex endpoint configs", () => {
const provider = buildGoogleProvider();
const result = provider.createStreamFn?.({
provider: "google-vertex",
modelId: "gemini-2.5-flash",
model: model({
api: "openai-completions",
baseUrl:
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
}),
} as never);
expect(result).toBeUndefined();
expect(streamFns.createVertex).not.toHaveBeenCalled();
expect(streamFns.createGenerativeAi).not.toHaveBeenCalled();
});
});

View File

@@ -10,7 +10,6 @@ import {
import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
import {
isGoogleVertexBaseUrl,
normalizeGoogleProviderConfig,
resolveGoogleGenerativeAiTransport,
} from "./provider-policy.js";
@@ -68,16 +67,12 @@ export function buildGoogleProvider(): ProviderPlugin {
ctx,
}),
createStreamFn: ({ model }) => {
if (
model.api === "google-vertex" ||
(model.api === "google-generative-ai" &&
(model.provider === "google-vertex" || isGoogleVertexBaseUrl(model.baseUrl)))
) {
return createGoogleVertexTransportStreamFn();
}
if (model.api === "google-generative-ai") {
return createGoogleGenerativeAiTransportStreamFn();
}
if (model.api === "google-vertex") {
return createGoogleVertexTransportStreamFn();
}
return undefined;
},
...GOOGLE_GEMINI_PROVIDER_HOOKS,

View File

@@ -71,28 +71,6 @@ type MemoryManagerPurpose = Parameters<typeof getMemorySearchManager>[0]["purpos
type MemorySourceName = "memory" | "sessions";
function formatMemoryIndexIdentityWarning(
status: ReturnType<MemoryManager["status"]>,
agentId: string,
): {
reason: string;
fix: string;
} | null {
const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity);
const reason =
(indexIdentity?.status === "mismatched" || indexIdentity?.status === "missing") &&
typeof indexIdentity.reason === "string"
? indexIdentity.reason
: undefined;
if (!reason) {
return null;
}
return {
reason,
fix: `Run: openclaw memory status --index --agent ${agentId}`,
};
}
type SourceScan = {
source: MemorySourceName;
totalFiles: number | null;
@@ -890,12 +868,6 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
}
}
const identityWarning = formatMemoryIndexIdentityWarning(status, agentId);
if (identityWarning) {
lines.push(`${label("Index identity")} ${warn(identityWarning.reason)}`);
lines.push(`${label("Vector search")} ${warn("paused until memory is rebuilt")}`);
lines.push(`${label("Fix")} ${muted(identityWarning.fix)}`);
}
if (status.sourceCounts?.length) {
lines.push(label("By source"));
for (const entry of status.sourceCounts) {
@@ -1284,15 +1256,6 @@ export async function runMemorySearch(
defaultRuntime.writeJson({ results });
return;
}
const identityWarning =
typeof manager.status === "function"
? formatMemoryIndexIdentityWarning(manager.status(), agentId)
: null;
if (identityWarning) {
defaultRuntime.error(
`Memory index warning: ${identityWarning.reason}. Vector memory search is paused until the index is rebuilt. ${identityWarning.fix}`,
);
}
if (results.length === 0) {
defaultRuntime.log("No matches.");
return;

View File

@@ -415,36 +415,6 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled();
});
it("prints index identity mismatch reasons", async () => {
const close = vi.fn(async () => {});
mockManager({
status: () =>
makeMemoryStatus({
dirty: true,
provider: "ollama",
model: "nomic-embed-text",
requestedProvider: "ollama",
custom: {
indexIdentity: {
status: "mismatched",
reason: "index was built for provider openai, expected ollama",
},
},
}),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expectLogged(log, "Provider: ollama (requested: ollama)");
expectLogged(log, "Dirty: yes");
expectLogged(log, "Index identity: index was built for provider openai, expected ollama");
expectLogged(log, "Vector search: paused until memory is rebuilt");
expectLogged(log, "Fix: Run: openclaw memory status --index --agent main");
expect(close).toHaveBeenCalled();
});
it("keeps plain status from probing vector or embeddings", async () => {
const close = vi.fn(async () => {});
const probeVectorAvailability = vi.fn(async () => {

View File

@@ -1682,8 +1682,7 @@ describe("gateway startup reconciliation", () => {
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
expect(harness.addCalls).toHaveLength(0);
expectLogNotContains(logger.warn, "cron service unavailable");
expectLogContains(logger.debug, "cron service not yet available at gateway_start");
expectLogContains(logger.warn, "cron service unavailable");
cronAvailable = true;
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
@@ -1702,58 +1701,6 @@ describe("gateway startup reconciliation", () => {
}
});
it("keeps startup cron retry warnings quiet until the retry window is exhausted", async () => {
vi.useFakeTimers();
clearInternalHooks();
const logger = createLogger();
const onMock = vi.fn();
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 4 * * *",
timezone: "UTC",
},
},
},
},
},
},
pluginConfig: {},
logger,
runtime: {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => undefined,
});
expectLogContains(logger.debug, "cron service not yet available at gateway_start");
await vi.advanceTimersByTimeAsync(
constants.STARTUP_CRON_RETRY_DELAY_MS * (constants.STARTUP_CRON_RETRY_MAX_ATTEMPTS - 1),
);
expectLogNotContains(logger.warn, "cron service unavailable");
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
expectLogContains(logger.warn, "cron service unavailable");
expect(logger.warn).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
await triggerGatewayStop(onMock);
clearInternalHooks();
}
});
it("retries disabled startup cleanup until cron is available", async () => {
vi.useFakeTimers();
clearInternalHooks();
@@ -1882,67 +1829,6 @@ describe("gateway startup reconciliation", () => {
}
});
it("does not recreate startup cron from stale enabled config after live memory-core config is removed", async () => {
vi.useFakeTimers();
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const onMock = vi.fn();
const runtimeCurrentConfig = vi.fn(
() =>
({
plugins: {
entries: {},
},
}) as OpenClawConfig,
);
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 4 * * *",
timezone: "UTC",
},
},
},
},
},
} as OpenClawConfig,
pluginConfig: {},
logger,
runtime: {
config: {
current: runtimeCurrentConfig,
},
},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
let cronAvailable = false;
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => (cronAvailable ? harness.cron : undefined),
});
cronAvailable = true;
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
expect(runtimeCurrentConfig).toHaveBeenCalled();
expect(harness.addCalls).toHaveLength(0);
expectLogNotContains(logger.warn, "cron service unavailable");
} finally {
vi.useRealTimers();
await triggerGatewayStop(onMock).catch(() => undefined);
clearInternalHooks();
}
});
it("clears pending startup cron retry on gateway stop", async () => {
vi.useFakeTimers();
clearInternalHooks();

View File

@@ -760,18 +760,18 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
].join("|");
const reconcileManagedDreamingCron = async (params: {
reason: "startup" | "startup_retry" | "runtime";
reason: "startup" | "runtime";
startupConfig?: OpenClawConfig;
startupCron?: (() => CronServiceLike | null) | null;
}): Promise<ShortTermPromotionDreamingConfig> => {
const startupCfg =
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
const pluginConfig =
params.reason === "startup"
? (resolveMemoryCorePluginConfig(startupCfg) ??
params.reason === "runtime"
? resolveMemoryCorePluginConfig(startupCfg)
: (resolveMemoryCorePluginConfig(startupCfg) ??
resolveMemoryCorePluginConfig(api.config) ??
api.pluginConfig)
: resolveMemoryCorePluginConfig(startupCfg);
api.pluginConfig);
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig,
cfg: startupCfg,
@@ -784,7 +784,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
// This handles the case where the cron service was not yet available during
// gateway_start (250ms deferred init race in startGatewaySidecars) but is
// available now. Fixes #67362.
if (!cron && params.reason !== "startup" && gatewayContext) {
if (!cron && params.reason === "runtime" && gatewayContext) {
try {
cron = resolveCronServiceFromGatewayContext(gatewayContext);
if (cron) {
@@ -800,7 +800,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
// Avoid a noisy startup-path warning when the gateway has not exposed cron yet.
// The runtime reconciliation path (heartbeat-driven) will still warn if the
// cron service remains unavailable after boot.
if (params.reason === "startup" || params.reason === "startup_retry") {
if (params.reason === "startup") {
api.logger.debug?.(
"memory-core: cron service not yet available at gateway_start; deferring to runtime reconciliation.",
);
@@ -815,11 +815,6 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
unavailableCronWarningEmitted = false;
clearStartupCronRetry();
}
// Startup retries only probe cron availability; the exhausted retry path
// re-enters runtime reconciliation so persistent failures still warn once.
if (!cron && params.reason === "startup_retry") {
return config;
}
if (params.reason === "runtime") {
const now = Date.now();
const withinThrottleWindow =
@@ -857,16 +852,12 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
return;
}
startupCronRetryAttempts += 1;
void reconcileManagedDreamingCron({ reason: "startup_retry" })
.then(async () => {
void reconcileManagedDreamingCron({ reason: "runtime" })
.then(() => {
if (disposed || hasStartupCron()) {
clearStartupCronRetry();
return;
}
if (startupCronRetryAttempts >= STARTUP_CRON_RETRY_MAX_ATTEMPTS) {
await reconcileManagedDreamingCron({ reason: "runtime" });
return;
}
scheduleStartupCronRetry();
})
.catch((err: unknown) => {

View File

@@ -86,10 +86,6 @@ export function setMemoryWorkspaceDir(next: string): void {
workspaceDir = next;
}
export function setMemoryCustomStatus(next: Record<string, unknown> | undefined): void {
customStatus = next;
}
export function setMemorySearchImpl(next: SearchImpl): void {
searchImpl = next;
}
@@ -134,10 +130,6 @@ export function getMemorySearchManagerMockCalls(): number {
return getMemorySearchManagerMock.mock.calls.length;
}
export function getMemorySyncMockCalls(): number {
return stubManager.sync.mock.calls.length;
}
export function getMemorySearchManagerMockConfigs(): unknown[] {
return getMemorySearchManagerMock.mock.calls.map(([params]) => params.cfg);
}

View File

@@ -26,7 +26,6 @@ export function resetEmbeddingMocks(): void {
}
vi.mock("./embeddings.js", () => ({
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {

View File

@@ -146,17 +146,6 @@ export function resolveEmbeddingProviderFallbackModel(
return adapter?.defaultModel ?? fallbackSourceModel;
}
export function resolveEmbeddingProviderAdapterId(
providerId: string,
config?: MemoryEmbeddingProviderCreateOptions["config"],
): string | undefined {
try {
return getAdapter(providerId, config).id;
} catch {
return undefined;
}
}
async function createWithAdapter(
adapter: MemoryEmbeddingProviderAdapter,
options: CreateEmbeddingProviderOptions,

View File

@@ -13,7 +13,6 @@ import "./test-runtime-mocks.js";
import type { MemoryIndexManager } from "./index.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import { LOCAL_EMBEDDING_WORKER_ERROR_CODES } from "./manager-local-worker-errors.js";
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
import { closeMemoryIndexManagersForAgent, EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js";
import {
DEFAULT_LOCAL_MODEL,
@@ -59,14 +58,6 @@ vi.mock("./embeddings.js", () => {
providerId === "gemini" || providerId === "fallback-provider"
? `${providerId}-embed`
: fallbackSourceModel,
resolveEmbeddingProviderAdapterId: (
providerId: string,
config?: {
models?: {
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
};
},
) => config?.models?.providers?.[providerId]?.api ?? providerId,
createEmbeddingProvider: async (options: {
provider?: string;
model?: string;
@@ -86,9 +77,7 @@ vi.mock("./embeddings.js", () => {
};
}
const providerId =
options.provider === "gemini" ||
options.provider === "fallback-provider" ||
options.provider === "ollama"
options.provider === "gemini" || options.provider === "fallback-provider"
? options.provider
: "mock";
const model = options.model ?? "mock-embed";
@@ -272,9 +261,8 @@ describe("memory index", () => {
extraPaths?: string[];
sources?: Array<"memory" | "sessions">;
sessionMemory?: boolean;
provider?: string;
provider?: "openai" | "gemini" | "fallback-provider";
fallback?: "none" | "gemini" | "fallback-provider";
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
model?: string;
outputDimensionality?: number;
multimodal?: {
@@ -314,7 +302,6 @@ describe("memory index", () => {
},
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
};
}
@@ -336,12 +323,9 @@ describe("memory index", () => {
return manager;
}
async function getFreshManager(
cfg: TestCfg,
purpose?: "default" | "status" | "cli",
): Promise<MemoryIndexManager> {
async function getFreshManager(cfg: TestCfg): Promise<MemoryIndexManager> {
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
return await getRequiredMemoryIndexManager({ cfg, agentId: "main", purpose });
return await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
}
async function expectHybridKeywordSearchFindsMemory(cfg: TestCfg) {
@@ -405,406 +389,6 @@ describe("memory index", () => {
}
});
it("does not full-reindex on search when existing metadata belongs to another provider", async () => {
const dbPath = path.join(workspaceDir, "index-provider-cutover.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
model: "old-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextCfg = createCfg({
storePath: dbPath,
provider: "gemini",
model: "new-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const nextManager = await getFreshManager(nextCfg);
try {
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "mismatched",
reason: "index was built for model old-embed, expected new-embed",
});
embedBatchCalls = 0;
const results = await nextManager.search("alpha");
expect(results).toStrictEqual([]);
expect(embedBatchCalls).toBe(0);
expect(nextManager.status().dirty).toBe(true);
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line changed.\nZebra memory line.",
);
await nextManager.sync({ reason: "watch" });
expect(embedBatchCalls).toBe(0);
const stillPausedResults = await nextManager.search("alpha");
expect(stillPausedResults).toStrictEqual([]);
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "mismatched",
reason: "index was built for model old-embed, expected new-embed",
});
} finally {
await nextManager.close?.();
}
});
it("keeps status clean when configured provider alias resolves to indexed adapter", async () => {
const dbPath = path.join(workspaceDir, "index-provider-alias-status.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
provider: "ollama",
model: "ollama-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const aliasCfg = createCfg({
storePath: dbPath,
provider: "ollama-west",
providerAliases: {
"ollama-west": {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
model: "ollama-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const statusManager = await getFreshManager(aliasCfg, "status");
try {
const status = statusManager.status();
expect(status.dirty).toBe(false);
expect(status.custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await statusManager.close?.();
}
});
it("does not search stale rows when index metadata is missing", async () => {
const dbPath = path.join(workspaceDir, "index-missing-meta-cutover.sqlite");
const cfg = createCfg({
storePath: dbPath,
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const oldManager = await getFreshManager(cfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
await fs.rm(path.join(memoryDir, "2026-01-12.md"));
const nextManager = await getFreshManager(cfg);
try {
(
nextManager as unknown as {
db: { exec: (sql: string) => void };
}
).db.exec(`DELETE FROM meta WHERE key = 'memory_index_meta_v1'`);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "missing",
reason: "index metadata is missing",
});
const results = await nextManager.search("alpha");
expect(results).toStrictEqual([]);
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "missing",
reason: "index metadata is missing",
});
} finally {
await nextManager.close?.();
}
});
it("does not search stale provider rows after embeddings become unavailable", async () => {
const dbPath = path.join(workspaceDir, "index-provider-unavailable-cutover.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
model: "semantic-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
forceNoProvider = true;
const nextManager = await getFreshManager(oldCfg);
try {
const results = await nextManager.search("alpha");
expect(results).toStrictEqual([]);
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toMatchObject({
status: "mismatched",
});
} finally {
await nextManager.close?.();
}
});
it("clears dirty after sessions-only identity reindex", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-only-reindex"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
path.join(sessionsDir, "session-identity.jsonl"),
[
JSON.stringify({
type: "session",
id: "session-identity",
timestamp: "2026-04-07T15:24:04.113Z",
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-07T15:25:04.113Z",
content: [{ type: "text", text: "Session-only identity marker." }],
},
}),
].join("\n") + "\n",
"utf8",
);
const dbPath = path.join(workspaceDir, "index-sessions-only-cutover.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
sources: ["sessions"],
sessionMemory: true,
model: "old-embed",
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextCfg = createCfg({
storePath: dbPath,
sources: ["sessions"],
sessionMemory: true,
provider: "gemini",
model: "new-embed",
});
const nextManager = await getFreshManager(nextCfg);
try {
expect(nextManager.status().dirty).toBe(true);
await nextManager.sync({ reason: "test", force: true });
expect(nextManager.status().dirty).toBe(false);
expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
}
});
it("marks sessions-only indexes dirty when metadata is missing but chunks exist", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-missing-meta"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
path.join(sessionsDir, "session-missing-meta.jsonl"),
[
JSON.stringify({
type: "session",
id: "session-missing-meta",
timestamp: "2026-04-07T15:24:04.113Z",
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-07T15:25:04.113Z",
content: [{ type: "text", text: "Sessions missing metadata marker." }],
},
}),
].join("\n") + "\n",
"utf8",
);
const dbPath = path.join(workspaceDir, "index-sessions-missing-meta.sqlite");
const cfg = createCfg({
storePath: dbPath,
sources: ["sessions"],
sessionMemory: true,
});
const oldManager = await getFreshManager(cfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextManager = await getFreshManager(cfg);
try {
(
nextManager as unknown as {
db: { exec: (sql: string) => void };
}
).db.exec(`DELETE FROM meta WHERE key = 'memory_index_meta_v1'`);
const status = nextManager.status();
expect(status.dirty).toBe(true);
expect(status.custom?.indexIdentity).toEqual({
status: "missing",
reason: "index metadata is missing",
});
} finally {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
}
});
it("keeps provider cutover vector search paused during targeted session sync", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-targeted-cutover"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "session-targeted-cutover.jsonl");
await fs.writeFile(
sessionFile,
[
JSON.stringify({
type: "session",
id: "session-targeted-cutover",
timestamp: "2026-04-07T15:24:04.113Z",
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-07T15:25:04.113Z",
content: [{ type: "text", text: "Targeted cutover marker." }],
},
}),
].join("\n") + "\n",
"utf8",
);
const dbPath = path.join(workspaceDir, "index-targeted-session-cutover.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
sources: ["memory", "sessions"],
sessionMemory: true,
model: "old-embed",
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextCfg = createCfg({
storePath: dbPath,
sources: ["memory", "sessions"],
sessionMemory: true,
provider: "gemini",
model: "new-embed",
});
const nextManager = await getFreshManager(nextCfg);
try {
expect(nextManager.status().dirty).toBe(true);
embedBatchCalls = 0;
await nextManager.sync({ reason: "test", sessionFiles: [sessionFile] });
expect(embedBatchCalls).toBe(0);
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "mismatched",
reason: "index was built for model old-embed, expected new-embed",
});
const results = await nextManager.search("alpha");
expect(results).toStrictEqual([]);
} finally {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
}
});
it("preserves memory dirty events raised during session identity reindex", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-dirty-during-session"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
path.join(sessionsDir, "session-dirty-during-reindex.jsonl"),
[
JSON.stringify({
type: "session",
id: "session-dirty-during-reindex",
timestamp: "2026-04-07T15:24:04.113Z",
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-07T15:25:04.113Z",
content: [{ type: "text", text: "Dirty during session marker." }],
},
}),
].join("\n") + "\n",
"utf8",
);
const dbPath = path.join(workspaceDir, "index-dirty-during-session.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
sources: ["memory", "sessions"],
sessionMemory: true,
model: "old-embed",
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextCfg = createCfg({
storePath: dbPath,
sources: ["memory", "sessions"],
sessionMemory: true,
provider: "gemini",
model: "new-embed",
});
const nextManager = await getFreshManager(nextCfg);
try {
const fields = nextManager as unknown as {
dirty: boolean;
syncSessionFiles: (params: unknown) => Promise<void>;
};
const syncSessionFiles = fields.syncSessionFiles.bind(nextManager);
fields.syncSessionFiles = async (params) => {
fields.dirty = true;
await syncSessionFiles(params);
};
await nextManager.sync({ reason: "test", force: true });
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
}
});
it("closes embedding providers when memory index managers close", async () => {
const cfg = createCfg({
storePath: indexMainPath,
@@ -1009,7 +593,7 @@ describe("memory index", () => {
waitForEmbeddingRetry: (delayMs: number, action: string) => Promise<void>;
}
).provider = {
id: "mock",
id: "openai",
model: "mock-embed",
embedQuery: async () => {
queryCalls += 1;
@@ -1053,7 +637,7 @@ describe("memory index", () => {
};
}
).provider = {
id: "mock",
id: "openai",
model: "mock-embed",
embedQuery: async () => {
queryCalls += 1;
@@ -1112,76 +696,6 @@ describe("memory index", () => {
expect(status.vector?.available).toBeUndefined();
});
it("marks older vector indexes dirty after vector store probing", async () => {
const dbPath = path.join(workspaceDir, "index-vector-missing-dims.sqlite");
const legacyCfg = createCfg({
storePath: dbPath,
provider: "gemini",
vectorEnabled: false,
});
const legacyManager = await getFreshManager(legacyCfg);
await legacyManager.sync({ reason: "test", force: true });
await legacyManager.close?.();
const cfg = createCfg({
storePath: dbPath,
provider: "gemini",
vectorEnabled: true,
});
const manager = await getFreshManager(cfg);
try {
const metaAccess = manager as unknown as {
readMeta(): MemoryIndexMeta | null;
};
const meta = metaAccess.readMeta();
if (!meta) {
throw new Error("expected index metadata");
}
expect(meta.vectorDims).toBeUndefined();
await manager.probeVectorStoreAvailability?.();
const status = manager.status();
expect(status.dirty).toBe(true);
expect(status.custom?.indexIdentity).toEqual({
status: "mismatched",
reason: "index vector dimensions are missing",
});
} finally {
await manager.close?.();
}
});
it("keeps empty vector indexes clean after vector store probing", async () => {
await fs.rm(path.join(memoryDir, "2026-01-12.md"));
const dbPath = path.join(workspaceDir, "index-empty-vector.sqlite");
const legacyCfg = createCfg({
storePath: dbPath,
provider: "gemini",
vectorEnabled: false,
});
const legacyManager = await getFreshManager(legacyCfg);
await legacyManager.sync({ reason: "test", force: true });
await legacyManager.close?.();
const cfg = createCfg({
storePath: dbPath,
provider: "gemini",
vectorEnabled: true,
});
const manager = await getFreshManager(cfg, "status");
try {
await manager.probeVectorStoreAvailability?.();
const status = manager.status();
expect(status.dirty).toBe(false);
expect(status.custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await manager.close?.();
}
});
it("caches embedding probe readiness across transient status managers", async () => {
const cfg = createCfg({ storePath: path.join(workspaceDir, "index-probe-cache.sqlite") });
const first = requireManager(
@@ -1264,7 +778,7 @@ describe("memory index", () => {
});
});
it("does not activate fallback during search when index identity is already mismatched", async () => {
it("activates configured fallback when local embeddings degrade during search", async () => {
const cfg = createCfg({
storePath: path.join(workspaceDir, "index-search-degraded-fallback.sqlite"),
fallback: "fallback-provider",
@@ -1296,68 +810,21 @@ describe("memory index", () => {
const results = await manager.search("alpha");
expect(results).toStrictEqual([]);
expect(providerCalls.slice(callsBeforeSearch)).toStrictEqual([]);
expect(results.length).toBeGreaterThan(0);
const resultKeys = results.map(
(result) => `${result.source}:${result.path}:${result.startLine}:${result.endLine}`,
);
expect(new Set(resultKeys).size).toBe(resultKeys.length);
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain(
"fallback-provider",
);
expect(
(
manager as unknown as {
provider: { id: string } | null;
}
).provider?.id,
).toBe("local");
});
it("rebuilds with fallback provider during explicit identity repair", async () => {
const dbPath = path.join(workspaceDir, "index-cli-fallback-identity-repair.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
model: "old-embed",
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const cfg = createCfg({
storePath: dbPath,
model: "new-embed",
fallback: "fallback-provider",
});
const manager = await getFreshManager(cfg);
try {
expect(manager.status().dirty).toBe(true);
const fields = manager as unknown as {
providerInitialized: boolean;
provider: {
id: string;
model: string;
embedQuery: (text: string) => Promise<number[]>;
embedBatch: (texts: string[]) => Promise<number[][]>;
close: () => Promise<void>;
};
};
fields.providerInitialized = true;
fields.provider = {
id: "mock",
model: "new-embed",
embedQuery: async () => {
throw createLocalWorkerExitError();
},
embedBatch: async () => {
throw createLocalWorkerExitError();
},
close: async () => {},
};
await manager.sync({ reason: "cli" });
expect(manager.status().dirty).toBe(false);
expect(manager.status().provider).toBe("fallback-provider");
expect(manager.status().model).toBe("fallback-provider-embed");
expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" });
await expect(manager.search("alpha")).resolves.not.toStrictEqual([]);
} finally {
await manager.close?.();
}
).toBe("fallback-provider");
});
it("activates configured fallback after probe-time local degradation", async () => {
@@ -1399,7 +866,7 @@ describe("memory index", () => {
const results = await manager.search("alpha");
expect(results).toStrictEqual([]);
expect(results.length).toBeGreaterThan(0);
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain(
"fallback-provider",
);
@@ -1412,73 +879,6 @@ describe("memory index", () => {
).toBe("fallback-provider");
});
it("clears identity dirty after status resolves the indexed fallback provider", async () => {
const dbPath = path.join(workspaceDir, "index-status-fallback-identity.sqlite");
const indexedCfg = createCfg({
storePath: dbPath,
provider: "fallback-provider",
model: "new-embed",
});
const indexedManager = await getFreshManager(indexedCfg);
await indexedManager.sync({ reason: "test", force: true });
await indexedManager.close?.();
const cfg = createCfg({
storePath: dbPath,
fallback: "fallback-provider",
model: "new-embed",
});
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
const manager = await getRequiredMemoryIndexManager({
cfg,
agentId: "main",
purpose: "status",
});
try {
expect(manager.status().dirty).toBe(true);
const fields = manager as unknown as {
provider: {
id: string;
model: string;
embedQuery: (text: string) => Promise<number[]>;
embedBatch: (texts: string[]) => Promise<number[][]>;
close: () => Promise<void>;
};
providerInitialized: boolean;
providerRuntime: {
id: string;
cacheKeyData: Record<string, unknown>;
};
providerKey: string;
computeProviderKey: () => string;
};
fields.provider = {
id: "fallback-provider",
model: "new-embed",
embedQuery: async () => [1, 0, 0, 0],
embedBatch: async (texts) => texts.map(() => [1, 0, 0, 0]),
close: async () => {},
};
fields.providerRuntime = {
id: "fallback-provider",
cacheKeyData: {
provider: "fallback-provider",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model: "new-embed",
headers: [],
},
};
fields.providerInitialized = true;
fields.providerKey = fields.computeProviderKey();
expect(manager.status().dirty).toBe(false);
expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await manager.close?.();
}
});
it("streams embedding cache rows during safe reindex", async () => {
vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0");
type EmbeddingCacheRow = {

View File

@@ -3,8 +3,7 @@ import { describe, expect, it } from "vitest";
import {
resolveConfiguredScopeHash,
resolveConfiguredSourcesForMeta,
resolveMemoryIndexIdentityState,
isMemoryIndexIdentityDirty,
shouldRunFullMemoryReindex,
type MemoryIndexMeta,
} from "./manager-reindex-state.js";
@@ -22,18 +21,16 @@ function createMeta(overrides: Partial<MemoryIndexMeta> = {}): MemoryIndexMeta {
};
}
function createIdentityParams(
function createFullReindexParams(
overrides: {
meta?: MemoryIndexMeta | null;
provider?: { id: string; model: string } | null;
providerKey?: string;
providerKeyKnown?: boolean;
configuredSources?: MemorySource[];
configuredScopeHash?: string;
chunkTokens?: number;
chunkOverlap?: number;
vectorReady?: boolean;
hasIndexedChunks?: boolean;
ftsTokenizer?: string;
} = {},
) {
@@ -46,41 +43,26 @@ function createIdentityParams(
chunkTokens: 4000,
chunkOverlap: 0,
vectorReady: false,
hasIndexedChunks: true,
ftsTokenizer: "unicode61",
...overrides,
};
}
describe("memory reindex state", () => {
it("marks identity dirty when the embedding model changes", () => {
it("requires a full reindex when the embedding model changes", () => {
expect(
isMemoryIndexIdentityDirty(
createIdentityParams({
shouldRunFullMemoryReindex(
createFullReindexParams({
provider: { id: "openai", model: "mock-embed-v2" },
}),
),
).toBe(true);
});
it("returns a mismatch reason when provider identity changes", () => {
it("requires a full reindex when the provider cache key changes", () => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({
provider: { id: "ollama", model: "mock-embed-v1" },
providerKey: "provider-key-ollama",
}),
),
).toEqual({
status: "mismatched",
reason: "index was built for provider openai, expected ollama",
});
});
it("marks identity dirty when the provider cache key changes", () => {
expect(
isMemoryIndexIdentityDirty(
createIdentityParams({
shouldRunFullMemoryReindex(
createFullReindexParams({
provider: { id: "gemini", model: "gemini-embedding-2-preview" },
providerKey: "provider-key-dims-768",
meta: createMeta({
@@ -93,30 +75,7 @@ describe("memory reindex state", () => {
).toBe(true);
});
it("can defer provider key comparison until provider initialization", () => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({
providerKey: undefined,
providerKeyKnown: false,
}),
),
).toEqual({ status: "valid" });
});
it("does not mark identity dirty for vector dimensions before chunks exist", () => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({
vectorReady: true,
hasIndexedChunks: false,
meta: createMeta({ vectorDims: undefined }),
}),
),
).toEqual({ status: "valid" });
});
it("marks identity dirty when extraPaths change", () => {
it("requires a full reindex when extraPaths change", () => {
const workspaceDir = "/tmp/workspace";
const firstScopeHash = resolveConfiguredScopeHash({
workspaceDir,
@@ -138,8 +97,8 @@ describe("memory reindex state", () => {
});
expect(
isMemoryIndexIdentityDirty(
createIdentityParams({
shouldRunFullMemoryReindex(
createFullReindexParams({
meta: createMeta({ scopeHash: firstScopeHash }),
configuredScopeHash: secondScopeHash,
}),
@@ -147,17 +106,17 @@ describe("memory reindex state", () => {
).toBe(true);
});
it("marks identity dirty when configured sources add sessions", () => {
it("requires a full reindex when configured sources add sessions", () => {
expect(
isMemoryIndexIdentityDirty(
createIdentityParams({
shouldRunFullMemoryReindex(
createFullReindexParams({
configuredSources: ["memory", "sessions"],
}),
),
).toBe(true);
});
it("marks identity dirty when multimodal settings change", () => {
it("requires a full reindex when multimodal settings change", () => {
const workspaceDir = "/tmp/workspace";
const firstScopeHash = resolveConfiguredScopeHash({
workspaceDir,
@@ -179,8 +138,8 @@ describe("memory reindex state", () => {
});
expect(
isMemoryIndexIdentityDirty(
createIdentityParams({
shouldRunFullMemoryReindex(
createFullReindexParams({
meta: createMeta({ scopeHash: firstScopeHash }),
configuredScopeHash: secondScopeHash,
}),
@@ -190,8 +149,8 @@ describe("memory reindex state", () => {
it("keeps older indexes with missing sources compatible with memory-only config", () => {
expect(
isMemoryIndexIdentityDirty(
createIdentityParams({
shouldRunFullMemoryReindex(
createFullReindexParams({
meta: createMeta({ sources: undefined }),
configuredSources: resolveConfiguredSourcesForMeta(new Set(["memory"])),
}),

View File

@@ -16,19 +16,6 @@ export type MemoryIndexMeta = {
ftsTokenizer?: string;
};
export type MemoryIndexIdentityState =
| {
status: "valid";
}
| {
status: "missing";
reason: string;
}
| {
status: "mismatched";
reason: string;
};
export function resolveConfiguredSourcesForMeta(sources: Iterable<MemorySource>): MemorySource[] {
const normalized = Array.from(sources)
.filter((source): source is MemorySource => source === "memory" || source === "sessions")
@@ -86,93 +73,31 @@ export function resolveConfiguredScopeHash(params: {
);
}
export function isMemoryIndexIdentityDirty(params: {
export function shouldRunFullMemoryReindex(params: {
meta: MemoryIndexMeta | null;
provider: { id: string; model: string } | null;
providerKey?: string;
providerKeyKnown?: boolean;
configuredSources: MemorySource[];
configuredScopeHash: string;
chunkTokens: number;
chunkOverlap: number;
vectorReady: boolean;
hasIndexedChunks?: boolean;
ftsTokenizer: string;
}): boolean {
return resolveMemoryIndexIdentityState(params).status !== "valid";
}
export function resolveMemoryIndexIdentityState(params: {
meta: MemoryIndexMeta | null;
provider: { id: string; model: string } | null;
providerKey?: string;
providerKeyKnown?: boolean;
configuredSources: MemorySource[];
configuredScopeHash: string;
chunkTokens: number;
chunkOverlap: number;
vectorReady: boolean;
hasIndexedChunks?: boolean;
ftsTokenizer: string;
}): MemoryIndexIdentityState {
const { meta } = params;
if (!meta) {
return { status: "missing", reason: "index metadata is missing" };
}
const expectedModel = params.provider ? params.provider.model : "fts-only";
if (meta.model !== expectedModel) {
return {
status: "mismatched",
reason: `index was built for model ${meta.model}, expected ${expectedModel}`,
};
}
const expectedProvider = params.provider ? params.provider.id : "none";
if (meta.provider !== expectedProvider) {
return {
status: "mismatched",
reason: `index was built for provider ${meta.provider}, expected ${expectedProvider}`,
};
}
if (params.providerKeyKnown !== false && meta.providerKey !== params.providerKey) {
return {
status: "mismatched",
reason: "index provider settings changed",
};
}
if (
return (
!meta ||
(params.provider ? meta.model !== params.provider.model : meta.model !== "fts-only") ||
(params.provider ? meta.provider !== params.provider.id : meta.provider !== "none") ||
meta.providerKey !== params.providerKey ||
configuredMetaSourcesDiffer({
meta,
configuredSources: params.configuredSources,
})
) {
return {
status: "mismatched",
reason: "index sources changed",
};
}
if (meta.scopeHash !== params.configuredScopeHash) {
return {
status: "mismatched",
reason: "index scope changed",
};
}
if (meta.chunkTokens !== params.chunkTokens || meta.chunkOverlap !== params.chunkOverlap) {
return {
status: "mismatched",
reason: "index chunking changed",
};
}
if (params.vectorReady && params.hasIndexedChunks !== false && !meta.vectorDims) {
return {
status: "mismatched",
reason: "index vector dimensions are missing",
};
}
if ((meta.ftsTokenizer ?? "unicode61") !== params.ftsTokenizer) {
return {
status: "mismatched",
reason: "index FTS tokenizer changed",
};
}
return { status: "valid" };
}) ||
meta.scopeHash !== params.configuredScopeHash ||
meta.chunkTokens !== params.chunkTokens ||
meta.chunkOverlap !== params.chunkOverlap ||
(params.vectorReady && !meta.vectorDims) ||
(meta.ftsTokenizer ?? "unicode61") !== params.ftsTokenizer
);
}

View File

@@ -573,11 +573,7 @@ describe("searchVector sqlite-vec KNN", () => {
function insertFallbackChunk(
db: InstanceType<typeof DatabaseSync>,
params: {
id: string;
model: string;
vector: number[];
},
params: { id: string; model: string; vector: number[] },
): void {
db.prepare(
"INSERT INTO chunks (id, path, source, start_line, end_line, hash, model, text, embedding, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",

View File

@@ -28,17 +28,6 @@ describe("memory manager status state", () => {
).toBe(true);
});
it("marks status-only managers dirty when index identity mismatches", () => {
expect(
resolveInitialMemoryDirty({
hasMemorySource: false,
statusOnly: true,
hasIndexedMeta: true,
indexIdentityMismatched: true,
}),
).toBe(true);
});
it("reports the requested provider before provider initialization", () => {
expect(
resolveStatusProviderInfo({

View File

@@ -27,12 +27,8 @@ export function resolveInitialMemoryDirty(params: {
hasMemorySource: boolean;
statusOnly: boolean;
hasIndexedMeta: boolean;
indexIdentityMismatched?: boolean;
}): boolean {
return (
Boolean(params.indexIdentityMismatched) ||
(params.hasMemorySource && (params.statusOnly ? !params.hasIndexedMeta : true))
);
return params.hasMemorySource && (params.statusOnly ? !params.hasIndexedMeta : true);
}
export function resolveStatusProviderInfo(params: {

View File

@@ -38,7 +38,6 @@ import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
createEmbeddingProvider,
resolveEmbeddingProviderAdapterId,
type EmbeddingProvider,
type EmbeddingProviderId,
type EmbeddingProviderRuntime,
@@ -55,9 +54,8 @@ import {
import {
resolveConfiguredScopeHash,
resolveConfiguredSourcesForMeta,
resolveMemoryIndexIdentityState,
shouldRunFullMemoryReindex,
type MemoryIndexMeta,
type MemoryIndexIdentityState,
} from "./manager-reindex-state.js";
import { shouldSyncSessionsForReindex } from "./manager-session-reindex.js";
import {
@@ -69,10 +67,7 @@ import {
loadMemorySourceFileState,
resolveMemorySourceExistingHash,
} from "./manager-source-state.js";
import {
markMemoryTargetSessionFilesDirty,
runMemoryTargetedSessionSync,
} from "./manager-targeted-sync.js";
import { runMemoryTargetedSessionSync } from "./manager-targeted-sync.js";
import {
recordMemoryWatchEventPath,
settleMemoryWatchEventPaths,
@@ -274,65 +269,6 @@ export abstract class MemoryManagerSyncOps {
options: { source: MemorySource; content?: string },
): Promise<void>;
protected hasIndexedChunks(): boolean {
const row = this.db.prepare(`SELECT 1 as found FROM chunks LIMIT 1`).get() as
| { found?: number }
| undefined;
return row?.found === 1;
}
protected resolveCurrentIndexIdentityState(params?: {
meta?: MemoryIndexMeta | null;
provider?: { id: string; model: string } | null;
providerKeyKnown?: boolean;
vectorReady?: boolean;
hasIndexedChunks?: boolean;
}): MemoryIndexIdentityState {
const hasProviderOverride = params && "provider" in params;
const configuredProvider =
this.settings.provider === "none"
? null
: {
id:
resolveEmbeddingProviderAdapterId(this.settings.provider, this.cfg) ??
this.settings.provider,
model: this.settings.model,
};
const provider = hasProviderOverride
? params.provider!
: this.provider
? { id: this.provider.id, model: this.provider.model }
: configuredProvider;
const vectorReady =
params && "vectorReady" in params
? Boolean(params.vectorReady)
: this.vector.available === true;
return resolveMemoryIndexIdentityState({
meta: params && "meta" in params ? params.meta! : this.readMeta(),
provider,
providerKey: params?.providerKeyKnown === false ? undefined : (this.providerKey ?? undefined),
providerKeyKnown: params?.providerKeyKnown,
configuredSources: resolveConfiguredSourcesForMeta(this.sources),
configuredScopeHash: resolveConfiguredScopeHash({
workspaceDir: this.workspaceDir,
extraPaths: this.settings.extraPaths,
multimodal: {
enabled: this.settings.multimodal.enabled,
modalities: this.settings.multimodal.modalities,
maxFileBytes: this.settings.multimodal.maxFileBytes,
},
}),
chunkTokens: this.settings.chunking.tokens,
chunkOverlap: this.settings.chunking.overlap,
vectorReady,
hasIndexedChunks:
params && "hasIndexedChunks" in params
? Boolean(params.hasIndexedChunks)
: this.hasIndexedChunks(),
ftsTokenizer: this.settings.store.fts.tokenizer,
});
}
protected resetVectorState(): void {
this.vectorReady = null;
this.vector.available = null;
@@ -1755,69 +1691,60 @@ export abstract class MemoryManagerSyncOps {
}
const vectorReady = await this.ensureVectorReady();
const meta = this.readMeta();
const configuredSources = resolveConfiguredSourcesForMeta(this.sources);
const configuredScopeHash = resolveConfiguredScopeHash({
workspaceDir: this.workspaceDir,
extraPaths: this.settings.extraPaths,
multimodal: {
enabled: this.settings.multimodal.enabled,
modalities: this.settings.multimodal.modalities,
maxFileBytes: this.settings.multimodal.maxFileBytes,
},
});
const targetSessionFiles = this.normalizeTargetSessionFiles(params?.sessionFiles);
const hasTargetSessionFiles = targetSessionFiles !== null;
if (params?.reason === "cli" && !params.force && !hasTargetSessionFiles) {
await this.markSessionStartupCatchupDirtyFiles();
}
const indexIdentity = resolveMemoryIndexIdentityState({
meta,
// Also detects provider→FTS-only transitions so orphaned old-model FTS rows are cleaned up.
provider: this.provider ? { id: this.provider.id, model: this.provider.model } : null,
providerKey: this.providerKey ?? undefined,
configuredSources: resolveConfiguredSourcesForMeta(this.sources),
configuredScopeHash: resolveConfiguredScopeHash({
workspaceDir: this.workspaceDir,
extraPaths: this.settings.extraPaths,
multimodal: {
enabled: this.settings.multimodal.enabled,
modalities: this.settings.multimodal.modalities,
maxFileBytes: this.settings.multimodal.maxFileBytes,
},
}),
chunkTokens: this.settings.chunking.tokens,
chunkOverlap: this.settings.chunking.overlap,
vectorReady,
hasIndexedChunks: this.hasIndexedChunks(),
ftsTokenizer: this.settings.store.fts.tokenizer,
const targetedSessionSync = await runMemoryTargetedSessionSync({
hasSessionSource: this.sources.has("sessions"),
targetSessionFiles,
reason: params?.reason,
progress: progress ?? undefined,
useUnsafeReindex:
process.env.OPENCLAW_TEST_FAST === "1" &&
process.env.OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX === "1",
sessionsDirtyFiles: this.sessionsDirtyFiles,
syncSessionFiles: async (targetedParams) => {
await this.syncSessionFiles(targetedParams);
},
shouldFallbackOnError: (err) => this.shouldFallbackOnError(err),
activateFallbackProvider: async (reason) => await this.activateFallbackProvider(reason),
runSafeReindex: async (reindexParams) => {
await this.runSafeReindex(reindexParams);
},
runUnsafeReindex: async (reindexParams) => {
await this.runUnsafeReindex(reindexParams);
},
});
const hasIndexedChunks = this.hasIndexedChunks();
const needsInitialIndex = indexIdentity.status !== "valid" && !hasIndexedChunks;
const needsExplicitIdentityReindex =
params?.reason === "cli" && indexIdentity.status !== "valid" && !hasTargetSessionFiles;
const needsFullReindex =
(params?.force && !hasTargetSessionFiles) ||
needsInitialIndex ||
needsExplicitIdentityReindex;
if (indexIdentity.status !== "valid" && !needsFullReindex) {
this.dirty = true;
const sessionsDirty = markMemoryTargetSessionFilesDirty({
sessionsDirtyFiles: this.sessionsDirtyFiles,
targetSessionFiles,
});
if (sessionsDirty) {
this.sessionsDirty = true;
}
if (targetedSessionSync.handled) {
this.sessionsDirty = targetedSessionSync.sessionsDirty;
return;
}
if (!needsFullReindex) {
const targetedSessionSync = await runMemoryTargetedSessionSync({
hasSessionSource: this.sources.has("sessions"),
targetSessionFiles,
reason: params?.reason,
progress: progress ?? undefined,
sessionsDirtyFiles: this.sessionsDirtyFiles,
syncSessionFiles: async (targetedParams) => {
await this.syncSessionFiles(targetedParams);
},
shouldFallbackOnError: (err) => this.shouldFallbackOnError(err),
activateFallbackProvider: async (reason) => await this.activateFallbackProvider(reason),
const needsFullReindex =
(params?.force && !hasTargetSessionFiles) ||
shouldRunFullMemoryReindex({
meta,
// Also detects provider→FTS-only transitions so orphaned old-model FTS rows are cleaned up.
provider: this.provider ? { id: this.provider.id, model: this.provider.model } : null,
providerKey: this.providerKey ?? undefined,
configuredSources,
configuredScopeHash,
chunkTokens: this.settings.chunking.tokens,
chunkOverlap: this.settings.chunking.overlap,
vectorReady,
ftsTokenizer: this.settings.store.fts.tokenizer,
});
if (targetedSessionSync.handled) {
this.sessionsDirty = targetedSessionSync.sessionsDirty;
return;
}
}
try {
if (needsFullReindex) {
if (
@@ -1867,17 +1794,20 @@ export abstract class MemoryManagerSyncOps {
const activated =
this.shouldFallbackOnError(err) && (await this.activateFallbackProvider(reason));
if (activated) {
if (needsFullReindex && !hasTargetSessionFiles) {
await this.runSafeReindex({
reason: params?.reason ?? "fallback",
force: true,
progress: progress ?? undefined,
});
}
await this.runSafeReindex({
reason: params?.reason ?? "fallback",
force: true,
progress: progress ?? undefined,
});
return;
}
if (!this.provider && this.fts.enabled && this.shouldFallbackOnError(err)) {
log.warn(`memory embeddings unavailable; leaving memory index dirty: ${reason}`);
log.warn(`memory embeddings unavailable; rebuilding lexical memory index only: ${reason}`);
await this.runSafeReindex({
reason: params?.reason ?? "embedding-degraded",
force: true,
progress: progress ?? undefined,
});
return;
}
throw err;
@@ -2035,9 +1965,6 @@ export abstract class MemoryManagerSyncOps {
} else {
this.sessionsDirty = false;
}
if (!shouldSyncMemory) {
this.dirty = false;
}
const meta: MemoryIndexMeta = {
model: this.provider?.model ?? "fts-only",
@@ -2118,9 +2045,6 @@ export abstract class MemoryManagerSyncOps {
} else {
this.sessionsDirty = false;
}
if (!shouldSyncMemory) {
this.dirty = false;
}
const nextMeta: MemoryIndexMeta = {
model: this.provider?.model ?? "fts-only",

View File

@@ -38,7 +38,6 @@ vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => {
});
vi.mock("./embeddings.js", () => ({
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
createEmbeddingProvider: vi.fn(),
}));

View File

@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
clearMemorySyncedSessionFiles,
markMemoryTargetSessionFilesDirty,
runMemoryTargetedSessionSync,
} from "./manager-targeted-sync.js";
@@ -19,48 +18,61 @@ describe("memory targeted session sync", () => {
expect(sessionsDirty).toBe(true);
});
it("marks target sessions dirty while identity sync is paused", () => {
const targetSessionPath = "/tmp/paused-target.jsonl";
const sessionsDirtyFiles = new Set(["/tmp/other-dirty.jsonl"]);
const sessionsDirty = markMemoryTargetSessionFilesDirty({
sessionsDirtyFiles,
targetSessionFiles: [targetSessionPath],
});
expect(sessionsDirty).toBe(true);
expect(sessionsDirtyFiles.has(targetSessionPath)).toBe(true);
expect(sessionsDirtyFiles.has("/tmp/other-dirty.jsonl")).toBe(true);
});
it("leaves targeted sessions dirty after fallback activates during targeted sync", async () => {
it("runs a full reindex after fallback activates during targeted sync", async () => {
const activateFallbackProvider = vi.fn(async () => true);
const syncSessionFiles = vi
.fn()
.mockRejectedValueOnce(new Error("embedding backend failed"))
.mockResolvedValueOnce(undefined);
const sessionsDirtyFiles = new Set(["/tmp/targeted-fallback.jsonl", "/tmp/other-dirty.jsonl"]);
const runSafeReindex = vi.fn(async () => {});
const runUnsafeReindex = vi.fn(async () => {});
const result = await runMemoryTargetedSessionSync({
await runMemoryTargetedSessionSync({
hasSessionSource: true,
targetSessionFiles: new Set(["/tmp/targeted-fallback.jsonl"]),
reason: "post-compaction",
progress: undefined,
sessionsDirtyFiles,
syncSessionFiles,
useUnsafeReindex: false,
sessionsDirtyFiles: new Set(),
syncSessionFiles: async () => {
throw new Error("embedding backend failed");
},
shouldFallbackOnError: () => true,
activateFallbackProvider,
runSafeReindex,
runUnsafeReindex,
});
expect(activateFallbackProvider).toHaveBeenCalledWith("embedding backend failed");
expect(syncSessionFiles).toHaveBeenCalledTimes(1);
expect(syncSessionFiles).toHaveBeenCalledWith({
needsFullReindex: false,
targetSessionFiles: ["/tmp/targeted-fallback.jsonl"],
expect(runSafeReindex).toHaveBeenCalledWith({
reason: "post-compaction",
force: true,
progress: undefined,
});
expect(result).toEqual({ handled: true, sessionsDirty: true });
expect(sessionsDirtyFiles.has("/tmp/targeted-fallback.jsonl")).toBe(true);
expect(sessionsDirtyFiles.has("/tmp/other-dirty.jsonl")).toBe(true);
expect(runUnsafeReindex).not.toHaveBeenCalled();
});
it("uses the unsafe reindex path when enabled", async () => {
const runSafeReindex = vi.fn(async () => {});
const runUnsafeReindex = vi.fn(async () => {});
await runMemoryTargetedSessionSync({
hasSessionSource: true,
targetSessionFiles: new Set(["/tmp/targeted-fallback.jsonl"]),
reason: "post-compaction",
progress: undefined,
useUnsafeReindex: true,
sessionsDirtyFiles: new Set(),
syncSessionFiles: async () => {
throw new Error("embedding backend failed");
},
shouldFallbackOnError: () => true,
activateFallbackProvider: async () => true,
runSafeReindex,
runUnsafeReindex,
});
expect(runUnsafeReindex).toHaveBeenCalledWith({
reason: "post-compaction",
force: true,
progress: undefined,
});
expect(runSafeReindex).not.toHaveBeenCalled();
});
});

View File

@@ -22,23 +22,12 @@ export function clearMemorySyncedSessionFiles(params: {
return params.sessionsDirtyFiles.size > 0;
}
export function markMemoryTargetSessionFilesDirty(params: {
sessionsDirtyFiles: Set<string>;
targetSessionFiles?: Iterable<string> | null;
}): boolean {
if (params.targetSessionFiles) {
for (const targetSessionFile of params.targetSessionFiles) {
params.sessionsDirtyFiles.add(targetSessionFile);
}
}
return params.sessionsDirtyFiles.size > 0;
}
export async function runMemoryTargetedSessionSync(params: {
hasSessionSource: boolean;
targetSessionFiles: Set<string> | null;
reason?: string;
progress?: TargetedSyncProgress;
useUnsafeReindex: boolean;
sessionsDirtyFiles: Set<string>;
syncSessionFiles: (params: {
needsFullReindex: boolean;
@@ -47,6 +36,16 @@ export async function runMemoryTargetedSessionSync(params: {
}) => Promise<void>;
shouldFallbackOnError: (err: unknown) => boolean;
activateFallbackProvider: (reason: string) => Promise<boolean>;
runSafeReindex: (params: {
reason?: string;
force?: boolean;
progress?: TargetedSyncProgress;
}) => Promise<void>;
runUnsafeReindex: (params: {
reason?: string;
force?: boolean;
progress?: TargetedSyncProgress;
}) => Promise<void>;
}): Promise<{ handled: boolean; sessionsDirty: boolean }> {
if (!params.hasSessionSource || !params.targetSessionFiles) {
return {
@@ -75,12 +74,19 @@ export async function runMemoryTargetedSessionSync(params: {
if (!activated) {
throw err;
}
const reindexParams = {
reason: params.reason,
force: true,
progress: params.progress,
};
if (params.useUnsafeReindex) {
await params.runUnsafeReindex(reindexParams);
} else {
await params.runSafeReindex(reindexParams);
}
return {
handled: true,
sessionsDirty: markMemoryTargetSessionFilesDirty({
sessionsDirtyFiles: params.sessionsDirtyFiles,
targetSessionFiles: params.targetSessionFiles,
}),
sessionsDirty: params.sessionsDirtyFiles.size > 0,
};
}
}

View File

@@ -15,7 +15,6 @@ vi.mock("./embeddings.js", () => ({
provider: null,
providerUnavailableReason: "No embeddings provider available.",
}),
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
resolveEmbeddingProviderFallbackModel: () => "fts-only",
}));

View File

@@ -47,7 +47,6 @@ import {
resolveMemoryProviderState,
type MemoryProviderLifecycleState,
} from "./manager-provider-state.js";
import type { MemoryIndexIdentityState } from "./manager-reindex-state.js";
import { resolveMemorySearchPreflight } from "./manager-search-preflight.js";
import { searchKeyword, searchVector } from "./manager-search.js";
import {
@@ -172,7 +171,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
protected override sessionsDirty = false;
protected override sessionsDirtyFiles = new Set<string>();
protected override sessionPendingFiles = new Set<string>();
private indexIdentityDirty = false;
protected override sessionDeltas = new Map<
string,
{ lastSize: number; pendingBytes: number; pendingMessages: number }
@@ -185,10 +183,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
private readonlyRecoverySuccesses = 0;
private readonlyRecoveryFailures = 0;
private readonlyRecoveryLastError?: string;
private indexIdentityState: MemoryIndexIdentityState = {
status: "missing",
reason: "index metadata is missing",
};
private static async loadProviderResult(params: {
cfg: OpenClawConfig;
@@ -273,14 +267,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (meta?.vectorDims) {
this.vector.dims = meta.vectorDims;
}
const initialIndexIdentity = this.resolveCurrentIndexIdentityState({
meta,
providerKeyKnown: Boolean(params.providerResult),
});
this.indexIdentityState = initialIndexIdentity;
this.indexIdentityDirty =
initialIndexIdentity.status === "mismatched" ||
(initialIndexIdentity.status === "missing" && this.sources.has("memory"));
const transient = params.purpose === "status" || params.purpose === "cli";
if (!transient) {
this.ensureWatcher();
@@ -391,23 +377,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
}
private refreshIndexIdentityDirty(params?: { providerKeyKnown?: boolean }) {
const provider = this.providerInitialized
? this.provider
? { id: this.provider.id, model: this.provider.model }
: null
: undefined;
const state = this.resolveCurrentIndexIdentityState({
...(provider !== undefined ? { provider } : {}),
providerKeyKnown: params?.providerKeyKnown,
});
this.indexIdentityState = state;
this.indexIdentityDirty =
state.status === "mismatched" ||
(state.status === "missing" && (this.sources.has("memory") || this.hasIndexedChunks()));
return state;
}
async search(
query: string,
opts?: {
@@ -454,27 +423,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (preflight.shouldInitializeProvider) {
await this.ensureProviderInitialized();
}
if (!this.provider && this.providerLifecycle.mode === "degraded") {
const activatedFallback = await this.activateFallbackProvider(
this.providerLifecycle.reason,
).catch((fallbackErr: unknown) => {
log.warn(
`memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`,
);
return false;
});
if (activatedFallback) {
this.refreshIndexIdentityDirty({
providerKeyKnown: this.providerInitialized,
});
}
}
const indexIdentity = this.refreshIndexIdentityDirty({
providerKeyKnown: this.providerInitialized,
});
if (indexIdentity.status !== "valid") {
return [];
}
const minScore = opts?.minScore ?? this.settings.query.minScore;
const maxResults = opts?.maxResults ?? this.settings.query.maxResults;
const searchSources =
@@ -495,6 +443,20 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
Math.max(1, Math.floor(maxResults * hybrid.candidateMultiplier)),
);
if (!this.provider && this.providerLifecycle.mode === "degraded") {
const activatedFallback = await this.activateFallbackProvider(
this.providerLifecycle.reason,
).catch((fallbackErr: unknown) => {
log.warn(
`memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`,
);
return false;
});
if (activatedFallback) {
await this.runSafeReindex({ reason: "fallback", force: true });
}
}
// FTS-only mode: no embedding provider available
if (!this.provider) {
if (!this.fts.enabled || !this.fts.available) {
@@ -590,13 +552,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
})
: false;
if (activatedFallback) {
if (
this.refreshIndexIdentityDirty({
providerKeyKnown: this.providerInitialized,
}).status !== "valid"
) {
return [];
}
await this.runSafeReindex({ reason: "fallback", force: true });
keywordResults = await loadKeywordResults();
queryVec = await this.embedQueryWithRetry(cleaned);
} else if (!this.provider && this.fts.enabled && this.fts.available) {
@@ -900,9 +856,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
status(): MemoryProviderStatus {
this.refreshIndexIdentityDirty({
providerKeyKnown: this.providerInitialized,
});
const sourceFilter = this.buildSourceFilter();
const aggregateState = collectMemoryStatusAggregate({
db: {
@@ -931,7 +884,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
backend: "builtin",
files: aggregateState.files,
chunks: aggregateState.chunks,
dirty: this.dirty || this.sessionsDirty || this.indexIdentityDirty,
dirty: this.dirty || this.sessionsDirty,
workspaceDir: this.workspaceDir,
dbPath: this.settings.store.path,
provider: providerInfo.provider,
@@ -984,7 +937,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
searchMode: providerInfo.searchMode,
providerState: this.providerLifecycle,
providerUnavailableReason: this.providerUnavailableReason,
indexIdentity: this.indexIdentityState,
readonlyRecovery: {
attempts: this.readonlyRecoveryAttempts,
successes: this.readonlyRecoverySuccesses,

View File

@@ -126,7 +126,6 @@ vi.mock("./sqlite-vec.js", () => ({
}));
vi.mock("./embeddings.js", () => ({
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {

View File

@@ -117,25 +117,15 @@ export function createMemoryTool(params: {
};
}
export function buildMemorySearchUnavailableResult(
error: string | undefined,
overrides?: {
warning?: string;
action?: string;
},
) {
export function buildMemorySearchUnavailableResult(error: string | undefined) {
const reason = (error ?? "memory search unavailable").trim() || "memory search unavailable";
const isQuotaError = /insufficient_quota|quota|429/.test(normalizeLowercaseStringOrEmpty(reason));
const warning =
overrides?.warning ??
(isQuotaError
? "Memory search is unavailable because the embedding provider quota is exhausted."
: "Memory search is unavailable due to an embedding/provider error.");
const action =
overrides?.action ??
(isQuotaError
? "Top up or switch embedding provider, then retry memory_search."
: "Check embedding provider configuration and retry memory_search.");
const warning = isQuotaError
? "Memory search is unavailable because the embedding provider quota is exhausted."
: "Memory search is unavailable due to an embedding/provider error.";
const action = isQuotaError
? "Top up or switch embedding provider, then retry memory_search."
: "Check embedding provider configuration and retry memory_search.";
return {
results: [],
disabled: true,

View File

@@ -3,10 +3,8 @@ import {
getMemorySearchManagerMockCalls,
getMemorySearchManagerMockConfigs,
getMemorySearchManagerMockParams,
getMemorySyncMockCalls,
resetMemoryToolMockState,
setMemoryBackend,
setMemoryCustomStatus,
setMemorySearchImpl,
setMemorySearchManagerImpl,
} from "./memory-tool-manager-mock.js";
@@ -258,39 +256,6 @@ describe("memory_search unavailable payloads", () => {
expect(searchCalls).toBe(2);
});
it("returns unavailable metadata when the index identity is paused", async () => {
let searchCalls = 0;
setMemorySearchImpl(async () => {
searchCalls += 1;
return [];
});
const reason = "index was built for provider openai, expected ollama";
setMemoryCustomStatus({
indexIdentity: {
status: "mismatched",
reason,
},
});
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { citations: "off" },
},
});
const result = await tool.execute("paused-index", { query: "hidden thread codename" });
expectUnavailableMemorySearchDetails(result.details, {
error: reason,
warning:
"Tell the user: memory search is paused because the memory index was built with a different embedding provider/model/settings.",
action:
"Tell the user to run: openclaw memory status --index or openclaw memory index --force.",
});
expect(searchCalls).toBe(1);
expect(getMemorySyncMockCalls()).toBe(0);
});
it("returns structured search debug metadata for qmd results", async () => {
setMemoryBackend("qmd");
setMemorySearchImpl(async (opts) => {

View File

@@ -18,7 +18,6 @@ import {
resolveMemoryDreamingConfig,
resolveMemoryDeepDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { asRecord } from "./dreaming-shared.js";
import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js";
import { recordShortTermRecalls } from "./short-term-promotion.js";
import {
@@ -110,28 +109,6 @@ async function runMemorySearchToolWithDeadline<T>(params: {
}
}
const PAUSED_MEMORY_INDEX_WARNING =
"Tell the user: memory search is paused because the memory index was built with a different embedding provider/model/settings.";
const PAUSED_MEMORY_INDEX_ACTION =
"Tell the user to run: openclaw memory status --index or openclaw memory index --force.";
function resolvePausedMemoryIndexIdentityReason(status: { custom?: unknown }): string | undefined {
const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity);
if (indexIdentity?.status !== "mismatched" && indexIdentity?.status !== "missing") {
return undefined;
}
return typeof indexIdentity.reason === "string" && indexIdentity.reason.trim()
? indexIdentity.reason.trim()
: "memory index identity is missing or mismatched";
}
function buildPausedMemoryIndexUnavailableResult(reason: string) {
return buildMemorySearchUnavailableResult(reason, {
warning: PAUSED_MEMORY_INDEX_WARNING,
action: PAUSED_MEMORY_INDEX_ACTION,
});
}
function sortMemorySearchToolResults<T extends { score: number; path: string }>(results: T[]): T[] {
return results.toSorted((left, right) => {
if (left.score !== right.score) {
@@ -339,7 +316,7 @@ export function createMemorySearchTool(options: {
label: "Memory Search",
name: "memory_search",
description:
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable; you must tell the user and include the warning/action guidance.",
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.",
parameters: MemorySearchSchema,
execute:
({ cfg, agentId }) =>
@@ -423,7 +400,6 @@ export function createMemorySearchTool(options: {
let model: string | undefined;
let fallback: unknown;
let searchMode: string | undefined;
let pausedIndexIdentityReason: string | undefined;
let searchDebug:
| {
backend: string;
@@ -471,21 +447,9 @@ export function createMemorySearchTool(options: {
activeMemory = refreshed;
rawResults = await activeMemory.manager.search(query, searchOptions);
}
const statusBeforeRetry = activeMemory.manager.status();
pausedIndexIdentityReason =
resolvePausedMemoryIndexIdentityReason(statusBeforeRetry);
if (pausedIndexIdentityReason) {
return;
}
if (rawResults.length === 0 && activeMemory.manager.sync) {
await activeMemory.manager.sync({ reason: "search", force: true });
rawResults = await activeMemory.manager.search(query, searchOptions);
pausedIndexIdentityReason = resolvePausedMemoryIndexIdentityReason(
activeMemory.manager.status(),
);
if (pausedIndexIdentityReason) {
return;
}
}
rawResults = await filterMemorySearchHitsBySessionVisibility({
cfg,
@@ -536,11 +500,6 @@ export function createMemorySearchTool(options: {
hits: rawResults.length,
};
});
if (pausedIndexIdentityReason) {
return jsonResult(
buildPausedMemoryIndexUnavailableResult(pausedIndexIdentityReason),
);
}
}
const supplementResults = shouldQuerySupplements
? await runUnavailablePhase(

View File

@@ -3,9 +3,7 @@ import {
canonicalizeCodexResponsesBaseUrl,
isOpenAIApiBaseUrl,
isOpenAICodexBaseUrl,
OPENAI_API_BASE_URL,
OPENAI_CODEX_RESPONSES_BASE_URL,
resolveOpenAIDefaultBaseUrl,
} from "./base-url.js";
describe("openai base URL helpers", () => {
@@ -59,12 +57,4 @@ describe("openai base URL helpers", () => {
);
expect(canonicalizeCodexResponsesBaseUrl(undefined)).toBeUndefined();
});
it("resolves default API base URL from OPENAI_BASE_URL", () => {
expect(resolveOpenAIDefaultBaseUrl({})).toBe(OPENAI_API_BASE_URL);
expect(resolveOpenAIDefaultBaseUrl({ OPENAI_BASE_URL: " " })).toBe(OPENAI_API_BASE_URL);
expect(resolveOpenAIDefaultBaseUrl({ OPENAI_BASE_URL: " https://proxy.example/v1 " })).toBe(
"https://proxy.example/v1",
);
});
});

View File

@@ -1,13 +1,6 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
export const OPENAI_CODEX_RESPONSES_BASE_URL = "https://chatgpt.com/backend-api/codex";
export const OPENAI_API_BASE_URL = "https://api.openai.com/v1";
export function resolveOpenAIDefaultBaseUrl(
env: Record<string, string | undefined> = process.env,
): string {
return normalizeOptionalString(env.OPENAI_BASE_URL) ?? OPENAI_API_BASE_URL;
}
export function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
const trimmed = normalizeOptionalString(baseUrl);

View File

@@ -11,11 +11,7 @@ import {
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OPENAI_ACCOUNT_WIZARD_GROUP, OPENAI_API_KEY_LABEL } from "./auth-choice-copy.js";
import {
isOpenAIApiBaseUrl,
isOpenAICodexBaseUrl,
resolveOpenAIDefaultBaseUrl,
} from "./base-url.js";
import { isOpenAIApiBaseUrl, isOpenAICodexBaseUrl } from "./base-url.js";
import { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "./default-models.js";
import {
buildOpenAIChatGPTAuthMethods,
@@ -207,7 +203,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: resolveOpenAIDefaultBaseUrl(),
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text", "image"],
cost: OPENAI_CHAT_LATEST_COST,
@@ -219,7 +215,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: resolveOpenAIDefaultBaseUrl(),
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
mediaInput: OPENAI_GPT_55_MEDIA_INPUT,
@@ -233,7 +229,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: resolveOpenAIDefaultBaseUrl(),
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_55_PRO_COST,
@@ -245,7 +241,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: resolveOpenAIDefaultBaseUrl(),
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_54_COST,
@@ -257,7 +253,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: resolveOpenAIDefaultBaseUrl(),
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_54_PRO_COST,
@@ -269,7 +265,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: resolveOpenAIDefaultBaseUrl(),
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_54_MINI_COST,
@@ -281,7 +277,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: resolveOpenAIDefaultBaseUrl(),
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_54_NANO_COST,

View File

@@ -42,10 +42,6 @@ describe("QQBot token manager", () => {
url: "https://bots.qq.com/app/getAppAccessToken",
auditContext: "qqbot-token",
capture: false,
policy: {
hostnameAllowlist: ["bots.qq.com"],
allowRfc2544BenchmarkRange: true,
},
init: {
method: "POST",
headers: {
@@ -58,25 +54,6 @@ describe("QQBot token manager", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("passes the RFC2544 SSRF allowance to the token fetch (regression for #88984)", async () => {
mockGuardedTokenResponse('{"access_token":"token-1","expires_in":7200}', {
status: 200,
headers: { "content-type": "application/json" },
});
await expect(new TokenManager().getAccessToken("app-id", "secret")).resolves.toBe("token-1");
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://bots.qq.com/app/getAppAccessToken",
auditContext: "qqbot-token",
policy: {
hostnameAllowlist: ["bots.qq.com"],
allowRfc2544BenchmarkRange: true,
},
}),
);
});
it("does not cache access tokens forever when expires_in is unsafe", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));

View File

@@ -12,31 +12,13 @@ import {
resolveExpiresAtMsFromDurationSeconds,
resolveTimestampMsToIsoString,
} from "openclaw/plugin-sdk/number-runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type { EngineLogger } from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 7200;
/**
* Host-scoped SSRF policy for the QQ Bot token endpoint.
*
* `TOKEN_URL` is a hard-coded `https://bots.qq.com/...` constant, so this
* relaxation only ever applies to that single host. Fake-IP proxy stacks
* (sing-box, Clash, Surge, WSL2 DNS, etc.) routinely map `bots.qq.com` into
* the RFC 2544 benchmark range `198.18.0.0/15`, which the default SSRF
* guard blocks. We mirror the existing media-path pattern
* (`QQBOT_MEDIA_SSRF_POLICY` in `../utils/file-utils.ts`) so the relaxation
* stays narrowly host-scoped instead of weakening the global default.
*
* See https://github.com/openclaw/openclaw/issues/88984.
*/
const QQBOT_TOKEN_SSRF_POLICY: SsrFPolicy = {
hostnameAllowlist: ["bots.qq.com"],
allowRfc2544BenchmarkRange: true,
};
interface CachedToken {
token: string;
expiresAt: number;
@@ -252,7 +234,6 @@ export class TokenManager {
url: TOKEN_URL,
auditContext: "qqbot-token",
capture: false,
policy: QQBOT_TOKEN_SSRF_POLICY,
init: {
method: "POST",
headers: {

View File

@@ -1,74 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "./data-paths.js";
const createdStateDirs: string[] = [];
function createTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
createdStateDirs.push(dir);
return dir;
}
describe("qqbot credential backup paths", () => {
afterEach(() => {
vi.unstubAllEnvs();
for (const stateDir of createdStateDirs.splice(0)) {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("scopes credential backups to the active OPENCLAW_STATE_DIR", () => {
const stateDir = createTempDir("qqbot-state-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
expect(getCredentialBackupFile("default")).toBe(
path.join(stateDir, "qqbot", "data", "credential-backup-default.json"),
);
expect(getLegacyCredentialBackupFile()).toBe(
path.join(stateDir, "qqbot", "data", "credential-backup.json"),
);
});
it("keeps same account IDs isolated across different state directories", () => {
const stateDirA = createTempDir("qqbot-state-a-");
const stateDirB = createTempDir("qqbot-state-b-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDirA);
const gatewayAPath = getCredentialBackupFile("default");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDirB);
const gatewayBPath = getCredentialBackupFile("default");
expect(gatewayAPath).toBe(
path.join(stateDirA, "qqbot", "data", "credential-backup-default.json"),
);
expect(gatewayBPath).toBe(
path.join(stateDirB, "qqbot", "data", "credential-backup-default.json"),
);
expect(gatewayBPath).not.toBe(gatewayAPath);
});
it("uses OPENCLAW_HOME for default credential backup state", () => {
const homeDir = createTempDir("qqbot-openclaw-home-");
vi.stubEnv("OPENCLAW_STATE_DIR", "");
vi.stubEnv("OPENCLAW_HOME", homeDir);
expect(getCredentialBackupFile("default")).toBe(
path.join(homeDir, ".openclaw", "qqbot", "data", "credential-backup-default.json"),
);
});
it("expands tilde state-dir overrides through the canonical state resolver", () => {
const homeDir = createTempDir("qqbot-home-");
vi.stubEnv("HOME", homeDir);
vi.stubEnv("OPENCLAW_HOME", "");
vi.stubEnv("OPENCLAW_STATE_DIR", "~/gateway-a");
expect(getCredentialBackupFile("default")).toBe(
path.join(homeDir, "gateway-a", "qqbot", "data", "credential-backup-default.json"),
);
});
});

View File

@@ -11,7 +11,7 @@
*/
import path from "node:path";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { getQQBotDataPath } from "./platform.js";
/**
* Normalise an identifier so it is safe to embed in a filename.
@@ -21,10 +21,6 @@ function safeName(id: string): string {
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
}
function getCredentialBackupRoot(): string {
return path.join(resolveStateDir(process.env), "qqbot", "data");
}
// ---- credential backup ----
/**
@@ -33,10 +29,10 @@ function getCredentialBackupRoot(): string {
* missing from the live config.
*/
export function getCredentialBackupFile(accountId: string): string {
return path.join(getCredentialBackupRoot(), `credential-backup-${safeName(accountId)}.json`);
return path.join(getQQBotDataPath("data"), `credential-backup-${safeName(accountId)}.json`);
}
/** Legacy single-file credential backup (pre-multi-account-isolation). */
export function getLegacyCredentialBackupFile(): string {
return path.join(getCredentialBackupRoot(), "credential-backup.json");
return path.join(getQQBotDataPath("data"), "credential-backup.json");
}

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