mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 16:53:02 +08:00
Compare commits
837 Commits
codex/node
...
codex/mark
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542baa3b43 | ||
|
|
72402b51c5 | ||
|
|
df90aac6e2 | ||
|
|
09796c6991 | ||
|
|
86ef2324a3 | ||
|
|
f1326d71ae | ||
|
|
cf6700486c | ||
|
|
537636b926 | ||
|
|
21648cf844 | ||
|
|
0841fe7d67 | ||
|
|
7ecc9551ff | ||
|
|
5d423e5f1a | ||
|
|
fc459ad376 | ||
|
|
abd52441c5 | ||
|
|
33711a477b | ||
|
|
5edcff17c8 | ||
|
|
a652a0529b | ||
|
|
d92f990126 | ||
|
|
5577442e84 | ||
|
|
fe01495f8e | ||
|
|
c8d313f742 | ||
|
|
82466b33c5 | ||
|
|
480091b9ee | ||
|
|
5ea7e9d071 | ||
|
|
6c4626eca1 | ||
|
|
b3f7436307 | ||
|
|
e327700c7d | ||
|
|
988b2e12a6 | ||
|
|
4cf2a2dd6f | ||
|
|
b65bd56a79 | ||
|
|
bed2f620dd | ||
|
|
eb1d0a3588 | ||
|
|
fbdbbe9e97 | ||
|
|
782a360580 | ||
|
|
0e9b65889b | ||
|
|
914f0f9315 | ||
|
|
1666686eba | ||
|
|
1cfc23afb4 | ||
|
|
5acb805f37 | ||
|
|
8361b69ff6 | ||
|
|
66c588e28f | ||
|
|
3d5c8b25a5 | ||
|
|
0ff9e3a88f | ||
|
|
4774d26cec | ||
|
|
0bff438190 | ||
|
|
2fbddd65e6 | ||
|
|
08ff5f7235 | ||
|
|
ca10f65280 | ||
|
|
79627a02a5 | ||
|
|
b8cd4513a6 | ||
|
|
cd003a688e | ||
|
|
7321e70b6f | ||
|
|
691baa3054 | ||
|
|
782de561c8 | ||
|
|
56c2ee2a77 | ||
|
|
7e59c8a48a | ||
|
|
2cb6f013ad | ||
|
|
c9417590c4 | ||
|
|
dae769e4d1 | ||
|
|
fd36d510ac | ||
|
|
1b7da5d000 | ||
|
|
4730f05e78 | ||
|
|
859d01c919 | ||
|
|
3549150d17 | ||
|
|
bb7339fe24 | ||
|
|
7033becd07 | ||
|
|
9326519c8d | ||
|
|
ace9d4c842 | ||
|
|
8ccb15f813 | ||
|
|
85aa7cca7a | ||
|
|
fd84a67e22 | ||
|
|
c4b7e5ebd7 | ||
|
|
6878fb25f9 | ||
|
|
da6e410690 | ||
|
|
7d013c1353 | ||
|
|
f733a37db3 | ||
|
|
42fae37d9f | ||
|
|
940e4e64ff | ||
|
|
30bf7310a5 | ||
|
|
f4952f3c42 | ||
|
|
a0590e113a | ||
|
|
645f3025a7 | ||
|
|
84398e8509 | ||
|
|
89d694b33a | ||
|
|
2b411b0298 | ||
|
|
f5c2e455c7 | ||
|
|
6495eb8355 | ||
|
|
f3dccaa707 | ||
|
|
3830ae5f86 | ||
|
|
955cc4a0fa | ||
|
|
e6049f5560 | ||
|
|
b949cd8a63 | ||
|
|
eb68d9e8e7 | ||
|
|
ee6b5eb51a | ||
|
|
57930933ce | ||
|
|
a9865297f9 | ||
|
|
8f952a1819 | ||
|
|
ef7f54e1db | ||
|
|
1bb275b4af | ||
|
|
8f8fba66e3 | ||
|
|
3663b216ea | ||
|
|
082e0e1e74 | ||
|
|
029eae8d4d | ||
|
|
7e91337292 | ||
|
|
5cddc8617b | ||
|
|
b29bc49452 | ||
|
|
ab0c86079c | ||
|
|
7cc4b178da | ||
|
|
8e21b7b791 | ||
|
|
70c180de5c | ||
|
|
3ab4ff1970 | ||
|
|
b80b736bec | ||
|
|
902a7f2e40 | ||
|
|
fd66568e9c | ||
|
|
cb50517168 | ||
|
|
461d582bf0 | ||
|
|
df403be1a6 | ||
|
|
ddec7f7583 | ||
|
|
f675c85e97 | ||
|
|
935e31e1f7 | ||
|
|
7a2312ed3b | ||
|
|
22408ff4ca | ||
|
|
a293e4ea36 | ||
|
|
d1d363f02c | ||
|
|
181937aa79 | ||
|
|
b05a9e64e7 | ||
|
|
052b9caa4c | ||
|
|
b8cd038b53 | ||
|
|
807a78d729 | ||
|
|
3ba3706e7b | ||
|
|
f8fbeca3b0 | ||
|
|
ecfdc422ff | ||
|
|
b2d4015559 | ||
|
|
38b3f872ec | ||
|
|
30342d1ff1 | ||
|
|
5772ce0bd2 | ||
|
|
8b615e7bdd | ||
|
|
74a6828e65 | ||
|
|
6f885c9e69 | ||
|
|
606f914786 | ||
|
|
0a37307b9e | ||
|
|
a45cf4aa3d | ||
|
|
627f937126 | ||
|
|
87a51de824 | ||
|
|
cf7aa53974 | ||
|
|
1dd7dcbb8b | ||
|
|
1c7bc0a70c | ||
|
|
2eef5e64ea | ||
|
|
44030e6a70 | ||
|
|
b4e3680c15 | ||
|
|
f5fed728d7 | ||
|
|
7e1c1293d2 | ||
|
|
2ba9dcc4d1 | ||
|
|
c4b2e5ede1 | ||
|
|
9b9481466b | ||
|
|
e887319d03 | ||
|
|
19cd359980 | ||
|
|
6d6f800b71 | ||
|
|
89e289bebf | ||
|
|
887da616a3 | ||
|
|
6f7111af77 | ||
|
|
fb184b23d8 | ||
|
|
3da99c9c5d | ||
|
|
4a5250bbd0 | ||
|
|
a01dad0467 | ||
|
|
3e0f205e21 | ||
|
|
7e89d1549c | ||
|
|
7fe48606d9 | ||
|
|
c9dba69584 | ||
|
|
04e960542d | ||
|
|
37d68a2c26 | ||
|
|
4f75d03f98 | ||
|
|
c56f0ad6e8 | ||
|
|
e7685a3442 | ||
|
|
0915a43ae3 | ||
|
|
40bc655224 | ||
|
|
c4c3649a69 | ||
|
|
982d81f613 | ||
|
|
cd01bd00fc | ||
|
|
133a0a3d1b | ||
|
|
542c2a667c | ||
|
|
eaa9da2d81 | ||
|
|
7c7c52640c | ||
|
|
7106593349 | ||
|
|
284c316fde | ||
|
|
f4a049d571 | ||
|
|
c7d3d09345 | ||
|
|
f43e8eac30 | ||
|
|
daa6405784 | ||
|
|
63d1572d40 | ||
|
|
6d3d1b4449 | ||
|
|
5198edc051 | ||
|
|
776121bf27 | ||
|
|
321bd8734d | ||
|
|
f5c3fc2033 | ||
|
|
eb925afda2 | ||
|
|
66dccf2111 | ||
|
|
fe976b19f5 | ||
|
|
c306bf9986 | ||
|
|
c9c71965d2 | ||
|
|
7e3832cb72 | ||
|
|
a71d83f1ea | ||
|
|
d14c004124 | ||
|
|
87881bb3f8 | ||
|
|
f529019f71 | ||
|
|
ad230f0072 | ||
|
|
57c15073bd | ||
|
|
3d4a170acd | ||
|
|
70954c5ef1 | ||
|
|
bc1ceb11f5 | ||
|
|
5c5ead97f2 | ||
|
|
5a451e4b29 | ||
|
|
74b7668ad7 | ||
|
|
fd820654f6 | ||
|
|
91bc6d2f75 | ||
|
|
c8f2b9864a | ||
|
|
845ae136e2 | ||
|
|
8bad7e3c5f | ||
|
|
5d916a47e0 | ||
|
|
ce6443d6c2 | ||
|
|
a4f270e960 | ||
|
|
25c19e98d9 | ||
|
|
be1d0283f7 | ||
|
|
6ea9de0ba9 | ||
|
|
9f9b233262 | ||
|
|
befc96d445 | ||
|
|
9de16d960e | ||
|
|
ac3fed0b90 | ||
|
|
8856a3e63f | ||
|
|
8348c97336 | ||
|
|
a1e7b5c2af | ||
|
|
93d27fd090 | ||
|
|
ae1d58e2e2 | ||
|
|
73d7448920 | ||
|
|
35e8f4aeb5 | ||
|
|
9e8e5f8b8e | ||
|
|
f5ee1d71a0 | ||
|
|
a1ac0e892c | ||
|
|
5554d29db7 | ||
|
|
ba9f3be82b | ||
|
|
c4618bd859 | ||
|
|
47c68db395 | ||
|
|
14d9a9d184 | ||
|
|
ad3e74f433 | ||
|
|
5869473dc3 | ||
|
|
005da3bfc0 | ||
|
|
c3042c8a53 | ||
|
|
595df6e4fc | ||
|
|
80a9f9171d | ||
|
|
a85df5a2fe | ||
|
|
3d39143851 | ||
|
|
578258775e | ||
|
|
9cbe85f2e6 | ||
|
|
035ca4106d | ||
|
|
ace66d9276 | ||
|
|
2e856ecf6d | ||
|
|
bb5a2a6c4b | ||
|
|
e3652a0541 | ||
|
|
9ac9c4014e | ||
|
|
a059c5e359 | ||
|
|
d1cc90f991 | ||
|
|
bbf74df187 | ||
|
|
9f56655cba | ||
|
|
32cf26edb9 | ||
|
|
38219de4a8 | ||
|
|
74de22592f | ||
|
|
49d563823e | ||
|
|
55c26f453a | ||
|
|
39dcc60cf3 | ||
|
|
74b77e746c | ||
|
|
b1562cf30e | ||
|
|
f46921dbc1 | ||
|
|
2873917a67 | ||
|
|
45e30ed8cb | ||
|
|
343901eed2 | ||
|
|
39987341ef | ||
|
|
e968912c0a | ||
|
|
a8946ceaa2 | ||
|
|
a38f8a7727 | ||
|
|
937c81d269 | ||
|
|
f741019d47 | ||
|
|
f3a66be5db | ||
|
|
1b3d42a5bf | ||
|
|
6920c31b59 | ||
|
|
bff56270f7 | ||
|
|
16a2e9797c | ||
|
|
3580dcc2c5 | ||
|
|
77cbf0bbe7 | ||
|
|
be604a74cc | ||
|
|
d3645e9a09 | ||
|
|
f430f7b35f | ||
|
|
5c8ad36c96 | ||
|
|
9cf089add3 | ||
|
|
2f42e28822 | ||
|
|
0dfecf5d38 | ||
|
|
0cf207ff69 | ||
|
|
34f5d18646 | ||
|
|
894f76f9b2 | ||
|
|
cf2f010c11 | ||
|
|
9dec94077c | ||
|
|
46fdc874ff | ||
|
|
07cfeb8825 | ||
|
|
748d15a7e8 | ||
|
|
44a41c983d | ||
|
|
5991581624 | ||
|
|
6462d5711f | ||
|
|
da00d620c8 | ||
|
|
5e2913b8f2 | ||
|
|
35efd98a8d | ||
|
|
a74d094a92 | ||
|
|
8501e1ab49 | ||
|
|
1b35fd6042 | ||
|
|
f199a3ec4a | ||
|
|
8b445c0b1c | ||
|
|
31cb21dc80 | ||
|
|
9141dac9ff | ||
|
|
641c8d3e8f | ||
|
|
48ef13f3f9 | ||
|
|
f002c11263 | ||
|
|
ed98cf4072 | ||
|
|
06bbffa56b | ||
|
|
b9dd6e2176 | ||
|
|
84941d8079 | ||
|
|
7c71652b97 | ||
|
|
68d189aee2 | ||
|
|
de62123e4d | ||
|
|
ffbfcf7ede | ||
|
|
af78281011 | ||
|
|
ea0411257d | ||
|
|
bcd4e91a26 | ||
|
|
03ccc1860d | ||
|
|
1a7ff3c75c | ||
|
|
657355d2b0 | ||
|
|
2b444e9b43 | ||
|
|
c603b71d40 | ||
|
|
ac33c605cc | ||
|
|
df13f8aa6d | ||
|
|
590b653d8d | ||
|
|
f126a99773 | ||
|
|
d44e59b737 | ||
|
|
dee8f41d99 | ||
|
|
3242949658 | ||
|
|
b1375ef40c | ||
|
|
0f3ef7d6e7 | ||
|
|
0ed2a3f6f4 | ||
|
|
1404b0e87e | ||
|
|
738bcde966 | ||
|
|
93f04f1edd | ||
|
|
a47f3b240d | ||
|
|
e90dea78a8 | ||
|
|
03b1d06980 | ||
|
|
78638ba4bb | ||
|
|
c4fcafcf8e | ||
|
|
e4c1182789 | ||
|
|
1cba4300a8 | ||
|
|
93084f6073 | ||
|
|
f4d53265da | ||
|
|
c77e69b27b | ||
|
|
b9fd6d96cc | ||
|
|
1fd4e90463 | ||
|
|
ae62e30ae7 | ||
|
|
7b11b3f782 | ||
|
|
0531beaf52 | ||
|
|
355c1354e9 | ||
|
|
064ac94744 | ||
|
|
b5f9cb6151 | ||
|
|
5e03331d19 | ||
|
|
75b6ebc524 | ||
|
|
f3a35fb09b | ||
|
|
7634b15b81 | ||
|
|
5122e14c6b | ||
|
|
2efa068f0b | ||
|
|
0a59b1319d | ||
|
|
222e6f5c60 | ||
|
|
cdd8bc862b | ||
|
|
762ad43b26 | ||
|
|
87c1417dab | ||
|
|
71c473a539 | ||
|
|
5fa93a09d6 | ||
|
|
e5b9d3c66b | ||
|
|
4c12cc9da1 | ||
|
|
0df70f2f9a | ||
|
|
ccbfcd3337 | ||
|
|
a564c7dd82 | ||
|
|
79c2c69ef1 | ||
|
|
c76863ec8a | ||
|
|
297d95b94c | ||
|
|
751eabc9c4 | ||
|
|
89d868733a | ||
|
|
1ea0f55fd6 | ||
|
|
7f2ab82410 | ||
|
|
2fc6ef9cd0 | ||
|
|
e90fb1feba | ||
|
|
7398020b1f | ||
|
|
3ce0abff1a | ||
|
|
71f9d68616 | ||
|
|
eae814770c | ||
|
|
9660aab819 | ||
|
|
a1f602765e | ||
|
|
243094a9e2 | ||
|
|
aa63357a88 | ||
|
|
7aff176ead | ||
|
|
c7ac8c0b58 | ||
|
|
ac29cbccc1 | ||
|
|
ab4ff72e05 | ||
|
|
1fd3e8a536 | ||
|
|
b57eb93646 | ||
|
|
188dbfbbbd | ||
|
|
279a3a00bb | ||
|
|
51ae46319a | ||
|
|
d9ef964c42 | ||
|
|
d47eeda8f9 | ||
|
|
5eaba4ce10 | ||
|
|
ff4a7f7e50 | ||
|
|
8a6472b4b0 | ||
|
|
9091d44ad2 | ||
|
|
5f2a996550 | ||
|
|
d096e788aa | ||
|
|
67a8225f3b | ||
|
|
3932238405 | ||
|
|
8dd47022bc | ||
|
|
429082e106 | ||
|
|
97c9ef2bad | ||
|
|
14b88e5193 | ||
|
|
55d0eebf38 | ||
|
|
32fe56d9b5 | ||
|
|
fb2e814383 | ||
|
|
6d4d2d662a | ||
|
|
3c1d353e33 | ||
|
|
85d0bd8c75 | ||
|
|
ba37ac552c | ||
|
|
ea4b3fd235 | ||
|
|
233a68e820 | ||
|
|
6b4d308045 | ||
|
|
c7befdc0e0 | ||
|
|
06e70c8ea5 | ||
|
|
d7dedeb427 | ||
|
|
6e53296c56 | ||
|
|
5bd5cbcc3e | ||
|
|
e3647f0c03 | ||
|
|
8ed427971d | ||
|
|
1cbf3a9114 | ||
|
|
37b3dd4008 | ||
|
|
4712707798 | ||
|
|
041d699c13 | ||
|
|
090d549a17 | ||
|
|
ce00659782 | ||
|
|
fc35ea8283 | ||
|
|
7b3803a4a6 | ||
|
|
68ce3a2d38 | ||
|
|
b9910b87a0 | ||
|
|
6c67c766ce | ||
|
|
4b2ccbf421 | ||
|
|
05c5d5a23d | ||
|
|
39daf6e335 | ||
|
|
b4cce6da21 | ||
|
|
458d49e8e4 | ||
|
|
36dd1f902e | ||
|
|
6d88c9416d | ||
|
|
ae3f999856 | ||
|
|
eac2c3db00 | ||
|
|
e60a8bac79 | ||
|
|
30a5337315 | ||
|
|
8382859716 | ||
|
|
4e004384e0 | ||
|
|
79074b7ee9 | ||
|
|
ab1415b62d | ||
|
|
8359e618ed | ||
|
|
86c3de42cf | ||
|
|
44413914a2 | ||
|
|
84d2aff5fb | ||
|
|
4354045ce1 | ||
|
|
fa305ad2e7 | ||
|
|
81d30ae3c8 | ||
|
|
b460cae176 | ||
|
|
ab3b585601 | ||
|
|
8061edd972 | ||
|
|
88b853cf7b | ||
|
|
b8b85fb402 | ||
|
|
a074ac6382 | ||
|
|
1a8e1f25ae | ||
|
|
26bde4dcbd | ||
|
|
f97c5946b7 | ||
|
|
3fb6b22133 | ||
|
|
8ea2dc7075 | ||
|
|
393ac2a110 | ||
|
|
ce908ef258 | ||
|
|
bd549a1a02 | ||
|
|
251d1a3c33 | ||
|
|
fb5c0da417 | ||
|
|
700003d25c | ||
|
|
5f4fbb1639 | ||
|
|
4c0a838b34 | ||
|
|
281e503a18 | ||
|
|
091df1fddc | ||
|
|
0826b75e9b | ||
|
|
79fae8a163 | ||
|
|
521861192b | ||
|
|
c94710b5f4 | ||
|
|
ccc4053def | ||
|
|
cb72a1ce2d | ||
|
|
ca23a63de1 | ||
|
|
b6288593c2 | ||
|
|
817f220aaa | ||
|
|
06502bc9ad | ||
|
|
3e74cc4d1a | ||
|
|
ffa248a523 | ||
|
|
7a4a814a3d | ||
|
|
5948160245 | ||
|
|
2656a8feca | ||
|
|
1d87ef5a86 | ||
|
|
ba8abd1357 | ||
|
|
4fabaea49b | ||
|
|
64b684e187 | ||
|
|
3cbf0d1faa | ||
|
|
e088d2cbbe | ||
|
|
75ba474c7d | ||
|
|
93ff68940d | ||
|
|
0d676cfd48 | ||
|
|
c0026f1811 | ||
|
|
112ce219fb | ||
|
|
937a5a1ee1 | ||
|
|
72edfa235e | ||
|
|
58ba60e14e | ||
|
|
5782a24b97 | ||
|
|
b03998ae37 | ||
|
|
18bf52fc94 | ||
|
|
026ec61336 | ||
|
|
c01cd303b2 | ||
|
|
be1009ea34 | ||
|
|
eaa1af3e56 | ||
|
|
f4833592b3 | ||
|
|
577636d728 | ||
|
|
a827663a5b | ||
|
|
07ca2b6871 | ||
|
|
5f431f4fcd | ||
|
|
da2d32c5f8 | ||
|
|
9ab59b4953 | ||
|
|
9e5dace9d3 | ||
|
|
544245826c | ||
|
|
d275f33bd5 | ||
|
|
9df20de599 | ||
|
|
4184e9833b | ||
|
|
92405fb43a | ||
|
|
e6232d218f | ||
|
|
8044db357f | ||
|
|
219ff4f299 | ||
|
|
2646058c9b | ||
|
|
5ed4298fb3 | ||
|
|
66c359839a | ||
|
|
48dc4444ae | ||
|
|
903612ab64 | ||
|
|
a4d7a8e3d9 | ||
|
|
1edf373908 | ||
|
|
e36f9bcb89 | ||
|
|
2aec8684a0 | ||
|
|
b32d6f48ca | ||
|
|
a69a86775b | ||
|
|
a116a0567e | ||
|
|
0e16019ead | ||
|
|
f56e36d828 | ||
|
|
7b18277681 | ||
|
|
e2990c76df | ||
|
|
7b8ff148af | ||
|
|
bfc66fb505 | ||
|
|
ecba8fb765 | ||
|
|
4bb06ec498 | ||
|
|
04f2a05a95 | ||
|
|
783a709a94 | ||
|
|
c7240c46a7 | ||
|
|
ec2f8ca948 | ||
|
|
5d489d45e8 | ||
|
|
bf3f207175 | ||
|
|
3c65961276 | ||
|
|
f31c30fece | ||
|
|
124bb53ea9 | ||
|
|
fe7fcc9091 | ||
|
|
adf128510b | ||
|
|
dcbf2dde4c | ||
|
|
32a5c3848a | ||
|
|
e1509529bf | ||
|
|
73b434f25b | ||
|
|
792976b76f | ||
|
|
8aaf6d9a84 | ||
|
|
30a4478c10 | ||
|
|
5d07ee772e | ||
|
|
fa9ef924a2 | ||
|
|
f066d1c87e | ||
|
|
05f2113302 | ||
|
|
3597ff0547 | ||
|
|
dd90fd0255 | ||
|
|
cb04dd3028 | ||
|
|
04505f86eb | ||
|
|
f0101337bb | ||
|
|
797777c813 | ||
|
|
c79b89173d | ||
|
|
2a2228e496 | ||
|
|
157fddee51 | ||
|
|
5ea6857491 | ||
|
|
59eb39e39a | ||
|
|
a2b0002d3f | ||
|
|
0308347fa7 | ||
|
|
cf6875e633 | ||
|
|
9cf1c116ff | ||
|
|
3030a4973e | ||
|
|
8c59fbbe92 | ||
|
|
443791ef52 | ||
|
|
9ee71023c2 | ||
|
|
731cfb6ff5 | ||
|
|
9e7f9915a0 | ||
|
|
ef20dc5f2f | ||
|
|
f58a38b522 | ||
|
|
05a13da12c | ||
|
|
e0cfcc3151 | ||
|
|
ac61833b62 | ||
|
|
1683b809c1 | ||
|
|
78d012ece4 | ||
|
|
67d008d00e | ||
|
|
adeafcee18 | ||
|
|
d9099828a4 | ||
|
|
0b66e2cd01 | ||
|
|
beabbe9219 | ||
|
|
62a27e1be5 | ||
|
|
cc31cddf54 | ||
|
|
d6fe20c350 | ||
|
|
a483a2cbc5 | ||
|
|
7fff122060 | ||
|
|
6b9185c6ec | ||
|
|
473188bd1f | ||
|
|
b734ccfa3c | ||
|
|
b8f1843909 | ||
|
|
4048b087c3 | ||
|
|
2a7e41b27b | ||
|
|
2f5f5307ef | ||
|
|
2439e2450a | ||
|
|
cec3fbae45 | ||
|
|
7881649f7e | ||
|
|
355a411e2a | ||
|
|
883f4cbf25 | ||
|
|
eaf86695b3 | ||
|
|
0b3465e9a3 | ||
|
|
2478bd2db4 | ||
|
|
c949857684 | ||
|
|
37218ccd2b | ||
|
|
298fcebd96 | ||
|
|
b62ab78f03 | ||
|
|
e46cb79e93 | ||
|
|
7f45dc815f | ||
|
|
f5556b500e | ||
|
|
19821c958d | ||
|
|
c94964e3a0 | ||
|
|
e6ecffc7fb | ||
|
|
fc5c22a238 | ||
|
|
a4c1d64a33 | ||
|
|
9d96e542de | ||
|
|
0ebc68745f | ||
|
|
dd42bb9e4c | ||
|
|
59ab73f417 | ||
|
|
cb2ec869ac | ||
|
|
1ca4396825 | ||
|
|
98b2385585 | ||
|
|
0a1adb9290 | ||
|
|
21662d3ee8 | ||
|
|
a33ec61daa | ||
|
|
1eb4a2a837 | ||
|
|
1e7c7caba5 | ||
|
|
d97ce8e7c1 | ||
|
|
8ef5d37f84 | ||
|
|
2858ced19f | ||
|
|
19e4a47ba5 | ||
|
|
4ab1f899c8 | ||
|
|
3159b1840b | ||
|
|
d44507dd58 | ||
|
|
1988f443dd | ||
|
|
a4e811a063 | ||
|
|
1e8b669bdc | ||
|
|
76412b9e76 | ||
|
|
515acdb6b7 | ||
|
|
64598efd21 | ||
|
|
e819d5718b | ||
|
|
51cf923f7e | ||
|
|
bf2628fd09 | ||
|
|
bc6ddea004 | ||
|
|
cf6f086114 | ||
|
|
85f262ad3b | ||
|
|
07642fd3ac | ||
|
|
e9eb6a5a6e | ||
|
|
058cf763b4 | ||
|
|
e3439e2019 | ||
|
|
7e5a7eff15 | ||
|
|
5d4b2081b5 | ||
|
|
b60e95ac50 | ||
|
|
9fbf3ab3f5 | ||
|
|
0a4ef8b44c | ||
|
|
9d27524aae | ||
|
|
37ee88c43a | ||
|
|
8c40322f6d | ||
|
|
9621d02c3b | ||
|
|
db9524334d | ||
|
|
023d1c1346 | ||
|
|
02257c6145 | ||
|
|
63a085603d | ||
|
|
6c1acbb51d | ||
|
|
3962e794a3 | ||
|
|
e93debe38a | ||
|
|
2aa74b8be8 | ||
|
|
641329157f | ||
|
|
069e616b40 | ||
|
|
b72867c4ef | ||
|
|
e7029418b2 | ||
|
|
0094f36bb9 | ||
|
|
6a96f5701a | ||
|
|
4d335bccae | ||
|
|
018a5dccf1 | ||
|
|
3cb4554fe8 | ||
|
|
d1d6900c6d | ||
|
|
bd46b791e9 | ||
|
|
3c781401ad | ||
|
|
86581bd139 | ||
|
|
c5fda5eb9a | ||
|
|
390434673e | ||
|
|
f27fdcbdb0 | ||
|
|
303141be85 | ||
|
|
580bc23dcc | ||
|
|
9e5f601c61 | ||
|
|
d22b8d1cdb | ||
|
|
01c5513c41 | ||
|
|
4dde1e9b54 | ||
|
|
5efffc9184 | ||
|
|
301aae5cd7 | ||
|
|
bd3f2929c0 | ||
|
|
6a540d945c | ||
|
|
68a780bb3c | ||
|
|
bd654bf5be | ||
|
|
aa5996ff28 | ||
|
|
4404474a99 | ||
|
|
edad8bd695 | ||
|
|
f1fcb4763c | ||
|
|
c996011b0c | ||
|
|
b7659b414e | ||
|
|
0c22351b0e | ||
|
|
a3c068ab46 | ||
|
|
34e60c7613 | ||
|
|
ae048ac2dc | ||
|
|
b97bc433ff | ||
|
|
82b69dceb8 | ||
|
|
2ae9055e8d | ||
|
|
b1ec36802c | ||
|
|
98b8eb02d2 | ||
|
|
9a6c1eb13f | ||
|
|
10d44e6e2a | ||
|
|
7c3bf80220 | ||
|
|
279f14f3fc | ||
|
|
9d50d2beb6 | ||
|
|
e9b481bbf6 | ||
|
|
2d2a4da093 | ||
|
|
aaebe74428 | ||
|
|
ac68783d81 | ||
|
|
11169b5c6a | ||
|
|
a190b16ced | ||
|
|
8be6591675 | ||
|
|
e9982ad288 | ||
|
|
ef68275a6d | ||
|
|
92aeda817d | ||
|
|
67a08ebadb | ||
|
|
e68c5861ac | ||
|
|
0109afd7fc | ||
|
|
3bdd36b718 | ||
|
|
0a88da285b | ||
|
|
3bab9e07d4 | ||
|
|
77b22b4e22 | ||
|
|
30f28516d7 | ||
|
|
ecea9a3d8c | ||
|
|
02565857e8 | ||
|
|
32f1e0e3ac | ||
|
|
ddfe936ebe | ||
|
|
2810c181ea | ||
|
|
911ebfa7fb | ||
|
|
8675ae253b | ||
|
|
20c0ef5341 | ||
|
|
ef1f870335 | ||
|
|
74dfd528cc | ||
|
|
624f279b6c | ||
|
|
48e0fb965d | ||
|
|
22ccabe92e | ||
|
|
bd3eea8a24 | ||
|
|
cb3ea96414 | ||
|
|
cf2f6e8902 | ||
|
|
c11eb54ff3 | ||
|
|
b25cc2cb97 | ||
|
|
9693b72e87 | ||
|
|
b7f733f828 | ||
|
|
8f5c762f9b | ||
|
|
170f7ac81b | ||
|
|
bb70e68f82 | ||
|
|
fbeb08967c | ||
|
|
69df840dd1 | ||
|
|
ea70737204 | ||
|
|
6950c25a89 | ||
|
|
45823c5f88 | ||
|
|
4d4b9a76ce | ||
|
|
e08890f356 | ||
|
|
f63647a799 | ||
|
|
d49ce1011b | ||
|
|
10f1f19a55 | ||
|
|
eb45f7506e | ||
|
|
72ddb522b4 | ||
|
|
1a45a6d112 | ||
|
|
a227ce9cd5 | ||
|
|
871ba88159 | ||
|
|
6588d77fa0 | ||
|
|
edbbbddf96 | ||
|
|
deddd60a50 | ||
|
|
8d5352fdf9 | ||
|
|
b19e8edd45 | ||
|
|
9b3f19377d | ||
|
|
035f50f0b3 | ||
|
|
8917f5fcd5 | ||
|
|
7f8ae918e3 | ||
|
|
a43dafe15d | ||
|
|
0f883cb654 | ||
|
|
d2c55f660f | ||
|
|
93efa868b9 | ||
|
|
0c15c69e8f | ||
|
|
40326fcd4f | ||
|
|
d0c99db71b | ||
|
|
333c28efe1 | ||
|
|
062c82ef82 | ||
|
|
732f2e5375 | ||
|
|
db1246e1bd | ||
|
|
fb614861e0 | ||
|
|
d887a39c28 | ||
|
|
f0e5fd6037 | ||
|
|
343c56a64c | ||
|
|
386044015b | ||
|
|
62edb0ccab | ||
|
|
8554efb754 |
@@ -22,8 +22,6 @@ Use when:
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
|
||||
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
|
||||
- Keep going until structured review returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
|
||||
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
|
||||
|
||||
6
.github/workflows/crabbox-hydrate.yml
vendored
6
.github/workflows/crabbox-hydrate.yml
vendored
@@ -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:
|
||||
|
||||
47
.github/workflows/full-release-validation.yml
vendored
47
.github/workflows/full-release-validation.yml
vendored
@@ -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]
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -45,19 +45,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
|
||||
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
|
||||
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
|
||||
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
|
||||
- Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.
|
||||
- Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.
|
||||
- Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.
|
||||
- Release/CI/E2E: normalize inherited Linux `C.UTF-8` locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.
|
||||
- Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.
|
||||
- Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.
|
||||
- Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as `null` or arrays.
|
||||
- Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.
|
||||
- Talk: preserve explicit `null` payloads on controller-created turn and output-audio lifecycle events.
|
||||
- Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.
|
||||
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
|
||||
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
|
||||
@@ -77,7 +64,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
|
||||
- Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.
|
||||
- Memory: serialize QMD update/embed writes per store, preserve phase signals on read errors, harden envelope metadata sanitization, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931) Thanks @openperf and @amittell.
|
||||
- Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512)
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -112,7 +112,6 @@ enum class OpenClawDeviceCommand(
|
||||
Info("device.info"),
|
||||
Permissions("device.permissions"),
|
||||
Health("device.health"),
|
||||
Apps("device.apps"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -714,7 +714,6 @@ private fun PhoneCapabilitiesScreen(
|
||||
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
||||
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
val installedAppsSharingEnabled by viewModel.installedAppsSharingEnabled.collectAsState()
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
viewModel.setCameraEnabled(granted)
|
||||
@@ -769,13 +768,6 @@ private fun PhoneCapabilitiesScreen(
|
||||
listOf(
|
||||
SettingsToggleRow("Camera", "Allow camera tools when requested.", Icons.Default.CameraAlt, cameraEnabled, ::setCameraAccess),
|
||||
SettingsToggleRow("Precise Location", "Share precise location while location is enabled.", Icons.Default.LocationOn, locationPreciseEnabled, ::setPreciseLocation),
|
||||
SettingsToggleRow(
|
||||
"Installed Apps",
|
||||
if (installedAppsSharingEnabled) "OpenClaw can list launcher-visible apps." else "App list stays on this phone.",
|
||||
Icons.Default.Storage,
|
||||
installedAppsSharingEnabled,
|
||||
viewModel::setInstalledAppsSharingEnabled,
|
||||
),
|
||||
SettingsToggleRow("Keep Awake", "Keep the node available during active work.", Icons.Default.Bolt, preventSleep, viewModel::setPreventSleep),
|
||||
SettingsToggleRow("Canvas Status", "Show screen-sharing debug state.", Icons.AutoMirrored.Filled.ScreenShare, canvasDebugStatusEnabled, viewModel::setCanvasDebugStatusEnabled),
|
||||
),
|
||||
@@ -1253,7 +1245,6 @@ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies query/system visibility rules while always preserving selected packages. */
|
||||
internal fun filterNotificationAppsForPicker(
|
||||
apps: List<InstalledApp>,
|
||||
selectedPackages: Set<String>,
|
||||
@@ -1272,7 +1263,6 @@ internal fun filterNotificationAppsForPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/** Summarizes allowlist/blocklist mode with an empty-state warning when needed. */
|
||||
private fun notificationPackageSelectionSummary(
|
||||
mode: NotificationPackageFilterMode,
|
||||
selectedCount: Int,
|
||||
@@ -1292,7 +1282,6 @@ private fun notificationPackageSelectionSummary(
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds compact two-letter app badges from package-picker labels. */
|
||||
private fun notificationAppBadge(label: String): String {
|
||||
val initials =
|
||||
label
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -32,8 +32,9 @@ This is for cooperative/shared inbox hardening. A single Gateway shared by mutua
|
||||
It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example open DM/group policy, configured group targets, or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default.
|
||||
For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime.
|
||||
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||
For webhook ingress, startup logs a non-fatal security warning and audit flags `hooks.token` reuse of active Gateway shared-secret auth values, including `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` and `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`. It also warns when:
|
||||
For webhook ingress, it warns when:
|
||||
|
||||
- `hooks.token` reuses an active Gateway shared-secret auth value (`gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` or `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`)
|
||||
- `hooks.token` is short
|
||||
- `hooks.path="/"`
|
||||
- `hooks.defaultSessionKey` is unset
|
||||
@@ -42,7 +43,7 @@ For webhook ingress, startup logs a non-fatal security warning and audit flags `
|
||||
- overrides are enabled without `hooks.allowedSessionKeyPrefixes`
|
||||
|
||||
If Gateway password auth is supplied only at startup, pass the same value to `openclaw security audit --auth password --password <password>` so the audit can check it against `hooks.token`.
|
||||
Run `openclaw doctor --fix` to rotate a persisted reused `hooks.token`, then update external hook senders to use the new hook token.
|
||||
Password-mode reuse is an audit finding for compatibility; rotate one of the secrets instead of expecting Gateway startup to reject that configuration.
|
||||
|
||||
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
|
||||
It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -730,8 +730,8 @@ Query-string hook tokens are rejected.
|
||||
Validation and safety notes:
|
||||
|
||||
- `hooks.enabled=true` requires a non-empty `hooks.token`.
|
||||
- `hooks.token` should be distinct from active Gateway shared-secret auth (`gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` or `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`); startup logs a non-fatal security warning when it detects reuse.
|
||||
- `openclaw security audit` flags hook/Gateway auth reuse as a critical finding, including Gateway password auth supplied only at audit time (`--auth password --password <password>`). Run `openclaw doctor --fix` to rotate a persisted reused `hooks.token`, then update external hook senders to use the new hook token.
|
||||
- `hooks.token` must be distinct from `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`; reusing the Gateway token fails startup validation.
|
||||
- `openclaw security audit` also flags `hooks.token` reuse of active Gateway password auth (`gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`, or `--auth password --password <password>`) as a critical finding; password-mode reuse stays startup-compatible and should be repaired by rotating one of the secrets.
|
||||
- `hooks.path` cannot be `/`; use a dedicated subpath such as `/hooks`.
|
||||
- If `hooks.allowRequestSessionKey=true`, constrain `hooks.allowedSessionKeyPrefixes` (for example `["hook:"]`).
|
||||
- If a mapping or preset uses a templated `sessionKey`, set `hooks.allowedSessionKeyPrefixes` and `hooks.allowRequestSessionKey=true`. Static mapping keys do not require that opt-in.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -120,7 +120,6 @@ observation-only.
|
||||
|
||||
- **`before_tool_call`** - rewrite tool params, block execution, or require approval
|
||||
- `after_tool_call` - observe tool results, errors, and duration
|
||||
- `resolve_exec_env` - contribute plugin-owned environment variables to `exec`
|
||||
- **`tool_result_persist`** - rewrite the assistant message produced from a tool result
|
||||
- **`before_message_write`** - inspect or block an in-progress message write (rare)
|
||||
|
||||
@@ -234,28 +233,6 @@ for host-trusted gates such as workspace policy, budget enforcement, or
|
||||
reserved workflow safety. External plugins should use normal `before_tool_call`
|
||||
hooks.
|
||||
|
||||
### Exec environment hook
|
||||
|
||||
`resolve_exec_env` lets plugins contribute environment variables to `exec`
|
||||
tool invocations after the base exec environment is built and before the
|
||||
command runs. It receives:
|
||||
|
||||
- `event.sessionKey`
|
||||
- `event.toolName`, currently always `"exec"`
|
||||
- `event.host`, one of `"gateway"`, `"sandbox"`, or `"node"`
|
||||
- context fields such as `ctx.agentId`, `ctx.sessionKey`,
|
||||
`ctx.messageProvider`, and `ctx.channelId`
|
||||
|
||||
Return a `Record<string, string>` to merge into the exec environment. Handlers
|
||||
run in priority order, and later hook results override earlier hook results for
|
||||
the same key.
|
||||
|
||||
Hook output is filtered through the host exec environment key policy before it
|
||||
is merged. Invalid keys, `PATH`, and dangerous host override keys such as
|
||||
`LD_*`, `DYLD_*`, `NODE_OPTIONS`, proxy variables, and TLS override variables
|
||||
are dropped. The filtered plugin env is included in gateway approval/audit
|
||||
metadata and forwarded to node-host execution requests.
|
||||
|
||||
### Tool result persistence
|
||||
|
||||
Tool results can include structured `details` for UI rendering, diagnostics,
|
||||
|
||||
@@ -353,7 +353,7 @@ API key auth, and dynamic model resolution.
|
||||
| --- | --- | --- |
|
||||
| `openai-compatible` | Shared OpenAI-style replay policy for OpenAI-compatible transports, including tool-call-id sanitation, assistant-first ordering fixes, and generic Gemini-turn validation where the transport needs it | `moonshot`, `ollama`, `xai`, `zai` |
|
||||
| `anthropic-by-model` | Claude-aware replay policy chosen by `modelId`, so Anthropic-message transports only get Claude-specific thinking-block cleanup when the resolved model is actually a Claude id | `amazon-bedrock`, `anthropic-vertex` |
|
||||
| `google-gemini` | Native Gemini replay policy plus bootstrap replay sanitation. The shared family keeps the text-output Gemini CLI on tagged reasoning; the direct `google` provider overrides `resolveReasoningOutputMode` to `native` because Gemini API thinking arrives as native thought parts. | `google`, `google-gemini-cli` |
|
||||
| `google-gemini` | Native Gemini replay policy plus bootstrap replay sanitation and tagged reasoning-output mode | `google`, `google-gemini-cli` |
|
||||
| `passthrough-gemini` | Gemini thought-signature sanitation for Gemini models running through OpenAI-compatible proxy transports; does not enable native Gemini replay validation or bootstrap rewrites | `openrouter`, `kilocode`, `opencode`, `opencode-go` |
|
||||
| `hybrid-anthropic-openai` | Hybrid policy for providers that mix Anthropic-message and OpenAI-compatible model surfaces in one plugin; optional Claude-only thinking-block dropping stays scoped to the Anthropic side | `minimax` |
|
||||
|
||||
@@ -376,13 +376,6 @@ API key auth, and dynamic model resolution.
|
||||
- `openclaw/plugin-sdk/provider-stream` - `ProviderStreamFamily`, `buildProviderStreamFamilyHooks(...)`, `composeProviderStreamWrappers(...)`, plus the shared OpenAI/Codex wrappers (`createOpenAIAttributionHeadersWrapper`, `createOpenAIFastModeWrapper`, `createOpenAIServiceTierWrapper`, `createOpenAIResponsesContextManagementWrapper`, `createCodexNativeWebSearchWrapper`), DeepSeek V4 OpenAI-compatible wrapper (`createDeepSeekV4OpenAICompatibleThinkingWrapper`), Anthropic Messages thinking prefill cleanup (`createAnthropicThinkingPrefillPayloadWrapper`), plain-text tool-call compat (`createPlainTextToolCallCompatWrapper`), and shared proxy/provider wrappers (`createOpenRouterWrapper`, `createToolStreamWrapper`, `createMinimaxFastModeWrapper`).
|
||||
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai")`, and underlying provider schema helpers.
|
||||
|
||||
For Gemini-family providers, keep the reasoning-output mode aligned with
|
||||
the transport. Direct Google Gemini API providers should use `native`
|
||||
reasoning output so OpenClaw consumes native thought parts without adding
|
||||
`<think>` / `<final>` prompt directives. Text-only Gemini CLI-style
|
||||
backends that parse a final JSON/text response can keep the shared
|
||||
`google-gemini` tagged contract.
|
||||
|
||||
Some stream helpers stay provider-local on purpose. `@openclaw/anthropic-provider` keeps `wrapAnthropicProviderStream`, `resolveAnthropicBetas`, `resolveAnthropicFastMode`, `resolveAnthropicServiceTier`, and the lower-level Anthropic wrapper builders in its own public `api.ts` / `contract-api.ts` seam because they encode Claude OAuth beta handling and `context1m` gating. The xAI plugin similarly keeps native xAI Responses shaping in its own `wrapStreamFn` (`/fast` aliases, default `tool_stream`, unsupported strict-tool cleanup, xAI-specific reasoning-payload removal).
|
||||
|
||||
The same package-root pattern also backs `@openclaw/openai-provider` (provider builders, default-model helpers, realtime provider builders) and `@openclaw/openrouter-provider` (provider builder plus onboarding/config helpers).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -105,7 +105,7 @@ Per-agent heartbeat is supported at `agents.list[].heartbeat`.
|
||||
|
||||
- Prompt caching is automatic on supported recent models. OpenClaw does not need to inject block-level cache markers.
|
||||
- OpenClaw uses `prompt_cache_key` to keep cache routing stable across turns. Direct OpenAI hosts use `prompt_cache_retention: "24h"` when `cacheRetention: "long"` is selected.
|
||||
- OpenAI-compatible Completions providers receive `prompt_cache_key` only when their model config explicitly sets `compat.supportsPromptCacheKey: true`. Long-retention forwarding is a separate capability: explicit `cacheRetention: "long"` sends `prompt_cache_retention: "24h"` only when that compat entry also supports long cache retention. Providers such as Mistral can opt into cache keys while setting `compat.supportsLongCacheRetention: false` to suppress the long-retention field. `cacheRetention: "none"` suppresses both fields.
|
||||
- OpenAI-compatible Completions providers receive `prompt_cache_key` only when their model config explicitly sets `compat.supportsPromptCacheKey: true`; with that same opt-in, explicit `cacheRetention: "long"` also forwards `prompt_cache_retention: "24h"`, and `cacheRetention: "none"` suppresses both fields.
|
||||
- OpenAI responses expose cached prompt tokens via `usage.prompt_tokens_details.cached_tokens` (or `input_tokens_details.cached_tokens` on Responses API events). OpenClaw maps that to `cacheRead`.
|
||||
- OpenAI does not expose a separate cache-write token counter, so `cacheWrite` stays `0` on OpenAI paths even when the provider is warming a cache.
|
||||
- OpenAI returns useful tracing and rate-limit headers such as `x-request-id`, `openai-processing-ms`, and `x-ratelimit-*`, but cache-hit accounting should come from the usage payload, not from headers.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "claude-config-file",
|
||||
nativeToolMode: "always-on",
|
||||
ownsNativeCompaction: true,
|
||||
config: {
|
||||
command: "claude",
|
||||
args: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import type { CodexAppServerClient } from "./src/app-server/client.js";
|
||||
import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js";
|
||||
@@ -174,11 +174,6 @@ function createFakeClient(options?: {
|
||||
}
|
||||
|
||||
describe("codex media understanding provider", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("runs image understanding through a bounded Codex app-server turn", async () => {
|
||||
const { client, requests } = createFakeClient();
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
@@ -236,8 +231,9 @@ describe("codex media understanding provider", () => {
|
||||
});
|
||||
|
||||
it("clamps oversized image understanding turn timeouts", async () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const { client } = createFakeClient();
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
|
||||
@@ -54,34 +54,6 @@ describe("Codex app-server steering queue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("batches queued steering after a nonzero debounce while the turn is active", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi.fn(async () => ({ turnId: "turn-1" }));
|
||||
const queue = createCodexSteeringQueue({
|
||||
client: { request } as never,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
answerPendingUserInput: () => false,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
const firstQueued = queue.queue("first", { debounceMs: 5 });
|
||||
const secondQueued = queue.queue("second", { debounceMs: 5 });
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await Promise.all([firstQueued, secondQueued]);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("turn/steer", {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects queued steering when the run aborts before debounce flush", async () => {
|
||||
const controller = new AbortController();
|
||||
const request = vi.fn(async () => ({ turnId: "turn-1" }));
|
||||
|
||||
@@ -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[], {
|
||||
|
||||
@@ -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">,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -1281,7 +1281,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-fresh");
|
||||
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
|
||||
expect(savedBinding?.contextEngine?.projection).toBeUndefined();
|
||||
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-before");
|
||||
});
|
||||
|
||||
it("preserves a newer context-engine binding when a stale resumed thread overflows", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { abortAgentHarnessRun } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
@@ -17,52 +18,48 @@ import {
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
let steeringSessionIndex = 0;
|
||||
|
||||
function createSteeringParams() {
|
||||
const sessionId = `steering-session-${++steeringSessionIndex}`;
|
||||
function createSteeringParams(name: string) {
|
||||
const params = createParams(
|
||||
path.join(tempDir, `${sessionId}.jsonl`),
|
||||
path.join(tempDir, `${sessionId}-workspace`),
|
||||
path.join(tempDir, `${name}.jsonl`),
|
||||
path.join(tempDir, `${name}-workspace`),
|
||||
);
|
||||
params.sessionId = sessionId;
|
||||
params.sessionKey = `agent:main:${sessionId}`;
|
||||
params.runId = `run-${sessionId}`;
|
||||
params.sessionId = `session-${name}`;
|
||||
params.sessionKey = `agent:main:session-${name}`;
|
||||
return params;
|
||||
}
|
||||
|
||||
async function waitAndQueueActiveRunMessage(
|
||||
async function queueActiveRunMessageEventually(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
options?: Parameters<typeof queueActiveRunMessageForTest>[2],
|
||||
) {
|
||||
let queued = false;
|
||||
await vi.waitFor(() => {
|
||||
if (!queued) {
|
||||
queued = queueActiveRunMessageForTest(sessionId, text, options);
|
||||
}
|
||||
expect(queued).toBe(true);
|
||||
}, fastWait);
|
||||
await vi.waitFor(
|
||||
() => expect(queueActiveRunMessageForTest(sessionId, text, options)).toBe(true),
|
||||
fastWait,
|
||||
);
|
||||
}
|
||||
|
||||
describe("runCodexAppServerAttempt steering", () => {
|
||||
it("forwards queued user input to the active app-server turn", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams();
|
||||
it("forwards queued user input and aborts the active app-server turn", async () => {
|
||||
const { requests, waitForMethod } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-forward");
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: { appServer: { mode: "yolo" } },
|
||||
});
|
||||
const run = runCodexAppServerAttempt(params, { pluginConfig: { appServer: { mode: "yolo" } } });
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "more context", { debounceMs: 0 });
|
||||
await queueActiveRunMessageEventually(params.sessionId, "more context", { debounceMs: 1 });
|
||||
await vi.waitFor(
|
||||
() => expect(requests.map((entry) => entry.method)).toContain("turn/steer"),
|
||||
fastWait,
|
||||
);
|
||||
expect(abortAgentHarnessRun(params.sessionId)).toBe(true);
|
||||
await vi.waitFor(
|
||||
() => expect(requests.map((entry) => entry.method)).toContain("turn/interrupt"),
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(true);
|
||||
const threadStart = requests.find((entry) => entry.method === "thread/start");
|
||||
const threadStartParams = threadStart?.params as
|
||||
| {
|
||||
@@ -84,21 +81,27 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
expectedTurnId: "turn-1",
|
||||
input: [{ type: "text", text: "more context", text_elements: [] }],
|
||||
});
|
||||
const interrupt = requests.find((entry) => entry.method === "turn/interrupt");
|
||||
expect(interrupt?.params).toEqual({ threadId: "thread-1", turnId: "turn-1" });
|
||||
});
|
||||
|
||||
it("accepts message-tool-only steering for active Codex app-server source replies", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams();
|
||||
const params = createSteeringParams("steering-message-tool");
|
||||
params.sourceReplyDeliveryMode = "message_tool_only";
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "subagent complete", {
|
||||
debounceMs: 0,
|
||||
steeringMode: "all",
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
await queueActiveRunMessageEventually(
|
||||
params.sessionId,
|
||||
"subagent complete",
|
||||
{
|
||||
debounceMs: 1,
|
||||
steeringMode: "all",
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
},
|
||||
);
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
@@ -112,51 +115,53 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ interval: 1 },
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
});
|
||||
|
||||
it("flushes batched default queued steering during normal turn cleanup", async () => {
|
||||
it("batches default queued steering before sending turn/steer", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams();
|
||||
const params = createSteeringParams("steering-batch-default");
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "first", { debounceMs: 30_000 });
|
||||
expect(queueActiveRunMessageForTest(params.sessionId, "second", { debounceMs: 30_000 })).toBe(
|
||||
true,
|
||||
await queueActiveRunMessageEventually(params.sessionId, "first", { debounceMs: 5 });
|
||||
expect(queueActiveRunMessageForTest(params.sessionId, "second", { debounceMs: 5 })).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
|
||||
{
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
|
||||
{
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flushes pending default queued steering during normal turn cleanup", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams();
|
||||
const params = createSteeringParams("steering-flush");
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "late steer", { debounceMs: 30_000 });
|
||||
await queueActiveRunMessageEventually(params.sessionId, "late steer", { debounceMs: 30_000 });
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
@@ -173,40 +178,44 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("flushes batched explicit all-mode steering during normal turn cleanup", async () => {
|
||||
it("batches explicit all-mode steering before sending turn/steer", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams();
|
||||
const params = createSteeringParams("steering-batch-all");
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "first", {
|
||||
debounceMs: 30_000,
|
||||
await queueActiveRunMessageEventually(params.sessionId, "first", {
|
||||
debounceMs: 5,
|
||||
steeringMode: "all",
|
||||
});
|
||||
expect(
|
||||
queueActiveRunMessageForTest(params.sessionId, "second", {
|
||||
debounceMs: 30_000,
|
||||
debounceMs: 5,
|
||||
steeringMode: "all",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
|
||||
{
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
|
||||
{
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes request_user_input prompts through the active run follow-up queue", async () => {
|
||||
@@ -244,7 +253,7 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const params = createSteeringParams();
|
||||
const params = createSteeringParams("steering-request-input");
|
||||
params.onBlockReply = vi.fn();
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await vi.waitFor(
|
||||
@@ -277,7 +286,7 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1), fastWait);
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "2");
|
||||
await queueActiveRunMessageEventually(params.sessionId, "2");
|
||||
await expect(response).resolves.toEqual({
|
||||
answers: { mode: { answers: ["Deep"] } },
|
||||
});
|
||||
|
||||
@@ -165,7 +165,7 @@ import {
|
||||
} from "./dynamic-tool-execution.js";
|
||||
import {
|
||||
filterCodexDynamicTools,
|
||||
resolveCodexDynamicToolsLoadingForModel,
|
||||
resolveCodexDynamicToolsLoading,
|
||||
} from "./dynamic-tool-profile.js";
|
||||
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js";
|
||||
@@ -595,7 +595,7 @@ export async function runCodexAppServerAttempt(
|
||||
tools,
|
||||
registeredTools,
|
||||
signal: runAbortController.signal,
|
||||
loading: resolveCodexDynamicToolsLoadingForModel(pluginConfig, params.modelId),
|
||||
loading: resolveCodexDynamicToolsLoading(pluginConfig),
|
||||
directToolNames: shouldForceMessageTool(params) ? ["message"] : [],
|
||||
hookContext: {
|
||||
agentId: sessionAgentId,
|
||||
@@ -1924,38 +1924,6 @@ export async function runCodexAppServerAttempt(
|
||||
);
|
||||
} else {
|
||||
thread = await restartContextEngineCodexThread();
|
||||
// The fresh retry thread was not bootstrapped with the
|
||||
// context-engine projection. Clear the stale projection from
|
||||
// the saved binding so the next run will re-project instead
|
||||
// of assuming the old epoch is still in the thread.
|
||||
{
|
||||
const retryBinding = await readCodexAppServerBinding(activeSessionFile);
|
||||
if (
|
||||
retryBinding &&
|
||||
retryBinding.threadId === thread.threadId &&
|
||||
retryBinding.contextEngine?.projection
|
||||
) {
|
||||
const {
|
||||
schemaVersion: _schemaVersion,
|
||||
sessionFile: _boundSessionFile,
|
||||
updatedAt: _updatedAt,
|
||||
...bindingForWrite
|
||||
} = retryBinding;
|
||||
await writeCodexAppServerBinding(activeSessionFile, {
|
||||
...bindingForWrite,
|
||||
contextEngine: bindingForWrite.contextEngine
|
||||
? { ...bindingForWrite.contextEngine, projection: undefined }
|
||||
: undefined,
|
||||
});
|
||||
embeddedAgentLog.info(
|
||||
"codex app-server cleared stale context-engine projection after overflow retry",
|
||||
{
|
||||
threadId: thread.threadId,
|
||||
previousEpoch: retryBinding.contextEngine.projection.epoch,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "thread_ready_retry", threadId: thread.threadId },
|
||||
@@ -2672,7 +2640,7 @@ export const testing = {
|
||||
buildDynamicTools,
|
||||
filterCodexDynamicToolsForAllowlist,
|
||||
includeForcedCodexDynamicToolAllow,
|
||||
resolveCodexDynamicToolsLoadingForModel,
|
||||
resolveCodexDynamicToolsLoading,
|
||||
resolveCodexAppServerHookChannelId,
|
||||
buildCodexAppServerPromptTimeoutOutcome,
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCodexConversationTurnCollector } from "./conversation-turn-collector.js";
|
||||
|
||||
describe("codex conversation turn collector", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("collects streamed assistant deltas for the active turn", async () => {
|
||||
const collector = createCodexConversationTurnCollector("thread-1");
|
||||
collector.setTurnId("turn-1");
|
||||
@@ -197,8 +192,9 @@ describe("codex conversation turn collector", () => {
|
||||
});
|
||||
|
||||
it("clamps oversized turn wait timers", async () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const collector = createCodexConversationTurnCollector("thread-1");
|
||||
collector.setTurnId("turn-1");
|
||||
const completion = collector.wait({ timeoutMs: MAX_TIMER_TIMEOUT_MS + 1 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -30,8 +30,6 @@ export {
|
||||
export {
|
||||
DEFAULT_GOOGLE_API_BASE_URL,
|
||||
isGoogleGenerativeAiApi,
|
||||
isGoogleVertexBaseUrl,
|
||||
isGoogleVertexHostname,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
normalizeGoogleGenerativeAiBaseUrl,
|
||||
normalizeGoogleProviderConfig,
|
||||
|
||||
@@ -65,13 +65,7 @@ describe("google provider plugin hooks", () => {
|
||||
modelApi: "google-generative-ai",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
} as never),
|
||||
).toBe("native");
|
||||
expect(
|
||||
provider.resolveReasoningOutputMode?.({
|
||||
provider: "google",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
} as never),
|
||||
).toBe("native");
|
||||
).toBe("tagged");
|
||||
|
||||
const sanitized = await Promise.resolve(
|
||||
provider.sanitizeReplayHistory?.({
|
||||
@@ -108,60 +102,6 @@ describe("google provider plugin hooks", () => {
|
||||
expect(customEntries[0]?.customType).toBe("google-turn-ordering-bootstrap");
|
||||
});
|
||||
|
||||
it("keeps google-gemini-cli on tagged reasoning mode", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: googleProviderPlugin,
|
||||
id: "google",
|
||||
name: "Google Provider",
|
||||
});
|
||||
const cliProvider = requireRegisteredProvider(providers, "google-gemini-cli");
|
||||
expect(
|
||||
cliProvider.resolveReasoningOutputMode?.({
|
||||
provider: "google-gemini-cli",
|
||||
modelApi: "google-gemini-cli",
|
||||
modelId: "gemini-2.5-pro",
|
||||
} as never),
|
||||
).toBe("tagged");
|
||||
});
|
||||
|
||||
it("keeps google-antigravity hook aliases on tagged reasoning mode", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: googleProviderPlugin,
|
||||
id: "google",
|
||||
name: "Google Provider",
|
||||
});
|
||||
const provider = requireRegisteredProvider(providers, "google-antigravity");
|
||||
expect(
|
||||
provider.resolveReasoningOutputMode?.({
|
||||
provider: "google-antigravity",
|
||||
modelApi: "openai-completions",
|
||||
modelId: "gemini-3-pro-low",
|
||||
} as never),
|
||||
).toBe("tagged");
|
||||
});
|
||||
|
||||
it("keeps google-vertex hook aliases on native reasoning mode", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: googleProviderPlugin,
|
||||
id: "google",
|
||||
name: "Google Provider",
|
||||
});
|
||||
const provider = requireRegisteredProvider(providers, "google-vertex");
|
||||
expect(
|
||||
provider.resolveReasoningOutputMode?.({
|
||||
provider: "google-vertex",
|
||||
modelApi: "google-vertex",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
} as never),
|
||||
).toBe("native");
|
||||
expect(
|
||||
provider.resolveReasoningOutputMode?.({
|
||||
provider: "google-vertex",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
} as never),
|
||||
).toBe("native");
|
||||
});
|
||||
|
||||
it("owns Gemini tool schema normalization for direct and CLI providers", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: googleProviderPlugin,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
ProviderReasoningOutputModeContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeGoogleModelId } from "./model-id.js";
|
||||
@@ -13,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";
|
||||
@@ -22,18 +18,6 @@ import {
|
||||
createGoogleVertexTransportStreamFn,
|
||||
} from "./transport-stream.js";
|
||||
|
||||
function resolveGoogleReasoningOutputMode(
|
||||
ctx: ProviderReasoningOutputModeContext,
|
||||
): "native" | "tagged" {
|
||||
if (ctx.provider === "google" || ctx.provider === "google-vertex") {
|
||||
const api = ctx.model?.api ?? ctx.modelApi;
|
||||
if (!api || api === "google-generative-ai" || api === "google-vertex") {
|
||||
return "native";
|
||||
}
|
||||
}
|
||||
return "tagged";
|
||||
}
|
||||
|
||||
export function buildGoogleProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "google",
|
||||
@@ -83,24 +67,15 @@ 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,
|
||||
// Gemini 2.5+ delivers reasoning via native thinkingParts (thinkingConfig.includeThoughts).
|
||||
// Tagged mode simultaneously injects <think>/<final> which the model opens before a tool
|
||||
// call, never closes, leaving the post-tool turn empty (payloads=0). The CLI backend keeps
|
||||
// tagged mode because it emits JSON text, not native thought parts.
|
||||
resolveReasoningOutputMode: resolveGoogleReasoningOutputMode,
|
||||
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1628,76 +1628,6 @@ describe("google transport stream", () => {
|
||||
expect(generationConfig).not.toHaveProperty("thinkingConfig");
|
||||
});
|
||||
|
||||
it("forwards configured stop sequences to the Gemini generationConfig", () => {
|
||||
const params = buildGoogleGenerativeAiParams(
|
||||
buildGeminiModel(),
|
||||
{
|
||||
messages: [{ role: "user", content: "hello", timestamp: 0 }],
|
||||
} as never,
|
||||
{
|
||||
stop: ["</tool>", "\n\nObservation:"],
|
||||
} as never,
|
||||
);
|
||||
|
||||
const generationConfig = requireGenerationConfig(params);
|
||||
expect(generationConfig.stopSequences).toEqual(["</tool>", "\n\nObservation:"]);
|
||||
});
|
||||
|
||||
it("omits stopSequences when the stop list is empty", () => {
|
||||
const params = buildGoogleGenerativeAiParams(
|
||||
buildGeminiModel(),
|
||||
{
|
||||
messages: [{ role: "user", content: "hello", timestamp: 0 }],
|
||||
} as never,
|
||||
{
|
||||
stop: [],
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(params.generationConfig ?? {}).not.toHaveProperty("stopSequences");
|
||||
});
|
||||
|
||||
it("sends stopSequences in the serialized Gemini request body via the guarded fetch transport", async () => {
|
||||
guardedFetchMock.mockResolvedValueOnce(buildSseResponse([]));
|
||||
|
||||
const model = attachModelProviderRequestTransport(
|
||||
{
|
||||
id: "gemini-3.1-pro-preview",
|
||||
name: "Gemini 3.1 Pro Preview",
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"google-generative-ai">,
|
||||
{},
|
||||
);
|
||||
|
||||
const streamFn = createGoogleGenerativeAiTransportStreamFn();
|
||||
const stream = await Promise.resolve(
|
||||
streamFn(
|
||||
model,
|
||||
{
|
||||
messages: [{ role: "user", content: "hello", timestamp: 0 }],
|
||||
} as Parameters<typeof streamFn>[1],
|
||||
{
|
||||
apiKey: "gemini-api-key",
|
||||
stop: ["</tool>", "\n\nObservation:"],
|
||||
} as Parameters<typeof streamFn>[2],
|
||||
),
|
||||
);
|
||||
await stream.result();
|
||||
|
||||
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
|
||||
const init = requireRequestInit(guardedCall, "guarded fetch");
|
||||
const payload = parseRequestJsonBody(init);
|
||||
const generationConfig = requireGenerationConfig(payload);
|
||||
expect(generationConfig.stopSequences).toEqual(["</tool>", "\n\nObservation:"]);
|
||||
});
|
||||
|
||||
it("strips explicit thinkingBudget=0 but preserves includeThoughts for Gemini 2.5 Pro", () => {
|
||||
const params = buildGoogleGenerativeAiParams(
|
||||
buildGeminiModel(),
|
||||
|
||||
@@ -703,9 +703,6 @@ export function buildGoogleGenerativeAiParams(
|
||||
if (typeof options?.maxTokens === "number") {
|
||||
generationConfig.maxOutputTokens = options.maxTokens;
|
||||
}
|
||||
if (options?.stop !== undefined && options.stop.length > 0) {
|
||||
generationConfig.stopSequences = options.stop;
|
||||
}
|
||||
const thinkingConfig = resolveGoogleThinkingConfig(model, options);
|
||||
if (thinkingConfig) {
|
||||
generationConfig.thinkingConfig = thinkingConfig;
|
||||
|
||||
@@ -281,82 +281,6 @@ describe("kimi tool-call markup wrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("strips Anthropic cache_control markers before Kimi requests are sent", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
|
||||
system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello", cache_control: { type: "ephemeral" } },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "done",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_2",
|
||||
name: "persist",
|
||||
input: {
|
||||
cache_control: "tool argument",
|
||||
nested: { cache_control: "nested argument" },
|
||||
},
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
{ type: "text", text: "bye" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const wrapped = createKimiThinkingWrapper(baseStreamFn, "enabled");
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
system: [{ type: "text", text: "stable" }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_2",
|
||||
name: "persist",
|
||||
input: {
|
||||
cache_control: "tool argument",
|
||||
nested: { cache_control: "nested argument" },
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "bye" },
|
||||
],
|
||||
},
|
||||
],
|
||||
thinking: { type: "enabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("lets explicit model params keep Kimi thinking disabled even when session thinking is on", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
|
||||
|
||||
|
||||
@@ -394,51 +394,9 @@ export function createKimiThinkingWrapper(
|
||||
delete payloadObj.reasoning;
|
||||
delete payloadObj.reasoning_effort;
|
||||
delete payloadObj.reasoningEffort;
|
||||
stripAnthropicCacheControlMarkers(payloadObj);
|
||||
});
|
||||
}
|
||||
|
||||
function stripContentBlockCacheControl(block: unknown): void {
|
||||
if (!block || typeof block !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = block as Record<string, unknown>;
|
||||
delete record.cache_control;
|
||||
|
||||
if (record.type === "tool_result" && Array.isArray(record.content)) {
|
||||
for (const nestedBlock of record.content) {
|
||||
stripContentBlockCacheControl(nestedBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stripContentArrayCacheControl(value: unknown): void {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const block of value) {
|
||||
stripContentBlockCacheControl(block);
|
||||
}
|
||||
}
|
||||
|
||||
function stripAnthropicCacheControlMarkers(payloadObj: Record<string, unknown>): void {
|
||||
stripContentArrayCacheControl(payloadObj.system);
|
||||
|
||||
if (!Array.isArray(payloadObj.messages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of payloadObj.messages) {
|
||||
if (!message || typeof message !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
stripContentArrayCacheControl((message as Record<string, unknown>).content);
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapKimiProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn {
|
||||
const thinkingConfig = resolveKimiThinkingConfig({
|
||||
configuredThinking: ctx.extraParams?.thinking,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export function resetEmbeddingMocks(): void {
|
||||
}
|
||||
|
||||
vi.mock("./embeddings.js", () => ({
|
||||
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
|
||||
createEmbeddingProvider: async () => ({
|
||||
requestedProvider: "openai",
|
||||
provider: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveEmbeddingTimeoutMs,
|
||||
resolveMemoryIndexConcurrency,
|
||||
@@ -97,61 +97,52 @@ describe("local embedding worker failure detection", () => {
|
||||
|
||||
describe("memory embedding timeout abort", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("aborts the provider operation when the timeout fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
let signalSeen: AbortSignal | undefined;
|
||||
|
||||
const resultPromise = runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings query timed out after 0s",
|
||||
run: async (signal) => {
|
||||
signalSeen = signal;
|
||||
return await new Promise<number[]>((resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rejection = expect(resultPromise).rejects.toThrow(
|
||||
"memory embeddings query timed out after 0s",
|
||||
);
|
||||
const result = expect(
|
||||
runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings query timed out after 0s",
|
||||
run: async (signal) => {
|
||||
signalSeen = signal;
|
||||
return await new Promise<number[]>((resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("memory embeddings query timed out after 0s");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await rejection;
|
||||
await result;
|
||||
|
||||
expect(signalSeen?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the timeout error when a provider abort listener rejects generically", async () => {
|
||||
vi.useFakeTimers();
|
||||
const resultPromise = runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings batch timed out after 0s",
|
||||
run: async (signal) =>
|
||||
await new Promise<number[]>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const rejection = expect(resultPromise).rejects.toThrow(
|
||||
"memory embeddings batch timed out after 0s",
|
||||
);
|
||||
const result = expect(
|
||||
runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings batch timed out after 0s",
|
||||
run: async (signal) =>
|
||||
await new Promise<number[]>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("memory embeddings batch timed out after 0s");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await rejection;
|
||||
await result;
|
||||
});
|
||||
|
||||
it("caps operation watchdog timers before scheduling", async () => {
|
||||
|
||||
@@ -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"])),
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user