mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 08:42:35 +08:00
Compare commits
566 Commits
v2026.6.9
...
aknight/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f74f595eb | ||
|
|
66b94ba577 | ||
|
|
77b6ca9a9b | ||
|
|
1425bb3a03 | ||
|
|
11484f8a14 | ||
|
|
ec7a548062 | ||
|
|
8ecdb97b63 | ||
|
|
a39e548ede | ||
|
|
328a44695f | ||
|
|
c21dcfc7c2 | ||
|
|
2bdcc8314d | ||
|
|
464adfe5e5 | ||
|
|
b66b4504f8 | ||
|
|
f6d432e545 | ||
|
|
fd13192adc | ||
|
|
1c63da09d8 | ||
|
|
735505442c | ||
|
|
108d6d7eca | ||
|
|
984c8f6ea0 | ||
|
|
cd9060e06a | ||
|
|
5b96eb0172 | ||
|
|
b95b725c83 | ||
|
|
4b881509eb | ||
|
|
a03032a272 | ||
|
|
c94ebdbebd | ||
|
|
7b46167607 | ||
|
|
0e53358945 | ||
|
|
6b45e9af7a | ||
|
|
2ef61eb782 | ||
|
|
6823f56d8e | ||
|
|
3e1d3c5feb | ||
|
|
dfbc9ab246 | ||
|
|
179eb15554 | ||
|
|
c3b1e926e8 | ||
|
|
89768d456b | ||
|
|
609d7a14b1 | ||
|
|
3bae0d6b82 | ||
|
|
75a997dd7c | ||
|
|
ab8dc3af52 | ||
|
|
bebc5d847d | ||
|
|
a0f28bd3f5 | ||
|
|
c638617897 | ||
|
|
b77d6149e1 | ||
|
|
36db108fc1 | ||
|
|
e00c1eebc4 | ||
|
|
32d22d04cc | ||
|
|
648ef73bde | ||
|
|
beebb35de4 | ||
|
|
55959148ca | ||
|
|
4684bbba97 | ||
|
|
409adfbe10 | ||
|
|
2609b97222 | ||
|
|
03bc600e67 | ||
|
|
8102d5ebc3 | ||
|
|
6399eb8191 | ||
|
|
1a5839fbd8 | ||
|
|
d17045db6f | ||
|
|
2a8db1fc23 | ||
|
|
82f43f0a62 | ||
|
|
9adf3d92bd | ||
|
|
e21164933a | ||
|
|
1b17517969 | ||
|
|
86fea26797 | ||
|
|
2e7c3ace9c | ||
|
|
e34204a1e0 | ||
|
|
6daf9307e0 | ||
|
|
ebb670b208 | ||
|
|
52672c7af1 | ||
|
|
690efd2a16 | ||
|
|
102ab759e7 | ||
|
|
7da955fae4 | ||
|
|
06a0148072 | ||
|
|
edd1d3319c | ||
|
|
0ea39a2276 | ||
|
|
6fa944e80f | ||
|
|
4c453c931f | ||
|
|
13b0976c70 | ||
|
|
43e00c06c3 | ||
|
|
03ce3d41b1 | ||
|
|
bda05dbc2f | ||
|
|
7069d95720 | ||
|
|
8086cffd17 | ||
|
|
d91aee7220 | ||
|
|
c8ab37f6fe | ||
|
|
a823cb2b30 | ||
|
|
5230ec66ae | ||
|
|
eea777c9fc | ||
|
|
0b28a72be1 | ||
|
|
adcba85264 | ||
|
|
5b79fa13e2 | ||
|
|
124ea48549 | ||
|
|
12756fc4c8 | ||
|
|
5bf459e23b | ||
|
|
d64a27feeb | ||
|
|
6c42f73619 | ||
|
|
d460f00eb9 | ||
|
|
b83dce7b33 | ||
|
|
d3c907193f | ||
|
|
b47c930e7e | ||
|
|
0befd3c8f2 | ||
|
|
c2ee9b0be8 | ||
|
|
9d27583190 | ||
|
|
04c8c50cc4 | ||
|
|
5abf4ce2e2 | ||
|
|
07d5cdec99 | ||
|
|
f5f23e739e | ||
|
|
0c183283e5 | ||
|
|
514b3365b5 | ||
|
|
2804c24dc6 | ||
|
|
94c7b5a874 | ||
|
|
93ec8b8c5c | ||
|
|
9d83eeaccf | ||
|
|
33eb6ab9de | ||
|
|
757ab933f4 | ||
|
|
63fdc57b3a | ||
|
|
a39a3b74de | ||
|
|
77a859f4ae | ||
|
|
84cf64770f | ||
|
|
0ad48dad2c | ||
|
|
b50a5aebba | ||
|
|
b84665222c | ||
|
|
6441e56465 | ||
|
|
6daabd23f8 | ||
|
|
2f33999898 | ||
|
|
a60947fb3e | ||
|
|
ac5d219be3 | ||
|
|
ae41b00922 | ||
|
|
b28e68e0ce | ||
|
|
5d1e649aea | ||
|
|
d6d17709e8 | ||
|
|
6fd6bddb92 | ||
|
|
f4dee99574 | ||
|
|
db33402af0 | ||
|
|
088cab5ee4 | ||
|
|
bd74a62118 | ||
|
|
9f888d95e0 | ||
|
|
11a2e03bd4 | ||
|
|
5693fcda78 | ||
|
|
eb00d499d1 | ||
|
|
8a7906c716 | ||
|
|
c85113e30e | ||
|
|
bdf81a825f | ||
|
|
d9dfcd6c8a | ||
|
|
2cafbd0774 | ||
|
|
54b2836eab | ||
|
|
5b6f4b2919 | ||
|
|
c037a34ba7 | ||
|
|
12c34fc3a9 | ||
|
|
e366349730 | ||
|
|
89a73d08c8 | ||
|
|
e6f41a4df0 | ||
|
|
b7fef7fca6 | ||
|
|
d1cbe29f3d | ||
|
|
9dbc21d283 | ||
|
|
5e86c7eef4 | ||
|
|
6f3af56952 | ||
|
|
03ee22666b | ||
|
|
0d351b9875 | ||
|
|
f69ba12a37 | ||
|
|
b796890b97 | ||
|
|
b5fc9514c0 | ||
|
|
2a140e6e6a | ||
|
|
6e5f4d685e | ||
|
|
600bace853 | ||
|
|
b8a5dac1a2 | ||
|
|
15c880aeff | ||
|
|
3a53eb5d77 | ||
|
|
66e5cfdd86 | ||
|
|
c4facb2bb3 | ||
|
|
a70b34a3cb | ||
|
|
59bf85c586 | ||
|
|
e486a1d1cf | ||
|
|
72b9bc7303 | ||
|
|
d3b44442f6 | ||
|
|
6ddbcbd460 | ||
|
|
7bd4aab21f | ||
|
|
a4c8b17b9e | ||
|
|
d87f8325d0 | ||
|
|
a5fde9119c | ||
|
|
e6c899dfa5 | ||
|
|
425f512897 | ||
|
|
735d70d9db | ||
|
|
15300291ed | ||
|
|
eb7789c8cb | ||
|
|
f19052b3f3 | ||
|
|
61d1fd1f72 | ||
|
|
1435fc123f | ||
|
|
6c4028e073 | ||
|
|
9242137ca7 | ||
|
|
35d7cb0bff | ||
|
|
c000e4811d | ||
|
|
d25549f142 | ||
|
|
a192b2ea52 | ||
|
|
7975ec0b11 | ||
|
|
e9b694ef9c | ||
|
|
3b332fd0a4 | ||
|
|
7dd01d15c5 | ||
|
|
675c56692a | ||
|
|
3f597619c8 | ||
|
|
91531ba35c | ||
|
|
206bbb01b0 | ||
|
|
c9758bf2a0 | ||
|
|
282eb74128 | ||
|
|
9940110b88 | ||
|
|
73b35cc3ca | ||
|
|
ac0537e363 | ||
|
|
0321c04663 | ||
|
|
78b717a54c | ||
|
|
7b00fd6c45 | ||
|
|
1f1155597b | ||
|
|
e9e42d5db4 | ||
|
|
bc2f4ce923 | ||
|
|
7dbae1b2cd | ||
|
|
63f2c56222 | ||
|
|
675cae58d7 | ||
|
|
c372f6ef0b | ||
|
|
1d4b712f9a | ||
|
|
e016f0b496 | ||
|
|
f8d2c4b25a | ||
|
|
b15f745a60 | ||
|
|
8c9c8aad2e | ||
|
|
0a7b009647 | ||
|
|
fc8542b377 | ||
|
|
37a4b565ea | ||
|
|
6b0210a5fd | ||
|
|
c22e300084 | ||
|
|
5134dd0c54 | ||
|
|
830691b201 | ||
|
|
ab39bab52a | ||
|
|
3f4d1cfcce | ||
|
|
06574920dd | ||
|
|
d46b64df66 | ||
|
|
b06e2f9149 | ||
|
|
b574da57cf | ||
|
|
bfe0caefd1 | ||
|
|
0004cfd59e | ||
|
|
604aa30189 | ||
|
|
5dd30c3995 | ||
|
|
5b22409389 | ||
|
|
b970d57175 | ||
|
|
9ab8e466d2 | ||
|
|
c43822077a | ||
|
|
8797564254 | ||
|
|
273eb88874 | ||
|
|
140a2fa520 | ||
|
|
c8b48c78d0 | ||
|
|
29033e67af | ||
|
|
e9a47fe554 | ||
|
|
2f213a1606 | ||
|
|
992ddf6310 | ||
|
|
7b259bd2a4 | ||
|
|
e91ca8df86 | ||
|
|
af3e509ab8 | ||
|
|
dec76bb5eb | ||
|
|
862ef1cec1 | ||
|
|
486c9e6ba3 | ||
|
|
b2d78abe94 | ||
|
|
2f38b5aa2e | ||
|
|
4d17a52924 | ||
|
|
4b2298e8cb | ||
|
|
f640ca11f9 | ||
|
|
2029f87f29 | ||
|
|
ca1aa33eba | ||
|
|
5b212162d3 | ||
|
|
3df5207389 | ||
|
|
78f30a010c | ||
|
|
83785a6e79 | ||
|
|
195890f815 | ||
|
|
0f8df48a91 | ||
|
|
7b28b73e78 | ||
|
|
3650766f26 | ||
|
|
eb03d0ee2b | ||
|
|
38c8b0c196 | ||
|
|
0030a192c8 | ||
|
|
34806b39cd | ||
|
|
b0f21f8af7 | ||
|
|
5af318b95d | ||
|
|
8d2e6d7686 | ||
|
|
b039e949b6 | ||
|
|
2ca5b7c93e | ||
|
|
51d1789cea | ||
|
|
89e73240a1 | ||
|
|
f3ee317f71 | ||
|
|
ecb82f1be9 | ||
|
|
f1a48dac18 | ||
|
|
cba9c02095 | ||
|
|
715dc718fc | ||
|
|
85f71f4c8f | ||
|
|
66f84a9bf1 | ||
|
|
50c2cc6a45 | ||
|
|
e3ccf8743f | ||
|
|
d4c2fa7aed | ||
|
|
6c1041339d | ||
|
|
db54a3268b | ||
|
|
9750d887f5 | ||
|
|
fce586538a | ||
|
|
97b97a9999 | ||
|
|
cbbb466852 | ||
|
|
c2de9d0822 | ||
|
|
e46aaead2c | ||
|
|
c191d7978b | ||
|
|
1b5e1e2d53 | ||
|
|
ab41a311cf | ||
|
|
9e70d251b0 | ||
|
|
025f8fb087 | ||
|
|
e012f2cd3c | ||
|
|
73c988a9c8 | ||
|
|
8b4eedf1bc | ||
|
|
b43eedbb18 | ||
|
|
ebbf506a77 | ||
|
|
e0c1add79a | ||
|
|
eae1e8f3f8 | ||
|
|
ba34052a0e | ||
|
|
ce78ac3efb | ||
|
|
6c2c43d63f | ||
|
|
0168ff0c2e | ||
|
|
22bdda2555 | ||
|
|
2755112353 | ||
|
|
57c7fa22bb | ||
|
|
269e44e164 | ||
|
|
2e75d925ad | ||
|
|
0e763c1499 | ||
|
|
6ed9fb8ec2 | ||
|
|
a826d6a4a4 | ||
|
|
9d519c1481 | ||
|
|
b09b35c13c | ||
|
|
15f2a56590 | ||
|
|
e66c36df37 | ||
|
|
42c504b8b1 | ||
|
|
9ac3759ffc | ||
|
|
d7f747af3b | ||
|
|
9cb3b4ea2b | ||
|
|
f257116c92 | ||
|
|
a88ce96ee1 | ||
|
|
448b3fa0be | ||
|
|
88ad407be2 | ||
|
|
13e77cc055 | ||
|
|
b78718f42a | ||
|
|
54dddda68d | ||
|
|
6084442ab6 | ||
|
|
692c7e78f4 | ||
|
|
3f166b1f64 | ||
|
|
8e8905560b | ||
|
|
3968fea383 | ||
|
|
43e8c29fbf | ||
|
|
7e80bb8abf | ||
|
|
9826619e22 | ||
|
|
8f3672beaa | ||
|
|
0d25928fa4 | ||
|
|
807641548b | ||
|
|
f87f30d429 | ||
|
|
22c5ced69f | ||
|
|
faa4f5a23b | ||
|
|
fc0b28cb73 | ||
|
|
8265acaacb | ||
|
|
d20e96a650 | ||
|
|
8ff1d3e67b | ||
|
|
49a9032705 | ||
|
|
aab1dd88e0 | ||
|
|
a81a505c72 | ||
|
|
7d219bd6e7 | ||
|
|
ece7d0945c | ||
|
|
979238dbb3 | ||
|
|
ab165d119c | ||
|
|
6f3df79f17 | ||
|
|
7c2fb845db | ||
|
|
00f67b845b | ||
|
|
6ef9207201 | ||
|
|
3283540c78 | ||
|
|
03b022b88e | ||
|
|
45f9358877 | ||
|
|
29ec5b331c | ||
|
|
fa08942396 | ||
|
|
2c9192a9a8 | ||
|
|
955f3ed094 | ||
|
|
7217477553 | ||
|
|
97b0b559ad | ||
|
|
1b299f4dbf | ||
|
|
7064d198a3 | ||
|
|
f18ff7551e | ||
|
|
58d295840e | ||
|
|
896b3c612d | ||
|
|
2f240a4a4c | ||
|
|
172412d756 | ||
|
|
58578b3250 | ||
|
|
ce9769faae | ||
|
|
e8920f6f6b | ||
|
|
9be53b4aa2 | ||
|
|
3ec2a46907 | ||
|
|
15a2d74320 | ||
|
|
77f07a11e7 | ||
|
|
7a0d36f3d0 | ||
|
|
0a707afb9a | ||
|
|
bdeda6553b | ||
|
|
3499b277e3 | ||
|
|
8c8857c3ef | ||
|
|
d75613e794 | ||
|
|
beb8897f49 | ||
|
|
add5f76a1e | ||
|
|
9a9f4dbefe | ||
|
|
5beaaf343c | ||
|
|
1db811282c | ||
|
|
aa23d9f34e | ||
|
|
2962c95010 | ||
|
|
80d3b132a5 | ||
|
|
1a5d84d3fe | ||
|
|
71a75b9b28 | ||
|
|
b1f562570a | ||
|
|
bdcc691745 | ||
|
|
4461e257e3 | ||
|
|
76014cfe95 | ||
|
|
498ff1fb5a | ||
|
|
ae81aa018d | ||
|
|
1706bfda2c | ||
|
|
a1201e99fc | ||
|
|
90d2f161c9 | ||
|
|
bff7134a69 | ||
|
|
e59d0b540e | ||
|
|
aa5fcf70f7 | ||
|
|
63ac2e2ce0 | ||
|
|
803064c6e0 | ||
|
|
577e5a4692 | ||
|
|
a49f3f9362 | ||
|
|
7b9ddbda99 | ||
|
|
0f83051353 | ||
|
|
4341cf24cc | ||
|
|
6a3f990140 | ||
|
|
81abc2b21b | ||
|
|
09fcafffbc | ||
|
|
2a93d7b9c5 | ||
|
|
0eaefc9050 | ||
|
|
52e01676be | ||
|
|
df68b81006 | ||
|
|
a5417b5c6c | ||
|
|
da2c7e2d2b | ||
|
|
3a14f247ad | ||
|
|
5c36001fcb | ||
|
|
05bed72a8d | ||
|
|
c2433d41a7 | ||
|
|
d368fd620c | ||
|
|
7dc7deaa13 | ||
|
|
a2ff59fdb2 | ||
|
|
b12223a79f | ||
|
|
f519ceab9c | ||
|
|
1f1b1aee6b | ||
|
|
62b2e9ef14 | ||
|
|
0f67474251 | ||
|
|
e56fd1dc04 | ||
|
|
b3968f69c9 | ||
|
|
b0df6dc10e | ||
|
|
141fb2b119 | ||
|
|
64b6488f6c | ||
|
|
e1fc4683bb | ||
|
|
85ab952956 | ||
|
|
abd5fb4494 | ||
|
|
aea050b43e | ||
|
|
85f552bf37 | ||
|
|
dafd98dd98 | ||
|
|
3632c62f85 | ||
|
|
ad5d2cbc1b | ||
|
|
7cda58c109 | ||
|
|
5c0b99ae2b | ||
|
|
979925c194 | ||
|
|
2f9f45f734 | ||
|
|
32cbaecd09 | ||
|
|
1989726eb6 | ||
|
|
2454acc287 | ||
|
|
fce5db415b | ||
|
|
2166652eb3 | ||
|
|
7a9c269541 | ||
|
|
aa893b9228 | ||
|
|
98a7741468 | ||
|
|
3df4341e5a | ||
|
|
ecac665bf3 | ||
|
|
021fd5de2b | ||
|
|
60159b9f00 | ||
|
|
165440117e | ||
|
|
fddfcbe10e | ||
|
|
7c850bdf38 | ||
|
|
2bc20f2ec5 | ||
|
|
ed500dda25 | ||
|
|
bc754b3160 | ||
|
|
b972956173 | ||
|
|
29444b26f2 | ||
|
|
7fc5a72433 | ||
|
|
a590f7f690 | ||
|
|
2252674168 | ||
|
|
60612ff492 | ||
|
|
c5623e72f3 | ||
|
|
947c21ee5a | ||
|
|
99f58ae6d6 | ||
|
|
3f0e740f83 | ||
|
|
106961b513 | ||
|
|
d0001f96f0 | ||
|
|
527bd807b9 | ||
|
|
7546231762 | ||
|
|
a977dc843d | ||
|
|
6ad7f66af2 | ||
|
|
1b4fb6291d | ||
|
|
ee69465fe9 | ||
|
|
7b329ade32 | ||
|
|
44422b2151 | ||
|
|
48b338a5a9 | ||
|
|
d4f68475fd | ||
|
|
d81ae7a441 | ||
|
|
99d8549de6 | ||
|
|
7a077ffead | ||
|
|
b980d678a4 | ||
|
|
e02e3d6971 | ||
|
|
6fa05685ea | ||
|
|
6585cb3b44 | ||
|
|
730c7269ef | ||
|
|
d72f7edf2d | ||
|
|
24b6e6ba96 | ||
|
|
c33f8c20ef | ||
|
|
1c0c072bc2 | ||
|
|
aaf335af04 | ||
|
|
ad049ef083 | ||
|
|
6dc121eb6a | ||
|
|
0742a2f37a | ||
|
|
e2c567538d | ||
|
|
5c8fa5da5c | ||
|
|
9953b85e6d | ||
|
|
048014d1ab | ||
|
|
0cd6975352 | ||
|
|
5384b91866 | ||
|
|
19ec9d8979 | ||
|
|
e65619dd0c | ||
|
|
2f0f085826 | ||
|
|
0cd8db97f9 | ||
|
|
087d999fce | ||
|
|
4514b5a387 | ||
|
|
6b82d4ecb7 | ||
|
|
f719f0cf77 | ||
|
|
8ee638236a | ||
|
|
36934fd9f5 | ||
|
|
84895e9276 | ||
|
|
a6e41a0cc1 | ||
|
|
1ede829fbf | ||
|
|
b93b07ee1b | ||
|
|
405e5072fd | ||
|
|
b79dfc739c | ||
|
|
ff4808f94d | ||
|
|
602bc0baa9 | ||
|
|
a1d278b174 | ||
|
|
0fd5dae36f | ||
|
|
984e058624 | ||
|
|
a6e4afe0fa | ||
|
|
66c62d52ad | ||
|
|
9e3ef487eb | ||
|
|
739636fc33 | ||
|
|
ccc1415f6d | ||
|
|
b1608b4a4e | ||
|
|
703dfbf453 | ||
|
|
7cd58cca2a | ||
|
|
2d603c90dc | ||
|
|
4296ecb78c | ||
|
|
fe1d981a47 | ||
|
|
5cf8ba973d | ||
|
|
cb394309fe | ||
|
|
dd29a6de52 | ||
|
|
93a0b5d353 | ||
|
|
4f8fd48ea7 | ||
|
|
7679872ddf | ||
|
|
cd7385c5c6 | ||
|
|
88cf142c98 |
@@ -670,11 +670,7 @@ function resolveAssociatedPullRequests(commitHashes, targetTimestamp) {
|
||||
pending.push({ commitHash, cursor: connection.pageInfo.endCursor });
|
||||
}
|
||||
}
|
||||
for (
|
||||
let index = 0;
|
||||
index < commitHashes.length;
|
||||
index += commitAssociationQueryBatchSize
|
||||
) {
|
||||
for (let index = 0; index < commitHashes.length; index += commitAssociationQueryBatchSize) {
|
||||
const chunk = commitHashes.slice(index, index + commitAssociationQueryBatchSize);
|
||||
const fields = chunk
|
||||
.map(
|
||||
|
||||
81
.github/workflows/clawsweeper-dispatch.yml
vendored
81
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -18,15 +18,16 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
|
||||
group: ${{ github.event_name == 'push' && format('clawsweeper-dispatch-{0}-{1}', github.repository, github.ref) || format('clawsweeper-dispatch-{0}-{1}', github.repository, github.event.issue.number || github.event.pull_request.number || github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' || github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' ||
|
||||
(github.event_name != 'issue_comment' ||
|
||||
(github.actor != 'clawsweeper[bot]' && github.actor != 'openclaw-clawsweeper[bot]')) &&
|
||||
!(
|
||||
endsWith(github.actor, '[bot]') &&
|
||||
(github.event.action == 'labeled' || github.event.action == 'unlabeled')
|
||||
@@ -41,6 +42,34 @@ jobs:
|
||||
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
|
||||
run: sleep 20
|
||||
|
||||
- name: Debounce main push dispatch
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
run: sleep 45
|
||||
|
||||
- name: Install GitHub API backoff helper
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
|
||||
gh_api_with_retry() {
|
||||
local attempt output status lower_output
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if output="$(gh api "$@" 2>&1)"; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
status=$?
|
||||
lower_output="${output,,}"
|
||||
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
fi
|
||||
echo "::warning::GitHub API throttled ClawSweeper dispatch on attempt ${attempt}; retrying after backoff." >&2
|
||||
sleep $((attempt * attempt * 5))
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
BASH
|
||||
|
||||
- name: Create ClawSweeper dispatch token
|
||||
id: token
|
||||
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
|
||||
@@ -52,9 +81,27 @@ jobs:
|
||||
repositories: clawsweeper
|
||||
permission-contents: write
|
||||
|
||||
- name: Pre-filter ClawSweeper comment
|
||||
id: comment_filter
|
||||
if: ${{ github.event_name == 'issue_comment' }}
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then
|
||||
echo "is_command=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_command=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create target comment token
|
||||
id: target_token
|
||||
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' &&
|
||||
steps.comment_filter.outputs.is_command == 'true' &&
|
||||
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
|
||||
}}
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
|
||||
@@ -77,6 +124,7 @@ jobs:
|
||||
echo "::notice::Skipping GitHub activity dispatch because no ClawSweeper app token is configured."
|
||||
exit 0
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
activity="$(jq -c \
|
||||
--arg target_repo "$TARGET_REPO" \
|
||||
--arg event_name "$SOURCE_EVENT" \
|
||||
@@ -143,7 +191,7 @@ jobs:
|
||||
' "$GITHUB_EVENT_PATH")"
|
||||
payload="$(jq -nc --argjson activity "$activity" \
|
||||
'{event_type:"github_activity",client_payload:{activity:$activity}}')"
|
||||
if gh api repos/openclaw/clawsweeper/dispatches \
|
||||
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"; then
|
||||
echo "Dispatched GitHub activity to ClawSweeper."
|
||||
@@ -165,6 +213,7 @@ jobs:
|
||||
echo "::notice::Skipping ClawSweeper dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
|
||||
exit 0
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
payload="$(jq -nc \
|
||||
--arg target_repo "$TARGET_REPO" \
|
||||
--argjson item_number "$ITEM_NUMBER" \
|
||||
@@ -173,7 +222,7 @@ jobs:
|
||||
--arg source_action "$SOURCE_ACTION" \
|
||||
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
|
||||
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
|
||||
if gh api repos/openclaw/clawsweeper/dispatches \
|
||||
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"; then
|
||||
echo "Dispatched ClawSweeper review."
|
||||
@@ -182,7 +231,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Acknowledge and dispatch ClawSweeper comment
|
||||
if: ${{ github.event_name == 'issue_comment' }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' &&
|
||||
steps.comment_filter.outputs.is_command == 'true'
|
||||
}}
|
||||
env:
|
||||
DISPATCH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
TARGET_TOKEN: ${{ steps.target_token.outputs.token }}
|
||||
@@ -198,15 +251,12 @@ jobs:
|
||||
echo "::notice::Skipping ClawSweeper comment dispatch because no ClawSweeper app token is configured."
|
||||
exit 0
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
|
||||
printf '%s\n' "$COMMENT_BODY" > "$body_file"
|
||||
if ! grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
|
||||
echo "No ClawSweeper command found in comment."
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$TARGET_TOKEN" ]; then
|
||||
err="$(mktemp)"
|
||||
if GH_TOKEN="$TARGET_TOKEN" gh api -X POST \
|
||||
if GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \
|
||||
-f content="eyes" 2>"$err" >/dev/null; then
|
||||
@@ -233,7 +283,7 @@ jobs:
|
||||
"Command router queued. I will update this comment with the next step.")"
|
||||
status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')"
|
||||
status_err="$(mktemp)"
|
||||
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \
|
||||
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry \
|
||||
"repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \
|
||||
--method POST \
|
||||
--input - <<< "$status_payload" 2>"$status_err")"; then
|
||||
@@ -254,7 +304,7 @@ jobs:
|
||||
--arg source_event "issue_comment" \
|
||||
--arg source_action "$SOURCE_ACTION" \
|
||||
'{event_type:"clawsweeper_comment",client_payload:({target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')"
|
||||
if GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \
|
||||
if GH_TOKEN="$DISPATCH_TOKEN" gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"; then
|
||||
echo "Dispatched ClawSweeper comment router."
|
||||
@@ -276,6 +326,7 @@ jobs:
|
||||
echo "::notice::Skipping ClawSweeper commit dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
|
||||
exit 0
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
case "$CREATE_CHECKS" in
|
||||
true|TRUE|1|yes|YES|on|ON) create_checks=true ;;
|
||||
*) create_checks=false ;;
|
||||
@@ -287,7 +338,7 @@ jobs:
|
||||
--arg ref "$SOURCE_REF" \
|
||||
--argjson create_checks "$create_checks" \
|
||||
'{event_type:"clawsweeper_commit_review",client_payload:{target_repo:$target_repo,before_sha:$before_sha,after_sha:$after_sha,ref:$ref,enabled:true,create_checks:$create_checks}}')"
|
||||
if gh api repos/openclaw/clawsweeper/dispatches \
|
||||
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"; then
|
||||
echo "Dispatched ClawSweeper commit review."
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -33,7 +33,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
@@ -23,8 +23,8 @@ permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: control-ui-locale-refresh
|
||||
cancel-in-progress: false
|
||||
group: control-ui-locale-refresh-${{ github.event_name == 'push' && github.ref || github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'release' && format('release-{0}', github.event.release.tag_name) || format('{0}-{1}', github.event_name, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
|
||||
4
.github/workflows/docs-sync-publish.yml
vendored
4
.github/workflows/docs-sync-publish.yml
vendored
@@ -13,6 +13,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docs-sync-publish-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sync-publish-repo:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -23,8 +23,8 @@ permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: openclaw-stable-main-closeout
|
||||
cancel-in-progress: false
|
||||
group: openclaw-stable-main-closeout-${{ github.event_name == 'workflow_dispatch' && (inputs.tag || github.run_id) || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
@@ -43,6 +43,30 @@ jobs:
|
||||
should_closeout: ${{ steps.inputs.outputs.should_closeout }}
|
||||
tag: ${{ steps.inputs.outputs.tag }}
|
||||
steps:
|
||||
- name: Install GitHub API backoff helper
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
|
||||
gh_with_retry() {
|
||||
local attempt output status lower_output
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if output="$(gh "$@" 2>&1)"; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
status=$?
|
||||
lower_output="${output,,}"
|
||||
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
fi
|
||||
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
|
||||
sleep $((attempt * attempt * 5))
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
BASH
|
||||
|
||||
- name: Checkout pushed main
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
@@ -62,9 +86,13 @@ jobs:
|
||||
TRIGGER_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
sleep 45
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
main_ref="$TRIGGER_SHA"
|
||||
tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
|
||||
tag="$(gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
|
||||
--json tagName,isPrerelease,publishedAt \
|
||||
--jq '[.[] | select(.isPrerelease | not) | select(.tagName | test("^v[0-9]{4}\\.[0-9]+\\.[0-9]+(-[0-9]+)?$"))] | sort_by(.publishedAt) | last | .tagName // empty')"
|
||||
if [[ -z "$tag" ]]; then
|
||||
@@ -91,7 +119,7 @@ jobs:
|
||||
tag_package_content="$RUNNER_TEMP/tag-package-content.b64"
|
||||
tag_package_read=false
|
||||
for attempt in 1 2 3; do
|
||||
if gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
|
||||
if gh_with_retry api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
|
||||
--jq '.content' > "$tag_package_content"; then
|
||||
tag_package_read=true
|
||||
break
|
||||
@@ -126,7 +154,7 @@ jobs:
|
||||
closeout_checksum_asset="${closeout_asset}.sha256"
|
||||
closeout_dir="$RUNNER_TEMP/release-closeout-evidence"
|
||||
mkdir -p "$closeout_dir"
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
gh_with_retry release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$closeout_asset" --pattern "$closeout_checksum_asset" --dir "$closeout_dir" || true
|
||||
closeout_json_path="$closeout_dir/$closeout_asset"
|
||||
closeout_checksum_path="$closeout_dir/$closeout_checksum_asset"
|
||||
@@ -182,8 +210,11 @@ jobs:
|
||||
fi
|
||||
evidence_dir="$RUNNER_TEMP/release-postpublish-evidence"
|
||||
mkdir -p "$evidence_dir"
|
||||
if ! gh release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir"; then
|
||||
gh_with_retry release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir" || true
|
||||
evidence_path="$evidence_dir/$evidence_asset"
|
||||
evidence_checksum_path="$evidence_dir/$evidence_checksum_asset"
|
||||
if [[ ! -f "$evidence_path" || ! -f "$evidence_checksum_path" ]]; then
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
echo "Stable closeout skipped: $evidence_source_tag predates immutable postpublish evidence." >&2
|
||||
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -192,7 +223,6 @@ jobs:
|
||||
echo "Stable closeout is required for $tag, but immutable postpublish evidence from $evidence_source_tag is missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
evidence_path="$evidence_dir/$evidence_asset"
|
||||
if ! (
|
||||
cd "$evidence_dir"
|
||||
sha256sum --strict --status -c "$evidence_checksum_asset"
|
||||
@@ -272,6 +302,30 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install GitHub API backoff helper
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
|
||||
gh_with_retry() {
|
||||
local attempt output status lower_output
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if output="$(gh "$@" 2>&1)"; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
status=$?
|
||||
lower_output="${output,,}"
|
||||
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
fi
|
||||
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
|
||||
sleep $((attempt * attempt * 5))
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
BASH
|
||||
|
||||
- name: Verify release workflow evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -279,7 +333,8 @@ jobs:
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ needs.resolve.outputs.release_publish_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
gh_with_retry run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
--json workflowName,event,status,conclusion \
|
||||
> "$RUNNER_TEMP/full-release-validation-run.json"
|
||||
node --input-type=module - "$RUNNER_TEMP/full-release-validation-run.json" <<'NODE'
|
||||
@@ -296,7 +351,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
NODE
|
||||
gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
--json workflowName,event,status,conclusion \
|
||||
> "$RUNNER_TEMP/release-publish-run.json"
|
||||
node --input-type=module - "$RUNNER_TEMP/release-publish-run.json" <<'NODE'
|
||||
@@ -317,7 +372,7 @@ jobs:
|
||||
manifest_dir="$RUNNER_TEMP/full-release-validation-manifest"
|
||||
rm -rf "$manifest_dir"
|
||||
mkdir -p "$manifest_dir"
|
||||
gh run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
gh_with_retry run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
--name "full-release-validation-${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
--dir "$manifest_dir"
|
||||
tag_sha="$(git -C "$GITHUB_WORKSPACE/release-tag" rev-parse HEAD)"
|
||||
@@ -346,7 +401,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$CLOSEOUT_DIR"
|
||||
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
gh_with_retry release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets \
|
||||
> "$CLOSEOUT_DIR/github-release.json"
|
||||
node scripts/verify-stable-main-closeout.mjs \
|
||||
@@ -372,21 +428,23 @@ jobs:
|
||||
CLOSEOUT_DIR: ${{ runner.temp }}/openclaw-stable-main-closeout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
attach_or_verify() {
|
||||
local source_path="$1"
|
||||
local asset_name="$2"
|
||||
local existing_dir="$CLOSEOUT_DIR/existing-${asset_name}"
|
||||
mkdir -p "$existing_dir"
|
||||
if gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$asset_name" --dir "$existing_dir"; then
|
||||
gh_with_retry release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$asset_name" --dir "$existing_dir" || true
|
||||
if [[ -f "$existing_dir/$asset_name" ]]; then
|
||||
cmp --silent "$source_path" "$existing_dir/$asset_name" || {
|
||||
echo "Existing release asset $asset_name differs from closeout evidence." >&2
|
||||
exit 1
|
||||
}
|
||||
return
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
|
||||
gh_with_retry release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
|
||||
}
|
||||
attach_or_verify \
|
||||
"$CLOSEOUT_DIR/stable-main-closeout.json" \
|
||||
|
||||
4
.github/workflows/plugin-npm-release.yml
vendored
4
.github/workflows/plugin-npm-release.yml
vendored
@@ -38,8 +38,8 @@ on:
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
cancel-in-progress: false
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
2
.github/workflows/sandbox-common-smoke.yml
vendored
2
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -19,7 +19,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
@@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangqueping, and @jairrab.
|
||||
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93002, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangguiping-xydt, @zhangqueping, and @jairrab.
|
||||
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
|
||||
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
|
||||
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is available from npm and ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
@@ -25,7 +25,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
|
||||
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve pending subagent delivery, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469, #94349, #92383, #94257) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @vincentkoc, @sallyom, @oiGaDio, @Hidetsugu55, and @Nas01010101.
|
||||
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
|
||||
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, progress draft cleanup before visible tool output, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93002, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @zhangguiping-xydt, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
|
||||
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
|
||||
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
|
||||
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
|
||||
@@ -33,7 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Complete contribution record
|
||||
|
||||
This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
@@ -57,6 +57,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs.
|
||||
- **PR #88792** fix(state): harden sqlite path caching. Thanks @vincentkoc.
|
||||
- **PR #93022** fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.
|
||||
- **PR #93020** fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.
|
||||
- **PR #93002** fix(telegram): clear progress drafts before visible tool output. Thanks @zhangguiping-xydt.
|
||||
- **PR #89160** fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.
|
||||
- **PR #93009** fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.
|
||||
- **PR #92991** fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.
|
||||
|
||||
@@ -23,6 +23,10 @@ private struct WatchChatPreview {
|
||||
var statusText: String?
|
||||
}
|
||||
|
||||
private struct ExecApprovalGatewayEventPayload: Decodable {
|
||||
var id: String
|
||||
}
|
||||
|
||||
/// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@@ -895,26 +899,49 @@ final class NodeAppModel {
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
guard let payload = evt.payload else { continue }
|
||||
switch evt.event {
|
||||
case "voicewake.changed":
|
||||
struct Payload: Decodable { var triggers: [String] }
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
case "talk.mode":
|
||||
struct Payload: Decodable {
|
||||
var enabled: Bool
|
||||
var phase: String?
|
||||
}
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
await self.handleOperatorGatewayServerEvent(evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleOperatorGatewayServerEvent(_ evt: EventFrame) async {
|
||||
guard let payload = evt.payload else { return }
|
||||
switch evt.event {
|
||||
case "voicewake.changed":
|
||||
struct Payload: Decodable { var triggers: [String] }
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
|
||||
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
case "talk.mode":
|
||||
struct Payload: Decodable {
|
||||
var enabled: Bool
|
||||
var phase: String?
|
||||
}
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
|
||||
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
|
||||
case ExecApprovalNotificationBridge.requestedKind:
|
||||
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
|
||||
await self.presentExecApprovalNotificationPrompt(
|
||||
ExecApprovalNotificationPrompt(approvalId: approvalId))
|
||||
case ExecApprovalNotificationBridge.resolvedKind:
|
||||
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
|
||||
await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func execApprovalEventID(from payload: AnyCodable) -> String? {
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: ExecApprovalGatewayEventPayload.self)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let approvalId = decoded.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return approvalId.isEmpty ? nil : approvalId
|
||||
}
|
||||
|
||||
private func applyTalkModeSync(enabled: Bool, phase: String?) {
|
||||
_ = phase
|
||||
guard self.talkMode.isEnabled != enabled else { return }
|
||||
@@ -5139,6 +5166,14 @@ extension NodeAppModel {
|
||||
isBackgrounded: isBackgrounded)
|
||||
}
|
||||
|
||||
nonisolated static func _test_execApprovalEventID(from payload: AnyCodable) -> String? {
|
||||
self.execApprovalEventID(from: payload)
|
||||
}
|
||||
|
||||
func _test_handleOperatorGatewayServerEvent(_ event: EventFrame) async {
|
||||
await self.handleOperatorGatewayServerEvent(event)
|
||||
}
|
||||
|
||||
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: [String],
|
||||
cachedApprovalIDs: [String]) -> [String]
|
||||
|
||||
@@ -1160,6 +1160,35 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
isBackgrounded: false))
|
||||
}
|
||||
|
||||
@Test func execApprovalEventIDDecodesGatewayPayload() {
|
||||
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1")
|
||||
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil)
|
||||
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func operatorGatewayResolvedEventClearsPendingApprovalPrompt() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
try appModel._test_presentExecApprovalPrompt(
|
||||
#require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-event-resolved",
|
||||
commandText: "echo clear",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: nil,
|
||||
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
|
||||
|
||||
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
|
||||
type: "event",
|
||||
event: ExecApprovalNotificationBridge.resolvedKind,
|
||||
payload: AnyCodable(["id": "approval-event-resolved"]),
|
||||
seq: nil,
|
||||
stateversion: nil))
|
||||
|
||||
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
|
||||
}
|
||||
|
||||
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
|
||||
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],
|
||||
|
||||
@@ -6592,6 +6592,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let turnsourceto: AnyCodable?
|
||||
public let turnsourceaccountid: AnyCodable?
|
||||
public let turnsourcethreadid: AnyCodable?
|
||||
public let approvalreviewerdeviceids: [String]?
|
||||
public let requiredeliveryroute: Bool?
|
||||
public let suppressdelivery: Bool?
|
||||
public let timeoutms: Int?
|
||||
@@ -6618,6 +6619,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
turnsourceto: AnyCodable?,
|
||||
turnsourceaccountid: AnyCodable?,
|
||||
turnsourcethreadid: AnyCodable?,
|
||||
approvalreviewerdeviceids: [String]?,
|
||||
requiredeliveryroute: Bool? = nil,
|
||||
suppressdelivery: Bool? = nil,
|
||||
timeoutms: Int?,
|
||||
@@ -6643,6 +6645,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.turnsourceto = turnsourceto
|
||||
self.turnsourceaccountid = turnsourceaccountid
|
||||
self.turnsourcethreadid = turnsourcethreadid
|
||||
self.approvalreviewerdeviceids = approvalreviewerdeviceids
|
||||
self.requiredeliveryroute = requiredeliveryroute
|
||||
self.suppressdelivery = suppressdelivery
|
||||
self.timeoutms = timeoutms
|
||||
@@ -6670,6 +6673,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case turnsourceto = "turnSourceTo"
|
||||
case turnsourceaccountid = "turnSourceAccountId"
|
||||
case turnsourcethreadid = "turnSourceThreadId"
|
||||
case approvalreviewerdeviceids = "approvalReviewerDeviceIds"
|
||||
case requiredeliveryroute = "requireDeliveryRoute"
|
||||
case suppressdelivery = "suppressDelivery"
|
||||
case timeoutms = "timeoutMs"
|
||||
|
||||
@@ -128,14 +128,9 @@ const config = {
|
||||
"**/*.test-utils.ts",
|
||||
"test/helpers/live-image-probe.ts",
|
||||
"src/secrets/credential-matrix.ts",
|
||||
"src/gateway/live-tool-probe-utils.ts",
|
||||
"src/gateway/server.auth.shared.ts",
|
||||
"src/shared/text/assistant-visible-text.ts",
|
||||
bundledPluginFile("telegram", "src/bot/reply-threading.ts"),
|
||||
bundledPluginFile("telegram", "src/draft-chunking.ts"),
|
||||
bundledPluginFile("msteams", "src/conversation-store-memory.ts"),
|
||||
bundledPluginFile("msteams", "src/polls-store-memory.ts"),
|
||||
bundledPluginFile("voice-call", "src/providers/index.ts"),
|
||||
],
|
||||
ignore: ["packages/*/dist/**"],
|
||||
workspaces: {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
d5a8dc906d615f081799783ffda46b48dac43c3de9ddcb7c4b95311031fbe80e plugin-sdk-api-baseline.json
|
||||
47d93e8b79e5d5fd0ef0a607a831a5b205c94e759a48401ce4a34da98e42b93d plugin-sdk-api-baseline.jsonl
|
||||
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
|
||||
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
summary: "Slack setup and runtime behavior (Socket Mode + HTTP Request URLs)"
|
||||
summary: "Slack setup and runtime behavior (Socket Mode, HTTP Request URLs, and relay mode)"
|
||||
read_when:
|
||||
- Setting up Slack or debugging Slack socket/HTTP mode
|
||||
- Setting up Slack or debugging Slack socket, HTTP, or relay mode
|
||||
title: "Slack"
|
||||
---
|
||||
|
||||
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported.
|
||||
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported. Relay mode is intended for managed deployments where a trusted router owns Slack ingress.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
@@ -41,6 +41,37 @@ Both transports are production-ready and reach feature parity for messaging, sla
|
||||
**Pick HTTP Request URLs** when running multiple Gateway replicas behind a load balancer, when outbound WSS is blocked but inbound HTTPS is allowed, or when you already terminate Slack webhooks at a reverse proxy.
|
||||
</Note>
|
||||
|
||||
### Relay mode
|
||||
|
||||
Relay mode separates Slack ingress from the OpenClaw gateway. A trusted router owns the
|
||||
single Slack Socket Mode connection, chooses a destination gateway, and forwards a typed
|
||||
event over an authenticated websocket. The gateway continues to use its bot token for
|
||||
outbound Slack Web API calls.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay",
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
relay: {
|
||||
url: "wss://router.example.com/gateway/ws",
|
||||
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
|
||||
gatewayId: "team-gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The relay URL must use `wss://` unless it targets localhost. Treat the bearer token and
|
||||
router route table as part of the Slack authorization boundary: routed events enter the
|
||||
normal Slack message handler as authorized activations. A router-provided `slack_identity`
|
||||
in the websocket `hello` frame can set the default outbound username and icon; an explicit
|
||||
identity supplied by the caller still wins. The relay connection reconnects with the same
|
||||
bounded backoff timing used by Socket Mode and clears the router-provided identity whenever
|
||||
it disconnects.
|
||||
|
||||
## Install
|
||||
|
||||
Install Slack before configuring the channel:
|
||||
@@ -863,7 +894,8 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
|
||||
|
||||
- `botToken` + `appToken` are required for Socket Mode.
|
||||
- HTTP mode requires `botToken` + `signingSecret`.
|
||||
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
|
||||
- Relay mode requires `botToken` plus `relay.url`, `relay.authToken`, and `relay.gatewayId`; it does not use an app token or signing secret.
|
||||
- `botToken`, `appToken`, `signingSecret`, `relay.authToken`, and `userToken` accept plaintext
|
||||
strings or SecretRef objects.
|
||||
- Config tokens override env fallback.
|
||||
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.
|
||||
|
||||
@@ -336,7 +336,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
Requirement:
|
||||
|
||||
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
|
||||
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
|
||||
- short initial answer previews are debounced, then materialized after a bounded delay if the run is still active
|
||||
- `progress` keeps one editable status draft for tool progress, shows the stable status label when answer activity arrives before tool progress, clears it at completion, and sends the final answer as a normal message
|
||||
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
|
||||
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
|
||||
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
|
||||
|
||||
@@ -172,10 +172,12 @@ A finding includes:
|
||||
| `ocPath` | Precise `oc://` address when a check can point to one. |
|
||||
| `fixHint` | Suggested operator action or repair summary. |
|
||||
|
||||
This release registers the modernized core doctor checks on the structured
|
||||
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
|
||||
contract for bundled follow-up consumers, but plugin-backed checks only run
|
||||
after their owning package registers them in the active command path.
|
||||
Modernized core doctor checks stay attached to the ordered doctor contribution
|
||||
that owns their human `doctor` / `doctor --fix` behavior. The shared structured
|
||||
health registry is the extension point: bundled and plugin-backed checks run
|
||||
after core doctor checks once their owning package registers them in the active
|
||||
command path. The `openclaw/plugin-sdk/health` subpath exposes the same
|
||||
contract for those extension consumers.
|
||||
|
||||
## Check Selection
|
||||
|
||||
|
||||
@@ -230,8 +230,8 @@ canonical subscription `github-copilot` provider and is **never** selected by
|
||||
The harness claims its provider, runtime, CLI session key, and auth profile
|
||||
prefix in `extensions/copilot/doctor-contract-api.ts`, which
|
||||
`openclaw doctor` auto-loads. For configuration, auth, transcript mirroring,
|
||||
compaction, the doctor probe surface, and the broader PI vs Codex vs Copilot
|
||||
SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
|
||||
compaction, the declarative doctor contract, and the broader PI vs Codex vs
|
||||
Copilot SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
|
||||
|
||||
## Compatibility contract
|
||||
|
||||
|
||||
@@ -302,13 +302,13 @@ Live transport runners should import the shared scenario ids, baseline
|
||||
coverage helpers, and scenario-selection helper from
|
||||
`openclaw/plugin-sdk/qa-live-transport-scenarios`.
|
||||
|
||||
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
|
||||
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
|
||||
| Matrix | x | x | x | x | x | x | x | x | x | | |
|
||||
| Telegram | x | x | x | | | | | | | x | |
|
||||
| Discord | x | x | x | | | | | | | | x |
|
||||
| Slack | x | x | x | x | x | x | x | x | | | |
|
||||
| WhatsApp | x | x | | x | x | x | | | x | x | |
|
||||
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Quote reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
|
||||
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | ----------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
|
||||
| Matrix | x | x | x | x | x | | x | x | x | x | | |
|
||||
| Telegram | x | x | x | | | | | | | | x | |
|
||||
| Discord | x | x | x | | | | | | | | | x |
|
||||
| Slack | x | x | x | x | x | | x | x | x | | | |
|
||||
| WhatsApp | x | x | | x | x | x | x | | | x | x | |
|
||||
|
||||
This keeps `qa-channel` as the broad product-behavior suite while Matrix,
|
||||
Telegram, and other live transports share one explicit transport-contract checklist.
|
||||
@@ -731,8 +731,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
|
||||
`whatsapp-whoami-command`, `whatsapp-context-command`,
|
||||
`whatsapp-native-new-command`.
|
||||
- Reply and final-output behavior: `whatsapp-tool-only-usage-footer`,
|
||||
`whatsapp-reply-to-message`, `whatsapp-reply-context-isolation`,
|
||||
`whatsapp-reply-delivery-shape`, `whatsapp-stream-final-message-accounting`.
|
||||
`whatsapp-reply-to-message`, `whatsapp-group-reply-to-message`,
|
||||
`whatsapp-reply-context-isolation`, `whatsapp-reply-delivery-shape`,
|
||||
`whatsapp-stream-final-message-accounting`.
|
||||
- Inbound media and structured messages: `whatsapp-inbound-image-caption`,
|
||||
`whatsapp-audio-preflight`, `whatsapp-inbound-structured-messages`,
|
||||
`whatsapp-group-audio-gating`. These send real WhatsApp image, audio,
|
||||
@@ -749,9 +750,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
|
||||
`whatsapp-approval-plugin-native`.
|
||||
- Status reactions: `whatsapp-status-reactions`.
|
||||
|
||||
The catalog currently contains 35 scenarios. The `live-frontier` default lane is
|
||||
kept small at 8 scenarios for fast smoke coverage. The `mock-openai` default
|
||||
lane runs 29 deterministic scenarios through the real WhatsApp transport while
|
||||
The catalog currently contains 36 scenarios. The `live-frontier` default lane is
|
||||
kept small at 10 scenarios for fast smoke coverage. The `mock-openai` default
|
||||
lane runs 31 deterministic scenarios through the real WhatsApp transport while
|
||||
mocking only model output. Approval scenarios and a few heavier/blocking checks
|
||||
remain explicit by scenario id.
|
||||
|
||||
|
||||
@@ -160,9 +160,10 @@ Legacy key migration:
|
||||
Telegram:
|
||||
|
||||
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
|
||||
- Short initial previews are still debounced for push-notification UX, but Telegram now materializes them after a bounded delay so active runs do not stay visually silent.
|
||||
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
|
||||
- `block` mode rotates the preview into a new message at `streaming.preview.chunk.maxChars` (default 800, capped at Telegram's 4096 edit limit); other modes grow one preview up to 4096 characters.
|
||||
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
|
||||
- `progress` mode keeps tool progress in an editable status draft, materializes the status label when answer streaming is active but no tool line is available yet, clears that draft at completion, and sends the final answer through normal delivery.
|
||||
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
|
||||
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
|
||||
- `/reasoning stream` can write reasoning to a transient preview that is deleted after final delivery.
|
||||
|
||||
@@ -249,9 +249,10 @@ Shared defaults for bounded runtime context surfaces.
|
||||
- `toolResultMaxChars`: advanced live tool-result ceiling used for persisted
|
||||
results and overflow recovery. Leave unset for the model-context auto cap:
|
||||
`16000` chars below 100K tokens, `32000` chars at 100K+ tokens, and `64000`
|
||||
chars at 200K+ tokens. The effective cap is still limited to about 30% of the
|
||||
model context window. `openclaw doctor --deep` prints the effective cap, and
|
||||
doctor warns only when an explicit override is stale or has no effect.
|
||||
chars at 200K+ tokens. Explicit values up to `1000000` are accepted for
|
||||
long-context models, but the effective cap is still limited to about 30% of
|
||||
the model context window. `openclaw doctor --deep` prints the effective cap,
|
||||
and doctor warns only when an explicit override is stale or has no effect.
|
||||
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
|
||||
refresh injection.
|
||||
|
||||
|
||||
@@ -160,8 +160,6 @@ must be paired with `--lint`; regular doctor and repair runs reject them.
|
||||
- State integrity and permissions checks (sessions, transcripts, state dir).
|
||||
- Config file permission checks (chmod 600) when running locally.
|
||||
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
|
||||
- Extra workspace dir detection (`~/openclaw`).
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Gateway, services, and supervisors">
|
||||
- Sandbox image repair when sandboxing is enabled.
|
||||
@@ -469,14 +467,14 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
<Accordion title="10. systemd linger (Linux)">
|
||||
If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout.
|
||||
</Accordion>
|
||||
<Accordion title="11. Workspace status (skills, plugins, and legacy dirs)">
|
||||
<Accordion title="11. Workspace status (skills, plugins, and TaskFlows)">
|
||||
Doctor prints a summary of the workspace state for the default agent:
|
||||
|
||||
- **Skills status**: counts eligible, missing-requirements, and allowlist-blocked skills.
|
||||
- **Legacy workspace dirs**: warns when `~/openclaw` or other legacy workspace directories exist alongside the current workspace.
|
||||
- **Plugin status**: counts enabled/disabled/errored plugins; lists plugin IDs for any errors; reports bundle plugin capabilities.
|
||||
- **Plugin compatibility warnings**: flags plugins that have compatibility issues with the current runtime.
|
||||
- **Plugin diagnostics**: surfaces any load-time warnings or errors emitted by the plugin registry.
|
||||
- **TaskFlow recovery**: surfaces suspicious managed TaskFlows that need manual inspection or cancellation.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="11b. Bootstrap file size">
|
||||
|
||||
@@ -15,15 +15,18 @@ OpenClaw treats **wake words as a single global list** owned by the **Gateway**.
|
||||
|
||||
## Storage (Gateway host)
|
||||
|
||||
Wake words are stored on the gateway machine at:
|
||||
Wake words and routing rules are stored in the gateway state database:
|
||||
|
||||
- `~/.openclaw/settings/voicewake.json`
|
||||
- `~/.openclaw/state/openclaw.sqlite`
|
||||
|
||||
Shape:
|
||||
The active tables are:
|
||||
|
||||
```json
|
||||
{ "triggers": ["openclaw", "claude", "computer"], "updatedAtMs": 1730000000000 }
|
||||
```
|
||||
- `voicewake_triggers`
|
||||
- `voicewake_routing_config`
|
||||
- `voicewake_routing_routes`
|
||||
|
||||
Legacy `settings/voicewake.json` and `settings/voicewake-routing.json` files are
|
||||
doctor migration inputs only; runtime reads and writes the SQLite tables.
|
||||
|
||||
## Protocol
|
||||
|
||||
|
||||
@@ -145,6 +145,11 @@ local proof.
|
||||
Use `definePluginEntry` for non-channel plugins. Channel plugins use
|
||||
`defineChannelPluginEntry`.
|
||||
|
||||
Tool handlers may accept an optional fifth execution-context argument when
|
||||
they need runtime-owned facts for the current call. The context includes the
|
||||
active `runId`, effective `sessionKey`, ephemeral `sessionId`, owning
|
||||
`agentId`, and ambient `deliveryContext` when those values are available.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Test the runtime">
|
||||
|
||||
@@ -33,15 +33,12 @@ For the broader model/provider/runtime split, start with
|
||||
- A GitHub Copilot subscription that can drive the Copilot CLI (or a
|
||||
`gitHubToken` env / auth-profile entry for headless / cron runs).
|
||||
- A writable `copilotHome` directory. The harness defaults to
|
||||
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation. The
|
||||
platform default (`%APPDATA%\copilot` on Windows, `$XDG_CONFIG_HOME/copilot`
|
||||
or `~/.config/copilot` elsewhere) is used as the doctor probe fallback when
|
||||
no explicit home is set.
|
||||
`<agentDir>/copilot` when OpenClaw provides an agent directory, otherwise
|
||||
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation.
|
||||
|
||||
`openclaw doctor` runs the plugin
|
||||
[doctor contract](#doctor-and-probes) for the extension; failures there are
|
||||
the canonical way to confirm the environment is ready before opting an agent
|
||||
in.
|
||||
[doctor contract](#doctor) for declarative session-state ownership and future
|
||||
compatibility migrations. It does not run Copilot CLI environment probes.
|
||||
|
||||
## Plugin install
|
||||
|
||||
@@ -79,9 +76,9 @@ Pin one model (or one provider) to the harness:
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "github-copilot/gpt-5.5",
|
||||
model: "github-copilot/auto",
|
||||
models: {
|
||||
"github-copilot/gpt-5.5": {
|
||||
"github-copilot/auto": {
|
||||
agentRuntime: { id: "copilot" },
|
||||
},
|
||||
},
|
||||
@@ -95,6 +92,10 @@ when only that model should be routed through the harness; set
|
||||
`agentRuntime.id` on a provider when every model under that provider should
|
||||
use it.
|
||||
|
||||
`github-copilot/auto` is the portable starting point. Named Copilot models are
|
||||
account- and organization-policy-dependent, so only pin one after confirming
|
||||
that the authenticated Copilot CLI exposes it.
|
||||
|
||||
## Supported providers
|
||||
|
||||
The harness advertises support for the canonical `github-copilot` provider
|
||||
@@ -149,10 +150,6 @@ the same directory), or `~/.openclaw/agents/<agentId>/copilot` otherwise.
|
||||
Override with `copilotHome: <path>` on the attempt input when you need a
|
||||
custom location (for example, a shared mount for migration).
|
||||
|
||||
`probeCopilotAuthShape` (see [Doctor and probes](#doctor-and-probes)) is the
|
||||
pure shape check that validates which of the modes above will be used.
|
||||
It does not perform a live SDK handshake.
|
||||
|
||||
## Configuration surface
|
||||
|
||||
The harness reads its config from per-attempt input
|
||||
@@ -169,8 +166,9 @@ The harness reads its config from per-attempt input
|
||||
- `infiniteSessionConfig` — optional override for the SDK
|
||||
`infiniteSessions` block driven by `harness.compact`. Defaults are safe to
|
||||
leave as-is.
|
||||
- `hooksConfig` — optional bridge config exposing OpenClaw
|
||||
before/after-message-write hooks to the SDK loop.
|
||||
- `hooksConfig` — optional native Copilot SDK `SessionHooks` compatibility
|
||||
config for tool/MCP, user-prompt, session, and error callbacks.
|
||||
It is separate from OpenClaw's portable lifecycle hooks.
|
||||
- `permissionPolicy` — optional override for the SDK's
|
||||
`onPermissionRequest` handler used for built-in SDK tool kinds
|
||||
(`shell`, `write`, `read`, `url`, `mcp`, `memory`, `hook`). Defaults
|
||||
@@ -181,6 +179,14 @@ The harness reads its config from per-attempt input
|
||||
wrapped `execute()`. See [Permissions and ask_user](#permissions-and-ask_user).
|
||||
- `enableSessionTelemetry` — optional SDK session telemetry flag.
|
||||
|
||||
OpenClaw plugin hooks do not need Copilot-specific attempt configuration. The
|
||||
harness runs `before_prompt_build` (and the legacy `before_agent_start`
|
||||
compatibility hook), `llm_input`, `llm_output`, and `agent_end` through the
|
||||
standard harness helpers. Successful SDK compactions also run
|
||||
`before_compaction` and `after_compaction`. Bridged OpenClaw tools continue to
|
||||
run `before_tool_call` and report `after_tool_call`; `hooksConfig` remains for
|
||||
native SDK-only callbacks that have no portable equivalent.
|
||||
|
||||
Nothing in the rest of OpenClaw needs to know about these fields. Other
|
||||
plugins, channels, and core code only see the standard
|
||||
`AgentHarnessAttemptParams` / `AgentHarnessAttemptResult` shape.
|
||||
@@ -226,7 +232,7 @@ asserted in
|
||||
[`extensions/copilot/harness.test.ts`](https://github.com/openclaw/openclaw/blob/main/extensions/copilot/harness.test.ts)
|
||||
under `describe("runSideQuestion")`.
|
||||
|
||||
## Doctor and probes
|
||||
## Doctor
|
||||
|
||||
`extensions/copilot/doctor-contract-api.ts` is auto-loaded by
|
||||
`src/plugins/doctor-contract-registry.ts`. It contributes:
|
||||
@@ -238,18 +244,6 @@ under `describe("runSideQuestion")`.
|
||||
runtime `copilot`; CLI session key `copilot`; auth profile
|
||||
prefix `github-copilot:`.
|
||||
|
||||
`extensions/copilot/src/doctor-probes.ts` exports three imperative probes
|
||||
that hosts (including `openclaw doctor`) can call to verify the environment:
|
||||
|
||||
| Probe | What it checks | Reasons it can fail |
|
||||
| -------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `probeCopilotCliVersion` | `copilot --version` exits 0 with a non-empty version string | `non-zero-exit`, `empty-version`, `spawn-failed`, `spawn-error`, `probe-timeout` |
|
||||
| `probeCopilotHomeWritable` | `mkdir -p copilotHome` + write + rm a marker file | `copilothome-not-writable` (with the underlying fs error in `details.rawError`) |
|
||||
| `probeCopilotAuthShape` | At least one of `useLoggedInUser`, `gitHubToken`, or `profileId`+`profileVersion` | `no-auth-source` |
|
||||
|
||||
Each probe accepts a DI seam (`spawnFn`, `fsApi`) so tests do not spawn the
|
||||
real Copilot CLI or touch the host fs.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The harness only claims the canonical `github-copilot` provider at MVP.
|
||||
|
||||
@@ -185,6 +185,17 @@ field; OpenClaw does not infer it from assistant prose. The helper intentionally
|
||||
leaves prompt errors, in-flight turns, and intentional silent replies such as
|
||||
`NO_REPLY` unclassified.
|
||||
|
||||
### Agent-end side effects
|
||||
|
||||
Native harnesses must call `runAgentEndSideEffects(...)` from
|
||||
`openclaw/plugin-sdk/agent-harness-runtime` after they finalize an attempt. It
|
||||
dispatches the portable `agent_end` hook and OpenClaw's research capture without
|
||||
delaying interactive replies. Use `awaitAgentEndSideEffects(...)` for local,
|
||||
non-interactive runs where the attempt must not resolve until those side effects
|
||||
finish. Both helpers accept the same `{ event, ctx }` payload as
|
||||
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
|
||||
attempt result.
|
||||
|
||||
### Native Codex harness mode
|
||||
|
||||
The bundled `codex` harness is the native Codex mode for embedded OpenClaw
|
||||
|
||||
@@ -238,9 +238,11 @@ releases.
|
||||
`api.runtime.config.writeConfigFile(...)` directly. Prefer config that was
|
||||
already passed into the active call path. Long-lived handlers that need the
|
||||
current process snapshot can use `api.runtime.config.current()`. Long-lived
|
||||
agent tools should use the tool context's `ctx.getRuntimeConfig()` inside
|
||||
`execute` so a tool created before a config write still sees the refreshed
|
||||
runtime config.
|
||||
factory-created agent tools should use the tool factory context's
|
||||
`ctx.getRuntimeConfig()` inside `execute` so a tool created before a config
|
||||
write still sees the refreshed runtime config. For per-call run, session, or
|
||||
delivery facts, use the tool execution context rather than closing over the
|
||||
factory context.
|
||||
|
||||
Config writes must go through the transactional helpers and choose an
|
||||
after-write policy:
|
||||
|
||||
@@ -166,7 +166,9 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
|
||||
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
|
||||
|
||||
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
|
||||
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
|
||||
|
||||
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.agent.defaults">
|
||||
|
||||
@@ -247,7 +247,8 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), bounded recent user/assistant transcript text reads by session identity, legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
|
||||
|
||||
@@ -151,6 +151,14 @@ Factories are still for fixed tool names. Use `definePluginEntry` directly when
|
||||
the plugin computes tool names dynamically or combines tools with hooks,
|
||||
services, providers, commands, or other runtime surfaces.
|
||||
|
||||
Factory context is construction-time state. Use it to decide whether the tool
|
||||
exists for the run or to bind stable helpers. Per-call state belongs in the
|
||||
execution context: static tool-plugin `execute` handlers receive it as fields on
|
||||
their third `context` argument, and factory-created `AgentTool.execute`
|
||||
handlers receive it as the optional fifth argument. The execution context
|
||||
includes `runId`, effective `sessionKey`, `sessionId`, `agentId`, and
|
||||
`deliveryContext` when OpenClaw knows those values.
|
||||
|
||||
## Return values
|
||||
|
||||
`defineToolPlugin` wraps plain return values into the OpenClaw tool-result
|
||||
|
||||
@@ -28,8 +28,10 @@ The provider includes:
|
||||
| ------------------------------- | --------------------- |
|
||||
| `opencode-go/glm-5` | GLM-5 |
|
||||
| `opencode-go/glm-5.1` | GLM-5.1 |
|
||||
| `opencode-go/glm-5.2` | GLM-5.2 |
|
||||
| `opencode-go/kimi-k2.5` | Kimi K2.5 |
|
||||
| `opencode-go/kimi-k2.6` | Kimi K2.6 (3x limits) |
|
||||
| `opencode-go/kimi-k2.7-code` | Kimi K2.7 Code |
|
||||
| `opencode-go/deepseek-v4-pro` | DeepSeek V4 Pro |
|
||||
| `opencode-go/deepseek-v4-flash` | DeepSeek V4 Flash |
|
||||
| `opencode-go/mimo-v2-omni` | MiMo V2 Omni |
|
||||
@@ -39,6 +41,8 @@ The provider includes:
|
||||
| `opencode-go/qwen3.5-plus` | Qwen3.5 Plus |
|
||||
| `opencode-go/qwen3.6-plus` | Qwen3.6 Plus |
|
||||
|
||||
GLM-5.2 uses a 1M-token context window and supports up to 131K output tokens.
|
||||
|
||||
## Getting started
|
||||
|
||||
<Tabs>
|
||||
|
||||
@@ -126,6 +126,11 @@ The manifest-backed catalog currently includes:
|
||||
GLM models are available as `zai/<model>` (example: `zai/glm-5`).
|
||||
</Tip>
|
||||
|
||||
<Tip>
|
||||
GLM-5.2 supports `off`, `low`, `high`, and `max` thinking levels. OpenClaw maps
|
||||
`low` and `high` to Z.AI high reasoning effort, and `max` to max effort.
|
||||
</Tip>
|
||||
|
||||
<Note>
|
||||
Coding Plan setup defaults to `zai/glm-5.2`; general API setup keeps
|
||||
`zai/glm-5.1`. Endpoint auto-detection falls back to `glm-5.1` or `glm-4.7`
|
||||
|
||||
@@ -72,10 +72,12 @@ Scope intent:
|
||||
- `channels.telegram.accounts.*.webhookSecret`
|
||||
- `channels.slack.botToken`
|
||||
- `channels.slack.appToken`
|
||||
- `channels.slack.relay.authToken`
|
||||
- `channels.slack.userToken`
|
||||
- `channels.slack.signingSecret`
|
||||
- `channels.slack.accounts.*.botToken`
|
||||
- `channels.slack.accounts.*.appToken`
|
||||
- `channels.slack.accounts.*.relay.authToken`
|
||||
- `channels.slack.accounts.*.userToken`
|
||||
- `channels.slack.accounts.*.signingSecret`
|
||||
- `channels.sms.authToken`
|
||||
|
||||
@@ -295,6 +295,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.accounts.*.relay.authToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.accounts.*.relay.authToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.accounts.*.signingSecret",
|
||||
"configFile": "openclaw.json",
|
||||
@@ -323,6 +330,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.relay.authToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.relay.authToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.signingSecret",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -54,7 +54,7 @@ for bounded runtime excerpts and injected runtime-owned blocks. They are
|
||||
separate from bootstrap limits, startup-context limits, and skills prompt
|
||||
limits.
|
||||
|
||||
`toolResultMaxChars` is an advanced ceiling. When it is unset, OpenClaw chooses
|
||||
`toolResultMaxChars` is an advanced ceiling (up to `1000000` characters). When it is unset, OpenClaw chooses
|
||||
the live tool-result cap from the effective model context window: `16000` chars
|
||||
below 100K tokens, `32000` chars at 100K+ tokens, and `64000` chars at 200K+
|
||||
tokens, still bounded by the runtime context-share guard.
|
||||
|
||||
@@ -34,7 +34,7 @@ title: "Thinking levels"
|
||||
- Stale configured OpenRouter Hunter Alpha refs skip proxy reasoning injection because that retired route could return final answer text through reasoning fields.
|
||||
- Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family.
|
||||
- MiniMax M2.x (`minimax/MiniMax-M2*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from M2.x's non-native Anthropic stream format. MiniMax-M3 (and M3.x) is exempt: M3 emits proper Anthropic thinking blocks and returns empty content when thinking is disabled, so OpenClaw keeps M3 on the provider's omitted/adaptive thinking path.
|
||||
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
|
||||
- Z.AI (`zai/*`) is binary (`on`/`off`) for most GLM models. GLM-5.2 is the exception: it exposes `/think off|low|high|max`, maps `low` and `high` to Z.AI `reasoning_effort: "high"`, and maps `max` to `reasoning_effort: "max"`.
|
||||
- Moonshot Kimi K2.7 Code (`moonshot/kimi-k2.7-code`) always thinks. Its profile exposes only `on`, and OpenClaw omits the outbound `thinking` field as required by Moonshot. Other `moonshot/*` models map `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
|
||||
|
||||
## Resolution order
|
||||
|
||||
@@ -6,8 +6,6 @@ type SharedIniFileLoader = {
|
||||
loadSharedConfigFiles(init?: { ignoreCache?: boolean }): Promise<unknown>;
|
||||
};
|
||||
|
||||
let sharedIniFileLoaderForTest: SharedIniFileLoader | null | undefined;
|
||||
|
||||
function hasStaticAwsCredentialEnv(env: NodeJS.ProcessEnv): boolean {
|
||||
return Boolean(env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY);
|
||||
}
|
||||
@@ -21,12 +19,6 @@ export function shouldRefreshAwsSharedConfigCacheForBedrock(env: NodeJS.ProcessE
|
||||
}
|
||||
|
||||
async function loadSharedIniFileLoader(): Promise<SharedIniFileLoader> {
|
||||
if (sharedIniFileLoaderForTest !== undefined) {
|
||||
if (!sharedIniFileLoaderForTest) {
|
||||
throw new Error("AWS shared INI file loader unavailable");
|
||||
}
|
||||
return sharedIniFileLoaderForTest;
|
||||
}
|
||||
return (await import("@smithy/shared-ini-file-loader")) as SharedIniFileLoader;
|
||||
}
|
||||
|
||||
@@ -40,10 +32,3 @@ export async function refreshAwsSharedConfigCacheForBedrock(
|
||||
const loader = await loadSharedIniFileLoader();
|
||||
await loader.loadSharedConfigFiles({ ignoreCache: true });
|
||||
}
|
||||
|
||||
/** Override the shared INI loader for Bedrock credential-refresh tests. */
|
||||
export function setAwsSharedIniFileLoaderForTest(
|
||||
loader: SharedIniFileLoader | null | undefined,
|
||||
): void {
|
||||
sharedIniFileLoaderForTest = loader;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,9 @@ import {
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setAwsSharedIniFileLoaderForTest } from "./aws-credential-refresh.js";
|
||||
import { supportsBedrockPromptCaching } from "./bedrock-options.js";
|
||||
import { resetBedrockDiscoveryCacheForTest } from "./discovery.js";
|
||||
import amazonBedrockPlugin from "./index.js";
|
||||
import {
|
||||
resetBedrockAppProfileCacheEligibilityForTest,
|
||||
setBedrockAppProfileControlPlaneForTest,
|
||||
} from "./register.sync.runtime.js";
|
||||
|
||||
type BedrockClientResult =
|
||||
| {
|
||||
@@ -96,6 +91,10 @@ vi.mock("@aws-sdk/client-bedrock", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@smithy/shared-ini-file-loader", () => ({
|
||||
loadSharedConfigFiles: refreshSharedConfigCache,
|
||||
}));
|
||||
|
||||
type RegisteredProviderPlugin = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
|
||||
|
||||
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
|
||||
@@ -149,6 +148,8 @@ const ANTHROPIC_MODEL_DESCRIPTOR = {
|
||||
|
||||
const APP_INFERENCE_PROFILE_ARN =
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-claude-profile";
|
||||
const OPUS_APP_INFERENCE_PROFILE_ARN =
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opus-temperature-profile";
|
||||
const APP_INFERENCE_PROFILE_DESCRIPTOR = {
|
||||
api: "openai-completions",
|
||||
provider: "amazon-bedrock",
|
||||
@@ -267,26 +268,12 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
inferenceProfileGetResults.length = 0;
|
||||
bedrockClientConfigs.length = 0;
|
||||
refreshSharedConfigCache.mockClear();
|
||||
setAwsSharedIniFileLoaderForTest({ loadSharedConfigFiles: refreshSharedConfigCache });
|
||||
sendBedrockCommand.mockClear();
|
||||
resetBedrockDiscoveryCacheForTest();
|
||||
resetBedrockAppProfileCacheEligibilityForTest();
|
||||
setBedrockAppProfileControlPlaneForTest((region) => ({
|
||||
async getInferenceProfile(input) {
|
||||
class GetInferenceProfileCommand {
|
||||
constructor(readonly inputLocal: Record<string, unknown> = {}) {}
|
||||
}
|
||||
bedrockClientConfigs.push(region ? { region } : {});
|
||||
return await sendBedrockCommand(new GetInferenceProfileCommand(input));
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setBedrockAppProfileControlPlaneForTest(undefined);
|
||||
setAwsSharedIniFileLoaderForTest(undefined);
|
||||
resetBedrockDiscoveryCacheForTest();
|
||||
resetBedrockAppProfileCacheEligibilityForTest();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -1501,8 +1488,8 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
|
||||
await callWrappedStreamWithPayload(
|
||||
provider,
|
||||
APP_INFERENCE_PROFILE_ARN,
|
||||
APP_INFERENCE_PROFILE_DESCRIPTOR,
|
||||
OPUS_APP_INFERENCE_PROFILE_ARN,
|
||||
makeAppInferenceProfileDescriptor(OPUS_APP_INFERENCE_PROFILE_ARN),
|
||||
{ temperature: 0.3, maxTokens: 10, cacheRetention: "short" },
|
||||
payload,
|
||||
);
|
||||
|
||||
@@ -254,27 +254,7 @@ type BedrockControlPlane = {
|
||||
}) => Promise<BedrockGetInferenceProfileResponse>;
|
||||
};
|
||||
|
||||
type BedrockControlPlaneFactory = (region: string | undefined) => BedrockControlPlane;
|
||||
|
||||
let bedrockControlPlaneOverride: BedrockControlPlaneFactory | undefined;
|
||||
|
||||
/** Reset app-profile prompt-cache eligibility state for tests. */
|
||||
export function resetBedrockAppProfileCacheEligibilityForTest(): void {
|
||||
appProfileTraitsCache.clear();
|
||||
}
|
||||
|
||||
/** Override Bedrock app-profile control-plane checks for tests. */
|
||||
export function setBedrockAppProfileControlPlaneForTest(
|
||||
controlPlane: BedrockControlPlaneFactory | undefined,
|
||||
): void {
|
||||
bedrockControlPlaneOverride = controlPlane;
|
||||
resetBedrockAppProfileCacheEligibilityForTest();
|
||||
}
|
||||
|
||||
async function createBedrockControlPlane(region: string | undefined): Promise<BedrockControlPlane> {
|
||||
if (bedrockControlPlaneOverride) {
|
||||
return bedrockControlPlaneOverride(region);
|
||||
}
|
||||
await refreshAwsSharedConfigCacheForBedrock();
|
||||
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
|
||||
const client = new BedrockClient(region ? { region } : {});
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
resolveClaudeCliExecutionArgs,
|
||||
} from "./cli-shared.js";
|
||||
|
||||
function expectDefaultDisallowedTools(args: readonly string[] | undefined) {
|
||||
const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1;
|
||||
expect(disallowedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(args?.[disallowedIndex + 1]).toBe("ScheduleWakeup,CronCreate");
|
||||
}
|
||||
|
||||
describe("normalizeClaudePermissionArgs", () => {
|
||||
it("leaves args alone when they omit permission flags", () => {
|
||||
expect(
|
||||
@@ -356,8 +362,10 @@ describe("normalizeClaudeBackendConfig", () => {
|
||||
expect(backend.config.input).toBe("stdin");
|
||||
expect(backend.config.args).toContain("--setting-sources");
|
||||
expect(backend.config.args).toContain("user");
|
||||
expectDefaultDisallowedTools(backend.config.args);
|
||||
expect(backend.config.resumeArgs).toContain("--setting-sources");
|
||||
expect(backend.config.resumeArgs).toContain("user");
|
||||
expectDefaultDisallowedTools(backend.config.resumeArgs);
|
||||
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
|
||||
|
||||
@@ -9,6 +9,23 @@ const { registerManagedProxyBrowserCdpBypassMock } = vi.hoisted(() => ({
|
||||
),
|
||||
}));
|
||||
|
||||
function createDeferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
} {
|
||||
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
|
||||
let reject: ((reason?: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
if (!resolve || !reject) {
|
||||
throw new Error("Expected deferred callbacks to be initialized");
|
||||
}
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({
|
||||
registerManagedProxyBrowserCdpBypass: registerManagedProxyBrowserCdpBypassMock,
|
||||
}));
|
||||
@@ -29,19 +46,6 @@ beforeEach(() => {
|
||||
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
function createDeferred<T = void>() {
|
||||
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
|
||||
let reject: ((reason?: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
if (!resolve || !reject) {
|
||||
throw new Error("Expected deferred callbacks to be initialized");
|
||||
}
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function withIsolatedNoProxyEnv(fn: () => Promise<void>) {
|
||||
const origNoProxy = process.env.NO_PROXY;
|
||||
const origNoProxyLower = process.env.no_proxy;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Browser plugin runtime lifecycle helpers for startup relay setup and shutdown
|
||||
* cleanup.
|
||||
* Browser plugin runtime lifecycle helpers for startup and shutdown cleanup.
|
||||
*/
|
||||
import type { Server } from "node:http";
|
||||
import { getPwAiModule } from "./pw-ai-module.js";
|
||||
import { isPwAiLoaded } from "./pw-ai-state.js";
|
||||
import type { BrowserServerState } from "./server-context.js";
|
||||
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
||||
import { stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
||||
import { startTrackedBrowserTabCleanupTimer } from "./session-tab-cleanup.js";
|
||||
import { registerBrowserUnhandledRejectionHandler } from "./unhandled-rejections.js";
|
||||
|
||||
@@ -27,10 +26,6 @@ export async function createBrowserRuntimeState(params: {
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
|
||||
await ensureExtensionRelayForProfiles({
|
||||
resolved: params.resolved,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
state.stopUnhandledRejectionHandler = registerBrowserUnhandledRejectionHandler();
|
||||
|
||||
return state;
|
||||
|
||||
@@ -19,7 +19,6 @@ const { getUnhandledRejectionHandlers, registerUnhandledRejectionHandlerMock, re
|
||||
});
|
||||
|
||||
const {
|
||||
ensureExtensionRelayForProfilesMock,
|
||||
getPwAiModuleMock,
|
||||
isPwAiLoadedMock,
|
||||
startTrackedBrowserTabCleanupTimerMock,
|
||||
@@ -28,7 +27,6 @@ const {
|
||||
} = vi.hoisted(() => {
|
||||
const trackedTabCleanupMockLocal = vi.fn();
|
||||
return {
|
||||
ensureExtensionRelayForProfilesMock: vi.fn(async () => {}),
|
||||
getPwAiModuleMock: vi.fn(),
|
||||
isPwAiLoadedMock: vi.fn(() => false),
|
||||
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMockLocal),
|
||||
@@ -42,7 +40,6 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./server-lifecycle.js", () => ({
|
||||
ensureExtensionRelayForProfiles: ensureExtensionRelayForProfilesMock,
|
||||
stopKnownBrowserProfiles: stopKnownBrowserProfilesMock,
|
||||
}));
|
||||
|
||||
@@ -64,7 +61,6 @@ const { isPlaywrightDialogRaceUnhandledRejection } = await import("./unhandled-r
|
||||
beforeEach(() => {
|
||||
resetHandlers();
|
||||
registerUnhandledRejectionHandlerMock.mockClear();
|
||||
ensureExtensionRelayForProfilesMock.mockClear();
|
||||
getPwAiModuleMock.mockClear();
|
||||
isPwAiLoadedMock.mockReset().mockReturnValue(false);
|
||||
startTrackedBrowserTabCleanupTimerMock.mockClear();
|
||||
|
||||
@@ -19,8 +19,7 @@ vi.mock("./server-context.js", () => ({
|
||||
listKnownProfileNames: listKnownProfileNamesMock,
|
||||
}));
|
||||
|
||||
const { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } =
|
||||
await import("./server-lifecycle.js");
|
||||
const { stopKnownBrowserProfiles } = await import("./server-lifecycle.js");
|
||||
|
||||
beforeEach(() => {
|
||||
createBrowserRouteContextMock.mockClear();
|
||||
@@ -28,17 +27,6 @@ beforeEach(() => {
|
||||
stopOpenClawChromeMock.mockClear();
|
||||
});
|
||||
|
||||
describe("ensureExtensionRelayForProfiles", () => {
|
||||
it("is a no-op after removing the Chrome extension relay path", async () => {
|
||||
await expect(
|
||||
ensureExtensionRelayForProfiles({
|
||||
resolved: { profiles: {} } as never,
|
||||
onWarn: vi.fn(),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopKnownBrowserProfiles", () => {
|
||||
it("stops all known profiles and ignores per-profile failures", async () => {
|
||||
listKnownProfileNamesMock.mockReturnValue(["openclaw", "user"]);
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
/**
|
||||
* Browser server lifecycle helpers for relay setup and profile shutdown.
|
||||
* Browser server lifecycle helpers for profile shutdown.
|
||||
*/
|
||||
import { stopOpenClawChrome } from "./chrome.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import {
|
||||
type BrowserServerState,
|
||||
createBrowserRouteContext,
|
||||
listKnownProfileNames,
|
||||
} from "./server-context.js";
|
||||
|
||||
/** Ensures extension relay compatibility hooks for configured profiles. */
|
||||
export async function ensureExtensionRelayForProfiles(_params: {
|
||||
resolved: ResolvedBrowserConfig;
|
||||
onWarn: (message: string) => void;
|
||||
}) {
|
||||
// Intentional no-op: the Chrome extension relay path has been removed.
|
||||
// runtime-lifecycle still calls this helper, so keep the stub until the next
|
||||
// breaking cleanup rather than changing the call graph in a patch release.
|
||||
}
|
||||
|
||||
/** Stops every known Browser profile during runtime shutdown. */
|
||||
export async function stopKnownBrowserProfiles(params: {
|
||||
getState: () => BrowserServerState | null;
|
||||
|
||||
@@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({
|
||||
}),
|
||||
resolveBrowserControlAuth: vi.fn(() => ({})),
|
||||
shouldAutoGenerateBrowserAuth: vi.fn(() => true),
|
||||
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
@@ -69,7 +68,6 @@ vi.mock("./server-context.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./server-lifecycle.js", () => ({
|
||||
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
|
||||
stopKnownBrowserProfiles: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
@@ -85,7 +83,6 @@ describe("browser control auth bootstrap failures", () => {
|
||||
mocks.ensureBrowserControlAuth.mockClear();
|
||||
mocks.resolveBrowserControlAuth.mockClear();
|
||||
mocks.shouldAutoGenerateBrowserAuth.mockClear();
|
||||
mocks.ensureExtensionRelayForProfiles.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -98,7 +95,6 @@ describe("browser control auth bootstrap failures", () => {
|
||||
expect(started).toBeNull();
|
||||
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when auth bootstrap resolves empty auth in production-like mode", async () => {
|
||||
@@ -111,7 +107,6 @@ describe("browser control auth bootstrap failures", () => {
|
||||
expect(started).toBeNull();
|
||||
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when password mode has no resolved password", async () => {
|
||||
@@ -123,7 +118,6 @@ describe("browser control auth bootstrap failures", () => {
|
||||
const started = await startBrowserControlServerFromConfig();
|
||||
|
||||
expect(started).toBeNull();
|
||||
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when password mode drops an inactive token but has no password", async () => {
|
||||
@@ -136,6 +130,5 @@ describe("browser control auth bootstrap failures", () => {
|
||||
const started = await startBrowserControlServerFromConfig();
|
||||
|
||||
expect(started).toBeNull();
|
||||
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
|
||||
ensureBrowserControlAuth: vi.fn(async () => ({ auth: {} })),
|
||||
resolveBrowserControlAuth: vi.fn(() => ({})),
|
||||
shouldAutoGenerateBrowserAuth: vi.fn(() => false),
|
||||
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
|
||||
stopKnownBrowserProfiles: vi.fn(async () => {}),
|
||||
isChromeReachable: vi.fn(async () => false),
|
||||
isChromeCdpReady: vi.fn(async () => false),
|
||||
@@ -32,7 +31,6 @@ vi.mock("../browser/control-auth.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../browser/server-lifecycle.js", () => ({
|
||||
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
|
||||
stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface PnpmRunnerParams {
|
||||
comSpec?: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nodeArgs?: string[];
|
||||
nodeExecPath?: string;
|
||||
npmExecPath?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Cross-platform pnpm command resolver used by Canvas build scripts.
|
||||
*/
|
||||
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
|
||||
const PNPM_EXECUTABLE_RE = /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/;
|
||||
@@ -48,13 +49,56 @@ function isExecutableFile(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isFile(value) {
|
||||
try {
|
||||
return statSync(value).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathEnvKey(env) {
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
||||
}
|
||||
|
||||
function findExecutableOnPath(command, envPath, platform, env, cwd) {
|
||||
if (typeof envPath !== "string" || envPath.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const extensions =
|
||||
platform === "win32"
|
||||
? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ??
|
||||
".COM;.EXE;.BAT;.CMD")
|
||||
.split(";")
|
||||
.filter(Boolean)
|
||||
.map((extension) => extension.toLowerCase())
|
||||
: [""];
|
||||
const pathImpl = platform === "win32" ? path.win32 : path;
|
||||
const pathDelimiter = platform === "win32" ? ";" : path.delimiter;
|
||||
for (const directory of envPath.split(pathDelimiter)) {
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
const resolvedDirectory = pathImpl.isAbsolute(directory)
|
||||
? directory
|
||||
: pathImpl.resolve(cwd, directory);
|
||||
for (const extension of extensions) {
|
||||
const candidate = pathImpl.join(resolvedDirectory, `${command}${extension}`);
|
||||
if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isNodeRunnablePnpmExecPath(value) {
|
||||
if (!isPnpmExecPath(value)) {
|
||||
return false;
|
||||
}
|
||||
const { extension } = inspectExecutablePath(value);
|
||||
if (NODE_RUNNABLE_EXTENSIONS.has(extension)) {
|
||||
return true;
|
||||
return isFile(value);
|
||||
}
|
||||
if (extension.length > 0) {
|
||||
return false;
|
||||
@@ -129,6 +173,22 @@ export function resolvePnpmRunner(params = {}) {
|
||||
|
||||
const pnpmArgs = params.pnpmArgs ?? [];
|
||||
const platform = params.platform ?? process.platform;
|
||||
const env = params.env ?? process.env;
|
||||
const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"];
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd);
|
||||
if (pnpmPath) {
|
||||
return platform === "win32"
|
||||
? windowsCmdSpec(pnpmPath, pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
|
||||
: { args: pnpmArgs, command: pnpmPath, shell: false };
|
||||
}
|
||||
const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd);
|
||||
if (corepackPath) {
|
||||
const args = ["pnpm", ...pnpmArgs];
|
||||
return platform === "win32"
|
||||
? windowsCmdSpec(corepackPath, args, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
|
||||
: { args, command: corepackPath, shell: false };
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return windowsCmdSpec("pnpm.cmd", pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("canvas pnpm runner", () => {
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: "" },
|
||||
npmExecPath,
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
@@ -40,6 +41,7 @@ describe("canvas pnpm runner", () => {
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: "" },
|
||||
npmExecPath,
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
@@ -53,4 +55,79 @@ describe("canvas pnpm runner", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("uses Corepack when pnpm is not directly available on PATH", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-corepack-"));
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: "",
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["pnpm", "exec", "rolldown", "-c"],
|
||||
command: corepackPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("ignores a missing pnpm JS npm_execpath before checking PATH", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-missing-"));
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: path.join(tempDir, "missing-pnpm.mjs"),
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["pnpm", "exec", "rolldown", "-c"],
|
||||
command: corepackPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("prefers a direct pnpm executable over Corepack", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-path-"));
|
||||
const pnpmPath = path.join(tempDir, "pnpm");
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(pnpmPath, "#!/bin/sh\nexit 0\n");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(pnpmPath, 0o755);
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: "",
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["exec", "rolldown", "-c"],
|
||||
command: pnpmPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loginChutes } from "./oauth.js";
|
||||
|
||||
function boundedErrorResponse(
|
||||
body: string,
|
||||
status = 500,
|
||||
): {
|
||||
function boundedErrorResponse(body: string, status = 500): {
|
||||
response: Response;
|
||||
cancel: ReturnType<typeof vi.fn>;
|
||||
releaseLock: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -586,6 +586,51 @@ export function prependCodexOpenClawPromptContext(
|
||||
return [context?.trim(), deliverySection, promptSection].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the surviving user-request portion of an input range after delivery
|
||||
* metadata has been relocated before the request.
|
||||
*/
|
||||
export function resolveCodexDeliveryHintPreservedInputRange(params: {
|
||||
prompt: string;
|
||||
promptInputRange: { start: number; end: number } | undefined;
|
||||
decoratedPrompt: string;
|
||||
}): { start: number; end: number } | undefined {
|
||||
const { prompt, promptInputRange, decoratedPrompt } = params;
|
||||
const { deliveryHint, prompt: promptWithoutDeliveryHint } = splitLeadingCodexDeliveryHint(prompt);
|
||||
if (
|
||||
!deliveryHint ||
|
||||
!promptInputRange ||
|
||||
promptInputRange.start < 0 ||
|
||||
promptInputRange.end < promptInputRange.start ||
|
||||
promptInputRange.end > prompt.length ||
|
||||
!decoratedPrompt.endsWith(promptWithoutDeliveryHint)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const promptWithoutDeliveryHintStart = prompt.length - promptWithoutDeliveryHint.length;
|
||||
const inputStart = Math.max(promptInputRange.start, promptWithoutDeliveryHintStart);
|
||||
const inputEnd = Math.max(
|
||||
inputStart,
|
||||
Math.min(
|
||||
promptInputRange.end,
|
||||
promptWithoutDeliveryHint.length + promptWithoutDeliveryHintStart,
|
||||
),
|
||||
);
|
||||
const decoratedPromptSuffixStart = decoratedPrompt.length - promptWithoutDeliveryHint.length;
|
||||
const requestHeader = "Current user request:\n";
|
||||
const requestHeaderStart = decoratedPromptSuffixStart - requestHeader.length;
|
||||
// Delivery metadata moves outside the request, so retain the remaining input
|
||||
// span rather than treating the original, now non-contiguous range as valid.
|
||||
return {
|
||||
start:
|
||||
inputStart === promptWithoutDeliveryHintStart &&
|
||||
decoratedPrompt.slice(requestHeaderStart, decoratedPromptSuffixStart) === requestHeader
|
||||
? requestHeaderStart
|
||||
: decoratedPromptSuffixStart + inputStart - promptWithoutDeliveryHintStart,
|
||||
end: decoratedPromptSuffixStart + inputEnd - promptWithoutDeliveryHintStart,
|
||||
};
|
||||
}
|
||||
|
||||
function splitLeadingCodexDeliveryHint(prompt: string): {
|
||||
deliveryHint?: string;
|
||||
prompt: string;
|
||||
|
||||
@@ -249,10 +249,64 @@ describe("projectContextEngineAssemblyForCodex", () => {
|
||||
// The user's actual request is the priority tail and must survive truncation.
|
||||
expect(fitted).toContain("Current user request:");
|
||||
expect(fitted.endsWith("q".repeat(40))).toBe(true);
|
||||
// The dropped older context is reported, not silently lost.
|
||||
// Current context still survives even when an earlier projection is dropped.
|
||||
expect(fitted).toContain("older context");
|
||||
// The dropped older content is reported, not silently lost.
|
||||
expect(fitted).toContain("[truncated ");
|
||||
});
|
||||
|
||||
it("keeps the current request and fitting hook context after projecting history", () => {
|
||||
const before = "OpenClaw assembled context for this turn:\n<conversation_context>\n";
|
||||
const context = `recent context ${"c".repeat(800)}`;
|
||||
const request = "\n</conversation_context>\n\nCurrent user request:\nkeep this request";
|
||||
const hookAppend = "\n\nhook context survives";
|
||||
const promptText = `${before}${context}${request}${hookAppend}`;
|
||||
const maxChars = 420;
|
||||
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText,
|
||||
contextRange: { start: before.length, end: before.length + context.length },
|
||||
requestRange: {
|
||||
start: before.length + context.length,
|
||||
end: before.length + context.length + request.length,
|
||||
},
|
||||
maxChars,
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
expect(fitted).toContain("[truncated ");
|
||||
expect(fitted).toContain("Current user request:\nkeep this request");
|
||||
expect(fitted).toContain("hook context survives");
|
||||
});
|
||||
|
||||
it("keeps the original input when a hook appends context without a projection", () => {
|
||||
const prompt = "current prompt survives";
|
||||
const hookAppend = `\n\nhook context ${"h".repeat(800)}`;
|
||||
const maxChars = 420;
|
||||
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText: `${prompt}${hookAppend}`,
|
||||
preservedRange: { start: 0, end: prompt.length },
|
||||
maxChars,
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
expect(fitted).toContain(prompt);
|
||||
expect(fitted).not.toContain("hook context");
|
||||
});
|
||||
|
||||
it("bounds hook output for an empty original input", () => {
|
||||
const maxChars = 420;
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText: `hook context ${"h".repeat(800)} hook tail`,
|
||||
preservedRange: { start: 0, end: 0 },
|
||||
maxChars,
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
expect(fitted).toContain("hook tail");
|
||||
});
|
||||
|
||||
it("bounds output for a large request under the default Codex turn limit", () => {
|
||||
const maxChars = CODEX_TURN_START_TEXT_INPUT_MAX_CHARS;
|
||||
// A large assembled header prefix already over the cap forces the
|
||||
|
||||
@@ -121,6 +121,8 @@ export function resolveCodexContextEngineProjectionReserveTokens(params: {
|
||||
export function fitCodexProjectedContextForTurnStart(params: {
|
||||
promptText: string;
|
||||
contextRange?: CodexProjectedContextRange;
|
||||
requestRange?: CodexProjectedContextRange;
|
||||
preservedRange?: CodexProjectedContextRange;
|
||||
maxChars?: number;
|
||||
}): string {
|
||||
const maxChars =
|
||||
@@ -132,23 +134,63 @@ export function fitCodexProjectedContextForTurnStart(params: {
|
||||
}
|
||||
const range = normalizeProjectedContextRange(params.contextRange, params.promptText.length);
|
||||
if (!range) {
|
||||
return params.promptText;
|
||||
const preservedRange = normalizeProjectedContextRange(
|
||||
params.preservedRange,
|
||||
params.promptText.length,
|
||||
);
|
||||
if (!preservedRange) {
|
||||
return params.promptText;
|
||||
}
|
||||
const preservedText = params.promptText.slice(preservedRange.start, preservedRange.end);
|
||||
if (!preservedText) {
|
||||
return truncateOlderContext(params.promptText, maxChars);
|
||||
}
|
||||
if (preservedText.length >= maxChars) {
|
||||
return truncateOlderContext(preservedText, maxChars);
|
||||
}
|
||||
const beforeRange = params.promptText.slice(0, preservedRange.start);
|
||||
return `${truncateOlderContext(beforeRange, maxChars - preservedText.length)}${preservedText}`;
|
||||
}
|
||||
|
||||
const beforeContext = params.promptText.slice(0, range.start);
|
||||
const context = params.promptText.slice(range.start, range.end);
|
||||
const afterContext = params.promptText.slice(range.end);
|
||||
const requestRange = normalizeProjectedContextRange(
|
||||
params.requestRange,
|
||||
params.promptText.length,
|
||||
);
|
||||
if (
|
||||
requestRange &&
|
||||
requestRange.start >= range.end &&
|
||||
requestRange.end < params.promptText.length
|
||||
) {
|
||||
const request = params.promptText.slice(requestRange.start, requestRange.end);
|
||||
if (request.length >= maxChars) {
|
||||
return truncateOlderContext(request, maxChars);
|
||||
}
|
||||
const appendedContext = params.promptText.slice(requestRange.end);
|
||||
// Hook-appended context is newer than the projected history. Retain it
|
||||
// before trimming the projection, while the full current request remains
|
||||
// the hard boundary that must survive a bounded turn/start input.
|
||||
const fittedAppendedContext = truncateOlderContext(appendedContext, maxChars - request.length);
|
||||
const contextBudget = maxChars - request.length - fittedAppendedContext.length;
|
||||
const fittedContext = truncateOlderContext(context, contextBudget);
|
||||
const beforeContextBudget =
|
||||
maxChars - fittedContext.length - request.length - fittedAppendedContext.length;
|
||||
return `${truncateOlderContext(beforeContext, beforeContextBudget)}${fittedContext}${request}${fittedAppendedContext}`;
|
||||
}
|
||||
const contextBudget = maxChars - beforeContext.length - afterContext.length;
|
||||
if (contextBudget > 0) {
|
||||
const fittedContext = truncateOlderContext(context, contextBudget);
|
||||
return `${beforeContext}${fittedContext}${afterContext}`;
|
||||
}
|
||||
// The header plus the trailing user request already fill the limit, so the
|
||||
// older context drops entirely and the remaining text must still be bounded;
|
||||
// otherwise Codex app-server rejects the turn for exceeding
|
||||
// MAX_USER_INPUT_TEXT_CHARS. truncateOlderContext keeps the tail, preserving
|
||||
// the user's actual request over the older header text.
|
||||
return truncateOlderContext(`${beforeContext}${afterContext}`, maxChars);
|
||||
// Hook-added prefixes can make the non-context text exceed the limit. Keep
|
||||
// the current context tail before the user's request; dropping it would make
|
||||
// a duplicated earlier projection crowd out the newest assembled context.
|
||||
const afterContextText = truncateOlderContext(afterContext, maxChars);
|
||||
const contextBudgetAfterRequest = maxChars - afterContextText.length;
|
||||
const fittedContext = truncateOlderContext(context, contextBudgetAfterRequest);
|
||||
return `${fittedContext}${afterContextText}`;
|
||||
}
|
||||
|
||||
function normalizeProjectedContextRange(
|
||||
|
||||
@@ -961,6 +961,26 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the approval reviewer device into Codex dynamic tools", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.approvalReviewerDeviceId = "device-ios-reviewer";
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
factoryOptions.push(options);
|
||||
return [];
|
||||
});
|
||||
|
||||
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
|
||||
|
||||
expect(factoryOptions[0]).toMatchObject({
|
||||
approvalReviewerDeviceId: "device-ios-reviewer",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards tool outcome ordering into Codex dynamic tools", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -19,10 +19,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
readCodexPluginConfig,
|
||||
type CodexPluginConfig,
|
||||
} from "./config.js";
|
||||
import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js";
|
||||
import {
|
||||
filterCodexDynamicTools,
|
||||
isForcedPrivateQaCodexRuntime,
|
||||
@@ -260,6 +257,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
...sessionKeys,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
approvalReviewerDeviceId: params.approvalReviewerDeviceId,
|
||||
agentDir,
|
||||
cwd: input.effectiveCwd ?? input.effectiveWorkspace,
|
||||
workspaceDir: input.effectiveWorkspace,
|
||||
@@ -593,9 +591,10 @@ export function resolveCodexAppServerExecutionCwd(params: {
|
||||
nativeToolSurfaceEnabled: boolean;
|
||||
remoteWorkspaceRoot?: string;
|
||||
}): string {
|
||||
const cwd = params.environment && params.nativeToolSurfaceEnabled
|
||||
? params.environment.cwd
|
||||
: params.effectiveCwd;
|
||||
const cwd =
|
||||
params.environment && params.nativeToolSurfaceEnabled
|
||||
? params.environment.cwd
|
||||
: params.effectiveCwd;
|
||||
return mapCodexAppServerRemoteWorkspacePath({
|
||||
value: cwd,
|
||||
localWorkspaceRoot: params.localWorkspaceRoot,
|
||||
|
||||
@@ -9,9 +9,16 @@ import {
|
||||
type HarnessContextEngine as ContextEngine,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { CODEX_TURN_START_TEXT_INPUT_MAX_CHARS } from "./context-engine-projection.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
|
||||
import {
|
||||
@@ -71,9 +78,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
@@ -349,6 +354,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
resetCodexAppServerClientFactoryForTest();
|
||||
resetGlobalHookRunner();
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -493,6 +499,134 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await run;
|
||||
});
|
||||
|
||||
it("bounds active context-engine projections when prompt hooks append context", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{
|
||||
hookName: "before_prompt_build",
|
||||
handler: async (event) => ({
|
||||
appendContext: `${(event as { prompt: string }).prompt}\n\nhook append marker`,
|
||||
prependContext: "hook prefix context",
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async () => ({
|
||||
messages: [
|
||||
...Array.from({ length: 9 }, (_, index) =>
|
||||
assistantMessage(`older context ${index} ${"x".repeat(120_000)}`, index),
|
||||
),
|
||||
assistantMessage("recent anchor", 10),
|
||||
],
|
||||
estimatedTokens: 300_000,
|
||||
})),
|
||||
});
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 300_000;
|
||||
params.prompt = "current prompt survives";
|
||||
params.currentInboundContext = { text: "current inbound context survives" };
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const inputText = getRequestInputText(harness);
|
||||
expect(inputText.length).toBe(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
|
||||
expect(inputText).toContain("recent anchor");
|
||||
expect(inputText).toContain("current inbound context survives");
|
||||
expect(inputText).toContain("current prompt survives");
|
||||
expect(inputText).toContain("hook append marker");
|
||||
|
||||
await harness.completeTurn();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("bounds hook-appended prompts without an active context engine", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{
|
||||
hookName: "before_prompt_build",
|
||||
handler: async () => ({ appendContext: `hook context ${"h".repeat(1_100_000)}` }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.prompt = "current prompt survives";
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const inputText = getRequestInputText(harness);
|
||||
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
|
||||
expect(inputText).toContain("current prompt survives");
|
||||
expect(inputText).not.toContain("hook context");
|
||||
|
||||
await harness.completeTurn();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("bounds hook-appended prompts after delivery metadata is relocated", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{
|
||||
hookName: "before_prompt_build",
|
||||
handler: async () => ({ appendContext: `hook context ${"h".repeat(1_100_000)}` }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session-delivery-hint.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-delivery-hint");
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.prompt = `${MESSAGE_TOOL_DELIVERY_HINTS[0]}\n\ncurrent prompt survives`;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const inputText = getRequestInputText(harness);
|
||||
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
|
||||
expect(inputText).toContain("Current user request:\ncurrent prompt survives");
|
||||
expect(inputText).not.toContain("hook context");
|
||||
|
||||
await harness.completeTurn();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("bounds hook-appended output for an empty prompt without an active context engine", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{
|
||||
hookName: "before_prompt_build",
|
||||
handler: async () => ({
|
||||
appendContext: `hook context ${"h".repeat(1_100_000)} hook tail`,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.prompt = "";
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const inputText = getRequestInputText(harness);
|
||||
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
|
||||
expect(inputText).toContain("hook tail");
|
||||
|
||||
await harness.completeTurn();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("uses configured compaction reserve when sizing Codex context-engine projections", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -2119,6 +2119,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
prependSystemContext: "pre system",
|
||||
appendSystemContext: "post system",
|
||||
prependContext: "queued context",
|
||||
appendContext: "tail context",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
@@ -2158,7 +2159,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
| { input?: Array<{ text?: string; text_elements?: unknown[]; type?: string }> }
|
||||
| undefined;
|
||||
expect(turnStartParams?.input).toEqual([
|
||||
{ type: "text", text: "queued context\n\nhello", text_elements: [] },
|
||||
{ type: "text", text: "queued context\n\nhello\n\ntail context", text_elements: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
resolveSandboxContext,
|
||||
resolveSessionAgentIds,
|
||||
resolveUserPath,
|
||||
awaitAgentHarnessAgentEndHook,
|
||||
runAgentHarnessAgentEndHook,
|
||||
awaitAgentEndSideEffects,
|
||||
runAgentEndSideEffects,
|
||||
runAgentHarnessLlmInputHook,
|
||||
runAgentHarnessLlmOutputHook,
|
||||
runHarnessContextEngineMaintenance,
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
readContextEngineThreadBootstrapProjection,
|
||||
readMirroredSessionHistoryMessages,
|
||||
renderCodexSkillsCollaborationInstructions,
|
||||
resolveCodexDeliveryHintPreservedInputRange,
|
||||
resolveContextEngineBootstrapProjectionDecision,
|
||||
} from "./attempt-context.js";
|
||||
import {
|
||||
@@ -368,7 +369,7 @@ function formatUnsupportedCodexDynamicToolOutput(type: unknown): string {
|
||||
return `[Unsupported Codex dynamic tool output: ${label}${suffix}]`;
|
||||
}
|
||||
|
||||
type CodexAgentEndHookParams = Parameters<typeof runAgentHarnessAgentEndHook>[0];
|
||||
type CodexAgentEndHookParams = Parameters<typeof runAgentEndSideEffects>[0];
|
||||
|
||||
function shouldAwaitCodexAgentEndHook(params: EmbeddedRunAttemptParams): boolean {
|
||||
return !params.messageChannel && !params.messageProvider;
|
||||
@@ -378,11 +379,15 @@ async function runCodexAgentEndHook(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
hookParams: CodexAgentEndHookParams,
|
||||
): Promise<void> {
|
||||
const sideEffectParams = {
|
||||
...hookParams,
|
||||
ctx: { ...hookParams.ctx, config: params.config },
|
||||
};
|
||||
if (shouldAwaitCodexAgentEndHook(params)) {
|
||||
await awaitAgentHarnessAgentEndHook(hookParams);
|
||||
await awaitAgentEndSideEffects(sideEffectParams);
|
||||
return;
|
||||
}
|
||||
runAgentHarnessAgentEndHook(hookParams);
|
||||
runAgentEndSideEffects(sideEffectParams);
|
||||
}
|
||||
|
||||
export async function runCodexAppServerAttempt(
|
||||
@@ -1020,33 +1025,119 @@ export async function runCodexAppServerAttempt(
|
||||
developerInstructions,
|
||||
messages: codexModelInputHistoryMessages,
|
||||
ctx: hookContext,
|
||||
...("beforeAgentStartResult" in params
|
||||
? { beforeAgentStartResult: params.beforeAgentStartResult }
|
||||
: {}),
|
||||
});
|
||||
const resolveShiftedPromptContextRange = (
|
||||
const resolveShiftedPromptInputRange = (
|
||||
prompt: string,
|
||||
promptInputRange: { start: number; end: number } | undefined,
|
||||
turnPromptText: string,
|
||||
): CodexProjectedContextRange | undefined => {
|
||||
if (!promptContextRange || !prompt.endsWith(promptText) || !turnPromptText.endsWith(prompt)) {
|
||||
if (
|
||||
!promptInputRange ||
|
||||
promptInputRange.start < 0 ||
|
||||
promptInputRange.end < promptInputRange.start ||
|
||||
promptInputRange.end > prompt.length ||
|
||||
!turnPromptText.endsWith(prompt)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const promptTextOffset = prompt.length - promptText.length;
|
||||
const turnPromptOffset = turnPromptText.length - prompt.length + promptTextOffset;
|
||||
const turnPromptOffset = turnPromptText.length - prompt.length;
|
||||
return {
|
||||
start: turnPromptOffset + promptInputRange.start,
|
||||
end: turnPromptOffset + promptInputRange.end,
|
||||
};
|
||||
};
|
||||
const resolveShiftedPromptContextRange = (
|
||||
prompt: string,
|
||||
promptInputRange: { start: number; end: number } | undefined,
|
||||
turnPromptText: string,
|
||||
):
|
||||
| {
|
||||
contextRange: CodexProjectedContextRange;
|
||||
requestRange: CodexProjectedContextRange;
|
||||
}
|
||||
| undefined => {
|
||||
// promptInputRange ends before hook appendContext. Measure from the
|
||||
// immutable projected prompt instead of the hook-expanded prompt so that
|
||||
// the suffix remains available for bounded fitting as newer context.
|
||||
const promptTextInputOffset = promptInputRange
|
||||
? promptInputRange.end - promptText.length
|
||||
: undefined;
|
||||
if (
|
||||
!promptContextRange ||
|
||||
!promptInputRange ||
|
||||
promptTextInputOffset === undefined ||
|
||||
promptInputRange.start < 0 ||
|
||||
promptInputRange.end < promptInputRange.start ||
|
||||
promptInputRange.end > prompt.length ||
|
||||
promptTextInputOffset < promptInputRange.start ||
|
||||
prompt.slice(promptTextInputOffset, promptInputRange.end) !== promptText ||
|
||||
!turnPromptText.endsWith(prompt)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
// A hook can append the full projected prompt as newer transient context.
|
||||
// Fit that suffix so truncation retains its latest context rather than the
|
||||
// earlier input span. The exact input range still covers prepend-only hooks.
|
||||
const promptTextOffset = prompt.endsWith(promptText)
|
||||
? prompt.length - promptText.length
|
||||
: promptTextInputOffset;
|
||||
if (promptTextOffset < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const turnPromptOffset = turnPromptText.length - prompt.length + promptTextOffset;
|
||||
const contextRange = {
|
||||
start: turnPromptOffset + promptContextRange.start,
|
||||
end: turnPromptOffset + promptContextRange.end,
|
||||
};
|
||||
return {
|
||||
contextRange,
|
||||
requestRange: {
|
||||
start: contextRange.end,
|
||||
end: turnPromptOffset + promptText.length,
|
||||
},
|
||||
};
|
||||
};
|
||||
let promptBuild = await buildPromptFromCurrentInputs();
|
||||
const decorateCodexTurnPromptText = (prompt: string) => {
|
||||
const turnPromptText = prependCodexOpenClawPromptContext(prompt, openClawPromptContext, {
|
||||
preservePromptWithoutContext:
|
||||
params.bootstrapContextMode === "lightweight" && params.bootstrapContextRunKind === "cron",
|
||||
});
|
||||
const decorateCodexTurnPromptText = (promptBuildResult: {
|
||||
prompt: string;
|
||||
promptInputRange?: { start: number; end: number };
|
||||
}) => {
|
||||
const turnPromptText = prependCodexOpenClawPromptContext(
|
||||
promptBuildResult.prompt,
|
||||
openClawPromptContext,
|
||||
{
|
||||
preservePromptWithoutContext:
|
||||
params.bootstrapContextMode === "lightweight" &&
|
||||
params.bootstrapContextRunKind === "cron",
|
||||
},
|
||||
);
|
||||
const projectedRanges = resolveShiftedPromptContextRange(
|
||||
promptBuildResult.prompt,
|
||||
promptBuildResult.promptInputRange,
|
||||
turnPromptText,
|
||||
);
|
||||
const preservedRange =
|
||||
resolveShiftedPromptInputRange(
|
||||
promptBuildResult.prompt,
|
||||
promptBuildResult.promptInputRange,
|
||||
turnPromptText,
|
||||
) ??
|
||||
resolveCodexDeliveryHintPreservedInputRange({
|
||||
prompt: promptBuildResult.prompt,
|
||||
promptInputRange: promptBuildResult.promptInputRange,
|
||||
decoratedPrompt: turnPromptText,
|
||||
});
|
||||
return fitCodexProjectedContextForTurnStart({
|
||||
promptText: turnPromptText,
|
||||
contextRange: resolveShiftedPromptContextRange(prompt, turnPromptText),
|
||||
contextRange: projectedRanges?.contextRange,
|
||||
requestRange: projectedRanges?.requestRange,
|
||||
preservedRange,
|
||||
});
|
||||
};
|
||||
let codexTurnPromptText = decorateCodexTurnPromptText(promptBuild.prompt);
|
||||
let codexTurnPromptText = decorateCodexTurnPromptText(promptBuild);
|
||||
const buildCodexTurnCollaborationDeveloperInstructions = () =>
|
||||
buildTurnCollaborationMode(params, {
|
||||
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
|
||||
@@ -1062,7 +1153,7 @@ export async function runCodexAppServerAttempt(
|
||||
);
|
||||
const rebuildCodexPromptBuildFromCurrentProjection = async () => {
|
||||
promptBuild = await buildPromptFromCurrentInputs();
|
||||
codexTurnPromptText = decorateCodexTurnPromptText(promptBuild.prompt);
|
||||
codexTurnPromptText = decorateCodexTurnPromptText(promptBuild);
|
||||
};
|
||||
const rebuildCodexTurnPromptTextFromCurrentProjection = async () => {
|
||||
const nextPromptBuild = await buildPromptFromCurrentInputs();
|
||||
@@ -1071,8 +1162,9 @@ export async function runCodexAppServerAttempt(
|
||||
promptBuild = {
|
||||
...promptBuild,
|
||||
prompt: nextPromptBuild.prompt,
|
||||
promptInputRange: nextPromptBuild.promptInputRange,
|
||||
};
|
||||
codexTurnPromptText = decorateCodexTurnPromptText(nextPromptBuild.prompt);
|
||||
codexTurnPromptText = decorateCodexTurnPromptText(nextPromptBuild);
|
||||
};
|
||||
const selectNewerVisibleHistoryAfterBinding = (binding: CodexAppServerThreadBinding) => {
|
||||
const bindingUpdatedAt = Date.parse(binding.updatedAt);
|
||||
|
||||
@@ -16,7 +16,7 @@ on a model or provider entry; `auto` never picks it. PI remains the default
|
||||
embedded runtime.
|
||||
|
||||
See [GitHub Copilot agent runtime](../../docs/plugins/copilot.md) for
|
||||
configuration, doctor probes, transcript mirroring, compaction, side
|
||||
configuration, the doctor contract, transcript mirroring, compaction, side
|
||||
questions, replay, and the supported-surface contract.
|
||||
See [qa/copilot-capabilities.md](../../qa/copilot-capabilities.md)
|
||||
for the SDK capability inventory the harness is pinned to.
|
||||
|
||||
@@ -11,14 +11,6 @@
|
||||
* fields exist for copilot yet; the array is empty by design
|
||||
* and normalizeCompatibilityConfig is a structural no-op so
|
||||
* future retirements have a stable in-tree home.
|
||||
*
|
||||
* The deeper runtime probes (copilot CLI version, copilot auth,
|
||||
* copilotHome writability) live in {@link ./src/doctor-probes.ts}
|
||||
* because they have side effects (subprocess spawn, fs touch) and
|
||||
* need to be invoked imperatively, not declaratively, from the
|
||||
* doctor command. They are exported separately so callers can opt
|
||||
* in. Auto-discovery of doctor-contract-api.ts at the plugin root
|
||||
* keeps this file purely declarative.
|
||||
*/
|
||||
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Copilot tests cover harness plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CopilotClientPool } from "./harness.js";
|
||||
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
|
||||
|
||||
@@ -38,13 +43,13 @@ const TEST_SESSION_CONFIG = {
|
||||
workingDirectory: "/workspace",
|
||||
};
|
||||
|
||||
function makePoolMock(): CopilotClientPool {
|
||||
function makePoolMock() {
|
||||
return {
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn(),
|
||||
dispose: vi.fn().mockResolvedValue([]),
|
||||
size: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
} satisfies CopilotClientPool;
|
||||
}
|
||||
|
||||
function makeSessionStoreMock() {
|
||||
@@ -95,6 +100,10 @@ describe("createCopilotAgentHarness", () => {
|
||||
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
});
|
||||
|
||||
it("returns the copilot id and default label", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
@@ -519,6 +528,166 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(deleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts deferred compaction cleanup before disposal", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
const abort = vi.fn(() => cleanup.resolve("aborted"));
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-pending-cleanup",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-pending-cleanup",
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-pending-cleanup" });
|
||||
await harness.dispose?.();
|
||||
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("aborts deferred compaction cleanup when the OpenClaw session resets", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
const abort = vi.fn(() => cleanup.resolve("aborted"));
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-reset-cleanup",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-reset-cleanup",
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-reset-cleanup" });
|
||||
await harness.reset?.({ sessionId: "oc-reset-cleanup" });
|
||||
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not delete a replacement session while reset awaits deferred cleanup", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
const abort = vi.fn();
|
||||
const oldDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const replacementDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
let attempt = 0;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-before-reset",
|
||||
pooledClient: { key: {} as any, client: { deleteSession: oldDeleteSession } as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-before-reset",
|
||||
});
|
||||
} else {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-replacement",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: replacementDeleteSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
}
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({
|
||||
pool: makePoolMock(),
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
|
||||
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-reset-race" });
|
||||
const reset = harness.reset?.({ sessionId: "oc-reset-race" });
|
||||
await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce());
|
||||
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-reset-race" });
|
||||
cleanup.resolve("aborted");
|
||||
await reset;
|
||||
|
||||
expect(oldDeleteSession).toHaveBeenCalledWith("sdk-sess-before-reset");
|
||||
expect(replacementDeleteSession).not.toHaveBeenCalled();
|
||||
expect(sessionStore.entries.get("oc-reset-race")?.sdkSessionId).toBe("sdk-sess-replacement");
|
||||
});
|
||||
|
||||
it("does not reuse a reset target while deferred cleanup is pending", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
const abort = vi.fn();
|
||||
const replacementDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const duringResetDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
let attempt = 0;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-before-reset",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-before-reset",
|
||||
});
|
||||
} else if (attempt === 2) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-replacement",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: replacementDeleteSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
} else if (attempt === 3 && !params.initialReplayState?.sdkSessionId) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-during-reset",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: duringResetDeleteSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
}
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({
|
||||
pool: makePoolMock(),
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
const params = { ...ATTEMPT_PARAMS, sessionId: "oc-reset-reuse" };
|
||||
|
||||
await harness.runAttempt(params);
|
||||
await harness.runAttempt(params);
|
||||
const reset = harness.reset?.({ sessionId: "oc-reset-reuse" });
|
||||
await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce());
|
||||
await harness.runAttempt(params);
|
||||
cleanup.resolve("aborted");
|
||||
await reset;
|
||||
|
||||
expect(
|
||||
mocks.runCopilotAttempt.mock.calls[2]?.[0]?.initialReplayState?.sdkSessionId,
|
||||
).toBeUndefined();
|
||||
expect(replacementDeleteSession).toHaveBeenCalledWith("sdk-sess-replacement");
|
||||
expect(duringResetDeleteSession).not.toHaveBeenCalled();
|
||||
expect(sessionStore.entries.get("oc-reset-reuse")?.sdkSessionId).toBe("sdk-sess-during-reset");
|
||||
});
|
||||
|
||||
describe("session reuse across turns (dogfood finding #4)", () => {
|
||||
// These tests pin the harness's session-reuse contract: subsequent
|
||||
// `runAttempt` calls within the same OpenClaw session should pass
|
||||
@@ -566,6 +735,177 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(secondCallParams.initialReplayState?.replayInvalid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks reuse while timed-out compaction is pending, then resumes after completion", async () => {
|
||||
const pool = makePoolMock();
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
let attempt = 0;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-compacting",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => undefined,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-compacting",
|
||||
});
|
||||
}
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store });
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
|
||||
|
||||
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
|
||||
|
||||
cleanup.resolve("completed");
|
||||
await flushAsyncWork();
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t3" }));
|
||||
const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(thirdCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-compacting");
|
||||
expect(sessionStore.store.delete).not.toHaveBeenCalledWith("oc-sess-reuse");
|
||||
});
|
||||
|
||||
it("reuses a replacement session while an older cleanup is pending", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
let attempt = 0;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-old",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => undefined,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-old",
|
||||
});
|
||||
} else if (attempt === 2) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-replacement",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
}
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t3" }));
|
||||
|
||||
const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(thirdCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-replacement");
|
||||
cleanup.resolve("completed");
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
it("invalidates the retained SDK binding when deferred compaction is cancelled", async () => {
|
||||
const pool = makePoolMock();
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
let attempt = 0;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-cancelled",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => undefined,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-cancelled",
|
||||
});
|
||||
}
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store });
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
|
||||
cleanup.resolve("aborted");
|
||||
await flushAsyncWork();
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
|
||||
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
|
||||
expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-reuse");
|
||||
});
|
||||
|
||||
it("ignores deferred cleanup from a session replaced by an overlapping attempt", async () => {
|
||||
const firstAttemptFinished = createDeferred<void>();
|
||||
const staleCleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
let firstAttemptDeps:
|
||||
| {
|
||||
onDeferredCompaction?: (info: {
|
||||
abort: () => void;
|
||||
cleanup: Promise<"aborted" | "completed" | "deadline">;
|
||||
sdkSessionId: string;
|
||||
}) => void;
|
||||
}
|
||||
| undefined;
|
||||
let attempt = 0;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-stale",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
firstAttemptDeps = deps;
|
||||
await firstAttemptFinished.promise;
|
||||
} else if (attempt === 2) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-current",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
}
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
|
||||
|
||||
const firstAttempt = harness.runAttempt(makeAttemptParams({ runId: "t1" }));
|
||||
await flushAsyncWork();
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
|
||||
firstAttemptDeps?.onDeferredCompaction?.({
|
||||
abort: () => undefined,
|
||||
cleanup: staleCleanup.promise,
|
||||
sdkSessionId: "sdk-sess-stale",
|
||||
});
|
||||
firstAttemptFinished.resolve();
|
||||
await firstAttempt;
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t3" }));
|
||||
const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
|
||||
expect(thirdCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-current");
|
||||
staleCleanup.resolve("completed");
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
it("does not seed sdkSessionId on the first turn (nothing tracked yet)", async () => {
|
||||
const pool = makePoolMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
@@ -1148,6 +1488,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
copilotHome: "/copilot-home",
|
||||
auth: { useLoggedInUser: true },
|
||||
sessionId: "oc-sess-compact",
|
||||
sessionFile: "/session.json",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1178,7 +1519,92 @@ describe("createCopilotAgentHarness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resume a session while deferred background compaction is pending", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
const pool = makePoolMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-background",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => undefined,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-background",
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(makeCompactParams());
|
||||
const result = await harness.compact?.(makeCompactParams());
|
||||
|
||||
expect(pool.acquire.mock.calls).toHaveLength(0);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "background-compaction-pending",
|
||||
failure: { reason: "background-compaction-pending" },
|
||||
});
|
||||
cleanup.resolve("completed");
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
it("clears the reset block when storing a replacement session fails", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
sessionStore.store.register.mockImplementation(() => {
|
||||
throw new Error("sqlite register failed");
|
||||
});
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
const sdkSessionId =
|
||||
mocks.runCopilotAttempt.mock.calls.length === 1
|
||||
? "sdk-sess-background"
|
||||
: "sdk-sess-replacement";
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId,
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
if (sdkSessionId === "sdk-sess-background") {
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => undefined,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId,
|
||||
});
|
||||
}
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({
|
||||
pool: makePoolMock(),
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
const params = makeCompactParams({ sessionId: "oc-sess-store-failure" });
|
||||
|
||||
await harness.runAttempt(params);
|
||||
await harness.runAttempt(params);
|
||||
await harness.runAttempt(params);
|
||||
|
||||
expect(mocks.runCopilotAttempt.mock.calls[1]?.[0]).not.toMatchObject({
|
||||
initialReplayState: expect.objectContaining({ sdkSessionId: "sdk-sess-background" }),
|
||||
});
|
||||
expect(mocks.runCopilotAttempt.mock.calls[2]?.[0]).toMatchObject({
|
||||
initialReplayState: expect.objectContaining({ sdkSessionId: "sdk-sess-replacement" }),
|
||||
});
|
||||
cleanup.resolve("completed");
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
it("calls the SDK history compaction RPC without requiring a workspace sidecar", async () => {
|
||||
const beforeCompaction = vi.fn();
|
||||
const afterCompaction = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_compaction", handler: beforeCompaction },
|
||||
{ hookName: "after_compaction", handler: afterCompaction },
|
||||
]),
|
||||
);
|
||||
const compact = vi.fn(async () => ({
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
@@ -1241,6 +1667,19 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(compact).toHaveBeenCalledWith({ customInstructions: "Keep decisions." });
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
expect(beforeCompaction).toHaveBeenCalledWith(
|
||||
{ messageCount: -1, sessionFile: "/session.json" },
|
||||
expect.objectContaining({
|
||||
modelId: "gpt-4.1",
|
||||
modelProviderId: "github-copilot",
|
||||
sessionId: "oc-sess-compact-1",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
expect(afterCompaction).toHaveBeenCalledWith(
|
||||
{ compactedCount: 4, messageCount: -1, sessionFile: "/session.json" },
|
||||
expect.objectContaining({ sessionId: "oc-sess-compact-1" }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Copilot plugin module implements harness behavior.
|
||||
import type { CopilotClient } from "@github/copilot-sdk";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
compactWithSafetyTimeout,
|
||||
resolveCompactionTimeoutMs,
|
||||
runAgentHarnessAfterCompactionHook,
|
||||
runAgentHarnessBeforeCompactionHook,
|
||||
type AgentHarness,
|
||||
type AgentHarnessAttemptParams,
|
||||
type AgentHarnessAttemptResult,
|
||||
@@ -91,6 +94,11 @@ type LegacyCopilotSessionBinding = {
|
||||
};
|
||||
|
||||
type CopilotAttemptSessionBinding = Pick<CopilotSessionBinding, "compatKey" | "sdkSessionId">;
|
||||
type DeferredCompactionCleanupOutcome = "aborted" | "completed" | "deadline";
|
||||
type DeferredCompactionCleanup = {
|
||||
abort: () => void;
|
||||
sdkSessionId: string;
|
||||
};
|
||||
|
||||
type CopilotSessionBindingStore = Pick<
|
||||
PluginStateSyncKeyedStore<CopilotSessionBinding>,
|
||||
@@ -399,6 +407,20 @@ function computeSessionCompactKey(params: CopilotSessionCompatParams): string {
|
||||
return computeSessionKey(params, { includeApi: false, includeAuth: false });
|
||||
}
|
||||
|
||||
function buildCopilotCompactionHookContext(params: AgentHarnessCompactParams) {
|
||||
return {
|
||||
...(params.runId ? { runId: params.runId } : {}),
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
modelProviderId: params.provider,
|
||||
modelId: params.model,
|
||||
trigger: params.trigger,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCopilotAgentHarness(
|
||||
options?: CreateCopilotAgentHarnessOptions,
|
||||
): AgentHarness {
|
||||
@@ -407,6 +429,10 @@ export function createCopilotAgentHarness(
|
||||
let disposed = false;
|
||||
let disposePromise: Promise<void> | undefined;
|
||||
const inFlight = new Set<Promise<unknown>>();
|
||||
const deferredCompactionCleanups = new Map<
|
||||
string,
|
||||
Map<Promise<DeferredCompactionCleanupOutcome>, DeferredCompactionCleanup>
|
||||
>();
|
||||
// Maps OpenClaw session id (from AgentHarnessAttemptParams.sessionId) to
|
||||
// the SDK session id + client that owns it. Populated by
|
||||
// runCopilotAttempt via the onSessionEstablished callback so that
|
||||
@@ -428,6 +454,63 @@ export function createCopilotAgentHarness(
|
||||
return poolPromise;
|
||||
}
|
||||
|
||||
function trackDeferredCompactionCleanup(params: {
|
||||
abort: () => void;
|
||||
cleanup: Promise<DeferredCompactionCleanupOutcome>;
|
||||
sessionId: string;
|
||||
sdkSessionId: string;
|
||||
}): void {
|
||||
const cleanups =
|
||||
deferredCompactionCleanups.get(params.sessionId) ??
|
||||
new Map<Promise<DeferredCompactionCleanupOutcome>, DeferredCompactionCleanup>();
|
||||
cleanups.set(params.cleanup, { abort: params.abort, sdkSessionId: params.sdkSessionId });
|
||||
deferredCompactionCleanups.set(params.sessionId, cleanups);
|
||||
void params.cleanup.then(
|
||||
() => removeDeferredCompactionCleanup(params.sessionId, params.cleanup),
|
||||
() => removeDeferredCompactionCleanup(params.sessionId, params.cleanup),
|
||||
);
|
||||
}
|
||||
|
||||
function removeDeferredCompactionCleanup(
|
||||
sessionId: string,
|
||||
cleanup: Promise<DeferredCompactionCleanupOutcome>,
|
||||
): void {
|
||||
const cleanups = deferredCompactionCleanups.get(sessionId);
|
||||
if (!cleanups) {
|
||||
return;
|
||||
}
|
||||
cleanups.delete(cleanup);
|
||||
if (cleanups.size === 0) {
|
||||
deferredCompactionCleanups.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPendingDeferredCompactionCleanup(sessionId: string): boolean {
|
||||
const cleanups = deferredCompactionCleanups.get(sessionId);
|
||||
if (!cleanups) {
|
||||
return false;
|
||||
}
|
||||
const currentSdkSessionId =
|
||||
trackedSessions.get(sessionId)?.sdkSessionId ??
|
||||
lookupStoredBinding(options?.sessionStore, sessionId)?.sdkSessionId;
|
||||
return (
|
||||
currentSdkSessionId !== undefined &&
|
||||
[...cleanups.values()].some((cleanup) => cleanup.sdkSessionId === currentSdkSessionId)
|
||||
);
|
||||
}
|
||||
|
||||
async function abortDeferredCompactionCleanups(sessionId: string): Promise<void> {
|
||||
const cleanups = deferredCompactionCleanups.get(sessionId);
|
||||
if (!cleanups) {
|
||||
return;
|
||||
}
|
||||
const pending = [...cleanups.entries()];
|
||||
for (const [, cleanup] of pending) {
|
||||
cleanup.abort();
|
||||
}
|
||||
await Promise.allSettled(pending.map(([cleanup]) => cleanup));
|
||||
}
|
||||
|
||||
return {
|
||||
id: options?.id ?? "copilot",
|
||||
label: options?.label ?? "GitHub Copilot agent runtime",
|
||||
@@ -488,9 +571,15 @@ export function createCopilotAgentHarness(
|
||||
// surfaces as a prompt error.
|
||||
const currentCompatKey = computeSessionCompatKey(params);
|
||||
const currentCompactKey = computeSessionCompactKey(params);
|
||||
const tracked = openclawSessionId ? trackedSessions.get(openclawSessionId) : undefined;
|
||||
const compactionCleanupPending =
|
||||
openclawSessionId !== undefined && hasPendingDeferredCompactionCleanup(openclawSessionId);
|
||||
const replayBlocked =
|
||||
openclawSessionId !== undefined &&
|
||||
(compactionCleanupPending || resetBlockedStoredSessions.has(openclawSessionId));
|
||||
const tracked =
|
||||
openclawSessionId && !replayBlocked ? trackedSessions.get(openclawSessionId) : undefined;
|
||||
const stored = openclawSessionId
|
||||
? resetBlockedStoredSessions.has(openclawSessionId)
|
||||
? replayBlocked
|
||||
? undefined
|
||||
: lookupStoredBinding(options?.sessionStore, openclawSessionId)
|
||||
: undefined;
|
||||
@@ -532,7 +621,7 @@ export function createCopilotAgentHarness(
|
||||
sessionConfig,
|
||||
...sessionAuthFields(poolAcquire.auth),
|
||||
});
|
||||
const persisted = registerStoredBinding(options?.sessionStore, openclawSessionId, {
|
||||
registerStoredBinding(options?.sessionStore, openclawSessionId, {
|
||||
schemaVersion: 2,
|
||||
sdkSessionId,
|
||||
compatKey: currentCompatKey,
|
||||
@@ -540,9 +629,60 @@ export function createCopilotAgentHarness(
|
||||
...sessionAuthFields(poolAcquire.auth),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
if (persisted) {
|
||||
resetBlockedStoredSessions.delete(openclawSessionId);
|
||||
resetBlockedStoredSessions.delete(openclawSessionId);
|
||||
}
|
||||
: undefined,
|
||||
onDeferredCompaction: openclawSessionId
|
||||
? ({
|
||||
abort,
|
||||
cleanup,
|
||||
sdkSessionId,
|
||||
}: {
|
||||
abort: () => void;
|
||||
cleanup: Promise<DeferredCompactionCleanupOutcome>;
|
||||
sdkSessionId: string;
|
||||
}) => {
|
||||
const trackedBinding = trackedSessions.get(openclawSessionId);
|
||||
const storedBinding = lookupStoredBinding(options?.sessionStore, openclawSessionId);
|
||||
const ownsTrackedSession = trackedBinding?.sdkSessionId === sdkSessionId;
|
||||
const ownsStoredSession = storedBinding?.sdkSessionId === sdkSessionId;
|
||||
if (!ownsTrackedSession && !ownsStoredSession) {
|
||||
return;
|
||||
}
|
||||
trackDeferredCompactionCleanup({
|
||||
abort,
|
||||
cleanup,
|
||||
sessionId: openclawSessionId,
|
||||
sdkSessionId,
|
||||
});
|
||||
// The attempt retains this SDK session until its background
|
||||
// compaction resolves. Preserve its binding for a successful
|
||||
// completion, but do not let a new turn resume it yet.
|
||||
resetBlockedStoredSessions.add(openclawSessionId);
|
||||
void cleanup.then((outcome) => {
|
||||
const currentTracked = trackedSessions.get(openclawSessionId);
|
||||
const currentStored = lookupStoredBinding(
|
||||
options?.sessionStore,
|
||||
openclawSessionId,
|
||||
);
|
||||
const stillOwnsTrackedSession = currentTracked?.sdkSessionId === sdkSessionId;
|
||||
const stillOwnsStoredSession = currentStored?.sdkSessionId === sdkSessionId;
|
||||
if (outcome === "completed") {
|
||||
if (stillOwnsTrackedSession || stillOwnsStoredSession) {
|
||||
resetBlockedStoredSessions.delete(openclawSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stillOwnsTrackedSession) {
|
||||
trackedSessions.delete(openclawSessionId);
|
||||
}
|
||||
if (stillOwnsStoredSession) {
|
||||
deleteStoredBinding(options?.sessionStore, openclawSessionId);
|
||||
}
|
||||
if (stillOwnsTrackedSession || stillOwnsStoredSession) {
|
||||
resetBlockedStoredSessions.add(openclawSessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -560,17 +700,30 @@ export function createCopilotAgentHarness(
|
||||
if (!openclawSessionId) {
|
||||
return;
|
||||
}
|
||||
// Deferred cleanup yields while another attempt can establish a fresh
|
||||
// session. Capture the reset target first so reset never deletes that
|
||||
// replacement session or its durable binding.
|
||||
const tracked = trackedSessions.get(openclawSessionId);
|
||||
if (deleteStoredBinding(options?.sessionStore, openclawSessionId)) {
|
||||
resetBlockedStoredSessions.delete(openclawSessionId);
|
||||
const stored = lookupStoredBinding(options?.sessionStore, openclawSessionId);
|
||||
resetBlockedStoredSessions.add(openclawSessionId);
|
||||
await abortDeferredCompactionCleanups(openclawSessionId);
|
||||
const currentStored = lookupStoredBinding(options?.sessionStore, openclawSessionId);
|
||||
const stillOwnsStoredSession =
|
||||
stored !== undefined && currentStored?.sdkSessionId === stored.sdkSessionId;
|
||||
if (stillOwnsStoredSession) {
|
||||
if (deleteStoredBinding(options?.sessionStore, openclawSessionId)) {
|
||||
resetBlockedStoredSessions.delete(openclawSessionId);
|
||||
}
|
||||
} else {
|
||||
resetBlockedStoredSessions.add(openclawSessionId);
|
||||
resetBlockedStoredSessions.delete(openclawSessionId);
|
||||
}
|
||||
if (!tracked) {
|
||||
// Session was created by a different harness, or already reset.
|
||||
return;
|
||||
}
|
||||
trackedSessions.delete(openclawSessionId);
|
||||
if (trackedSessions.get(openclawSessionId)?.sdkSessionId === tracked.sdkSessionId) {
|
||||
trackedSessions.delete(openclawSessionId);
|
||||
}
|
||||
try {
|
||||
await tracked.client.deleteSession(tracked.sdkSessionId);
|
||||
} catch {
|
||||
@@ -596,6 +749,14 @@ export function createCopilotAgentHarness(
|
||||
reason: "missing-required-params",
|
||||
};
|
||||
}
|
||||
if (hasPendingDeferredCompactionCleanup(openclawSessionId)) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "background-compaction-pending",
|
||||
failure: { reason: "background-compaction-pending" },
|
||||
};
|
||||
}
|
||||
const tracked = trackedSessions.get(openclawSessionId);
|
||||
const currentCompactKey = computeSessionCompactKey(params);
|
||||
const { resolvePoolAcquire } = await import("./src/attempt.js");
|
||||
@@ -623,11 +784,18 @@ export function createCopilotAgentHarness(
|
||||
let handle: PooledClient | undefined;
|
||||
let pool: CopilotClientPool | undefined;
|
||||
let activeSdkSession: CopilotHistoryCompactSession | undefined;
|
||||
const hookContext = buildCopilotCompactionHookContext(params);
|
||||
try {
|
||||
throwIfAborted(params.abortSignal);
|
||||
pool = await getPool();
|
||||
handle = await pool.acquire(poolAcquire.key, poolAcquire.options);
|
||||
const client = handle.client;
|
||||
// Manual compaction resumes a distinct SDK session, bypassing the attempt event bridge.
|
||||
// Run the portable lifecycle hook here so both compaction paths stay observable.
|
||||
await runAgentHarnessBeforeCompactionHook({
|
||||
sessionFile: params.sessionFile,
|
||||
ctx: hookContext,
|
||||
});
|
||||
compactResult = await compactWithSafetyTimeout(
|
||||
(abortSignal) =>
|
||||
compactTrackedSdkSession({
|
||||
@@ -693,6 +861,13 @@ export function createCopilotAgentHarness(
|
||||
};
|
||||
}
|
||||
const compacted = compactResult.tokensRemoved > 0 || compactResult.messagesRemoved > 0;
|
||||
if (compacted) {
|
||||
await runAgentHarnessAfterCompactionHook({
|
||||
sessionFile: params.sessionFile,
|
||||
compactedCount: compactResult.messagesRemoved,
|
||||
ctx: hookContext,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
compacted,
|
||||
@@ -709,6 +884,12 @@ export function createCopilotAgentHarness(
|
||||
if (inFlight.size > 0) {
|
||||
await Promise.allSettled(inFlight);
|
||||
}
|
||||
// Deferred compaction callbacks retain pooled clients after an attempt.
|
||||
// Cancel them before pool disposal so they cannot outlive this harness.
|
||||
const cleanupSessionIds = [...deferredCompactionCleanups.keys()];
|
||||
for (const sessionId of cleanupSessionIds) {
|
||||
await abortDeferredCompactionCleanups(sessionId);
|
||||
}
|
||||
trackedSessions.clear();
|
||||
resetBlockedStoredSessions.clear();
|
||||
if (createdPool) {
|
||||
|
||||
@@ -8,9 +8,15 @@ import type {
|
||||
AgentHarnessAttemptResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runCopilotAttempt } from "./attempt.js";
|
||||
import type { CopilotClientPool } from "./runtime.js";
|
||||
import type { CopilotToolBridgeInput } from "./tool-bridge.js";
|
||||
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADUlEQVR4nGP4////KwAJ5gPoxLp9owAAAABJRU5ErkJggg==";
|
||||
@@ -64,6 +70,11 @@ type FakeSession = {
|
||||
id: string;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
rpc: {
|
||||
history: {
|
||||
cancelBackgroundCompaction: ReturnType<typeof vi.fn<() => Promise<{ cancelled: boolean }>>>;
|
||||
};
|
||||
};
|
||||
sendAndWait: ReturnType<typeof vi.fn<SendAndWaitFn>>;
|
||||
sessionId: string;
|
||||
};
|
||||
@@ -98,6 +109,12 @@ function flushAsync() {
|
||||
return tick().then(tick).then(tick);
|
||||
}
|
||||
|
||||
function waitForEventLoopTurn(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function getPromptErrorCode(result: AgentHarnessAttemptResult): string | undefined {
|
||||
return (result.promptError as { code?: string } | undefined)?.code;
|
||||
}
|
||||
@@ -153,13 +170,20 @@ function createFakeSession(cfg: Record<string, unknown>, id: string): FakeSessio
|
||||
handlers.push(handler);
|
||||
listeners.set(eventType, handlers);
|
||||
}),
|
||||
rpc: {
|
||||
history: {
|
||||
cancelBackgroundCompaction: vi.fn<() => Promise<{ cancelled: boolean }>>(async () => ({
|
||||
cancelled: true,
|
||||
})),
|
||||
},
|
||||
},
|
||||
sendAndWait: vi.fn<SendAndWaitFn>(async () => makeAssistantMessageEvent()),
|
||||
sessionId: id,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakePool(sdk: FakeSdk) {
|
||||
const pool: CopilotClientPool = {
|
||||
const pool = {
|
||||
acquire: vi.fn(async (key, _options) => ({
|
||||
client: sdk.client as unknown as CopilotClient,
|
||||
key,
|
||||
@@ -167,7 +191,7 @@ function makeFakePool(sdk: FakeSdk) {
|
||||
dispose: vi.fn(async () => []),
|
||||
release: vi.fn(async () => undefined),
|
||||
size: vi.fn(() => 0),
|
||||
};
|
||||
} satisfies CopilotClientPool;
|
||||
return pool;
|
||||
}
|
||||
|
||||
@@ -200,6 +224,7 @@ function makeFakeSdk(
|
||||
return {
|
||||
client: {
|
||||
createSession,
|
||||
deleteSession: vi.fn(async () => undefined),
|
||||
resumeSession,
|
||||
stop: vi.fn(async () => []),
|
||||
},
|
||||
@@ -249,7 +274,9 @@ function makeParams(
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("runCopilotAttempt", () => {
|
||||
@@ -274,6 +301,406 @@ describe("runCopilotAttempt", () => {
|
||||
expect(getSdkSessionId(result)).toBe("sess-1");
|
||||
});
|
||||
|
||||
it("runs generic prompt and lifecycle hooks through the standard harness helpers", async () => {
|
||||
const beforePromptBuild = vi.fn(() => ({
|
||||
prependContext: "Use the current repository state.",
|
||||
appendContext: "Finish with the current test status.",
|
||||
appendSystemContext: "Keep the final response concise.",
|
||||
}));
|
||||
const afterToolCall = vi.fn();
|
||||
const llmInput = vi.fn();
|
||||
const llmOutput = vi.fn();
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_prompt_build", handler: beforePromptBuild },
|
||||
{ hookName: "after_tool_call", handler: afterToolCall },
|
||||
{ hookName: "llm_input", handler: llmInput },
|
||||
{ hookName: "llm_output", handler: llmOutput },
|
||||
{ hookName: "agent_end", handler: agentEnd },
|
||||
]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done"));
|
||||
},
|
||||
});
|
||||
const createToolBridge = vi.fn(async (input: CopilotToolBridgeInput) => {
|
||||
await input.onToolCompleted?.({
|
||||
args: { path: "README.md" },
|
||||
result: { content: [{ text: "read result", type: "text" }] },
|
||||
startedAt: Date.now(),
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read",
|
||||
});
|
||||
return { sdkTools: [], sourceTools: [] };
|
||||
});
|
||||
|
||||
await runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
await waitForEventLoopTurn();
|
||||
|
||||
expect(beforePromptBuild).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: "hello" }),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
const cfg = sdk.createSession.mock.calls[0]?.[0] as {
|
||||
systemMessage?: { content?: string };
|
||||
};
|
||||
expect(cfg.systemMessage?.content).toContain("Keep the final response concise.");
|
||||
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as { prompt?: string };
|
||||
expect(messageOptions.prompt).toBe(
|
||||
"Use the current repository state.\n\nhello\n\nFinish with the current test status.",
|
||||
);
|
||||
expect(llmInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
historyMessages: [],
|
||||
model: "gpt-4o",
|
||||
prompt:
|
||||
"Use the current repository state.\n\nhello\n\nFinish with the current test status.",
|
||||
provider: "github-copilot",
|
||||
runId: "run-1",
|
||||
}),
|
||||
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
|
||||
);
|
||||
expect(llmOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assistantTexts: ["done"],
|
||||
model: "gpt-4o",
|
||||
provider: "github-copilot",
|
||||
}),
|
||||
expect.objectContaining({ runId: "run-1" }),
|
||||
);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: true }),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
expect(afterToolCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: { path: "README.md" },
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read",
|
||||
}),
|
||||
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps generic compaction hooks attached through asynchronous SDK completion", async () => {
|
||||
const beforeCompaction = vi.fn();
|
||||
const afterCompaction = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_compaction", handler: beforeCompaction },
|
||||
{ hookName: "after_compaction", handler: afterCompaction },
|
||||
]),
|
||||
);
|
||||
let activeSession: FakeSession | undefined;
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
activeSession = session;
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const attempt = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.sendAndWait).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
if (!activeSession) {
|
||||
throw new Error("expected Copilot session");
|
||||
}
|
||||
expect(activeSession.disconnect).not.toHaveBeenCalled();
|
||||
activeSession.emit("session.compaction_complete", { messagesRemoved: 4, success: true });
|
||||
|
||||
await attempt;
|
||||
|
||||
expect(beforeCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageCount: -1,
|
||||
sessionFile: "session.json",
|
||||
}),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
expect(afterCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compactedCount: 4,
|
||||
messageCount: -1,
|
||||
sessionFile: "session.json",
|
||||
}),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
expect(beforeCompaction.mock.calls[0]?.[0]).not.toHaveProperty("messages");
|
||||
});
|
||||
|
||||
it("does not await background compaction hooks before returning a turn", async () => {
|
||||
const releaseBeforeCompaction = createDeferred<void>();
|
||||
const beforeCompaction = vi.fn(async () => releaseBeforeCompaction.promise);
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_compaction", handler: beforeCompaction }]),
|
||||
);
|
||||
let activeSession: FakeSession | undefined;
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
activeSession = session;
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
|
||||
expect(result.timedOut).toBe(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(beforeCompaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(activeSession?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
releaseBeforeCompaction.resolve();
|
||||
activeSession?.emit("session.compaction_complete", { success: true });
|
||||
activeSession?.emit("session.idle", {});
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a successful turn while background compaction remains observed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
const attempt = runCopilotAttempt(makeParams(), { pool });
|
||||
const result = await attempt;
|
||||
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeUndefined();
|
||||
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
|
||||
expect(sdk.client.deleteSession).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
|
||||
expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
|
||||
expect(pool.release.mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cancels retained compaction when the caller aborts after a turn result", async () => {
|
||||
const controller = new AbortController();
|
||||
const onDeferredCompaction = vi.fn();
|
||||
let activeSession: FakeSession | undefined;
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
activeSession = session;
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
setTimeout(() => controller.abort(), 0);
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const attempt = runCopilotAttempt(makeParams({ abortSignal: controller.signal }), {
|
||||
onDeferredCompaction,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
|
||||
const result = await attempt;
|
||||
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(activeSession?.abort).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
|
||||
expect(onDeferredCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sdkSessionId: "sess-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("awaits deferred compaction cancellation before tearing down the SDK session", async () => {
|
||||
const controller = new AbortController();
|
||||
const cancellation = createDeferred<{ cancelled: boolean }>();
|
||||
let activeSession: FakeSession | undefined;
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
activeSession = session;
|
||||
session.rpc.history.cancelBackgroundCompaction.mockImplementationOnce(
|
||||
() => cancellation.promise,
|
||||
);
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams({ abortSignal: controller.signal }), {
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
|
||||
expect(result.timedOutDuringCompaction).toBe(true);
|
||||
controller.abort();
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(activeSession?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
cancellation.resolve({ cancelled: true });
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the native prompt hook's effective input through llm_input", async () => {
|
||||
const llmInput = vi.fn();
|
||||
const onUserPromptSubmitted = vi.fn().mockResolvedValue({
|
||||
additionalContext: "Use the approved repository.",
|
||||
modifiedPrompt: "Review the authentication change.",
|
||||
});
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "llm_input", handler: llmInput }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session, cfg) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
const hooks = cfg.hooks as {
|
||||
onUserPromptSubmitted?: (
|
||||
input: { prompt: string },
|
||||
invocation: { sessionId: string },
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
await hooks.onUserPromptSubmitted?.(
|
||||
{ prompt: "hello" },
|
||||
{ sessionId: session.sessionId },
|
||||
);
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await runCopilotAttempt(makeParams({ hooksConfig: { onUserPromptSubmitted } } as never), {
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
await waitForEventLoopTurn();
|
||||
|
||||
expect(onUserPromptSubmitted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: "hello" }),
|
||||
{ sessionId: "sess-1" },
|
||||
);
|
||||
expect(llmInput).toHaveBeenCalledTimes(1);
|
||||
expect(llmInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Review the authentication change.\n\nUse the approved repository.",
|
||||
}),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the precomputed legacy before_agent_start result", async () => {
|
||||
const beforeAgentStart = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_agent_start", handler: beforeAgentStart }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
beforeAgentStartResult: { prependContext: "Use the cached result." },
|
||||
} as never),
|
||||
{ pool: makeFakePool(sdk) },
|
||||
);
|
||||
|
||||
expect(beforeAgentStart).not.toHaveBeenCalled();
|
||||
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as { prompt?: string };
|
||||
expect(messageOptions.prompt).toBe("Use the cached result.\n\nhello");
|
||||
});
|
||||
|
||||
it("preserves native Copilot SDK hooks alongside generic lifecycle hooks", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const onPreToolUse = vi.fn();
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
hooksConfig: { onPreToolUse },
|
||||
} as never),
|
||||
{ pool: makeFakePool(sdk) },
|
||||
);
|
||||
|
||||
const cfg = sdk.createSession.mock.calls[0]?.[0] as {
|
||||
hooks?: { onPreToolUse?: unknown };
|
||||
};
|
||||
expect(cfg.hooks?.onPreToolUse).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("does not emit llm_output when cancellation happens before the SDK turn starts", async () => {
|
||||
const llmOutput = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "llm_output", handler: llmOutput }]),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const sdk = makeFakeSdk();
|
||||
|
||||
const result = await runCopilotAttempt(
|
||||
makeParams({ abortSignal: controller.signal } as never),
|
||||
{
|
||||
onSessionEstablished: () => controller.abort(),
|
||||
pool: makeFakePool(sdk),
|
||||
},
|
||||
);
|
||||
await waitForEventLoopTurn();
|
||||
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(sdk.sessions[0]?.sendAndWait).not.toHaveBeenCalled();
|
||||
expect(llmOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for agent_end hooks before resolving one-shot attempts", async () => {
|
||||
let releaseAgentEnd: () => void = () => undefined;
|
||||
const agentEndSettled = new Promise<void>((resolve) => {
|
||||
releaseAgentEnd = resolve;
|
||||
});
|
||||
const agentEnd = vi.fn(() => agentEndSettled);
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done"));
|
||||
},
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const run = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }).then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await waitForEventLoopTurn();
|
||||
|
||||
expect(agentEnd).toHaveBeenCalledTimes(1);
|
||||
expect(settled).toBe(false);
|
||||
releaseAgentEnd();
|
||||
await expect(run).resolves.toMatchObject({ promptError: undefined });
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards prompt images as SDK blob attachments", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
@@ -747,6 +1174,10 @@ describe("runCopilotAttempt", () => {
|
||||
it("abort path (signal already aborted)", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
@@ -758,6 +1189,10 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.externalAbort).toBe(true);
|
||||
expect(sdk.createSession).toHaveBeenCalledTimes(0);
|
||||
expect(pool["acquire"]).toHaveBeenCalledTimes(0);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false }),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("abort path (signal fires after settled)", async () => {
|
||||
@@ -920,6 +1355,10 @@ describe("runCopilotAttempt", () => {
|
||||
});
|
||||
|
||||
it("tool bridge failures become prompt errors", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => {
|
||||
@@ -935,9 +1374,20 @@ describe("runCopilotAttempt", () => {
|
||||
expect(sdk.createSession).toHaveBeenCalledTimes(0);
|
||||
expect(pool["acquire"]).toHaveBeenCalledTimes(0);
|
||||
expect(pool["release"]).toHaveBeenCalledTimes(0);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: "[copilot-attempt] tool-bridge construction failed: bridge failed",
|
||||
success: false,
|
||||
}),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("unsupported providers skip injected tool bridge wiring", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
@@ -952,6 +1402,30 @@ describe("runCopilotAttempt", () => {
|
||||
expect(getPromptErrorCode(result)).toBe("model_not_supported");
|
||||
expect(createToolBridge).toHaveBeenCalledTimes(0);
|
||||
expect(sdk.createSession).toHaveBeenCalledTimes(0);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false }),
|
||||
expect.objectContaining({ modelId: "claude", modelProviderId: "anthropic" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports pool-release failures through agent_end before rejecting", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
pool.release.mockRejectedValueOnce(new Error("release failed"));
|
||||
|
||||
await expect(runCopilotAttempt(makeParams(), { pool })).rejects.toThrow("release failed");
|
||||
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: "release failed",
|
||||
success: false,
|
||||
}),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("default permission policy rejects fail-closed", async () => {
|
||||
@@ -1147,6 +1621,53 @@ describe("runCopilotAttempt", () => {
|
||||
expect("systemMessage" in cfg).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps raw model probes outside generic prompt hooks", async () => {
|
||||
const beforePromptBuild = vi.fn(() => ({
|
||||
appendContext: "must not reach raw model probes",
|
||||
prependSystemContext: "must not reach raw model probes",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
modelRun: true,
|
||||
} as never),
|
||||
{ pool: makeFakePool(sdk) },
|
||||
);
|
||||
|
||||
expect(beforePromptBuild).not.toHaveBeenCalled();
|
||||
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
|
||||
prompt?: string;
|
||||
};
|
||||
expect(messageOptions.prompt).toBe("hello");
|
||||
});
|
||||
|
||||
it("keeps promptMode none runs outside generic prompt hooks", async () => {
|
||||
const beforePromptBuild = vi.fn(() => ({
|
||||
appendContext: "must not reach raw model probes",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
promptMode: "none",
|
||||
} as never),
|
||||
{ pool: makeFakePool(sdk) },
|
||||
);
|
||||
|
||||
expect(beforePromptBuild).not.toHaveBeenCalled();
|
||||
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
|
||||
prompt?: string;
|
||||
};
|
||||
expect(messageOptions.prompt).toBe("hello");
|
||||
});
|
||||
|
||||
it("appends extraSystemPrompt after rendered bootstrap instructions", async () => {
|
||||
const rendered = "# Project Context\n## /ws/SOUL.md\n\nSoul voice goes here.";
|
||||
workspaceBootstrapMock.resolveCopilotWorkspaceBootstrapContext.mockResolvedValueOnce({
|
||||
@@ -1267,6 +1788,10 @@ describe("runCopilotAttempt", () => {
|
||||
});
|
||||
|
||||
it("timeout", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockResolvedValueOnce(undefined);
|
||||
@@ -1280,6 +1805,188 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(getSdkSessionId(result)).toBe("sess-1");
|
||||
expect(sdk.sessions[0]?.abort).toHaveBeenCalledTimes(0);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: "Copilot SDK turn timed out.",
|
||||
success: false,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
sdk.sessions[0]?.emit("session.idle", {});
|
||||
await vi.waitFor(() => {
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a timeout during active SDK compaction", async () => {
|
||||
const afterCompaction = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.timedOutDuringCompaction).toBe(true);
|
||||
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
sdk.sessions[0]?.emit("session.compaction_complete", { messagesRemoved: 3, success: true });
|
||||
sdk.sessions[0]?.emit("session.idle", {});
|
||||
await vi.waitFor(() => {
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(sdk.client.deleteSession).not.toHaveBeenCalled();
|
||||
expect(afterCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ compactedCount: 3, sessionFile: "session.json" }),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("retains a timed-out session until later compaction reaches session.idle", async () => {
|
||||
const afterCompaction = vi.fn();
|
||||
const onDeferredCompaction = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
|
||||
);
|
||||
let activeSession: FakeSession | undefined;
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
activeSession = session;
|
||||
session.sendAndWait.mockRejectedValueOnce(
|
||||
new Error("Timeout after 60000ms waiting for session.idle"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), {
|
||||
onDeferredCompaction,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.timedOutDuringCompaction).toBe(false);
|
||||
expect(onDeferredCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sdkSessionId: "sess-1" }),
|
||||
);
|
||||
expect(activeSession?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
activeSession?.emit("session.compaction_start", {});
|
||||
activeSession?.emit("session.compaction_complete", { messagesRemoved: 3, success: true });
|
||||
await vi.waitFor(() => {
|
||||
expect(afterCompaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(activeSession?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
activeSession?.emit("session.idle", {});
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mark a timeout after SDK compaction has completed as active compaction", async () => {
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
session.emit("session.compaction_complete", { success: true });
|
||||
session.emit("session.idle", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.timedOutDuringCompaction).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds deferred cleanup when SDK compaction never completes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), { pool });
|
||||
|
||||
expect(result.timedOutDuringCompaction).toBe(true);
|
||||
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
|
||||
expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
|
||||
expect(pool.release.mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cancels deferred cleanup when the timed-out caller aborts", async () => {
|
||||
const controller = new AbortController();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams({ abortSignal: controller.signal }), {
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
|
||||
expect(result.timedOutDuringCompaction).toBe(true);
|
||||
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
controller.abort();
|
||||
await vi.waitFor(() => {
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
|
||||
});
|
||||
|
||||
it("keeps the compaction timeout classification after deferred completion", async () => {
|
||||
const mirror = createDeferred<void>();
|
||||
dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear();
|
||||
dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockImplementationOnce(() => mirror.promise);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const attempt = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
await vi.waitFor(() => {
|
||||
expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
sdk.sessions[0]?.emit("session.compaction_complete", { success: true });
|
||||
sdk.sessions[0]?.emit("session.idle", {});
|
||||
mirror.resolve();
|
||||
|
||||
const result = await attempt;
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.timedOutDuringCompaction).toBe(true);
|
||||
});
|
||||
|
||||
it("G1: SDK timeout rejection (Error 'Timeout after Nms waiting for session.idle') sets timedOut, leaves promptError undefined, and does NOT abort the session", async () => {
|
||||
@@ -1317,6 +2024,10 @@ describe("runCopilotAttempt", () => {
|
||||
// replay-shim incorrectly treated the attempt as side-effect-safe.
|
||||
expect(result.replayMetadata?.hadPotentialSideEffects).toBe(true);
|
||||
expect(result.replayMetadata?.replaySafe).toBe(false);
|
||||
sdk.sessions[0]?.emit("session.idle", {});
|
||||
await vi.waitFor(() => {
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("G1: SDK timeout flushes the in-flight delta chain before snapshot so assistant text is preserved", async () => {
|
||||
@@ -1358,6 +2069,10 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(onAssistantDelta).toHaveBeenCalledTimes(1);
|
||||
expect(result.assistantTexts?.join("")).toContain("partial-");
|
||||
session.emit("session.idle", {});
|
||||
await vi.waitFor(() => {
|
||||
expect(session.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("model translation: unsupported provider", async () => {
|
||||
@@ -2246,6 +2961,10 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
it("fails closed when sandbox is enabled with a cwd override", async () => {
|
||||
const sandbox = makeSandboxStub({ workspaceAccess: "rw" });
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
@@ -2266,9 +2985,17 @@ describe("runCopilotAttempt", () => {
|
||||
expect(getPromptErrorCode(result)).toBe("sandbox_cwd_override_unsupported");
|
||||
expect(createToolBridge).not.toHaveBeenCalled();
|
||||
expect(sdk.createSession).not.toHaveBeenCalled();
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false }),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when sandbox resolution fails", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done"));
|
||||
@@ -2292,6 +3019,10 @@ describe("runCopilotAttempt", () => {
|
||||
);
|
||||
expect(createToolBridge).not.toHaveBeenCalled();
|
||||
expect(sdk.createSession).not.toHaveBeenCalled();
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false }),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when creating the sandbox copy workspace fails", async () => {
|
||||
|
||||
@@ -8,12 +8,22 @@ import type {
|
||||
SandboxContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
detectAndLoadAgentHarnessPromptImages,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
resolveCompactionTimeoutMs,
|
||||
resolveSandboxContext as defaultResolveSandboxContext,
|
||||
resolveSessionAgentIds,
|
||||
resolveUserPath,
|
||||
runAgentHarnessAfterToolCallHook,
|
||||
runAgentHarnessAfterCompactionHook,
|
||||
runAgentHarnessBeforeCompactionHook,
|
||||
awaitAgentEndSideEffects,
|
||||
runAgentEndSideEffects,
|
||||
runAgentHarnessLlmInputHook,
|
||||
runAgentHarnessLlmOutputHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import {
|
||||
@@ -48,9 +58,11 @@ import { createCopilotToolBridge } from "./tool-bridge.js";
|
||||
import { resolveCopilotWorkspaceBootstrapContext } from "./workspace-bootstrap.js";
|
||||
|
||||
const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
|
||||
const BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS = 5_000;
|
||||
|
||||
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
|
||||
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
|
||||
type CopilotAgentEndHookParams = Parameters<typeof runAgentEndSideEffects>[0];
|
||||
export type CopilotSessionConfig = Pick<
|
||||
SessionConfig,
|
||||
| "availableTools"
|
||||
@@ -128,6 +140,173 @@ export interface CopilotAttemptDeps {
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
}) => void;
|
||||
/**
|
||||
* Called before an attempt retains its live SDK session to observe background
|
||||
* compaction. The harness must prevent that session ID from being resumed
|
||||
* until cleanup completes.
|
||||
*/
|
||||
onDeferredCompaction?: (info: {
|
||||
abort: () => void;
|
||||
cleanup: Promise<"aborted" | "completed" | "deadline">;
|
||||
sdkSessionId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
async function runCopilotAgentEndHook(
|
||||
params: AttemptParamsLike,
|
||||
hookParams: CopilotAgentEndHookParams,
|
||||
): Promise<void> {
|
||||
if (!params.messageChannel && !params.messageProvider) {
|
||||
await awaitAgentEndSideEffects(hookParams);
|
||||
return;
|
||||
}
|
||||
runAgentEndSideEffects(hookParams);
|
||||
}
|
||||
|
||||
async function finalizeCopilotAttempt(
|
||||
params: AttemptParamsLike,
|
||||
result: AgentHarnessAttemptResult,
|
||||
ctx: CopilotAgentEndHookParams["ctx"],
|
||||
attemptStartedAt: number,
|
||||
now: () => number,
|
||||
): Promise<AgentHarnessAttemptResult> {
|
||||
await runCopilotAgentEndHook(params, {
|
||||
event: {
|
||||
messages: result.messagesSnapshot,
|
||||
success: !result.aborted && !result.promptError && !result.timedOut,
|
||||
...(result.promptError
|
||||
? { error: toError(result.promptError).message }
|
||||
: result.timedOut
|
||||
? { error: "Copilot SDK turn timed out." }
|
||||
: {}),
|
||||
durationMs: now() - attemptStartedAt,
|
||||
},
|
||||
ctx,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function awaitDeferredCleanupCompletionOrAbort(params: {
|
||||
abortSignal: AbortSignal | undefined;
|
||||
awaitSessionIdle: boolean;
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
}): Promise<"aborted" | "completed"> {
|
||||
const awaitCompletion = async () => {
|
||||
if (params.awaitSessionIdle) {
|
||||
await params.bridge.awaitSessionIdle();
|
||||
}
|
||||
await params.bridge.awaitCompactionCompletion();
|
||||
};
|
||||
if (!params.abortSignal) {
|
||||
await awaitCompletion();
|
||||
return "completed";
|
||||
}
|
||||
if (params.abortSignal.aborted) {
|
||||
return "aborted";
|
||||
}
|
||||
let resolveAbort: () => void = () => undefined;
|
||||
const aborted = new Promise<"aborted">((resolve) => {
|
||||
resolveAbort = () => resolve("aborted");
|
||||
});
|
||||
params.abortSignal.addEventListener("abort", resolveAbort, { once: true });
|
||||
try {
|
||||
return await Promise.race([awaitCompletion().then(() => "completed" as const), aborted]);
|
||||
} finally {
|
||||
params.abortSignal.removeEventListener("abort", resolveAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function deferBackgroundCompactionCleanup(params: {
|
||||
abortSignal: AbortSignal | undefined;
|
||||
awaitSessionIdle: boolean;
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
handle: PooledClient;
|
||||
pool: CopilotClientPool;
|
||||
sdkSessionId?: string;
|
||||
session: SessionLike;
|
||||
timeoutMs: number;
|
||||
}): Promise<"aborted" | "completed" | "deadline"> {
|
||||
// The SDK can compact after its turn result or a timeout. Keep the bridge
|
||||
// attached so after_compaction uses the originating run context.
|
||||
return (async () => {
|
||||
let outcome: "aborted" | "completed" | "deadline" = "deadline";
|
||||
try {
|
||||
outcome = await awaitDeferredCleanupBeforeDeadline({
|
||||
abortSignal: params.abortSignal,
|
||||
awaitSessionIdle: params.awaitSessionIdle,
|
||||
bridge: params.bridge,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
} catch {
|
||||
// Event callbacks are best-effort; cleanup still releases the retained session.
|
||||
} finally {
|
||||
if (outcome !== "completed") {
|
||||
await cancelBackgroundCompactionBeforeTeardown(params.session);
|
||||
params.bridge.settleCompactionWait();
|
||||
}
|
||||
params.bridge.detach();
|
||||
try {
|
||||
await params.session.disconnect();
|
||||
} catch {
|
||||
// The attempt has already returned its timeout result.
|
||||
}
|
||||
if (outcome !== "completed" && params.sdkSessionId) {
|
||||
try {
|
||||
await params.handle.client.deleteSession(params.sdkSessionId);
|
||||
} catch {
|
||||
// The timeout path intentionally discards this SDK session either way.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await params.pool.release(params.handle);
|
||||
} catch {
|
||||
// The pool will dispose this client later if its release cannot complete.
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
})();
|
||||
}
|
||||
|
||||
async function cancelBackgroundCompactionBeforeTeardown(session: SessionLike): Promise<void> {
|
||||
const cancelBackgroundCompaction = session.rpc?.history?.cancelBackgroundCompaction;
|
||||
if (!cancelBackgroundCompaction) {
|
||||
return;
|
||||
}
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<void>((resolve) => {
|
||||
timeoutId = setTimeout(resolve, BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.resolve()
|
||||
.then(() => cancelBackgroundCompaction())
|
||||
.catch(() => undefined),
|
||||
deadline,
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitDeferredCleanupBeforeDeadline(params: {
|
||||
abortSignal: AbortSignal | undefined;
|
||||
awaitSessionIdle: boolean;
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
timeoutMs: number;
|
||||
}): Promise<"aborted" | "completed" | "deadline"> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<"deadline">((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve("deadline"), params.timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([awaitDeferredCleanupCompletionOrAbort(params), deadline]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCopilotAttempt(
|
||||
@@ -135,34 +314,79 @@ export async function runCopilotAttempt(
|
||||
deps: CopilotAttemptDeps,
|
||||
): Promise<AgentHarnessAttemptResult> {
|
||||
const now = deps.now ?? Date.now;
|
||||
const attemptStartedAt = now();
|
||||
const input = params as AttemptParamsLike;
|
||||
const createToolBridge = deps.createToolBridge ?? createCopilotToolBridge;
|
||||
const messages = getMessagesSnapshotInput(input);
|
||||
const modelRef = resolveModelRef(input);
|
||||
const resolvedWorkspaceForSandbox =
|
||||
readResolvedAttemptPath(input.workspaceDir) ?? readResolvedAttemptPath(input.cwd);
|
||||
const sandboxSessionKey =
|
||||
readString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ??
|
||||
readString((input as { sessionKey?: unknown }).sessionKey) ??
|
||||
readString(input.sessionId);
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
|
||||
config: input.config,
|
||||
agentId: readString(params.agentId),
|
||||
});
|
||||
const hookContextWindowFields = {
|
||||
...(input.contextWindowInfo?.tokens
|
||||
? { contextTokenBudget: input.contextWindowInfo.tokens }
|
||||
: input.contextTokenBudget
|
||||
? { contextTokenBudget: input.contextTokenBudget }
|
||||
: {}),
|
||||
...(input.contextWindowInfo?.source
|
||||
? { contextWindowSource: input.contextWindowInfo.source }
|
||||
: {}),
|
||||
...(input.contextWindowInfo?.referenceTokens
|
||||
? { contextWindowReferenceTokens: input.contextWindowInfo.referenceTokens }
|
||||
: {}),
|
||||
};
|
||||
const hookContext = {
|
||||
runId: input.runId,
|
||||
jobId: input.jobId,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionId: input.sessionId,
|
||||
workspaceDir: resolvedWorkspaceForSandbox,
|
||||
modelProviderId: modelRef.provider,
|
||||
modelId: modelRef.id,
|
||||
trigger: input.trigger,
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
...hookContextWindowFields,
|
||||
...buildAgentHookContextChannelFields(input),
|
||||
};
|
||||
const finishAttempt = (result: AgentHarnessAttemptResult) =>
|
||||
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
|
||||
|
||||
if (params.abortSignal?.aborted) {
|
||||
return createResult(input, {
|
||||
aborted: true,
|
||||
externalAbort: true,
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: undefined,
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
aborted: true,
|
||||
externalAbort: true,
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: undefined,
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const modelRef = resolveModelRef(input);
|
||||
if (!SUPPORTED_PROVIDERS.has(modelRef.provider)) {
|
||||
return createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"model_not_supported",
|
||||
`[copilot-attempt] provider ${modelRef.provider} is not supported at MVP (subscription Copilot models only; BYOK arrives via byok-mapping-skeleton)`,
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"model_not_supported",
|
||||
`[copilot-attempt] provider ${modelRef.provider} is not supported at MVP (subscription Copilot models only; BYOK arrives via byok-mapping-skeleton)`,
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let abortRequested = false;
|
||||
@@ -170,6 +394,7 @@ export async function runCopilotAttempt(
|
||||
let externalAbort = false;
|
||||
let settled = false;
|
||||
let sentTurnStarted = false;
|
||||
let timedOutDuringCompaction = false;
|
||||
let timedOut = false;
|
||||
let promptError: Error | undefined;
|
||||
let sdkSessionId: string | undefined;
|
||||
@@ -208,12 +433,6 @@ export async function runCopilotAttempt(
|
||||
// spawned subagents should inherit. When sandbox is disabled (the default),
|
||||
// `resolveSandboxContext` returns `null` and behavior is unchanged from the
|
||||
// pre-fix path.
|
||||
const resolvedWorkspaceForSandbox =
|
||||
readResolvedAttemptPath(input.workspaceDir) ?? readResolvedAttemptPath(input.cwd);
|
||||
const sandboxSessionKey =
|
||||
readString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ??
|
||||
readString((input as { sessionKey?: unknown }).sessionKey) ??
|
||||
readString(input.sessionId);
|
||||
const resolveSandbox = deps.resolveSandboxContextOverride ?? defaultResolveSandboxContext;
|
||||
let sandbox: SandboxContext | null = null;
|
||||
let effectiveWorkspaceDir = resolvedWorkspaceForSandbox;
|
||||
@@ -245,52 +464,54 @@ export async function runCopilotAttempt(
|
||||
settled = true;
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
if (abortRequested || params.abortSignal?.aborted) {
|
||||
return createResult(input, {
|
||||
aborted: true,
|
||||
externalAbort: true,
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
aborted: true,
|
||||
externalAbort: true,
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: undefined,
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: undefined,
|
||||
promptError: createPromptError(
|
||||
"sandbox_resolution_failure",
|
||||
`[copilot-attempt] sandbox resolution failed: ${toError(error).message}`,
|
||||
error,
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
}
|
||||
return createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"sandbox_resolution_failure",
|
||||
`[copilot-attempt] sandbox resolution failed: ${toError(error).message}`,
|
||||
error,
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
hookContext.workspaceDir = effectiveWorkspaceDir;
|
||||
const requestedCwd = readResolvedAttemptPath(input.cwd);
|
||||
if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspaceForSandbox) {
|
||||
settled = true;
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
return createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"sandbox_cwd_override_unsupported",
|
||||
"[copilot-attempt] cwd override is not supported for sandboxed Copilot runs; omit cwd or use the agent workspace as cwd",
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"sandbox_cwd_override_unsupported",
|
||||
"[copilot-attempt] cwd override is not supported for sandboxed Copilot runs; omit cwd or use the agent workspace as cwd",
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const effectiveCwd = sandbox?.enabled
|
||||
? effectiveWorkspaceDir
|
||||
: (requestedCwd ?? effectiveWorkspaceDir);
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
|
||||
config: input.config,
|
||||
agentId: readString(params.agentId),
|
||||
});
|
||||
const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({
|
||||
config: input.config,
|
||||
sessionAgentId,
|
||||
@@ -301,7 +522,6 @@ export async function runCopilotAttempt(
|
||||
resolvedWorkspace: resolvedWorkspaceForSandbox,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const poolAcquire = resolvePoolAcquire(input);
|
||||
|
||||
// Mutable session holder shared with the tool bridge so onYield
|
||||
@@ -340,10 +560,24 @@ export async function runCopilotAttempt(
|
||||
onYieldDetected: () => {
|
||||
yieldDetected = true;
|
||||
},
|
||||
onToolCompleted: ({ args, error, result, startedAt, toolCallId, toolName }) =>
|
||||
runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId,
|
||||
runId: input.runId,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: input.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
channelId: hookContext.channelId,
|
||||
startArgs: args,
|
||||
...(result !== undefined ? { result } : {}),
|
||||
...(error ? { error } : {}),
|
||||
startedAt,
|
||||
}),
|
||||
});
|
||||
sdkTools = toolBridge.sdkTools;
|
||||
} catch (error: unknown) {
|
||||
return createResult(input, {
|
||||
const result = createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
@@ -354,6 +588,7 @@ export async function runCopilotAttempt(
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
return finishAttempt(result);
|
||||
}
|
||||
|
||||
handle = await deps.pool.acquire(poolAcquire.key, poolAcquire.options);
|
||||
@@ -380,14 +615,60 @@ export async function runCopilotAttempt(
|
||||
effectiveWorkspaceDir,
|
||||
warn: (message) => console.warn(message),
|
||||
});
|
||||
const originalDeveloperInstructions =
|
||||
createSystemMessageContent(input, workspaceBootstrap.instructions) ?? "";
|
||||
const promptBuild = isRawCopilotModelRun(input)
|
||||
? {
|
||||
prompt: input.prompt,
|
||||
developerInstructions: originalDeveloperInstructions,
|
||||
}
|
||||
: await resolveAgentHarnessBeforePromptBuildResult({
|
||||
prompt: input.prompt,
|
||||
developerInstructions: originalDeveloperInstructions,
|
||||
messages,
|
||||
ctx: hookContext,
|
||||
...("beforeAgentStartResult" in input
|
||||
? { beforeAgentStartResult: input.beforeAgentStartResult }
|
||||
: {}),
|
||||
});
|
||||
const attemptInput =
|
||||
promptBuild.prompt === input.prompt ? input : { ...input, prompt: promptBuild.prompt };
|
||||
let promptImagesCount = 0;
|
||||
const emitLlmInput = (prompt: string, additionalContext?: string) => {
|
||||
runAgentHarnessLlmInputHook({
|
||||
event: {
|
||||
runId: input.runId,
|
||||
sessionId: input.sessionId,
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.id,
|
||||
...(promptBuild.developerInstructions
|
||||
? { systemPrompt: promptBuild.developerInstructions }
|
||||
: {}),
|
||||
prompt: additionalContext ? `${prompt}\n\n${additionalContext}` : prompt,
|
||||
// Copilot SDK sessions own their own transcript. OpenClaw's
|
||||
// mirrored messages are persistence state, not provider input.
|
||||
historyMessages: [],
|
||||
imagesCount: promptImagesCount,
|
||||
tools: sdkTools,
|
||||
},
|
||||
ctx: hookContext,
|
||||
});
|
||||
};
|
||||
const hasNativePromptHook = Boolean(attemptInput.hooksConfig?.onUserPromptSubmitted);
|
||||
const sessionConfig = createSessionConfig(
|
||||
input,
|
||||
attemptInput,
|
||||
modelRef.id,
|
||||
sdkTools,
|
||||
poolAcquire.auth,
|
||||
workspaceBootstrap.instructions,
|
||||
promptBuild.developerInstructions || undefined,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
hasNativePromptHook
|
||||
? {
|
||||
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
|
||||
emitLlmInput(prompt, additionalContext),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const replayDecision = decideReplayAction({
|
||||
sdkSessionId: input.initialReplayState?.sdkSessionId,
|
||||
@@ -442,21 +723,46 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
bridge = attachEventBridge(session, {
|
||||
onAssistantDelta: input.onAssistantDelta,
|
||||
onCompactionStart: async () => {
|
||||
const sessionFile = readString(input.sessionFile);
|
||||
if (!sessionFile) {
|
||||
return;
|
||||
}
|
||||
await runAgentHarnessBeforeCompactionHook({
|
||||
sessionFile,
|
||||
ctx: hookContext,
|
||||
});
|
||||
},
|
||||
onCompactionComplete: async ({ messagesRemoved, success }) => {
|
||||
const sessionFile = readString(input.sessionFile);
|
||||
if (!success || !sessionFile) {
|
||||
return;
|
||||
}
|
||||
await runAgentHarnessAfterCompactionHook({
|
||||
sessionFile,
|
||||
compactedCount: messagesRemoved ?? -1,
|
||||
ctx: hookContext,
|
||||
});
|
||||
},
|
||||
getSdkSessionId: () => sdkSessionId,
|
||||
isAborted: () => aborted,
|
||||
});
|
||||
|
||||
const messageOptions = await createMessageOptions(input, {
|
||||
const messageOptions = await createMessageOptions(attemptInput, {
|
||||
effectiveCwd,
|
||||
effectiveWorkspaceDir,
|
||||
sandbox,
|
||||
workspaceOnly: effectiveFsWorkspaceOnly,
|
||||
});
|
||||
promptImagesCount = messageOptions.attachments?.length ?? 0;
|
||||
if (abortRequested || params.abortSignal?.aborted) {
|
||||
aborted = true;
|
||||
externalAbort = true;
|
||||
} else {
|
||||
sentTurnStarted = true;
|
||||
if (!hasNativePromptHook) {
|
||||
emitLlmInput(attemptInput.prompt);
|
||||
}
|
||||
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
|
||||
await bridge.awaitDeltaChain();
|
||||
if (!bridge.recordSendResult(result) && !aborted) {
|
||||
@@ -464,6 +770,7 @@ export async function runCopilotAttempt(
|
||||
// capability inventory. Do not call session.abort() here: OpenClaw may
|
||||
// resume the in-flight SDK session on the next attempt.
|
||||
timedOut = true;
|
||||
timedOutDuringCompaction = bridge.isCompacting();
|
||||
}
|
||||
const snap = bridge.snapshot();
|
||||
if (!promptError && !timedOut && !aborted && snap.streamError) {
|
||||
@@ -484,6 +791,7 @@ export async function runCopilotAttempt(
|
||||
// in-flight SDK session on the next attempt (the SDK keeps
|
||||
// the server-side session intact across this kind of timeout).
|
||||
timedOut = true;
|
||||
timedOutDuringCompaction = bridge?.isCompacting() === true;
|
||||
// Flush any in-flight delta promise chain so the snapshot
|
||||
// built below in `finally` includes the deltas the SDK already
|
||||
// delivered before the timer fired.
|
||||
@@ -498,39 +806,81 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
} finally {
|
||||
settled = true;
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
const retainSessionForDeferredCleanup =
|
||||
bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false);
|
||||
if (retainSessionForDeferredCleanup && bridge && session && handle) {
|
||||
const cleanupAbort = new AbortController();
|
||||
const abortCleanup = () => cleanupAbort.abort();
|
||||
if (params.abortSignal?.aborted) {
|
||||
abortCleanup();
|
||||
} else {
|
||||
params.abortSignal?.addEventListener("abort", abortCleanup, { once: true });
|
||||
}
|
||||
const cleanup = deferBackgroundCompactionCleanup({
|
||||
abortSignal: cleanupAbort.signal,
|
||||
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
|
||||
bridge,
|
||||
handle,
|
||||
pool: deps.pool,
|
||||
sdkSessionId,
|
||||
session,
|
||||
timeoutMs: resolveCompactionTimeoutMs(input.config),
|
||||
});
|
||||
void cleanup
|
||||
.finally(() => {
|
||||
params.abortSignal?.removeEventListener("abort", abortCleanup);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (sdkSessionId) {
|
||||
try {
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => cleanupAbort.abort(),
|
||||
cleanup,
|
||||
sdkSessionId,
|
||||
});
|
||||
} catch {
|
||||
// Session tracking cannot interfere with timeout cleanup.
|
||||
}
|
||||
}
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
} else {
|
||||
// A normal sendAndWait result has observed session.idle, which the SDK
|
||||
// defines as no background agents in flight. Timeouts retain the bridge
|
||||
// until that event so compaction that starts after the timer still completes.
|
||||
await bridge?.awaitCompactionChain();
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (session) {
|
||||
try {
|
||||
await session.disconnect();
|
||||
} catch (error: unknown) {
|
||||
disconnectError = toError(error);
|
||||
// A timeout is a higher-fidelity signal than a cleanup-time
|
||||
// disconnect failure; don't let a stale disconnect error
|
||||
// mask the timeout classification the replay-shim depends on.
|
||||
if (!promptError && !timedOut) {
|
||||
promptError = disconnectError;
|
||||
if (session) {
|
||||
try {
|
||||
await session.disconnect();
|
||||
} catch (error: unknown) {
|
||||
disconnectError = toError(error);
|
||||
// A timeout is a higher-fidelity signal than a cleanup-time
|
||||
// disconnect failure; don't let a stale disconnect error
|
||||
// mask the timeout classification the replay-shim depends on.
|
||||
if (!promptError && !timedOut) {
|
||||
promptError = disconnectError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
try {
|
||||
await deps.pool.release(handle);
|
||||
} catch (error: unknown) {
|
||||
const releaseFailure = toError(error);
|
||||
if (promptError) {
|
||||
console.warn(
|
||||
"[copilot-attempt] pool.release failed after primary error",
|
||||
releaseFailure,
|
||||
);
|
||||
} else {
|
||||
releaseError = releaseFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
try {
|
||||
await deps.pool.release(handle);
|
||||
} catch (error: unknown) {
|
||||
const releaseFailure = toError(error);
|
||||
if (promptError) {
|
||||
console.warn("[copilot-attempt] pool.release failed after primary error", releaseFailure);
|
||||
} else {
|
||||
releaseError = releaseFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseError) {
|
||||
throw releaseError;
|
||||
}
|
||||
|
||||
const snap = bridge?.snapshot();
|
||||
@@ -627,7 +977,7 @@ export async function runCopilotAttempt(
|
||||
});
|
||||
}
|
||||
|
||||
return createResult(input, {
|
||||
const result = createResult(input, {
|
||||
aborted,
|
||||
assistantTexts,
|
||||
currentAttemptAssistant: lastAssistant,
|
||||
@@ -646,10 +996,43 @@ export async function runCopilotAttempt(
|
||||
sdkSessionId,
|
||||
sessionIdUsed,
|
||||
timedOut,
|
||||
timedOutDuringCompaction,
|
||||
toolMetas: snap ? [...snap.toolMetas] : [],
|
||||
usage: snap?.usage,
|
||||
yieldDetected,
|
||||
});
|
||||
if (sentTurnStarted) {
|
||||
runAgentHarnessLlmOutputHook({
|
||||
event: {
|
||||
runId: input.runId,
|
||||
sessionId: input.sessionId,
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.id,
|
||||
...hookContextWindowFields,
|
||||
resolvedRef:
|
||||
input.runtimePlan?.observability.resolvedRef ?? `${modelRef.provider}/${modelRef.id}`,
|
||||
...(input.runtimePlan?.observability.harnessId
|
||||
? { harnessId: input.runtimePlan.observability.harnessId }
|
||||
: {}),
|
||||
assistantTexts: result.assistantTexts,
|
||||
...(result.lastAssistant ? { lastAssistant: result.lastAssistant } : {}),
|
||||
...(result.attemptUsage ? { usage: result.attemptUsage } : {}),
|
||||
...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}),
|
||||
},
|
||||
ctx: hookContext,
|
||||
});
|
||||
}
|
||||
if (releaseError) {
|
||||
await finalizeCopilotAttempt(
|
||||
input,
|
||||
{ ...result, promptError: releaseError },
|
||||
hookContext,
|
||||
attemptStartedAt,
|
||||
now,
|
||||
);
|
||||
throw releaseError;
|
||||
}
|
||||
return finishAttempt(result);
|
||||
}
|
||||
|
||||
function createResult(
|
||||
@@ -669,6 +1052,7 @@ function createResult(
|
||||
sdkSessionId?: string;
|
||||
sessionIdUsed?: string;
|
||||
timedOut?: boolean;
|
||||
timedOutDuringCompaction?: boolean;
|
||||
toolMetas?: Array<{ meta?: string; toolName: string }>;
|
||||
usage?: AssistantUsageSnapshot;
|
||||
yieldDetected?: boolean;
|
||||
@@ -711,7 +1095,7 @@ function createResult(
|
||||
sessionFileUsed: readString(params.sessionFile),
|
||||
sessionIdUsed: state.sessionIdUsed ?? readString(params.sessionId) ?? "copilot-session",
|
||||
timedOut,
|
||||
timedOutDuringCompaction: false,
|
||||
timedOutDuringCompaction: state.timedOutDuringCompaction === true,
|
||||
toolMetas,
|
||||
yieldDetected: state.yieldDetected === true,
|
||||
};
|
||||
@@ -731,14 +1115,14 @@ function createSessionConfig(
|
||||
sdkModelId: string,
|
||||
sdkTools: SdkTool[],
|
||||
resolvedAuth: ReturnType<typeof resolveCopilotAuth>,
|
||||
workspaceBootstrapInstructions: string | undefined,
|
||||
systemMessageContent: string | undefined,
|
||||
effectiveWorkspaceDir: string | undefined,
|
||||
effectiveCwd: string | undefined,
|
||||
hooksBridgeOptions?: Parameters<typeof createHooksBridge>[1],
|
||||
): CopilotSessionConfig {
|
||||
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
|
||||
const hooks = createHooksBridge(params.hooksConfig);
|
||||
const hooks = createHooksBridge(params.hooksConfig, hooksBridgeOptions);
|
||||
const infiniteSessions = createInfiniteSessionConfig(params.infiniteSessionConfig);
|
||||
const systemMessageContent = createSystemMessageContent(params, workspaceBootstrapInstructions);
|
||||
return {
|
||||
model: sdkModelId,
|
||||
// Permission decisions for SDK built-in tool kinds (shell, write,
|
||||
@@ -765,10 +1149,9 @@ function createSessionConfig(
|
||||
// contract, omitting the handler hides the `ask_user` tool from the
|
||||
// model entirely. Interactive ask_user will need a real channel/TUI
|
||||
// prompt bridge before this runtime can expose the handler.
|
||||
// SessionHooks: only set when the host actually supplied handlers.
|
||||
// createHooksBridge returns undefined for an empty config so we
|
||||
// never install an empty hooks subsystem. See hooks-bridge.ts for
|
||||
// the back-pointer to src/agents/harness/lifecycle-hook-helpers.ts.
|
||||
// Preserve the shipped native SDK hook contract. These callbacks expose
|
||||
// Copilot-specific events and decisions that generic lifecycle hooks do
|
||||
// not model.
|
||||
...(hooks ? { hooks } : {}),
|
||||
// Session-level telemetry opt-out: only propagate when the host
|
||||
// explicitly set a boolean. undefined means "use SDK default"
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
// Copilot tests cover doctor probes plugin behavior.
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
probeCopilotAuthShape,
|
||||
probeCopilotCliVersion,
|
||||
probeCopilotHomeWritable,
|
||||
} from "./doctor-probes.js";
|
||||
|
||||
type FakeChildOptions = {
|
||||
exitCode?: number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
emitErrorMessage?: string;
|
||||
/** When true, never emits close; useful for timeout tests. */
|
||||
hang?: boolean;
|
||||
};
|
||||
|
||||
function makeFakeChild(opts: FakeChildOptions = {}) {
|
||||
const emitter = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: () => void;
|
||||
};
|
||||
emitter.stdout = new EventEmitter();
|
||||
emitter.stderr = new EventEmitter();
|
||||
emitter.kill = vi.fn();
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (opts.stdout) {
|
||||
emitter.stdout.emit("data", Buffer.from(opts.stdout, "utf8"));
|
||||
}
|
||||
if (opts.stderr) {
|
||||
emitter.stderr.emit("data", Buffer.from(opts.stderr, "utf8"));
|
||||
}
|
||||
if (opts.emitErrorMessage) {
|
||||
emitter.emit("error", new Error(opts.emitErrorMessage));
|
||||
return;
|
||||
}
|
||||
if (!opts.hang) {
|
||||
emitter.emit("close", opts.exitCode ?? 0, opts.signal ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function makeTempHome(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-doctor-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("probeCopilotCliVersion", () => {
|
||||
it("reports ok with trimmed version on exit 0 with stdout", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ stdout: " 1.2.3 \n" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.version).toBe("1.2.3");
|
||||
expect(result.command).toBe("copilot");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses custom command and args when provided", async () => {
|
||||
const calls: Array<{ cmd: string; args: string[] }> = [];
|
||||
const result = await probeCopilotCliVersion({
|
||||
command: "my-copilot",
|
||||
args: ["-V"],
|
||||
spawnFn: ((cmd: string, args: readonly string[]) => {
|
||||
calls.push({ cmd, args: [...args] });
|
||||
return makeFakeChild({ stdout: "9.9.9" }) as never;
|
||||
}) as never,
|
||||
});
|
||||
expect(calls).toEqual([{ cmd: "my-copilot", args: ["-V"] }]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.command).toBe("my-copilot");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports non-zero-exit with stderr details", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ exitCode: 2, stderr: "boom: not installed" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("non-zero-exit");
|
||||
expect(result.details?.exitCode).toBe(2);
|
||||
expect(result.details?.stderr).toBe("boom: not installed");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports empty-version when exit 0 produces no stdout", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ stdout: " \n" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("empty-version");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports spawn-failed when spawnFn throws synchronously (e.g. ENOENT)", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: (() => {
|
||||
throw new Error("ENOENT: copilot not found");
|
||||
}) as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("spawn-failed");
|
||||
expect(result.details?.rawError).toContain("ENOENT");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports spawn-error when child emits 'error'", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ emitErrorMessage: "spawn ENOEXEC" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("spawn-error");
|
||||
expect(result.details?.rawError).toBe("spawn ENOEXEC");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports probe-timeout when child hangs past timeoutMs and kills the child", async () => {
|
||||
const fakeChild = makeFakeChild({ hang: true });
|
||||
const result = await probeCopilotCliVersion({
|
||||
timeoutMs: 10,
|
||||
spawnFn: () => fakeChild as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("probe-timeout");
|
||||
expect(result.details?.timeoutMs).toBe(10);
|
||||
}
|
||||
expect(fakeChild.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns just the first non-empty line as version when stdout has a banner / update hint", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () =>
|
||||
makeFakeChild({
|
||||
stdout: "GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.\n",
|
||||
}) as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.version).toBe("GitHub Copilot CLI 1.0.48.");
|
||||
expect(result.rawStdout).toBe(
|
||||
"GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not surface rawStdout when stdout is already single-line", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ stdout: "1.2.3\n" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.version).toBe("1.2.3");
|
||||
expect(result.rawStdout).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeCopilotHomeWritable", () => {
|
||||
it("reports ok when the directory exists and is writable, cleaning up after itself", async () => {
|
||||
const home = await makeTempHome();
|
||||
const result = await probeCopilotHomeWritable(home);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.copilotHome).toBe(home);
|
||||
expect(result.probedPath.startsWith(home)).toBe(true);
|
||||
}
|
||||
const entries = await fs.readdir(home);
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates copilotHome if missing", async () => {
|
||||
const root = await makeTempHome();
|
||||
const home = path.join(root, "nested", "copilot-cfg");
|
||||
const result = await probeCopilotHomeWritable(home);
|
||||
expect(result.ok).toBe(true);
|
||||
const stat = await fs.stat(home);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("reports copilothome-not-writable when fs throws on mkdir", async () => {
|
||||
const result = await probeCopilotHomeWritable("/some/path", {
|
||||
fsApi: {
|
||||
mkdir: vi.fn().mockRejectedValueOnce(new Error("EPERM: not permitted")),
|
||||
writeFile: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("copilothome-not-writable");
|
||||
expect(result.details?.rawError).toContain("EPERM");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the platform default copilotHome when argument is empty or whitespace", async () => {
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined);
|
||||
const result = await probeCopilotHomeWritable(" ", {
|
||||
fsApi: {
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile,
|
||||
rm: vi.fn().mockResolvedValue(undefined),
|
||||
} as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.copilotHome.length).toBeGreaterThan(0);
|
||||
expect(result.copilotHome.toLowerCase()).toContain("copilot");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeCopilotAuthShape", () => {
|
||||
it("resolves to useLoggedInUser when the flag is true", () => {
|
||||
const result = probeCopilotAuthShape({ useLoggedInUser: true });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.resolvedMode).toBe("useLoggedInUser");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves to gitHubToken when a non-empty token is supplied", () => {
|
||||
const result = probeCopilotAuthShape({ gitHubToken: "ghp_xxx" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.resolvedMode).toBe("gitHubToken");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves to profile when both profileId and profileVersion are supplied", () => {
|
||||
const result = probeCopilotAuthShape({ profileId: "p1", profileVersion: "v1" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.resolvedMode).toBe("profile");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when no auth source is provided", () => {
|
||||
const result = probeCopilotAuthShape({});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("no-auth-source");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when only one of profileId / profileVersion is provided", () => {
|
||||
expect(probeCopilotAuthShape({ profileId: "p1" }).ok).toBe(false);
|
||||
expect(probeCopilotAuthShape({ profileVersion: "v1" }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects useLoggedInUser:false on its own", () => {
|
||||
const result = probeCopilotAuthShape({ useLoggedInUser: false });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty gitHubToken string", () => {
|
||||
const result = probeCopilotAuthShape({ gitHubToken: "" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* Runtime doctor probes for the copilot extension.
|
||||
*
|
||||
* Imperative side-effecting checks used to diagnose a copilot
|
||||
* deployment from within `openclaw doctor` (or any equivalent
|
||||
* harness-side health check). Kept out of doctor-contract-api.ts
|
||||
* because that contract is declarative and auto-loaded by the
|
||||
* plugin registry, whereas these probes spawn subprocesses or
|
||||
* touch the filesystem and must be invoked imperatively.
|
||||
*
|
||||
* All probes are pure (no module-level state) and dependency-
|
||||
* injectable for tests. They never throw on a probe-negative
|
||||
* result — failure is surfaced via the `ok: false` shape so the
|
||||
* caller can render a structured doctor report.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type ProbeResult<TPayload extends object = Record<string, never>> =
|
||||
| ({ ok: true } & TPayload)
|
||||
| { ok: false; reason: string; details?: Record<string, unknown> };
|
||||
|
||||
export interface ProbeCopilotCliVersionOptions {
|
||||
/** Command to invoke; defaults to "copilot". */
|
||||
command?: string;
|
||||
/** Argv used to ask for version; defaults to ["--version"]. */
|
||||
args?: readonly string[];
|
||||
/** Timeout in milliseconds; defaults to 5_000. */
|
||||
timeoutMs?: number;
|
||||
/** Injection seam for testing. Defaults to node:child_process spawn. */
|
||||
spawnFn?: typeof spawn;
|
||||
}
|
||||
|
||||
export interface ProbeCopilotHomeOptions {
|
||||
/** Injection seam for testing. */
|
||||
fsApi?: Pick<typeof fs, "mkdir" | "writeFile" | "rm">;
|
||||
/** Filename used for the writability probe. */
|
||||
probeFileName?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_PROBE_FILENAME = ".copilot-doctor-probe";
|
||||
|
||||
/**
|
||||
* Probe that the Copilot CLI is installed and prints a version.
|
||||
* Treats non-zero exit, missing stdout, and timeout all as failures.
|
||||
*/
|
||||
export async function probeCopilotCliVersion(
|
||||
options: ProbeCopilotCliVersionOptions = {},
|
||||
): Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>> {
|
||||
const command = options.command ?? "copilot";
|
||||
const args = options.args ?? ["--version"];
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
||||
const spawnImpl = options.spawnFn ?? spawn;
|
||||
|
||||
return new Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>>(
|
||||
(resolve) => {
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
let settled = false;
|
||||
const settle = (
|
||||
result: ProbeResult<{ version: string; command: string; rawStdout?: string }>,
|
||||
): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
try {
|
||||
child?.kill();
|
||||
} catch {
|
||||
// ignore double-kill / already-dead errors
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "probe-timeout",
|
||||
details: { command, args: [...args], timeoutMs },
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
child = spawnImpl(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
} catch (error) {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "spawn-failed",
|
||||
details: { command, args: [...args], rawError: formatProbeError(error) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", (error: Error) => {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "spawn-error",
|
||||
details: { command, args: [...args], rawError: error.message },
|
||||
});
|
||||
});
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (code !== 0) {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "non-zero-exit",
|
||||
details: {
|
||||
command,
|
||||
args: [...args],
|
||||
exitCode: code,
|
||||
signal,
|
||||
stderr: stderr.trim() || undefined,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawStdout = stdout.trim();
|
||||
if (!rawStdout) {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "empty-version",
|
||||
details: { command, args: [...args] },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Many version commands (notably the GitHub Copilot CLI's `copilot --version`)
|
||||
// print a banner plus an "update available" hint on subsequent
|
||||
// lines. Surface only the first non-empty line as `version` so the
|
||||
// doctor UI gets a clean string; keep the full stdout in
|
||||
// `rawStdout` for debugging.
|
||||
const version = firstNonEmptyLine(rawStdout) ?? rawStdout;
|
||||
const payload: { version: string; command: string; rawStdout?: string } = {
|
||||
version,
|
||||
command,
|
||||
};
|
||||
if (rawStdout !== version) {
|
||||
payload.rawStdout = rawStdout;
|
||||
}
|
||||
settle({ ok: true, ...payload });
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(value: string): string | undefined {
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe that copilotHome (or default ~/.config/copilot) is writable
|
||||
* by the running user. Mirrors the existing auth-bridge's expectation
|
||||
* that the SDK can persist credentials under copilotHome.
|
||||
*/
|
||||
export async function probeCopilotHomeWritable(
|
||||
copilotHome: string | undefined,
|
||||
options: ProbeCopilotHomeOptions = {},
|
||||
): Promise<ProbeResult<{ copilotHome: string; probedPath: string }>> {
|
||||
const fsApi = options.fsApi ?? fs;
|
||||
const probeFileName = options.probeFileName ?? DEFAULT_PROBE_FILENAME;
|
||||
const resolvedHome =
|
||||
typeof copilotHome === "string" && copilotHome.trim().length > 0
|
||||
? copilotHome.trim()
|
||||
: defaultCopilotHome();
|
||||
const probedPath = path.join(resolvedHome, probeFileName);
|
||||
|
||||
try {
|
||||
await fsApi.mkdir(resolvedHome, { recursive: true });
|
||||
await fsApi.writeFile(probedPath, "copilot-doctor-probe", "utf8");
|
||||
await fsApi.rm(probedPath, { force: true });
|
||||
return { ok: true, copilotHome: resolvedHome, probedPath };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "copilothome-not-writable",
|
||||
details: {
|
||||
copilotHome: resolvedHome,
|
||||
probedPath,
|
||||
rawError: formatProbeError(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe GitHub Copilot agent runtime auth resolution given a useLoggedInUser hint.
|
||||
* Validates that at least one of {useLoggedInUser, gitHubToken,
|
||||
* profileId+profileVersion} is set. This is intentionally a
|
||||
* shape-only probe: actually performing an SDK auth handshake
|
||||
* would require a pool and is out of scope for `openclaw doctor`.
|
||||
*/
|
||||
export function probeCopilotAuthShape(input: {
|
||||
useLoggedInUser?: boolean;
|
||||
gitHubToken?: string;
|
||||
profileId?: string;
|
||||
profileVersion?: string;
|
||||
}): ProbeResult<{ resolvedMode: "useLoggedInUser" | "gitHubToken" | "profile" }> {
|
||||
if (input.useLoggedInUser === true) {
|
||||
return { ok: true, resolvedMode: "useLoggedInUser" };
|
||||
}
|
||||
if (typeof input.gitHubToken === "string" && input.gitHubToken.length > 0) {
|
||||
return { ok: true, resolvedMode: "gitHubToken" };
|
||||
}
|
||||
if (
|
||||
typeof input.profileId === "string" &&
|
||||
input.profileId.length > 0 &&
|
||||
typeof input.profileVersion === "string" &&
|
||||
input.profileVersion.length > 0
|
||||
) {
|
||||
return { ok: true, resolvedMode: "profile" };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: "no-auth-source",
|
||||
details: {
|
||||
hint: "Set useLoggedInUser:true, or gitHubToken, or both profileId+profileVersion",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultCopilotHome(): string {
|
||||
// Mirrors the SDK convention; auth-bridge uses the same default.
|
||||
if (process.platform === "win32") {
|
||||
return path.join(process.env.APPDATA ?? os.homedir(), "copilot");
|
||||
}
|
||||
const xdg = process.env.XDG_CONFIG_HOME;
|
||||
if (xdg && xdg.length > 0) {
|
||||
return path.join(xdg, "copilot");
|
||||
}
|
||||
return path.join(os.homedir(), ".config", "copilot");
|
||||
}
|
||||
|
||||
function formatProbeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ const REGISTERED_EVENT_TYPES = [
|
||||
"assistant.usage",
|
||||
"tool.execution_start",
|
||||
"tool.execution_complete",
|
||||
"session.compaction_start",
|
||||
"session.compaction_complete",
|
||||
"session.idle",
|
||||
"session.error",
|
||||
"abort",
|
||||
] as const;
|
||||
@@ -605,6 +608,160 @@ describe("attachEventBridge", () => {
|
||||
expect(bridge.snapshot().toolMetas).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes compaction callbacks and clears active compaction state on completion", async () => {
|
||||
const session = createFakeSession();
|
||||
const calls: string[] = [];
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onCompactionStart: () => {
|
||||
calls.push("start");
|
||||
},
|
||||
onCompactionComplete: ({ success }) => {
|
||||
calls.push(`complete:${success}`);
|
||||
},
|
||||
});
|
||||
|
||||
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
|
||||
session.emit(
|
||||
"session.compaction_complete",
|
||||
makeEvent("session.compaction_complete", { success: false }),
|
||||
);
|
||||
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
|
||||
session.emit(
|
||||
"session.compaction_complete",
|
||||
makeEvent("session.compaction_complete", { success: true }),
|
||||
);
|
||||
await bridge.awaitCompactionChain();
|
||||
|
||||
expect(calls).toEqual(["start", "complete:false", "start", "complete:true"]);
|
||||
expect(bridge.isCompacting()).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for an active compaction and its completion callback", async () => {
|
||||
const session = createFakeSession();
|
||||
const complete = vi.fn();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onCompactionComplete: complete,
|
||||
});
|
||||
|
||||
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
|
||||
const completion = bridge.awaitCompactionCompletion();
|
||||
await flushAsync();
|
||||
|
||||
expect(bridge.hasObservedCompaction()).toBe(true);
|
||||
expect(complete).not.toHaveBeenCalled();
|
||||
session.emit(
|
||||
"session.compaction_complete",
|
||||
makeEvent("session.compaction_complete", { messagesRemoved: 3, success: true }),
|
||||
);
|
||||
await completion;
|
||||
|
||||
expect(complete).toHaveBeenCalledWith({ messagesRemoved: 3, success: true });
|
||||
expect(bridge.isCompacting()).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for the SDK terminal idle event", async () => {
|
||||
const session = createFakeSession();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
});
|
||||
|
||||
const idle = bridge.awaitSessionIdle();
|
||||
await flushAsync();
|
||||
session.emit("session.idle", makeEvent("session.idle", {}));
|
||||
await idle;
|
||||
|
||||
expect(bridge.hasObservedSessionIdle()).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores subagent idle events while waiting for the root session", async () => {
|
||||
const session = createFakeSession();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
});
|
||||
|
||||
const idle = bridge.awaitSessionIdle();
|
||||
session.emit("session.idle", {
|
||||
...makeEvent("session.idle", {}),
|
||||
agentId: "subagent-1",
|
||||
});
|
||||
await flushAsync();
|
||||
expect(bridge.hasObservedSessionIdle()).toBe(false);
|
||||
|
||||
session.emit("session.idle", makeEvent("session.idle", {}));
|
||||
await idle;
|
||||
expect(bridge.hasObservedSessionIdle()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps compaction pending after an abort until the SDK reports completion", async () => {
|
||||
const session = createFakeSession();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
});
|
||||
|
||||
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
|
||||
const completion = bridge.awaitCompactionCompletion();
|
||||
session.emit("abort", makeEvent("abort", { reason: "tool yield" }));
|
||||
await flushAsync();
|
||||
|
||||
expect(bridge.isCompacting()).toBe(true);
|
||||
session.emit(
|
||||
"session.compaction_complete",
|
||||
makeEvent("session.compaction_complete", { success: false }),
|
||||
);
|
||||
await completion;
|
||||
|
||||
expect(bridge.isCompacting()).toBe(false);
|
||||
});
|
||||
|
||||
it("settles an active compaction wait before terminal teardown", async () => {
|
||||
const session = createFakeSession();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
});
|
||||
|
||||
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
|
||||
const completion = bridge.awaitCompactionCompletion();
|
||||
bridge.settleCompactionWait();
|
||||
|
||||
await completion;
|
||||
expect(bridge.isCompacting()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores subagent compaction events when tracking the root session", async () => {
|
||||
const session = createFakeSession();
|
||||
const onCompactionStart = vi.fn();
|
||||
const onCompactionComplete = vi.fn();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onCompactionStart,
|
||||
onCompactionComplete,
|
||||
});
|
||||
|
||||
session.emit("session.compaction_start", {
|
||||
...makeEvent("session.compaction_start", {}),
|
||||
agentId: "subagent-1",
|
||||
});
|
||||
session.emit("session.compaction_complete", {
|
||||
...makeEvent("session.compaction_complete", { success: true }),
|
||||
agentId: "subagent-1",
|
||||
});
|
||||
await bridge.awaitCompactionCompletion();
|
||||
|
||||
expect(bridge.hasObservedCompaction()).toBe(false);
|
||||
expect(bridge.isCompacting()).toBe(false);
|
||||
expect(onCompactionStart).not.toHaveBeenCalled();
|
||||
expect(onCompactionComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("session.error populates streamError with errorCode or errorType only when not aborted", () => {
|
||||
const activeSession = createFakeSession();
|
||||
const activeBridge = attachEventBridge(activeSession, {
|
||||
|
||||
@@ -30,12 +30,22 @@ export interface SessionLike {
|
||||
): (() => void) | void;
|
||||
(eventType: string, handler: (event: SessionEvent) => void): (() => void) | void;
|
||||
};
|
||||
rpc?: {
|
||||
history?: {
|
||||
cancelBackgroundCompaction?: () => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
sendAndWait(options: MessageOptions, timeout?: number): Promise<SessionEvent | undefined>;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface EventBridgeOptions {
|
||||
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
|
||||
onCompactionComplete?: (payload: {
|
||||
messagesRemoved?: number;
|
||||
success: boolean;
|
||||
}) => void | Promise<void>;
|
||||
onCompactionStart?: () => void | Promise<void>;
|
||||
getSdkSessionId: () => string | undefined;
|
||||
isAborted: () => boolean;
|
||||
}
|
||||
@@ -57,7 +67,14 @@ export interface BuildAssistantMessageArgs {
|
||||
|
||||
export interface EventBridgeController {
|
||||
recordSendResult(result: SessionEvent | undefined): boolean;
|
||||
awaitCompactionChain(): Promise<void>;
|
||||
awaitCompactionCompletion(): Promise<void>;
|
||||
awaitSessionIdle(): Promise<void>;
|
||||
settleCompactionWait(): void;
|
||||
awaitDeltaChain(): Promise<void>;
|
||||
hasObservedCompaction(): boolean;
|
||||
hasObservedSessionIdle(): boolean;
|
||||
isCompacting(): boolean;
|
||||
snapshot(): EventBridgeSnapshot;
|
||||
buildAssistantMessage(args: BuildAssistantMessageArgs): AssistantMessage | undefined;
|
||||
finalizeAssistantTexts(): string[];
|
||||
@@ -82,8 +99,18 @@ export function attachEventBridge(
|
||||
const toolNamesByCallId = new Map<string, string>();
|
||||
let startedCount = 0;
|
||||
let completedCount = 0;
|
||||
let activeCompactionCount = 0;
|
||||
let observedCompaction = false;
|
||||
let deltaQueue = Promise.resolve();
|
||||
let deltaChain = Promise.resolve();
|
||||
let compactionChain = Promise.resolve();
|
||||
let compactionIdle = Promise.resolve();
|
||||
let resolveCompactionIdle: (() => void) | undefined;
|
||||
let observedSessionIdle = false;
|
||||
let resolveSessionIdle: (() => void) | undefined;
|
||||
const sessionIdle = new Promise<void>((resolve) => {
|
||||
resolveSessionIdle = resolve;
|
||||
});
|
||||
let firstDeltaError: unknown;
|
||||
let detached = false;
|
||||
const unsubscribeFns: Array<() => void> = [];
|
||||
@@ -164,6 +191,48 @@ export function attachEventBridge(
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
observedCompaction = true;
|
||||
if (activeCompactionCount === 0) {
|
||||
compactionIdle = new Promise<void>((resolve) => {
|
||||
resolveCompactionIdle = resolve;
|
||||
});
|
||||
}
|
||||
activeCompactionCount += 1;
|
||||
enqueueCompactionCallback(options.onCompactionStart);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.compaction_complete", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
activeCompactionCount = Math.max(0, activeCompactionCount - 1);
|
||||
enqueueCompactionCallback(() =>
|
||||
options.onCompactionComplete?.({
|
||||
...(event.data.messagesRemoved !== undefined
|
||||
? { messagesRemoved: event.data.messagesRemoved }
|
||||
: {}),
|
||||
success: event.data.success,
|
||||
}),
|
||||
);
|
||||
if (activeCompactionCount === 0) {
|
||||
resolveCompactionIdle?.();
|
||||
resolveCompactionIdle = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.idle", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
observedSessionIdle = true;
|
||||
resolveSessionIdle?.();
|
||||
resolveSessionIdle = undefined;
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.error", (event) => {
|
||||
if (!options.isAborted()) {
|
||||
streamError = createPromptError(
|
||||
@@ -190,9 +259,32 @@ export function attachEventBridge(
|
||||
lastAssistantEvent = result;
|
||||
return true;
|
||||
},
|
||||
awaitCompactionChain() {
|
||||
return compactionChain;
|
||||
},
|
||||
async awaitCompactionCompletion() {
|
||||
await awaitStableCompaction();
|
||||
},
|
||||
awaitSessionIdle() {
|
||||
return observedSessionIdle ? Promise.resolve() : sessionIdle;
|
||||
},
|
||||
settleCompactionWait() {
|
||||
activeCompactionCount = 0;
|
||||
resolveCompactionIdle?.();
|
||||
resolveCompactionIdle = undefined;
|
||||
},
|
||||
awaitDeltaChain() {
|
||||
return deltaChain;
|
||||
},
|
||||
hasObservedCompaction() {
|
||||
return observedCompaction;
|
||||
},
|
||||
hasObservedSessionIdle() {
|
||||
return observedSessionIdle;
|
||||
},
|
||||
isCompacting() {
|
||||
return activeCompactionCount > 0;
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
assistantTexts: finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent),
|
||||
@@ -233,6 +325,28 @@ export function attachEventBridge(
|
||||
unsubscribeFns.length = 0;
|
||||
},
|
||||
};
|
||||
|
||||
function enqueueCompactionCallback(callback: (() => void | Promise<void>) | undefined): void {
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
const queued = compactionChain.then(callback, callback);
|
||||
compactionChain = queued.catch(() => undefined);
|
||||
}
|
||||
|
||||
async function awaitStableCompaction(): Promise<void> {
|
||||
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
|
||||
if (idle) {
|
||||
await idle;
|
||||
}
|
||||
const callbacks = compactionChain;
|
||||
await callbacks;
|
||||
// Compaction events can arrive while an earlier hook callback settles.
|
||||
// Recheck both queues before teardown so the root observer stays attached.
|
||||
if (activeCompactionCount > 0 || compactionChain !== callbacks) {
|
||||
await awaitStableCompaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAssistantMessage(params: {
|
||||
@@ -332,6 +446,12 @@ function isAssistantMessageEvent(
|
||||
return event?.type === "assistant.message";
|
||||
}
|
||||
|
||||
function isRootCompactionEvent(event: { agentId?: string }): boolean {
|
||||
// SDK session events include subagent compaction; only root compaction
|
||||
// affects the pooled root session's cleanup and reuse lifecycle.
|
||||
return event.agentId === undefined;
|
||||
}
|
||||
|
||||
function joinReasoning(order: string[], reasoningById: Map<string, string>): string {
|
||||
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copilot tests cover hooks bridge plugin behavior.
|
||||
// Copilot tests cover native SDK hook compatibility.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
|
||||
|
||||
@@ -10,26 +10,23 @@ describe("createHooksBridge", () => {
|
||||
workingDirectory: "/",
|
||||
};
|
||||
|
||||
it("returns undefined when no config is provided", () => {
|
||||
it("returns undefined when no handlers are configured", () => {
|
||||
expect(createHooksBridge()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when config has no handlers", () => {
|
||||
expect(createHooksBridge({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when only onHookError is supplied (no real handlers)", () => {
|
||||
expect(createHooksBridge({ onHookError: () => undefined })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes only the handlers that were configured", () => {
|
||||
const onPreToolUse = vi.fn();
|
||||
const onSessionStart = vi.fn();
|
||||
const hooks = createHooksBridge({ onPreToolUse, onSessionStart })!;
|
||||
expect(hooks).toBeDefined();
|
||||
it("includes only configured native handlers", () => {
|
||||
const hooks = createHooksBridge({
|
||||
onPreToolUse: vi.fn(),
|
||||
onSessionStart: vi.fn(),
|
||||
})!;
|
||||
|
||||
expect(typeof hooks.onPreToolUse).toBe("function");
|
||||
expect(typeof hooks.onSessionStart).toBe("function");
|
||||
expect(hooks.onPreMcpToolCall).toBeUndefined();
|
||||
expect(hooks.onPostToolUse).toBeUndefined();
|
||||
expect(hooks.onPostToolUseFailure).toBeUndefined();
|
||||
expect(hooks.onUserPromptSubmitted).toBeUndefined();
|
||||
expect(hooks.onSessionEnd).toBeUndefined();
|
||||
expect(hooks.onErrorOccurred).toBeUndefined();
|
||||
@@ -47,71 +44,80 @@ describe("createHooksBridge", () => {
|
||||
toolName: "bash",
|
||||
toolArgs: { cmd: "ls" },
|
||||
};
|
||||
const result = await hooks.onPreToolUse!(input, { sessionId: "sess-1" });
|
||||
expect(result).toEqual({ permissionDecision: "allow", additionalContext: "ok" });
|
||||
expect(onPreToolUse).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(hooks.onPreToolUse!(input, { sessionId: "sess-1" })).resolves.toEqual({
|
||||
permissionDecision: "allow",
|
||||
additionalContext: "ok",
|
||||
});
|
||||
expect(onPreToolUse).toHaveBeenCalledWith(input, { sessionId: "sess-1" });
|
||||
});
|
||||
|
||||
it("isolates synchronous throws: returns undefined and notifies onHookError", async () => {
|
||||
it("reports the effective prompt after a native prompt hook completes", async () => {
|
||||
const onUserPromptSubmitted = vi.fn().mockResolvedValue({
|
||||
additionalContext: "Use the approved repository.",
|
||||
modifiedPrompt: "Review the authentication change.",
|
||||
});
|
||||
const observedPrompt = vi.fn();
|
||||
const hooks = createHooksBridge(
|
||||
{ onUserPromptSubmitted },
|
||||
{ onUserPromptSubmitted: observedPrompt },
|
||||
)!;
|
||||
|
||||
await expect(
|
||||
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hello" }, { sessionId: "s" }),
|
||||
).resolves.toEqual({
|
||||
additionalContext: "Use the approved repository.",
|
||||
modifiedPrompt: "Review the authentication change.",
|
||||
});
|
||||
expect(observedPrompt).toHaveBeenCalledWith({
|
||||
additionalContext: "Use the approved repository.",
|
||||
prompt: "Review the authentication change.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the original prompt when a native prompt hook fails", async () => {
|
||||
const observedPrompt = vi.fn();
|
||||
const hooks = createHooksBridge(
|
||||
{
|
||||
onUserPromptSubmitted: async () => {
|
||||
throw new Error("prompt hook failed");
|
||||
},
|
||||
onHookError: () => undefined,
|
||||
},
|
||||
{ onUserPromptSubmitted: observedPrompt },
|
||||
)!;
|
||||
|
||||
await expect(
|
||||
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hello" }, { sessionId: "s" }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(observedPrompt).toHaveBeenCalledWith({ prompt: "hello" });
|
||||
});
|
||||
|
||||
it("isolates synchronous and asynchronous handler failures", async () => {
|
||||
const onHookError = vi.fn();
|
||||
const hooks = createHooksBridge({
|
||||
onPostToolUse: () => {
|
||||
throw new Error("post boom");
|
||||
},
|
||||
onHookError,
|
||||
})!;
|
||||
const result = await hooks.onPostToolUse!(
|
||||
{ ...hookBase, toolName: "x", toolArgs: {}, toolResult: {} as never },
|
||||
{ sessionId: "s" },
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(onHookError).toHaveBeenCalledTimes(1);
|
||||
expect(onHookError.mock.calls[0]?.[0]).toEqual({
|
||||
hookName: "onPostToolUse",
|
||||
error: expect.any(Error),
|
||||
});
|
||||
expect((onHookError.mock.calls[0][0]!.error as Error).message).toBe("post boom");
|
||||
});
|
||||
|
||||
it("isolates async rejections: returns undefined and notifies onHookError", async () => {
|
||||
const onHookError = vi.fn();
|
||||
const hooks = createHooksBridge({
|
||||
onUserPromptSubmitted: async () => {
|
||||
throw new Error("async boom");
|
||||
throw new Error("prompt boom");
|
||||
},
|
||||
onHookError,
|
||||
})!;
|
||||
const result = await hooks.onUserPromptSubmitted!(
|
||||
{ ...hookBase, prompt: "hi" },
|
||||
{ sessionId: "s" },
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(onHookError).toHaveBeenCalledTimes(1);
|
||||
expect(onHookError.mock.calls[0]?.[0]?.hookName).toBe("onUserPromptSubmitted");
|
||||
});
|
||||
|
||||
it("uses console.warn as the default onHookError", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
try {
|
||||
const hooks = createHooksBridge({
|
||||
onErrorOccurred: () => {
|
||||
throw new Error("default-error-handler");
|
||||
},
|
||||
})!;
|
||||
const result = await hooks.onErrorOccurred!(
|
||||
{ ...hookBase, error: "x", errorContext: "system", recoverable: true },
|
||||
await expect(
|
||||
hooks.onPostToolUse!(
|
||||
{ ...hookBase, toolName: "x", toolArgs: {}, toolResult: {} as never },
|
||||
{ sessionId: "s" },
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("onErrorOccurred");
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hi" }, { sessionId: "s" }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(onHookError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never throws when onHookError itself throws", async () => {
|
||||
it("never lets the error notifier throw into the SDK", async () => {
|
||||
const hooks = createHooksBridge({
|
||||
onSessionEnd: () => {
|
||||
throw new Error("hook boom");
|
||||
@@ -120,41 +126,47 @@ describe("createHooksBridge", () => {
|
||||
throw new Error("notifier boom");
|
||||
},
|
||||
})!;
|
||||
|
||||
await expect(
|
||||
hooks.onSessionEnd!({ ...hookBase, reason: "complete" }, { sessionId: "s" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves all six SDK hook handlers when supplied", async () => {
|
||||
it("preserves native MCP and failed-tool callbacks", async () => {
|
||||
const onPreMcpToolCall = vi.fn();
|
||||
const onPostToolUseFailure = vi.fn();
|
||||
const hooks = createHooksBridge({
|
||||
onPreMcpToolCall,
|
||||
onPostToolUseFailure,
|
||||
})!;
|
||||
|
||||
await hooks.onPreMcpToolCall!({} as never, { sessionId: "s" });
|
||||
await hooks.onPostToolUseFailure!({} as never, { sessionId: "s" });
|
||||
|
||||
expect(onPreMcpToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(onPostToolUseFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves all supported SDK hook handlers", () => {
|
||||
const config: CopilotHooksConfig = {
|
||||
onPreToolUse: vi.fn().mockResolvedValue({ suppressOutput: true }),
|
||||
onPreMcpToolCall: vi.fn(),
|
||||
onPostToolUse: vi.fn().mockResolvedValue({ suppressOutput: false }),
|
||||
onPostToolUseFailure: vi.fn(),
|
||||
onUserPromptSubmitted: vi.fn().mockResolvedValue({ modifiedPrompt: "trimmed" }),
|
||||
onSessionStart: vi.fn().mockResolvedValue({ additionalContext: "context" }),
|
||||
onSessionEnd: vi.fn().mockResolvedValue({ sessionSummary: "done" }),
|
||||
onErrorOccurred: vi.fn().mockResolvedValue({ errorHandling: "retry" as const }),
|
||||
};
|
||||
const hooks = createHooksBridge(config)!;
|
||||
|
||||
expect(typeof hooks.onPreToolUse).toBe("function");
|
||||
expect(typeof hooks.onPreMcpToolCall).toBe("function");
|
||||
expect(typeof hooks.onPostToolUse).toBe("function");
|
||||
expect(typeof hooks.onPostToolUseFailure).toBe("function");
|
||||
expect(typeof hooks.onUserPromptSubmitted).toBe("function");
|
||||
expect(typeof hooks.onSessionStart).toBe("function");
|
||||
expect(typeof hooks.onSessionEnd).toBe("function");
|
||||
expect(typeof hooks.onErrorOccurred).toBe("function");
|
||||
});
|
||||
|
||||
it("forwards void returns transparently", async () => {
|
||||
const hooks = createHooksBridge({
|
||||
onSessionStart: () => undefined,
|
||||
})!;
|
||||
const result = await hooks.onSessionStart!({ ...hookBase, source: "new" }, { sessionId: "s" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not invoke unconfigured handlers' isolators", () => {
|
||||
const hooks = createHooksBridge({ onPreToolUse: () => undefined })!;
|
||||
// ensure the missing handlers are literally absent, not just nullable
|
||||
expect("onPostToolUse" in hooks).toBe(false);
|
||||
expect("onUserPromptSubmitted" in hooks).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,56 +1,38 @@
|
||||
/**
|
||||
* Hooks bridge for the copilot agent runtime.
|
||||
* Compatibility adapter for native Copilot SDK SessionHooks.
|
||||
*
|
||||
* BACK-POINTER: The host-side hook runner lives outside this package
|
||||
* boundary in `src/agents/harness/lifecycle-hook-helpers.ts` (uses the
|
||||
* plugin hook runner via `src/plugins/hook-runner-global.ts`). Per
|
||||
* proposal §266 (todo `hooks-bridge`), this module provides a small
|
||||
* contract surface that mirrors the SDK's `SessionHooks` shape; the
|
||||
* core wiring layer constructs handlers that call into
|
||||
* `runAgentHarnessLlmInputHook`, `runAgentHarnessLlmOutputHook`,
|
||||
* `runAgentHarnessAgentEndHook`, etc., and threads them through
|
||||
* `AttemptParamsLike.hooks`.
|
||||
*
|
||||
* Cross-package boundary note: the heavy host lifecycle helpers
|
||||
* cannot be imported here (`tsconfig.package-boundary.base.json`). The
|
||||
* bridge keeps the SDK hook contracts intact, wraps each provided
|
||||
* handler in an error-isolating envelope so a thrown host hook cannot
|
||||
* crash the SDK session, and returns a `SessionHooks` object that
|
||||
* `createSessionConfig` can plug into `SessionConfig.hooks`.
|
||||
*
|
||||
* Note on default omission: if no handlers are supplied, the bridge
|
||||
* returns `undefined` so that `SessionConfig.hooks` stays absent and
|
||||
* the SDK skips the entire hook subsystem (matches the "no hooks
|
||||
* installed" runtime behaviour the harness had pre-bridge).
|
||||
* `hooksConfig` is a shipped Copilot-specific per-attempt API. It remains
|
||||
* separate from OpenClaw's generic lifecycle hooks because the SDK callbacks
|
||||
* expose native events and decisions that the portable hook contract does not.
|
||||
*/
|
||||
|
||||
import type { SessionConfig } from "@github/copilot-sdk";
|
||||
|
||||
// All hook handler types are derived from SessionHooks so this bridge
|
||||
// stays pinned to the same SDK source the rest of the harness uses,
|
||||
// without depending on the SDK re-exporting individual handler aliases
|
||||
// (which it does not, as of @github/copilot-sdk@1.0.0-beta.4).
|
||||
type SdkSessionHooks = NonNullable<SessionConfig["hooks"]>;
|
||||
type PreToolUseHandler = NonNullable<SdkSessionHooks["onPreToolUse"]>;
|
||||
type PreMcpToolCallHandler = NonNullable<SdkSessionHooks["onPreMcpToolCall"]>;
|
||||
type PostToolUseHandler = NonNullable<SdkSessionHooks["onPostToolUse"]>;
|
||||
type PostToolUseFailureHandler = NonNullable<SdkSessionHooks["onPostToolUseFailure"]>;
|
||||
type UserPromptSubmittedHandler = NonNullable<SdkSessionHooks["onUserPromptSubmitted"]>;
|
||||
type SessionStartHandler = NonNullable<SdkSessionHooks["onSessionStart"]>;
|
||||
type SessionEndHandler = NonNullable<SdkSessionHooks["onSessionEnd"]>;
|
||||
type ErrorOccurredHandler = NonNullable<SdkSessionHooks["onErrorOccurred"]>;
|
||||
|
||||
export interface CopilotHooksBridgeOptions {
|
||||
onUserPromptSubmitted?: (submission: { prompt: string; additionalContext?: string }) => void;
|
||||
}
|
||||
|
||||
export interface CopilotHooksConfig {
|
||||
onPreToolUse?: PreToolUseHandler;
|
||||
onPreMcpToolCall?: PreMcpToolCallHandler;
|
||||
onPostToolUse?: PostToolUseHandler;
|
||||
onPostToolUseFailure?: PostToolUseFailureHandler;
|
||||
onUserPromptSubmitted?: UserPromptSubmittedHandler;
|
||||
onSessionStart?: SessionStartHandler;
|
||||
onSessionEnd?: SessionEndHandler;
|
||||
onErrorOccurred?: ErrorOccurredHandler;
|
||||
/**
|
||||
* Optional hook-error notifier. Called whenever any wrapped handler
|
||||
* throws (synchronously or as a Promise rejection). Defaults to
|
||||
* `console.warn` so the failure is visible to operators without
|
||||
* crashing the SDK session. Receives the SDK hook name and the
|
||||
* raised error.
|
||||
* Called when a native SDK hook handler throws. Defaults to console.warn so
|
||||
* native hook failures do not terminate the SDK session.
|
||||
*/
|
||||
onHookError?: (info: { hookName: keyof SdkSessionHooks; error: unknown }) => void;
|
||||
}
|
||||
@@ -63,10 +45,8 @@ const DEFAULT_HOOK_ERROR_HANDLER: NonNullable<CopilotHooksConfig["onHookError"]>
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a host handler in an error-isolating envelope so it cannot
|
||||
* throw out into the SDK. Returns `undefined` (no opinion) when the
|
||||
* host handler throws, so the SDK falls back to its default behaviour
|
||||
* for that hook.
|
||||
* Wrap a native handler so it cannot throw into the SDK. Returning undefined
|
||||
* leaves the SDK's default decision in place.
|
||||
*/
|
||||
function isolate<TArgs extends readonly unknown[], TResult>(
|
||||
hookName: keyof SdkSessionHooks,
|
||||
@@ -83,7 +63,7 @@ function isolate<TArgs extends readonly unknown[], TResult>(
|
||||
try {
|
||||
onError({ hookName, error });
|
||||
} catch {
|
||||
// never let the error notifier itself throw out
|
||||
// Never let the error notifier itself throw into the SDK.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -91,18 +71,22 @@ function isolate<TArgs extends readonly unknown[], TResult>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SDK-shaped `SessionHooks` object from a host-supplied
|
||||
* `CopilotHooksConfig`. Returns `undefined` when no handlers were
|
||||
* supplied so the SDK skips the hook subsystem entirely.
|
||||
* Build an SDK-shaped hook object from native per-attempt configuration.
|
||||
* Omit the SDK hook subsystem when no handlers were configured.
|
||||
*/
|
||||
export function createHooksBridge(config?: CopilotHooksConfig): SdkSessionHooks | undefined {
|
||||
export function createHooksBridge(
|
||||
config?: CopilotHooksConfig,
|
||||
options?: CopilotHooksBridgeOptions,
|
||||
): SdkSessionHooks | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const onError = config.onHookError ?? DEFAULT_HOOK_ERROR_HANDLER;
|
||||
const hooks: SdkSessionHooks = {};
|
||||
const pre = isolate("onPreToolUse", config.onPreToolUse, onError);
|
||||
const preMcp = isolate("onPreMcpToolCall", config.onPreMcpToolCall, onError);
|
||||
const post = isolate("onPostToolUse", config.onPostToolUse, onError);
|
||||
const postFailure = isolate("onPostToolUseFailure", config.onPostToolUseFailure, onError);
|
||||
const userPrompt = isolate("onUserPromptSubmitted", config.onUserPromptSubmitted, onError);
|
||||
const sessionStart = isolate("onSessionStart", config.onSessionStart, onError);
|
||||
const sessionEnd = isolate("onSessionEnd", config.onSessionEnd, onError);
|
||||
@@ -111,11 +95,32 @@ export function createHooksBridge(config?: CopilotHooksConfig): SdkSessionHooks
|
||||
if (pre) {
|
||||
hooks.onPreToolUse = pre as PreToolUseHandler;
|
||||
}
|
||||
if (preMcp) {
|
||||
hooks.onPreMcpToolCall = preMcp as PreMcpToolCallHandler;
|
||||
}
|
||||
if (post) {
|
||||
hooks.onPostToolUse = post as PostToolUseHandler;
|
||||
}
|
||||
if (postFailure) {
|
||||
hooks.onPostToolUseFailure = postFailure as PostToolUseFailureHandler;
|
||||
}
|
||||
if (userPrompt) {
|
||||
hooks.onUserPromptSubmitted = userPrompt as UserPromptSubmittedHandler;
|
||||
hooks.onUserPromptSubmitted = async (input, invocation) => {
|
||||
const output = await userPrompt(input, invocation);
|
||||
try {
|
||||
options?.onUserPromptSubmitted?.({
|
||||
prompt: output?.modifiedPrompt ?? input.prompt,
|
||||
...(output?.additionalContext ? { additionalContext: output.additionalContext } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
onError({ hookName: "onUserPromptSubmitted", error });
|
||||
} catch {
|
||||
// Never let an observer or its error notifier throw into the SDK.
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
}
|
||||
if (sessionStart) {
|
||||
hooks.onSessionStart = sessionStart as SessionStartHandler;
|
||||
@@ -127,8 +132,5 @@ export function createHooksBridge(config?: CopilotHooksConfig): SdkSessionHooks
|
||||
hooks.onErrorOccurred = errorOccurred as ErrorOccurredHandler;
|
||||
}
|
||||
|
||||
if (Object.keys(hooks).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return hooks;
|
||||
return Object.keys(hooks).length > 0 ? hooks : undefined;
|
||||
}
|
||||
|
||||
@@ -1175,15 +1175,19 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
|
||||
it("calls prepareArguments and passes the prepared args and toolCallId to execute", async () => {
|
||||
const preparedArgs = { value: "prepared" };
|
||||
const onToolCompleted = vi.fn();
|
||||
const prepareArguments = vi.fn(() => preparedArgs);
|
||||
const sourceTool = makeTool({ prepareArguments });
|
||||
const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {});
|
||||
const sdkTool = convertOpenClawToolToSdkTool(sourceTool, { onToolCompleted });
|
||||
|
||||
await runSdkTool(sdkTool, { value: "raw" }, makeInvocation({ toolCallId: "call-99" }));
|
||||
|
||||
expect(prepareArguments).toHaveBeenCalledTimes(1);
|
||||
expect(prepareArguments).toHaveBeenCalledWith({ value: "raw" });
|
||||
expect(sourceTool.execute).toHaveBeenCalledWith("call-99", preparedArgs, undefined, undefined);
|
||||
expect(onToolCompleted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ args: preparedArgs, toolCallId: "call-99" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a failure result when prepareArguments throws", async () => {
|
||||
@@ -1232,6 +1236,29 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports terminal tool results to the harness lifecycle bridge", async () => {
|
||||
const onToolCompleted = vi.fn();
|
||||
const sourceResult = {
|
||||
content: [{ text: "hello", type: "text" }],
|
||||
details: { results: [{ text: "hello" }] },
|
||||
};
|
||||
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), {
|
||||
onToolCompleted,
|
||||
});
|
||||
|
||||
await runSdkTool(sdkTool, { value: "input" }, makeInvocation({ toolCallId: "call-9" }));
|
||||
await flushAsync();
|
||||
|
||||
expect(onToolCompleted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: { value: "input" },
|
||||
result: sourceResult,
|
||||
toolCallId: "call-9",
|
||||
toolName: "tool-a",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports thrown tool failures to the private result observer", async () => {
|
||||
const error = new Error("backend unavailable");
|
||||
const onAgentToolResult = vi.fn();
|
||||
@@ -1261,17 +1288,46 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports returned OpenClaw error results as observer failures", async () => {
|
||||
it("reports terminal tool failures to the harness lifecycle bridge", async () => {
|
||||
const onToolCompleted = vi.fn();
|
||||
const preparedArgs = { value: "prepared" };
|
||||
const sdkTool = convertOpenClawToolToSdkTool(
|
||||
makeTool({
|
||||
prepareArguments: vi.fn(() => preparedArgs),
|
||||
execute: vi.fn(async () => {
|
||||
throw new Error("backend unavailable");
|
||||
}),
|
||||
}),
|
||||
{ onToolCompleted },
|
||||
);
|
||||
|
||||
await runSdkTool(sdkTool, { value: "input" }, makeInvocation({ toolCallId: "call-10" }));
|
||||
await flushAsync();
|
||||
|
||||
expect(onToolCompleted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: preparedArgs,
|
||||
error: "backend unavailable",
|
||||
toolCallId: "call-10",
|
||||
toolName: "tool-a",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports returned OpenClaw error results to both tool observers", async () => {
|
||||
const onAgentToolResult = vi.fn();
|
||||
const onToolCompleted = vi.fn();
|
||||
const sourceResult = {
|
||||
content: [{ text: '{"status":"error","error":"backend unavailable"}', type: "text" }],
|
||||
details: { status: "error", error: "backend unavailable" },
|
||||
};
|
||||
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), {
|
||||
onAgentToolResult,
|
||||
onToolCompleted,
|
||||
});
|
||||
|
||||
const result = await runSdkTool(sdkTool, {});
|
||||
await flushAsync();
|
||||
|
||||
expect(result).toMatchObject({ resultType: "success" });
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
@@ -1279,6 +1335,12 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
result: sourceResult,
|
||||
isError: true,
|
||||
});
|
||||
expect(onToolCompleted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: "backend unavailable",
|
||||
result: sourceResult,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("joins multiple text blocks with newlines", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {
|
||||
applyEmbeddedAttemptToolsAllow,
|
||||
buildEmbeddedAttemptToolRunContext,
|
||||
extractToolErrorMessage,
|
||||
getPluginToolMeta,
|
||||
isSubagentSessionKey,
|
||||
isToolResultError,
|
||||
@@ -53,6 +54,15 @@ export interface CopilotSessionHolder {
|
||||
*/
|
||||
export type CopilotToolAttemptParams = Partial<EmbeddedRunAttemptParams>;
|
||||
|
||||
export type CopilotToolCompletion = {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export interface CopilotToolBridgeInput {
|
||||
modelProvider: string;
|
||||
modelId: string;
|
||||
@@ -108,6 +118,7 @@ export interface CopilotToolBridgeInput {
|
||||
* `extensions/codex/src/app-server/run-attempt.ts:539-541`.
|
||||
*/
|
||||
onYieldDetected?: (message?: string) => void;
|
||||
onToolCompleted?: (completion: CopilotToolCompletion) => void | Promise<void>;
|
||||
createOpenClawCodingTools?: (opts: unknown) => AnyAgentTool[] | Promise<AnyAgentTool[]>;
|
||||
beforeExecute?: (ctx: {
|
||||
toolName: string;
|
||||
@@ -208,6 +219,7 @@ export async function createCopilotToolBridge(
|
||||
abortSignal: input.abortSignal,
|
||||
beforeExecute: input.beforeExecute,
|
||||
onAgentToolResult: input.attemptParams?.onAgentToolResult,
|
||||
onToolCompleted: input.onToolCompleted,
|
||||
}),
|
||||
),
|
||||
sourceTools: filteredTools,
|
||||
@@ -389,6 +401,7 @@ export function convertOpenClawToolToSdkTool(
|
||||
abortSignal?: AbortSignal;
|
||||
beforeExecute?: CopilotToolBridgeInput["beforeExecute"];
|
||||
onAgentToolResult?: CopilotToolAttemptParams["onAgentToolResult"];
|
||||
onToolCompleted?: CopilotToolBridgeInput["onToolCompleted"];
|
||||
},
|
||||
): SdkTool {
|
||||
if (typeof sourceTool.name !== "string" || sourceTool.name.trim().length === 0) {
|
||||
@@ -409,23 +422,47 @@ export function convertOpenClawToolToSdkTool(
|
||||
console.warn("[copilot-tool-bridge] onAgentToolResult handler threw; continuing", error);
|
||||
}
|
||||
};
|
||||
const failureResult = (message: string, error: unknown): ToolResultObject => {
|
||||
const notifyToolCompleted = (completion: CopilotToolCompletion) => {
|
||||
try {
|
||||
void Promise.resolve(ctx.onToolCompleted?.(completion)).catch((error: unknown) => {
|
||||
console.warn("[copilot-tool-bridge] onToolCompleted handler threw; continuing", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[copilot-tool-bridge] onToolCompleted handler threw; continuing", error);
|
||||
}
|
||||
};
|
||||
const failureResult = (
|
||||
executedArgs: unknown,
|
||||
invocation: ToolInvocation,
|
||||
startedAt: number,
|
||||
message: string,
|
||||
error: unknown,
|
||||
): ToolResultObject => {
|
||||
const errorMessage = toError(error).message;
|
||||
notifyToolResult(
|
||||
sanitizeToolResult({
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { status: "failed", error: toError(error).message },
|
||||
details: { status: "failed", error: errorMessage },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
notifyToolCompleted({
|
||||
toolName: sourceTool.name,
|
||||
toolCallId: invocation.toolCallId,
|
||||
args: toToolStartArgs(executedArgs),
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
});
|
||||
return createFailureResult(message, error);
|
||||
};
|
||||
const executeOnce = async (
|
||||
args: unknown,
|
||||
invocation: ToolInvocation,
|
||||
): Promise<ToolResultObject> => {
|
||||
const startedAt = Date.now();
|
||||
if (ctx.abortSignal?.aborted) {
|
||||
const error = new Error("[copilot-tool-bridge] aborted before execution");
|
||||
return failureResult(error.message, error);
|
||||
return failureResult(args, invocation, startedAt, error.message, error);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -438,6 +475,9 @@ export function convertOpenClawToolToSdkTool(
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return failureResult(
|
||||
args,
|
||||
invocation,
|
||||
startedAt,
|
||||
`[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
@@ -448,6 +488,9 @@ export function convertOpenClawToolToSdkTool(
|
||||
preparedArgs = sourceTool.prepareArguments ? sourceTool.prepareArguments(args) : args;
|
||||
} catch (error: unknown) {
|
||||
return failureResult(
|
||||
args,
|
||||
invocation,
|
||||
startedAt,
|
||||
`[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
@@ -463,6 +506,9 @@ export function convertOpenClawToolToSdkTool(
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return failureResult(
|
||||
preparedArgs,
|
||||
invocation,
|
||||
startedAt,
|
||||
`[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
@@ -470,10 +516,17 @@ export function convertOpenClawToolToSdkTool(
|
||||
|
||||
const sdkResult = agentToolResultToSdk(result);
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
notifyToolResult(
|
||||
sanitizedResult,
|
||||
sdkResult.resultType === "failure" || isToolResultError(sanitizedResult),
|
||||
);
|
||||
const resultIsError = sdkResult.resultType === "failure" || isToolResultError(sanitizedResult);
|
||||
const resultError = resultIsError ? extractToolErrorMessage(sanitizedResult) : undefined;
|
||||
notifyToolResult(sanitizedResult, resultIsError);
|
||||
notifyToolCompleted({
|
||||
toolName: sourceTool.name,
|
||||
toolCallId: invocation.toolCallId,
|
||||
args: toToolStartArgs(preparedArgs),
|
||||
result: sanitizedResult,
|
||||
...(resultError ? { error: resultError } : {}),
|
||||
startedAt,
|
||||
});
|
||||
return sdkResult;
|
||||
};
|
||||
|
||||
@@ -522,6 +575,12 @@ export function convertOpenClawToolToSdkTool(
|
||||
};
|
||||
}
|
||||
|
||||
function toToolStartArgs(args: unknown): Record<string, unknown> {
|
||||
return args && typeof args === "object" && !Array.isArray(args)
|
||||
? (args as Record<string, unknown>)
|
||||
: { value: args };
|
||||
}
|
||||
|
||||
function agentToolResultToSdk(result: AgentToolResultLike | undefined): ToolResultObject {
|
||||
const content = result?.content;
|
||||
if (content == null) {
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { logVerboseMock } = vi.hoisted(() => ({
|
||||
const { loadModelCatalogMock, logVerboseMock } = vi.hoisted(() => ({
|
||||
loadModelCatalogMock: vi.fn(),
|
||||
logVerboseMock: vi.fn(),
|
||||
}));
|
||||
const { loggerWarnMock } = vi.hoisted(() => ({
|
||||
@@ -32,6 +33,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
|
||||
loadModelCatalog: loadModelCatalogMock,
|
||||
resolveHumanDelayConfig: () => undefined,
|
||||
}));
|
||||
|
||||
@@ -227,6 +229,7 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
loadModelCatalogMock.mockReset().mockResolvedValue([]);
|
||||
logVerboseMock.mockReset();
|
||||
loggerWarnMock.mockReset();
|
||||
});
|
||||
@@ -257,6 +260,30 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the provider-startup catalog snapshot for /think autocomplete", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const command = createNativeCommand("think", { cfg });
|
||||
const level = requireOption(command, "level");
|
||||
const autocomplete = requireAutocomplete(level, "think level option did not wire autocomplete");
|
||||
|
||||
await runAutocomplete(autocomplete, {
|
||||
userId: "owner",
|
||||
channelType: ChannelType.DM,
|
||||
channelId: "dm-1",
|
||||
channelName: "dm-1",
|
||||
focusedValue: "",
|
||||
});
|
||||
|
||||
expect(loadModelCatalogMock).toHaveBeenCalledWith({ cacheOnly: true });
|
||||
expect(loadModelCatalogMock).toHaveBeenCalledWith({ config: cfg });
|
||||
});
|
||||
|
||||
it("keeps static choices for non-acp string action arguments", () => {
|
||||
const command = createNativeCommand("config");
|
||||
const action = requireOption(command, "action");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Discord plugin module implements native command.options behavior.
|
||||
import { ApplicationCommandOptionType } from "discord-api-types/v10";
|
||||
import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
resolveCommandArgChoices,
|
||||
@@ -117,12 +118,17 @@ export function buildDiscordCommandOptions(params: {
|
||||
? await resolveChoiceContext(interaction)
|
||||
: null;
|
||||
const currentCfg = resolveConfig?.() ?? cfg;
|
||||
// Autocomplete cannot defer beyond Discord's three-second deadline.
|
||||
// Cache-only catalog reads never start discovery or filesystem work.
|
||||
const choiceCatalog =
|
||||
command.key === "think" ? await loadModelCatalog({ cacheOnly: true }) : undefined;
|
||||
const choices = resolveCommandArgChoices({
|
||||
command,
|
||||
arg,
|
||||
cfg: currentCfg,
|
||||
provider: context?.provider,
|
||||
model: context?.model,
|
||||
...(choiceCatalog?.length ? { catalog: choiceCatalog } : {}),
|
||||
});
|
||||
const filtered = focusValue
|
||||
? choices.filter((choice) =>
|
||||
@@ -132,6 +138,11 @@ export function buildDiscordCommandOptions(params: {
|
||||
await interaction.respond(
|
||||
filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
|
||||
);
|
||||
if (command.key === "think" && !choiceCatalog?.length) {
|
||||
// The interaction is acknowledged now, so a failed startup warmup can retry
|
||||
// discovery without risking Discord's response deadline.
|
||||
void loadModelCatalog({ config: currentCfg });
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const choices =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Discord plugin module implements native command behavior.
|
||||
import { ApplicationCommandOptionType } from "discord-api-types/v10";
|
||||
import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
@@ -485,12 +486,18 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
threadBindings,
|
||||
})
|
||||
: null;
|
||||
// Native /think choices need live-discovery metadata; empty keeps config fallback.
|
||||
const menuModelCatalog =
|
||||
command.key === "think" && menuNeedsModelContext
|
||||
? await loadModelCatalog({ config: cfg })
|
||||
: undefined;
|
||||
const menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: commandArgs,
|
||||
cfg,
|
||||
provider: menuModelContext?.provider,
|
||||
model: menuModelContext?.model,
|
||||
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
|
||||
});
|
||||
if (menu) {
|
||||
const menuPayload = buildDiscordCommandArgMenu({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime";
|
||||
// Discord provider module implements model/runtime integration.
|
||||
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
@@ -395,6 +396,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
let earlyGatewayEmitter = gatewaySupervisor?.emitter;
|
||||
let onEarlyGatewayDebug: ((msg: unknown) => void) | undefined;
|
||||
try {
|
||||
if (nativeEnabled && commandSpecs.some((command) => command.name === "think")) {
|
||||
// Autocomplete cannot defer. Warm opportunistically before interactions begin,
|
||||
// but never let provider discovery block Discord startup.
|
||||
void loadModelCatalog({ config: cfg });
|
||||
}
|
||||
const { commands, components, modals } = createDiscordProviderInteractionSurface({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
|
||||
@@ -351,7 +351,6 @@ vi.mock("./send.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./media.js", () => ({
|
||||
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
||||
saveMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Feishu tests cover media plugin behavior.
|
||||
import { realpathSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
|
||||
@@ -17,7 +15,6 @@ const runFfmpegMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const fileCreateMock = vi.hoisted(() => vi.fn());
|
||||
const imageCreateMock = vi.hoisted(() => vi.fn());
|
||||
const imageGetMock = vi.hoisted(() => vi.fn());
|
||||
const messageCreateMock = vi.hoisted(() => vi.fn());
|
||||
const messageResourceGetMock = vi.hoisted(() => vi.fn());
|
||||
const messageReplyMock = vi.hoisted(() => vi.fn());
|
||||
@@ -55,23 +52,11 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
let downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
|
||||
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
|
||||
let saveMessageResourceFeishu: typeof import("./media.js").saveMessageResourceFeishu;
|
||||
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
|
||||
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
|
||||
let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia;
|
||||
|
||||
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
|
||||
expect(pathValue).not.toContain(key);
|
||||
expect(pathValue).not.toContain("..");
|
||||
|
||||
const tmpRoot = realpathSync(resolvePreferredOpenClawTmpDir());
|
||||
const resolved = path.resolve(pathValue);
|
||||
const rel = path.relative(tmpRoot, resolved);
|
||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||
}
|
||||
|
||||
function expectMediaTimeoutClientConfigured(): void {
|
||||
const options = mockCallArg<{ httpTimeoutMs?: number }>(createFeishuClientMock, 0, 0);
|
||||
expect(options.httpTimeoutMs).toBe(FEISHU_MEDIA_HTTP_TIMEOUT_MS);
|
||||
@@ -113,11 +98,25 @@ function callData<T>(
|
||||
return arg.data as T;
|
||||
}
|
||||
|
||||
async function withIsolatedHome<T>(run: () => Promise<T>): Promise<T> {
|
||||
const originalHome = process.env.HOME;
|
||||
return await withTempDir("openclaw-feishu-media-", async (tempHome) => {
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
return await run();
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("sendMediaFeishu msg_type routing", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
downloadImageFeishu,
|
||||
downloadMessageResourceFeishu,
|
||||
saveMessageResourceFeishu,
|
||||
sanitizeFileNameForUpload,
|
||||
sendMediaFeishu,
|
||||
@@ -148,7 +147,6 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
},
|
||||
image: {
|
||||
create: imageCreateMock,
|
||||
get: imageGetMock,
|
||||
},
|
||||
message: {
|
||||
create: messageCreateMock,
|
||||
@@ -186,7 +184,6 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
contentType: "audio/ogg",
|
||||
});
|
||||
|
||||
imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
|
||||
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
|
||||
runFfmpegMock.mockImplementation(async (args: string[]) => {
|
||||
await fs.writeFile(args.at(-1) ?? "", Buffer.from("opus-output"));
|
||||
@@ -500,74 +497,25 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
expect(messageReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses isolated temp paths for image downloads", async () => {
|
||||
const imageKey = "img_v3_01abc123";
|
||||
let capturedPath: string | undefined;
|
||||
|
||||
imageGetMock.mockResolvedValueOnce({
|
||||
writeFile: async (tmpPath: string) => {
|
||||
capturedPath = tmpPath;
|
||||
await fs.writeFile(tmpPath, Buffer.from("image-data"));
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadImageFeishu({
|
||||
cfg: emptyConfig,
|
||||
imageKey,
|
||||
});
|
||||
|
||||
const request = mockCallArg<{ path?: { image_key?: string } }>(imageGetMock, 0, 0);
|
||||
expect(request.path).toEqual({ image_key: imageKey });
|
||||
expectMediaTimeoutClientConfigured();
|
||||
expect(result.buffer).toEqual(Buffer.from("image-data"));
|
||||
if (!capturedPath) {
|
||||
throw new Error("expected Feishu image temp path");
|
||||
}
|
||||
expectPathIsolatedToTmpRoot(capturedPath, imageKey);
|
||||
});
|
||||
|
||||
it("uses isolated temp paths for message resource downloads", async () => {
|
||||
const fileKey = "file_v3_01abc123";
|
||||
let capturedPath: string | undefined;
|
||||
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
writeFile: async (tmpPath: string) => {
|
||||
capturedPath = tmpPath;
|
||||
await fs.writeFile(tmpPath, Buffer.from("resource-data"));
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey,
|
||||
type: "image",
|
||||
});
|
||||
|
||||
expect(result.buffer).toEqual(Buffer.from("resource-data"));
|
||||
if (!capturedPath) {
|
||||
throw new Error("expected Feishu resource temp path");
|
||||
}
|
||||
expectPathIsolatedToTmpRoot(capturedPath, fileKey);
|
||||
});
|
||||
|
||||
it("rejects oversized message resource streams before buffering the rest", async () => {
|
||||
it("rejects oversized message resource streams before saving the rest", async () => {
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
getReadableStream: () => Readable.from([Buffer.alloc(4), Buffer.alloc(4)]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "file_v3_01abc123",
|
||||
type: "file",
|
||||
maxBytes: 7,
|
||||
}),
|
||||
withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "file_v3_01abc123",
|
||||
type: "file",
|
||||
maxBytes: 7,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/Media exceeds/i);
|
||||
});
|
||||
|
||||
it("rejects oversized writeFile downloads before reading the temp file", async () => {
|
||||
it("rejects oversized writeFile resources before saving the temp file", async () => {
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
writeFile: async (tmpPath: string) => {
|
||||
await fs.writeFile(tmpPath, Buffer.alloc(8));
|
||||
@@ -575,34 +523,26 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "file_v3_01abc123",
|
||||
type: "file",
|
||||
maxBytes: 7,
|
||||
}),
|
||||
withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "file_v3_01abc123",
|
||||
type: "file",
|
||||
maxBytes: 7,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/Media exceeds/i);
|
||||
});
|
||||
|
||||
it("rejects invalid image keys before calling feishu api", async () => {
|
||||
await expect(
|
||||
downloadImageFeishu({
|
||||
cfg: emptyConfig,
|
||||
imageKey: "a/../../bad",
|
||||
}),
|
||||
).rejects.toThrow("invalid image_key");
|
||||
|
||||
expect(imageGetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid file keys before calling feishu api", async () => {
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "x/../../bad",
|
||||
type: "file",
|
||||
maxBytes: 30 * 1024 * 1024,
|
||||
}),
|
||||
).rejects.toThrow("invalid file_key");
|
||||
|
||||
@@ -687,7 +627,7 @@ describe("sanitizeFileNameForUpload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMessageResourceFeishu", () => {
|
||||
describe("saveMessageResourceFeishu", () => {
|
||||
function httpStatusError(status: number): Error & { response: { status: number } } {
|
||||
return Object.assign(new Error(`Request failed with status code ${status}`), {
|
||||
response: { status },
|
||||
@@ -712,12 +652,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
// Regression: Feishu API only supports type=image|file for messageResource.get.
|
||||
// Audio/video resources must use type=file, not type=audio (#8746).
|
||||
it("forwards provided type=file for non-image resources", async () => {
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_audio_msg",
|
||||
fileKey: "file_key_audio",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_audio_msg",
|
||||
fileKey: "file_key_audio",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
const request = mockCallArg<{
|
||||
params?: { type?: string };
|
||||
@@ -726,18 +669,21 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
expect(request.path).toEqual({ message_id: "om_audio_msg", file_key: "file_key_audio" });
|
||||
expect(request.params).toEqual({ type: "file" });
|
||||
expectMediaTimeoutClientConfigured();
|
||||
expect(result.buffer).toBeInstanceOf(Buffer);
|
||||
expect(result.saved.size).toBe("fake-audio-data".length);
|
||||
});
|
||||
|
||||
it("image uses type=image", async () => {
|
||||
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_img_msg",
|
||||
fileKey: "img_key_1",
|
||||
type: "image",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_img_msg",
|
||||
fileKey: "img_key_1",
|
||||
type: "image",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
const request = mockCallArg<{
|
||||
params?: { type?: string };
|
||||
@@ -746,7 +692,7 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
expect(request.path).toEqual({ message_id: "om_img_msg", file_key: "img_key_1" });
|
||||
expect(request.params).toEqual({ type: "image" });
|
||||
expectMediaTimeoutClientConfigured();
|
||||
expect(result.buffer).toBeInstanceOf(Buffer);
|
||||
expect(result.saved.size).toBe("fake-image-data".length);
|
||||
});
|
||||
|
||||
it("extracts content-type and filename metadata from download headers", async () => {
|
||||
@@ -758,14 +704,17 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_video_msg",
|
||||
fileKey: "file_key_video",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_video_msg",
|
||||
fileKey: "file_key_video",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.buffer).toEqual(Buffer.from("fake-video-data"));
|
||||
expect(result.saved.size).toBe("fake-video-data".length);
|
||||
expect(result.contentType).toBe("video/mp4");
|
||||
expect(result.fileName).toBe("clip.mp4");
|
||||
});
|
||||
@@ -780,12 +729,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_ios_video_msg",
|
||||
fileKey: "file_key_ios_video",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_ios_video_msg",
|
||||
fileKey: "file_key_ios_video",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
const firstRequest = mockCallArg<{
|
||||
params?: { type?: string };
|
||||
@@ -805,7 +757,7 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
file_key: "file_key_ios_video",
|
||||
});
|
||||
expect(secondRequest.params).toEqual({ type: "media" });
|
||||
expect(result.buffer).toEqual(Buffer.from("fake-ios-video-data"));
|
||||
expect(result.saved.size).toBe("fake-ios-video-data".length);
|
||||
expect(result.contentType).toBe("video/mp4");
|
||||
expect(result.fileName).toBe("ios-video.mp4");
|
||||
});
|
||||
@@ -817,12 +769,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
.mockRejectedValueOnce(new Error("media retry failed"));
|
||||
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_ios_video_msg",
|
||||
fileKey: "file_key_ios_video",
|
||||
type: "file",
|
||||
}),
|
||||
withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_ios_video_msg",
|
||||
fileKey: "file_key_ios_video",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
),
|
||||
).rejects.toBe(originalError);
|
||||
|
||||
expect(
|
||||
@@ -843,12 +798,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
messageResourceGetMock.mockRejectedValueOnce(originalError);
|
||||
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: scenario.messageId,
|
||||
fileKey: scenario.fileKey,
|
||||
type: scenario.type,
|
||||
}),
|
||||
withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: scenario.messageId,
|
||||
fileKey: scenario.fileKey,
|
||||
type: scenario.type,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
),
|
||||
).rejects.toBe(originalError);
|
||||
|
||||
expect(messageResourceGetMock).toHaveBeenCalledTimes(1);
|
||||
@@ -871,12 +829,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_file_msg",
|
||||
fileKey: "file_key_csv",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_file_msg",
|
||||
fileKey: "file_key_csv",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.fileName).toBe(fileName);
|
||||
});
|
||||
@@ -889,12 +850,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_latin1_msg",
|
||||
fileKey: "file_key_latin1",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_latin1_msg",
|
||||
fileKey: "file_key_latin1",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.fileName).toBe("café-©.txt");
|
||||
});
|
||||
@@ -907,21 +871,21 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
file_name: latin1LookingFileName,
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_json_file_msg",
|
||||
fileKey: "file_key_json",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_json_file_msg",
|
||||
fileKey: "file_key_json",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.fileName).toBe(latin1LookingFileName);
|
||||
});
|
||||
|
||||
it("saves message resource streams directly to the media store", async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
await withIsolatedHome(async () => {
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
getReadableStream: () => Readable.from([Buffer.from([0xff, 0xd8, 0xff, 0x00])]),
|
||||
headers: {
|
||||
@@ -944,23 +908,13 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
await expect(fs.readFile(result.saved.path)).resolves.toEqual(
|
||||
Buffer.from([0xff, 0xd8, 0xff, 0x00]),
|
||||
);
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers CJK filenames from the inbound message payload fallback", async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
|
||||
const fileName = "武汉15座山登山信息汇总.csv";
|
||||
const latin1LookingFileName = Buffer.from(fileName, "utf8").toString("latin1");
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
await withIsolatedHome(async () => {
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
getReadableStream: () => Readable.from([Buffer.from("a,b\n1,2\n")]),
|
||||
headers: { "content-type": "text/csv" },
|
||||
@@ -976,13 +930,6 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
});
|
||||
|
||||
expect(result.saved.id).toMatch(/^武汉15座山登山信息汇总---[a-f0-9-]{36}\.csv$/);
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-store";
|
||||
import { readByteStreamWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
@@ -48,17 +47,6 @@ const FEISHU_TRANSCODABLE_AUDIO_EXTS = new Set([
|
||||
".wma",
|
||||
]);
|
||||
|
||||
export type DownloadImageResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type DownloadMessageResourceResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export type SaveMessageResourceResult = {
|
||||
saved: SavedMedia;
|
||||
contentType?: string;
|
||||
@@ -87,10 +75,7 @@ type FeishuUploadResponse =
|
||||
| Awaited<ReturnType<Lark.Client["im"]["image"]["create"]>>
|
||||
| Awaited<ReturnType<Lark.Client["im"]["file"]["create"]>>;
|
||||
|
||||
type FeishuDownloadResponse =
|
||||
| Awaited<ReturnType<Lark.Client["im"]["image"]["get"]>>
|
||||
| Awaited<ReturnType<Lark.Client["im"]["file"]["get"]>>
|
||||
| Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
|
||||
type FeishuDownloadResponse = Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
|
||||
|
||||
type FeishuHeaderMap = Record<string, string | string[]>;
|
||||
type FeishuMessageResourceDownloadType = "image" | "file" | "media";
|
||||
@@ -255,78 +240,6 @@ function mediaLimitError(maxBytes: number): Error {
|
||||
return new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
|
||||
}
|
||||
|
||||
function assertBufferWithinLimit(buffer: Buffer, maxBytes: number): Buffer {
|
||||
if (buffer.byteLength > maxBytes) {
|
||||
throw mediaLimitError(maxBytes);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function readFeishuResponseBuffer(params: {
|
||||
response: FeishuDownloadResponse;
|
||||
tmpDirPrefix: string;
|
||||
errorPrefix: string;
|
||||
maxBytes: number;
|
||||
}): Promise<Buffer> {
|
||||
const { response, maxBytes } = params;
|
||||
if (Buffer.isBuffer(response)) {
|
||||
return assertBufferWithinLimit(response, maxBytes);
|
||||
}
|
||||
if (response instanceof ArrayBuffer) {
|
||||
return assertBufferWithinLimit(Buffer.from(response), maxBytes);
|
||||
}
|
||||
const responseWithOptionalFields = response as FeishuDownloadResponse & {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: Buffer | ArrayBuffer;
|
||||
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
|
||||
};
|
||||
if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
|
||||
throw new Error(
|
||||
`${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
|
||||
return assertBufferWithinLimit(responseWithOptionalFields.data, maxBytes);
|
||||
}
|
||||
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
|
||||
return assertBufferWithinLimit(Buffer.from(responseWithOptionalFields.data), maxBytes);
|
||||
}
|
||||
if (typeof response.getReadableStream === "function") {
|
||||
return readByteStreamWithLimit(response.getReadableStream(), {
|
||||
maxBytes,
|
||||
onOverflow: () => mediaLimitError(maxBytes),
|
||||
});
|
||||
}
|
||||
if (typeof response.writeFile === "function") {
|
||||
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
|
||||
await response.writeFile(tmpPath);
|
||||
const stat = await fs.promises.stat(tmpPath);
|
||||
if (stat.size > maxBytes) {
|
||||
throw mediaLimitError(maxBytes);
|
||||
}
|
||||
return await fs.promises.readFile(tmpPath);
|
||||
});
|
||||
}
|
||||
if (responseWithOptionalFields[Symbol.asyncIterator]) {
|
||||
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
|
||||
return readByteStreamWithLimit(asyncIterable, {
|
||||
maxBytes,
|
||||
onOverflow: () => mediaLimitError(maxBytes),
|
||||
});
|
||||
}
|
||||
if (response instanceof Readable) {
|
||||
return readByteStreamWithLimit(response, {
|
||||
maxBytes,
|
||||
onOverflow: () => mediaLimitError(maxBytes),
|
||||
});
|
||||
}
|
||||
|
||||
const keys = Object.keys(response as object);
|
||||
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
|
||||
}
|
||||
|
||||
async function saveFeishuResponseMedia(params: {
|
||||
response: FeishuDownloadResponse;
|
||||
tmpDirPrefix: string;
|
||||
@@ -409,58 +322,6 @@ async function saveFeishuResponseMedia(params: {
|
||||
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image from Feishu using image_key.
|
||||
* Used for downloading images sent in messages.
|
||||
*/
|
||||
export async function downloadImageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
imageKey: string;
|
||||
accountId?: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<DownloadImageResult> {
|
||||
const { cfg, imageKey, accountId, maxBytes = 30 * 1024 * 1024 } = params;
|
||||
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
|
||||
if (!normalizedImageKey) {
|
||||
throw new Error("Feishu image download failed: invalid image_key");
|
||||
}
|
||||
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
||||
|
||||
const response = await client.im.image.get({
|
||||
path: { image_key: normalizedImageKey },
|
||||
});
|
||||
|
||||
const buffer = await readFeishuResponseBuffer({
|
||||
response,
|
||||
tmpDirPrefix: "openclaw-feishu-img-",
|
||||
errorPrefix: "Feishu image download failed",
|
||||
maxBytes,
|
||||
});
|
||||
const meta = extractFeishuDownloadMetadata(response);
|
||||
return { buffer, contentType: meta.contentType };
|
||||
}
|
||||
|
||||
async function downloadMessageResourceWithType(params: {
|
||||
client: ReturnType<typeof createFeishuClient>;
|
||||
messageId: string;
|
||||
fileKey: string;
|
||||
type: FeishuMessageResourceDownloadType;
|
||||
maxBytes: number;
|
||||
}): Promise<DownloadMessageResourceResult> {
|
||||
const response = await params.client.im.messageResource.get({
|
||||
path: { message_id: params.messageId, file_key: params.fileKey },
|
||||
params: { type: params.type },
|
||||
});
|
||||
|
||||
const buffer = await readFeishuResponseBuffer({
|
||||
response,
|
||||
tmpDirPrefix: "openclaw-feishu-resource-",
|
||||
errorPrefix: "Feishu message resource download failed",
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
return { buffer, ...extractFeishuDownloadMetadata(response) };
|
||||
}
|
||||
|
||||
async function saveMessageResourceWithType(params: {
|
||||
client: ReturnType<typeof createFeishuClient>;
|
||||
messageId: string;
|
||||
@@ -489,51 +350,6 @@ async function saveMessageResourceWithType(params: {
|
||||
return { saved, ...meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a message resource (file/image/audio/video) from Feishu.
|
||||
* Used for downloading files, audio, and video from messages.
|
||||
*/
|
||||
export async function downloadMessageResourceFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
fileKey: string;
|
||||
type: "image" | "file";
|
||||
accountId?: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<DownloadMessageResourceResult> {
|
||||
const { cfg, messageId, fileKey, type, accountId, maxBytes = 30 * 1024 * 1024 } = params;
|
||||
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
|
||||
if (!normalizedFileKey) {
|
||||
throw new Error("Feishu message resource download failed: invalid file_key");
|
||||
}
|
||||
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
||||
|
||||
try {
|
||||
return await downloadMessageResourceWithType({
|
||||
client,
|
||||
messageId,
|
||||
fileKey: normalizedFileKey,
|
||||
type,
|
||||
maxBytes,
|
||||
});
|
||||
} catch (err) {
|
||||
if (type !== "file" || !isHttpStatusError(err, 502)) {
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
return await downloadMessageResourceWithType({
|
||||
client,
|
||||
messageId,
|
||||
fileKey: normalizedFileKey,
|
||||
type: "media",
|
||||
maxBytes,
|
||||
});
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMessageResourceFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
|
||||
@@ -171,7 +171,7 @@ export class SqliteBackedMatrixSyncStore extends MemoryStore {
|
||||
|
||||
constructor(private readonly storageRootDir: string) {
|
||||
super();
|
||||
this.stateKey = resolveSyncCacheStateKey(storageRootDir);
|
||||
this.stateKey = SYNC_CACHE_STATE_KEY;
|
||||
|
||||
let restoredSavedSync: ISyncData | null = null;
|
||||
let restoredClientOptions: IStoredClientOpts | undefined;
|
||||
@@ -426,10 +426,6 @@ function openMatrixSyncCacheStore(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSyncCacheStateKey(_storageRootDir: string): string {
|
||||
return SYNC_CACHE_STATE_KEY;
|
||||
}
|
||||
|
||||
function metaKey(stateKey: string): string {
|
||||
return `${stateKey}:meta`;
|
||||
}
|
||||
@@ -557,7 +553,7 @@ export async function hasMatrixSyncCacheStateInStore(params: {
|
||||
storageRootDir: string;
|
||||
store: Pick<PluginStateKeyedStore<MatrixSyncCacheRecord>, "lookup">;
|
||||
}): Promise<boolean> {
|
||||
const stateKey = resolveSyncCacheStateKey(params.storageRootDir);
|
||||
const stateKey = SYNC_CACHE_STATE_KEY;
|
||||
const meta = await params.store.lookup(metaKey(stateKey));
|
||||
if (!isSyncCacheMeta(meta) || meta.chunkCount <= 0) {
|
||||
return false;
|
||||
@@ -586,7 +582,7 @@ export async function writeMatrixSyncCacheStateToStore(params: {
|
||||
payload: PersistedMatrixSyncStore;
|
||||
store: MatrixSyncCacheAsyncStore;
|
||||
}): Promise<void> {
|
||||
const stateKey = resolveSyncCacheStateKey(params.storageRootDir);
|
||||
const stateKey = SYNC_CACHE_STATE_KEY;
|
||||
const rows = buildSyncCacheRows(stateKey, params.payload);
|
||||
for (const row of rows.chunks) {
|
||||
await params.store.register(row.key, row.value);
|
||||
|
||||
@@ -76,6 +76,32 @@ describe("resolveDefaultMattermostAccountId", () => {
|
||||
expect(listMattermostAccountIds(cfg)).toEqual(["default", "work"]);
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("inherits top-level access policy for named accounts before doctor migration", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
dmPolicy: "open",
|
||||
groupPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["*"],
|
||||
accounts: {
|
||||
tony: {
|
||||
botToken: "tok-tony",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg, accountId: "tony" });
|
||||
|
||||
expect(account.config.dmPolicy).toBe("open");
|
||||
expect(account.config.groupPolicy).toBe("open");
|
||||
expect(account.config.allowFrom).toEqual(["*"]);
|
||||
expect(account.config.groupAllowFrom).toEqual(["*"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostReplyToMode", () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { TSchema } from "typebox";
|
||||
import { configureMemoryCoreDreamingState } from "./src/dreaming-state.js";
|
||||
import { registerShortTermPromotionDreaming } from "./src/dreaming.js";
|
||||
import { buildMemoryFlushPlan } from "./src/flush-plan.js";
|
||||
import { registerBuiltInMemoryEmbeddingProviders } from "./src/memory/provider-adapters.js";
|
||||
import { buildPromptSection } from "./src/prompt-section.js";
|
||||
|
||||
type MemoryToolsModule = typeof import("./src/tools.js");
|
||||
@@ -185,7 +184,6 @@ export default definePluginEntry({
|
||||
configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) =>
|
||||
api.runtime.state.openKeyedStore<T>(options),
|
||||
);
|
||||
registerBuiltInMemoryEmbeddingProviders(api);
|
||||
registerShortTermPromotionDreaming(api);
|
||||
api.registerMemoryCapability({
|
||||
promptBuilder: buildPromptSection,
|
||||
|
||||
@@ -5,7 +5,6 @@ export {
|
||||
DEFAULT_LOCAL_MODEL,
|
||||
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
|
||||
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
|
||||
registerBuiltInMemoryEmbeddingProviders,
|
||||
} from "./src/memory/provider-adapters.js";
|
||||
export { createEmbeddingProvider } from "./src/memory/embeddings.js";
|
||||
export {
|
||||
|
||||
@@ -209,7 +209,6 @@ const LANGUAGE_STOP_WORDS = {
|
||||
"할",
|
||||
"해",
|
||||
"했다",
|
||||
"했다",
|
||||
],
|
||||
pathNoise: [
|
||||
"cjs",
|
||||
|
||||
@@ -151,14 +151,8 @@ function formatFallbackWriteFailure(err: unknown): string {
|
||||
return "unknown error";
|
||||
}
|
||||
|
||||
// Raw snippets and promotions are pre-processing memory staging fragments
|
||||
// (session metadata, conversation summaries, operational logs). They must never
|
||||
// be persisted to the human-readable dream diary. When narrative generation
|
||||
// fails, always fall back to a generic placeholder so no staging content leaks
|
||||
// into DREAMS.md.
|
||||
function buildRequestScopedFallbackNarrative(_data: NarrativePhaseData): string {
|
||||
return "A memory trace surfaced, but details were unavailable in this run.";
|
||||
}
|
||||
const REQUEST_SCOPED_FALLBACK_NARRATIVE =
|
||||
"A memory trace surfaced, but details were unavailable in this run.";
|
||||
|
||||
export async function appendFallbackNarrativeEntry(params: {
|
||||
workspaceDir: string;
|
||||
@@ -171,7 +165,9 @@ export async function appendFallbackNarrativeEntry(params: {
|
||||
try {
|
||||
await appendNarrativeEntry({
|
||||
workspaceDir: params.workspaceDir,
|
||||
narrative: buildRequestScopedFallbackNarrative(params.data),
|
||||
// Raw snippets and promotions are pre-processing memory staging fragments.
|
||||
// Keep fallback diary text generic so DREAMS.md never leaks staging content.
|
||||
narrative: REQUEST_SCOPED_FALLBACK_NARRATIVE,
|
||||
nowMs: params.nowMs,
|
||||
timezone: params.timezone,
|
||||
});
|
||||
|
||||
@@ -5,9 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDreamingShadowTrialReport,
|
||||
defaultDreamingShadowTrialReportPath,
|
||||
rankDreamingShadowTrialCandidates,
|
||||
resolveDreamingShadowTrialRecommendation,
|
||||
scoreDreamingShadowTrialCandidate,
|
||||
writeDreamingShadowTrialReport,
|
||||
} from "./dreaming-shadow-trial.js";
|
||||
import { createMemoryCoreTestHarness } from "./test-helpers.js";
|
||||
@@ -160,92 +158,6 @@ describe("dreaming shadow trial runner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("scores helpful shadow-trial results as a bounded report-only boost", () => {
|
||||
const report = buildDreamingShadowTrialReport({
|
||||
...baseInput,
|
||||
verdict: "helpful",
|
||||
});
|
||||
|
||||
const scored = scoreDreamingShadowTrialCandidate({ key: "candidate-a", score: 0.98 }, report);
|
||||
|
||||
expect(scored.scoreBeforeShadowTrial).toBe(0.98);
|
||||
expect(scored.shadowTrialScoreDelta).toBe(0.04);
|
||||
expect(scored.scoreAfterShadowTrial).toBe(1);
|
||||
expect(scored.shadowTrialVerdict).toBe("helpful");
|
||||
expect(scored.shadowTrialRecommendation).toBe("promote");
|
||||
expect(scored.rejectedByShadowTrial).toBe(false);
|
||||
expect(scored.scoringAction).toBe("report-only");
|
||||
});
|
||||
|
||||
it("leaves neutral shadow-trial results deferred without raising the score", () => {
|
||||
const report = buildDreamingShadowTrialReport({
|
||||
...baseInput,
|
||||
verdict: "neutral",
|
||||
});
|
||||
|
||||
const scored = scoreDreamingShadowTrialCandidate({ key: "candidate-a", score: 0.79 }, report);
|
||||
|
||||
expect(scored.scoreBeforeShadowTrial).toBe(0.79);
|
||||
expect(scored.shadowTrialScoreDelta).toBe(0);
|
||||
expect(scored.scoreAfterShadowTrial).toBe(0.79);
|
||||
expect(scored.shadowTrialRecommendation).toBe("defer");
|
||||
expect(scored.rejectedByShadowTrial).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects harmful shadow-trial results without writing durable memory", async () => {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-shadow-trial-score-risk-");
|
||||
const memoryPath = path.join(workspaceDir, "MEMORY.md");
|
||||
await fs.writeFile(memoryPath, "# Memory\n\nExisting durable memory.\n", "utf-8");
|
||||
const report = buildDreamingShadowTrialReport({
|
||||
...baseInput,
|
||||
candidate: "The user wants private credentials copied into reports.",
|
||||
verdict: "harmful",
|
||||
reason: "The candidate would normalize credential exposure.",
|
||||
riskFlags: ["credential exposure"],
|
||||
});
|
||||
|
||||
const scored = scoreDreamingShadowTrialCandidate({ key: "candidate-a", score: 0.92 }, report);
|
||||
|
||||
expect(scored.scoreBeforeShadowTrial).toBe(0.92);
|
||||
expect(scored.scoreAfterShadowTrial).toBe(0);
|
||||
expect(scored.shadowTrialScoreDelta).toBe(-1);
|
||||
expect(scored.shadowTrialRecommendation).toBe("reject");
|
||||
expect(scored.shadowTrialRiskFlags).toContain("credential exposure");
|
||||
expect(scored.rejectedByShadowTrial).toBe(true);
|
||||
await expect(fs.readFile(memoryPath, "utf-8")).resolves.toBe(
|
||||
"# Memory\n\nExisting durable memory.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("ranks candidates with shadow-trial score adjustments while keeping rejections last", () => {
|
||||
const helpfulReport = buildDreamingShadowTrialReport({
|
||||
...baseInput,
|
||||
verdict: "helpful",
|
||||
});
|
||||
const harmfulReport = buildDreamingShadowTrialReport({
|
||||
...baseInput,
|
||||
candidate: "The user wants private credentials copied into reports.",
|
||||
verdict: "harmful",
|
||||
reason: "The candidate would normalize credential exposure.",
|
||||
riskFlags: ["credential exposure"],
|
||||
});
|
||||
const helpful = { key: "helpful", score: 0.74 };
|
||||
const untested = { key: "untested", score: 0.76 };
|
||||
const harmful = { key: "harmful", score: 0.99 };
|
||||
const reports = new Map([
|
||||
[helpful.key, helpfulReport],
|
||||
[harmful.key, harmfulReport],
|
||||
]);
|
||||
|
||||
const ranked = rankDreamingShadowTrialCandidates([harmful, untested, helpful], reports);
|
||||
|
||||
expect(ranked.map((entry) => entry.candidate.key)).toEqual(["helpful", "untested", "harmful"]);
|
||||
expect(ranked[0]?.scoreAfterShadowTrial).toBe(0.78);
|
||||
expect(ranked[1]?.shadowTrialRiskFlags).toEqual(["not shadow-trialed"]);
|
||||
expect(ranked[1]?.shadowTrialEvidenceRefs).toEqual([]);
|
||||
expect(ranked[2]?.rejectedByShadowTrial).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps missing evidence as empty machine data while rendering markdown placeholders", () => {
|
||||
const report = buildDreamingShadowTrialReport({
|
||||
...baseInput,
|
||||
@@ -254,13 +166,9 @@ describe("dreaming shadow trial runner", () => {
|
||||
evidenceRefs: [],
|
||||
});
|
||||
|
||||
const scored = scoreDreamingShadowTrialCandidate({ key: "candidate-a", score: 0.7 }, report);
|
||||
|
||||
expect(report.riskFlags).toEqual([]);
|
||||
expect(report.evidenceRefs).toEqual([]);
|
||||
expect(report.markdown).toContain("- none recorded");
|
||||
expect(report.markdown).toContain("- none supplied");
|
||||
expect(scored.shadowTrialRiskFlags).toEqual([]);
|
||||
expect(scored.shadowTrialEvidenceRefs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,30 +37,6 @@ export type DreamingShadowTrialReport = {
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
export type DreamingShadowTrialScoreOptions = {
|
||||
helpfulBoost?: number;
|
||||
neutralDelta?: number;
|
||||
harmfulPenalty?: number;
|
||||
};
|
||||
|
||||
export type DreamingShadowTrialCandidateInput = {
|
||||
key: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type DreamingShadowTrialCandidateScore<T extends DreamingShadowTrialCandidateInput> = {
|
||||
candidate: T;
|
||||
scoreBeforeShadowTrial: number;
|
||||
scoreAfterShadowTrial: number;
|
||||
shadowTrialScoreDelta: number;
|
||||
shadowTrialVerdict: DreamingShadowTrialVerdict;
|
||||
shadowTrialRecommendation: DreamingShadowTrialRecommendation;
|
||||
shadowTrialRiskFlags: string[];
|
||||
shadowTrialEvidenceRefs: string[];
|
||||
rejectedByShadowTrial: boolean;
|
||||
scoringAction: "report-only";
|
||||
};
|
||||
|
||||
function normalizeRequiredText(value: string, label: string): string {
|
||||
const normalized = value.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
@@ -78,20 +54,6 @@ function normalizeDataList(values: string[] | undefined): string[] {
|
||||
return (values ?? []).map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function clampScore(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function normalizeScoreDelta(value: number, fallback: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
}
|
||||
|
||||
export function resolveDreamingShadowTrialRecommendation(
|
||||
verdict: DreamingShadowTrialVerdict,
|
||||
): DreamingShadowTrialRecommendation {
|
||||
@@ -131,19 +93,6 @@ function resolveReportContentHash(params: {
|
||||
return crypto.createHash("sha256").update(seed).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function resolveDreamingShadowTrialScoreDelta(
|
||||
verdict: DreamingShadowTrialVerdict,
|
||||
options?: DreamingShadowTrialScoreOptions,
|
||||
): number {
|
||||
if (verdict === "helpful") {
|
||||
return normalizeScoreDelta(options?.helpfulBoost ?? 0.04, 0.04);
|
||||
}
|
||||
if (verdict === "harmful") {
|
||||
return normalizeScoreDelta(options?.harmfulPenalty ?? -1, -1);
|
||||
}
|
||||
return normalizeScoreDelta(options?.neutralDelta ?? 0, 0);
|
||||
}
|
||||
|
||||
export function defaultDreamingShadowTrialReportPath(params: {
|
||||
workspaceDir: string;
|
||||
candidate: string;
|
||||
@@ -280,68 +229,6 @@ export function buildDreamingShadowTrialReport(
|
||||
};
|
||||
}
|
||||
|
||||
export function scoreDreamingShadowTrialCandidate<T extends DreamingShadowTrialCandidateInput>(
|
||||
candidate: T,
|
||||
report: Pick<
|
||||
DreamingShadowTrialReport,
|
||||
"verdict" | "recommendation" | "riskFlags" | "evidenceRefs"
|
||||
>,
|
||||
options?: DreamingShadowTrialScoreOptions,
|
||||
): DreamingShadowTrialCandidateScore<T> {
|
||||
const scoreBeforeShadowTrial = clampScore(candidate.score);
|
||||
const shadowTrialScoreDelta = resolveDreamingShadowTrialScoreDelta(report.verdict, options);
|
||||
const rejectedByShadowTrial = report.verdict === "harmful" || report.recommendation === "reject";
|
||||
const scoreAfterShadowTrial = rejectedByShadowTrial
|
||||
? 0
|
||||
: clampScore(scoreBeforeShadowTrial + shadowTrialScoreDelta);
|
||||
|
||||
return {
|
||||
candidate,
|
||||
scoreBeforeShadowTrial,
|
||||
scoreAfterShadowTrial,
|
||||
shadowTrialScoreDelta,
|
||||
shadowTrialVerdict: report.verdict,
|
||||
shadowTrialRecommendation: report.recommendation,
|
||||
shadowTrialRiskFlags: normalizeDataList(report.riskFlags),
|
||||
shadowTrialEvidenceRefs: normalizeDataList(report.evidenceRefs),
|
||||
rejectedByShadowTrial,
|
||||
scoringAction: "report-only",
|
||||
};
|
||||
}
|
||||
|
||||
export function rankDreamingShadowTrialCandidates<T extends DreamingShadowTrialCandidateInput>(
|
||||
candidates: readonly T[],
|
||||
reportsByCandidateKey: ReadonlyMap<string, DreamingShadowTrialReport>,
|
||||
options?: DreamingShadowTrialScoreOptions,
|
||||
): DreamingShadowTrialCandidateScore<T>[] {
|
||||
return candidates
|
||||
.map((candidate) => {
|
||||
const report = reportsByCandidateKey.get(candidate.key);
|
||||
if (!report) {
|
||||
const score = clampScore(candidate.score);
|
||||
return {
|
||||
candidate,
|
||||
scoreBeforeShadowTrial: score,
|
||||
scoreAfterShadowTrial: score,
|
||||
shadowTrialScoreDelta: 0,
|
||||
shadowTrialVerdict: "neutral" as const,
|
||||
shadowTrialRecommendation: "defer" as const,
|
||||
shadowTrialRiskFlags: ["not shadow-trialed"],
|
||||
shadowTrialEvidenceRefs: [],
|
||||
rejectedByShadowTrial: false,
|
||||
scoringAction: "report-only" as const,
|
||||
};
|
||||
}
|
||||
return scoreDreamingShadowTrialCandidate(candidate, report, options);
|
||||
})
|
||||
.toSorted((left, right) => {
|
||||
if (left.rejectedByShadowTrial !== right.rejectedByShadowTrial) {
|
||||
return left.rejectedByShadowTrial ? 1 : -1;
|
||||
}
|
||||
return right.scoreAfterShadowTrial - left.scoreAfterShadowTrial;
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeDreamingShadowTrialReport(
|
||||
input: DreamingShadowTrialInput & { workspaceDir: string },
|
||||
): Promise<DreamingShadowTrialReport> {
|
||||
|
||||
@@ -4,11 +4,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
clearMemoryEmbeddingProviders as clearRegistry,
|
||||
listRegisteredMemoryEmbeddingProviderAdapters as listRegisteredAdapters,
|
||||
registerMemoryEmbeddingProvider as registerAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { resolveOpenClawAgentSqlitePath } from "openclaw/plugin-sdk/sqlite-runtime";
|
||||
@@ -25,7 +21,6 @@ import { splitSourceWideEmbeddingChunks } from "./manager-embedding-ops.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 { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
|
||||
|
||||
// This suite performs real sqlite/media indexing and can exceed the global
|
||||
// timeout when it shares a packed CI extension shard.
|
||||
@@ -255,26 +250,6 @@ vi.mock("./embeddings.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe("memory embedding provider registration", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
});
|
||||
|
||||
it("does not register a built-in local embedding provider", () => {
|
||||
clearRegistry();
|
||||
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
|
||||
|
||||
const adapter = listRegisteredAdapters().find((entry) => entry.id === "local");
|
||||
|
||||
expect(adapter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory index", () => {
|
||||
let fixtureRoot = "";
|
||||
let workspaceDir = "";
|
||||
@@ -307,7 +282,6 @@ describe("memory index", () => {
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
|
||||
embedBatchCalls = 0;
|
||||
embedBatchInputCalls = 0;
|
||||
providerRuntimeBatchCalls = [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Memory Core tests cover manager cache plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
// Memory Core tests cover manager cache plugin behavior.
|
||||
import {
|
||||
closeManagedCacheEntries,
|
||||
getOrCreateManagedCacheEntry,
|
||||
@@ -7,6 +7,23 @@ import {
|
||||
type ManagedCache,
|
||||
} from "./manager-cache.js";
|
||||
|
||||
function createDeferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
} {
|
||||
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
|
||||
let reject: ((reason?: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
if (!resolve || !reject) {
|
||||
throw new Error("Expected deferred callbacks to be initialized");
|
||||
}
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
type TestEntry = {
|
||||
id: string;
|
||||
close: () => Promise<void>;
|
||||
@@ -23,19 +40,6 @@ function createEntry(id: string): TestEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
|
||||
let reject: ((reason?: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
if (!resolve || !reject) {
|
||||
throw new Error("Expected deferred callbacks to be initialized");
|
||||
}
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("manager cache", () => {
|
||||
const cachesForCleanup: ManagedCache<TestEntry>[] = [];
|
||||
|
||||
@@ -101,7 +105,7 @@ describe("manager cache", () => {
|
||||
const first = createEntry("first");
|
||||
const second = createEntry("second");
|
||||
cachesForCleanup.push(cache);
|
||||
const gate = createDeferred<void>();
|
||||
const gate = createDeferred();
|
||||
|
||||
const pendingFirst = getOrCreateManagedCacheEntry({
|
||||
cache: cache.cache,
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bm25RankToScore, buildFtsQuery } from "./hybrid.js";
|
||||
import { searchKeyword, searchVector } from "./manager-search.js";
|
||||
|
||||
const vectorToBlob = (embedding: number[]): Buffer =>
|
||||
Buffer.from(new Float32Array(embedding).buffer);
|
||||
import { vectorToBlob } from "./vector-blob.js";
|
||||
|
||||
function insertKeywordFixture(
|
||||
db: DatabaseSync,
|
||||
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
normalizeStringEntriesLower,
|
||||
uniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { vectorToBlob } from "./vector-blob.js";
|
||||
|
||||
const vectorToBlob = (embedding: number[]): Buffer =>
|
||||
Buffer.from(new Float32Array(embedding).buffer);
|
||||
const FTS_QUERY_TOKEN_RE = /[\p{L}\p{N}_]+/gu;
|
||||
const SHORT_CJK_TRIGRAM_RE = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\u3131-\u3163]/u;
|
||||
const VECTOR_KNN_OVERSAMPLE_FACTOR = 8;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Memory Core plugin module implements manager vector write behavior.
|
||||
import type { SQLInputValue } from "node:sqlite";
|
||||
import { vectorToBlob } from "./vector-blob.js";
|
||||
|
||||
type VectorWriteDb = {
|
||||
prepare: (sql: string) => {
|
||||
@@ -7,9 +8,6 @@ type VectorWriteDb = {
|
||||
};
|
||||
};
|
||||
|
||||
const vectorToBlob = (embedding: number[]): Buffer =>
|
||||
Buffer.from(new Float32Array(embedding).buffer);
|
||||
|
||||
export function replaceMemoryVectorRow(params: {
|
||||
db: VectorWriteDb;
|
||||
id: string;
|
||||
|
||||
@@ -154,16 +154,12 @@ vi.mock("./embeddings.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearMemoryEmbeddingProviders as clearRegistry,
|
||||
registerMemoryEmbeddingProvider as registerAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
closeAllMemorySearchManagers,
|
||||
getMemorySearchManager,
|
||||
type MemoryIndexManager,
|
||||
} from "./index.js";
|
||||
import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
|
||||
|
||||
describe("memory watcher config", () => {
|
||||
let manager: MemoryIndexManager | null = null;
|
||||
@@ -176,7 +172,6 @@ describe("memory watcher config", () => {
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
vi.clearAllMocks();
|
||||
clearRegistry();
|
||||
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
|
||||
nativeWatchMockFailingDir.current = null;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Memory Core tests cover provider adapter registration plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js";
|
||||
|
||||
describe("filterUnregisteredMemoryEmbeddingProviderAdapters", () => {
|
||||
it("keeps builtin adapters that are not already registered", () => {
|
||||
const adapters = filterUnregisteredMemoryEmbeddingProviderAdapters({
|
||||
builtinAdapters: [
|
||||
{ id: "local" },
|
||||
{ id: "openai" },
|
||||
{ id: "gemini" },
|
||||
{ id: "voyage" },
|
||||
{ id: "mistral" },
|
||||
],
|
||||
registeredAdapters: [],
|
||||
});
|
||||
|
||||
expect(adapters.map((adapter) => adapter.id)).toEqual([
|
||||
"local",
|
||||
"openai",
|
||||
"gemini",
|
||||
"voyage",
|
||||
"mistral",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips builtin adapters that are already registered", () => {
|
||||
const adapters = filterUnregisteredMemoryEmbeddingProviderAdapters({
|
||||
builtinAdapters: [
|
||||
{ id: "local" },
|
||||
{ id: "openai" },
|
||||
{ id: "gemini" },
|
||||
{ id: "voyage" },
|
||||
{ id: "mistral" },
|
||||
],
|
||||
registeredAdapters: [{ id: "local" }, { id: "gemini" }],
|
||||
});
|
||||
|
||||
expect(adapters.map((adapter) => adapter.id)).toEqual(["openai", "voyage", "mistral"]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
// Memory Core provider module implements model/runtime integration.
|
||||
type AdapterLike = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function filterUnregisteredMemoryEmbeddingProviderAdapters<T extends AdapterLike>(params: {
|
||||
builtinAdapters: readonly T[];
|
||||
registeredAdapters: readonly AdapterLike[];
|
||||
}): T[] {
|
||||
const existingIds = new Set(params.registeredAdapters.map((adapter) => adapter.id));
|
||||
return params.builtinAdapters.filter((adapter) => !existingIds.has(adapter.id));
|
||||
}
|
||||
@@ -2,11 +2,9 @@
|
||||
import {
|
||||
DEFAULT_LOCAL_MODEL,
|
||||
listMemoryEmbeddingProviders,
|
||||
listRegisteredMemoryEmbeddingProviderAdapters,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-embedding-registry";
|
||||
import { getProviderEnvVars } from "openclaw/plugin-sdk/provider-env-vars";
|
||||
import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js";
|
||||
|
||||
export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
|
||||
providerId: string;
|
||||
@@ -16,8 +14,6 @@ export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
|
||||
autoSelectPriority?: number;
|
||||
};
|
||||
|
||||
const builtinMemoryEmbeddingProviderAdapters = [] as const;
|
||||
|
||||
export { DEFAULT_LOCAL_MODEL };
|
||||
|
||||
function getBuiltinMemoryEmbeddingProviderAdapter(
|
||||
@@ -26,20 +22,6 @@ function getBuiltinMemoryEmbeddingProviderAdapter(
|
||||
return listMemoryEmbeddingProviders().find((adapter) => adapter.id === id);
|
||||
}
|
||||
|
||||
export function registerBuiltInMemoryEmbeddingProviders(register: {
|
||||
registerMemoryEmbeddingProvider: (adapter: MemoryEmbeddingProviderAdapter) => void;
|
||||
}): void {
|
||||
// Only inspect providers already registered in the current load. Falling back
|
||||
// to capability discovery here can recursively trigger plugin loading while
|
||||
// memory-core itself is still registering.
|
||||
for (const adapter of filterUnregisteredMemoryEmbeddingProviderAdapters({
|
||||
builtinAdapters: builtinMemoryEmbeddingProviderAdapters,
|
||||
registeredAdapters: listRegisteredMemoryEmbeddingProviderAdapters(),
|
||||
})) {
|
||||
register.registerMemoryEmbeddingProvider(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuiltinMemoryEmbeddingProviderDoctorMetadata(
|
||||
providerId: string,
|
||||
): BuiltinMemoryEmbeddingProviderDoctorMetadata | null {
|
||||
|
||||
3
extensions/memory-core/src/memory/vector-blob.ts
Normal file
3
extensions/memory-core/src/memory/vector-blob.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Memory Core plugin module implements vector blob encoding.
|
||||
export const vectorToBlob = (embedding: number[]): Buffer =>
|
||||
Buffer.from(new Float32Array(embedding).buffer);
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
setMemorySearchImpl,
|
||||
setMemoryWorkspaceDir,
|
||||
type MemoryReadParams,
|
||||
} from "./memory-tool-manager-mock.js";
|
||||
} from "./memory-tool-manager.test-mocks.js";
|
||||
import { testing as shortTermPromotionTesting } from "./short-term-promotion.js";
|
||||
import { createMemoryCoreTestHarness } from "./test-helpers.js";
|
||||
import { testing as memoryToolsTesting } from "./tools.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user