mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
638
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f4bef828d | ||
|
|
94f3dc758d | ||
|
|
443d9afb6d | ||
|
|
30b2cf89de | ||
|
|
addbb5bf05 | ||
|
|
dedc3e5af3 | ||
|
|
6c53e4d6a4 | ||
|
|
506ef7e5de | ||
|
|
d45454a300 | ||
|
|
d0f220096b | ||
|
|
d1488d5d0c | ||
|
|
a91aaa6c4d | ||
|
|
1502533094 | ||
|
|
e47de51cab | ||
|
|
4b297506db | ||
|
|
2a51c686bc | ||
|
|
189f7672c7 | ||
|
|
ffe2885867 | ||
|
|
2234b0f8a0 | ||
|
|
27c92d2dfb | ||
|
|
d74e5ad218 | ||
|
|
6d74605fd0 | ||
|
|
b8fa5ed0f6 | ||
|
|
3f6e122e30 | ||
|
|
1ce9cb84c5 | ||
|
|
03c71ce0ee | ||
|
|
02bdb8b8bc | ||
|
|
41318d5da9 | ||
|
|
5bea200f3a | ||
|
|
639a53278c | ||
|
|
d53132fdf6 | ||
|
|
e5d01a32ce | ||
|
|
8e4fe65a87 | ||
|
|
a4bc92bcc9 | ||
|
|
b739448a83 | ||
|
|
d586ffdb67 | ||
|
|
42a50deb7e | ||
|
|
907aba3e90 | ||
|
|
fed3ca2b75 | ||
|
|
b952a3fbd6 | ||
|
|
e35748e0ba | ||
|
|
d0f3e547fa | ||
|
|
842aa364ce | ||
|
|
1eecbc113b | ||
|
|
b72c6b4506 | ||
|
|
3d983ca0f3 | ||
|
|
f2b47cdfe0 | ||
|
|
0e740d3299 | ||
|
|
522b9f61e4 | ||
|
|
0bd263426b | ||
|
|
8300f72870 | ||
|
|
b4e9ae74bb | ||
|
|
9a67cbaa26 | ||
|
|
824e406f40 | ||
|
|
8809ed83bb | ||
|
|
ddb3d81907 | ||
|
|
154c8a8d9e | ||
|
|
d0c3dd5212 | ||
|
|
bf02618c64 | ||
|
|
5c0ea6f90a | ||
|
|
295a981d83 | ||
|
|
121c0cf7ca | ||
|
|
f2e3ff8433 | ||
|
|
1a0732b97e | ||
|
|
e9388db72b | ||
|
|
5d99a796ce | ||
|
|
af765d6b82 | ||
|
|
4eef35a0b5 | ||
|
|
720dc69c75 | ||
|
|
81729ee851 | ||
|
|
bb62bb8057 | ||
|
|
fdb5096f0c | ||
|
|
e930472cf7 | ||
|
|
d6302bfae2 | ||
|
|
8bf412accd | ||
|
|
72de55dc11 | ||
|
|
0264316a83 | ||
|
|
a0efc71493 | ||
|
|
507debd468 | ||
|
|
4e57ca2009 | ||
|
|
99ec610785 | ||
|
|
f19d2fc208 | ||
|
|
faa5520f45 | ||
|
|
9ab3feecde | ||
|
|
b6e34eb144 | ||
|
|
801f53bf46 | ||
|
|
28fc37a14a | ||
|
|
a51bb50d07 | ||
|
|
91aa427a7c | ||
|
|
b543d8629e | ||
|
|
7f5cdad445 | ||
|
|
2b3b7c4d0c | ||
|
|
2d21bbc582 | ||
|
|
a49133cc46 | ||
|
|
a481f2d0b1 | ||
|
|
63618012a3 | ||
|
|
83437904bb | ||
|
|
ebe180c771 | ||
|
|
9fb2bbb542 | ||
|
|
ccb2a7ad64 | ||
|
|
db06675c87 | ||
|
|
97c9c5b24e | ||
|
|
d74c6bec95 | ||
|
|
639b852ed1 | ||
|
|
55b61653ef | ||
|
|
e997e19599 | ||
|
|
427d1a24b4 | ||
|
|
16d82778ff | ||
|
|
88260228ad | ||
|
|
c13c7fde60 | ||
|
|
436726ef56 | ||
|
|
52a10d8793 | ||
|
|
880a9e0b94 | ||
|
|
a317b0998e | ||
|
|
58e94afbf3 | ||
|
|
f61017127a | ||
|
|
49c08b17c7 | ||
|
|
dbb6ded60a | ||
|
|
5304594bb0 | ||
|
|
cd81a8aea4 | ||
|
|
b6b6001e06 | ||
|
|
4e4597c630 | ||
|
|
31370255fb | ||
|
|
7d870bdef3 | ||
|
|
370b5a252c | ||
|
|
4c6ea3b000 | ||
|
|
c23b749298 | ||
|
|
c37f2656d8 | ||
|
|
1cb9b4300c | ||
|
|
1f424fbff0 | ||
|
|
3ae1b7baef | ||
|
|
0d68e0a986 | ||
|
|
19640573dc | ||
|
|
79e0e9e73d | ||
|
|
ec014ebd0a | ||
|
|
2b103885e8 | ||
|
|
b46a1e11ec | ||
|
|
3306732c50 | ||
|
|
ca2da5c53a | ||
|
|
b5d3c529f2 | ||
|
|
0cac9ae4b9 | ||
|
|
e5d3291c03 | ||
|
|
03f9c22072 | ||
|
|
dbfaf19aae | ||
|
|
f375354301 | ||
|
|
6fbeb4ce21 | ||
|
|
dfb0ff4bc4 | ||
|
|
1f5aa55677 | ||
|
|
21200e3312 | ||
|
|
3dcc52b6d2 | ||
|
|
872ed93d49 | ||
|
|
574723954c | ||
|
|
f5e9b01e91 | ||
|
|
477ce369bb | ||
|
|
f46ac4f856 | ||
|
|
0d64b3ec14 | ||
|
|
4aae38f890 | ||
|
|
1778e4cc43 | ||
|
|
6a2e9af854 | ||
|
|
5e5996d059 | ||
|
|
f1a5b77c80 | ||
|
|
6397a75614 | ||
|
|
07705bb0d1 | ||
|
|
618807518b | ||
|
|
95936b5167 | ||
|
|
84199bf82d | ||
|
|
d40dc23eab | ||
|
|
450243769c | ||
|
|
63a4755b12 | ||
|
|
b13f478809 | ||
|
|
bcf0f3f5cb | ||
|
|
4296a8365b | ||
|
|
f8747c5abd | ||
|
|
bc89385120 | ||
|
|
20eb088e61 | ||
|
|
d596bf63c2 | ||
|
|
6560ae0c62 | ||
|
|
42dbb39367 | ||
|
|
e122025906 | ||
|
|
052a7424b4 | ||
|
|
6767b6baff | ||
|
|
bbe3163ad8 | ||
|
|
65e973a63d | ||
|
|
c412db874b | ||
|
|
9c29eace57 | ||
|
|
042c7fbe9f | ||
|
|
a95d08ddb6 | ||
|
|
b73ac5f285 | ||
|
|
138167ec7d | ||
|
|
7b7b98faec | ||
|
|
9cb5fbace7 | ||
|
|
87d1aa19b4 | ||
|
|
e958985ec1 | ||
|
|
e5f440d2c3 | ||
|
|
4c57f2293c | ||
|
|
c982081998 | ||
|
|
7260e9b303 | ||
|
|
22cacc9001 | ||
|
|
97bbdea635 | ||
|
|
227ca9aa00 | ||
|
|
bc9b0a7429 | ||
|
|
1a1f7c7834 | ||
|
|
1c36b1ab7d | ||
|
|
c2b66a05fa | ||
|
|
656ebacea5 | ||
|
|
febabc561a | ||
|
|
04b43991a6 | ||
|
|
18a6ee58db | ||
|
|
74ceafaf16 | ||
|
|
e0cd5e5d97 | ||
|
|
c4009946fd | ||
|
|
739d22e0fe | ||
|
|
f7bc5e1e16 | ||
|
|
8dd7b6dee3 | ||
|
|
0d7a0aedc7 | ||
|
|
9350f7f125 | ||
|
|
24ab1a0a4f | ||
|
|
dd1d63b3ce | ||
|
|
0b1c62be41 | ||
|
|
ec08aa36b1 | ||
|
|
10472be8d4 | ||
|
|
9400099593 | ||
|
|
99e0c42d37 | ||
|
|
6924986399 | ||
|
|
4244fead38 | ||
|
|
288416be16 | ||
|
|
df9ac7c6bc | ||
|
|
17b3541ff1 | ||
|
|
144d2ff802 | ||
|
|
48f0e7c207 | ||
|
|
2fd609387e | ||
|
|
0f5fc69554 | ||
|
|
b4f819872f | ||
|
|
01d1e7cfb1 | ||
|
|
da480c3181 | ||
|
|
7cf009fbf0 | ||
|
|
1c4e89b0aa | ||
|
|
566f48044c | ||
|
|
e53d5e348f | ||
|
|
022dd7faee | ||
|
|
ee10c06d63 | ||
|
|
cab7fb6afb | ||
|
|
3ff71f0882 | ||
|
|
46a139b533 | ||
|
|
f3079d585d | ||
|
|
82d939bf94 | ||
|
|
ac59488c0c | ||
|
|
6773367d05 | ||
|
|
6f5e7a1a6d | ||
|
|
3260a68023 | ||
|
|
daa4cf4044 | ||
|
|
8e6aad3ee0 | ||
|
|
5b93b29c97 | ||
|
|
dfd8d1a89c | ||
|
|
47a4aad04e | ||
|
|
bdaf57e287 | ||
|
|
9082368925 | ||
|
|
2dffa4d0ed | ||
|
|
b148ae453d | ||
|
|
95ecd55391 | ||
|
|
440fc18a62 | ||
|
|
b806b594d0 | ||
|
|
acc368ee2a | ||
|
|
40bd3f06b8 | ||
|
|
da4bb5886d | ||
|
|
063ad81319 | ||
|
|
f7e19e9ee5 | ||
|
|
a0301e55fc | ||
|
|
d02933aeaf | ||
|
|
5c33fb2e06 | ||
|
|
144d1817ad | ||
|
|
ff224380d2 | ||
|
|
c3c19e0748 | ||
|
|
82697031ca | ||
|
|
c99dcf42bf | ||
|
|
76b326c602 | ||
|
|
a691b52eb9 | ||
|
|
38f8a56f1b | ||
|
|
adc41fdd97 | ||
|
|
94d1976713 | ||
|
|
95d7cb2cf6 | ||
|
|
92d084515c | ||
|
|
f33caaca90 | ||
|
|
8d49317e5d | ||
|
|
3765035e3d | ||
|
|
f82277b80d | ||
|
|
db902db79d | ||
|
|
f5b06287a1 | ||
|
|
ae30efbfca | ||
|
|
23eafa84f5 | ||
|
|
07a5c0d8d3 | ||
|
|
1fbb650324 | ||
|
|
e3be160d52 | ||
|
|
eaff7c8ae2 | ||
|
|
7f37c01a91 | ||
|
|
7a41081c4f | ||
|
|
7dace9cfb0 | ||
|
|
a4cf9d0591 | ||
|
|
4c549631c5 | ||
|
|
42a1d24662 | ||
|
|
783fef7409 | ||
|
|
f5116c49fa | ||
|
|
423c906b25 | ||
|
|
82e8118db2 | ||
|
|
0836d7a2c6 | ||
|
|
06a4b4622a | ||
|
|
1a1b953233 | ||
|
|
7c406be221 | ||
|
|
b456bd34e1 | ||
|
|
b7b7687e17 | ||
|
|
7eeebe89be | ||
|
|
c929e444b9 | ||
|
|
6b383ad920 | ||
|
|
8cb8af0c14 | ||
|
|
8cf0b5e94f | ||
|
|
c0aad054f4 | ||
|
|
d952d8c7fd | ||
|
|
1e312b66d8 | ||
|
|
eb384283d1 | ||
|
|
5829f2a1c5 | ||
|
|
8370d9cf45 | ||
|
|
bfba90b356 | ||
|
|
2a3756b9e5 | ||
|
|
95c4c6da28 | ||
|
|
cc66ec38d3 | ||
|
|
5d4676a277 | ||
|
|
93c13cf4e7 | ||
|
|
3687254d05 | ||
|
|
7180fc96fc | ||
|
|
3959932434 | ||
|
|
bee7a23b66 | ||
|
|
cb195c983d | ||
|
|
1f30e8e747 | ||
|
|
a1f63d7964 | ||
|
|
d72352b23d | ||
|
|
450a59d0f7 | ||
|
|
38ebf5f677 | ||
|
|
340bbd24b6 | ||
|
|
5cec67c0d4 | ||
|
|
25dcf4d60f | ||
|
|
448e7fe468 | ||
|
|
c6f134a4c7 | ||
|
|
cb138521d5 | ||
|
|
1ea7d64cf3 | ||
|
|
c79454f8ef | ||
|
|
e0bbc694d7 | ||
|
|
8c35e9c8b2 | ||
|
|
b60a5cfe0e | ||
|
|
1bccebf836 | ||
|
|
df2a1f6769 | ||
|
|
caeef8eb94 | ||
|
|
0b8457466e | ||
|
|
ece69f0930 | ||
|
|
064f04c1e9 | ||
|
|
2332e65698 | ||
|
|
b94404c2f2 | ||
|
|
c32febd274 | ||
|
|
02c7d24ec4 | ||
|
|
fc65474e6b | ||
|
|
040e48f927 | ||
|
|
9765c5db5e | ||
|
|
9c36cb3a3b | ||
|
|
f4010fc6b6 | ||
|
|
e778a376b5 | ||
|
|
de77acf72d | ||
|
|
e2c861d525 | ||
|
|
115de56961 | ||
|
|
2350eb91b1 | ||
|
|
3c3b000d81 | ||
|
|
67baf20bd2 | ||
|
|
f4a14f3325 | ||
|
|
c6446343b8 | ||
|
|
d5544f8368 | ||
|
|
b634815e3a | ||
|
|
7d21a467c4 | ||
|
|
8ae2737bf8 | ||
|
|
74f8239944 | ||
|
|
22ba1cdf2d | ||
|
|
438f1e7d4a | ||
|
|
82a0084226 | ||
|
|
d75669c7c6 | ||
|
|
f8e132334a | ||
|
|
e14e031993 | ||
|
|
ad01fab3d6 | ||
|
|
80d7b978c0 | ||
|
|
83b530f0d3 | ||
|
|
27f0dd00fc | ||
|
|
70a21569fd | ||
|
|
d9a3e7b930 | ||
|
|
a440eed706 | ||
|
|
ec66acdf93 | ||
|
|
d51242c956 | ||
|
|
e64d1e0b49 | ||
|
|
678a7f6eb5 | ||
|
|
15507b8e22 | ||
|
|
d76f5e1594 | ||
|
|
c1c5676e69 | ||
|
|
b1e369773c | ||
|
|
f02c20db16 | ||
|
|
f47ee0f2fb | ||
|
|
6c9e696290 | ||
|
|
c57dc18a7a | ||
|
|
2ca0e9b771 | ||
|
|
21f226e608 | ||
|
|
e9b5b44c1d | ||
|
|
c1150171e3 | ||
|
|
1b55821b6f | ||
|
|
5c7992e840 | ||
|
|
8325582049 | ||
|
|
02107977f4 | ||
|
|
4215adc58a | ||
|
|
d06ffdddd5 | ||
|
|
1a140858c5 | ||
|
|
b96b977897 | ||
|
|
e795d822f6 | ||
|
|
9f33c8c45e | ||
|
|
6858c06de7 | ||
|
|
e715e2eac3 | ||
|
|
4342f7033f | ||
|
|
fad7beb5f8 | ||
|
|
b08c7b0ea9 | ||
|
|
23618027e5 | ||
|
|
1ade11a727 | ||
|
|
5a2df57570 | ||
|
|
ed121465e6 | ||
|
|
900cf4c064 | ||
|
|
ad67dd9d1b | ||
|
|
5f4dc69ebf | ||
|
|
02aa56aaa8 | ||
|
|
1a06a37546 | ||
|
|
e76f82b095 | ||
|
|
ce3a71736d | ||
|
|
af7e4166e6 | ||
|
|
fb2bab37ca | ||
|
|
6f073db2ab | ||
|
|
7cd0830d47 | ||
|
|
653d49bdb2 | ||
|
|
5add43c159 | ||
|
|
343d100610 | ||
|
|
5cd4b0347d | ||
|
|
13404bd977 | ||
|
|
ff55e2dfa2 | ||
|
|
ecc8138c11 | ||
|
|
d2020b1309 | ||
|
|
7df661b5ef | ||
|
|
5d79d0f450 | ||
|
|
bc2c691ba4 | ||
|
|
fe1dceced9 | ||
|
|
81bb10fae1 | ||
|
|
55c3d81bbc | ||
|
|
ffd73e115e | ||
|
|
1a33ce5692 | ||
|
|
6e2ed58a34 | ||
|
|
2be1dba2da | ||
|
|
a717691db9 | ||
|
|
dfdbfbd4d8 | ||
|
|
7b7af1a78c | ||
|
|
59526e5156 | ||
|
|
935200d9e8 | ||
|
|
9006ff804c | ||
|
|
c57cf43aeb | ||
|
|
a9e2a12927 | ||
|
|
97dee44a3d | ||
|
|
2192bfb20c | ||
|
|
5516d5a7d8 | ||
|
|
d10871190c | ||
|
|
ccdd8a721f | ||
|
|
941b66d0fc | ||
|
|
008d5c5e35 | ||
|
|
18abe2b8b0 | ||
|
|
e43caacac5 | ||
|
|
b3056b2326 | ||
|
|
4f2a495578 | ||
|
|
d3e45aff25 | ||
|
|
0fe09f3a1d | ||
|
|
3d18fee663 | ||
|
|
ee9e753baf | ||
|
|
da3359c59b | ||
|
|
d7f0055d5e | ||
|
|
f5a010780c | ||
|
|
fbfdf8bdbe | ||
|
|
b238636ce4 | ||
|
|
a6b496de58 | ||
|
|
9edb081dab | ||
|
|
95ec1dd78b | ||
|
|
421dd778cd | ||
|
|
0c199e0c25 | ||
|
|
878baaae07 | ||
|
|
373c1e773c | ||
|
|
9bd7e7f104 | ||
|
|
824975d344 | ||
|
|
04c74845ea | ||
|
|
65192c35a5 | ||
|
|
c39c9e2155 | ||
|
|
8a36ceab41 | ||
|
|
b9b482df5e | ||
|
|
d2f075edeb | ||
|
|
1d90810a02 | ||
|
|
3336055f25 | ||
|
|
65d5a9b8ed | ||
|
|
5e45f2481a | ||
|
|
c8bf792009 | ||
|
|
3fd20922ce | ||
|
|
879debe477 | ||
|
|
bdf9629c4b | ||
|
|
23fb0e3b3d | ||
|
|
fc0ddd5082 | ||
|
|
8d5b0e79f5 | ||
|
|
786395c23e | ||
|
|
8d83e5322f | ||
|
|
b610ed563d | ||
|
|
c671e7aee7 | ||
|
|
cf0b55cbf6 | ||
|
|
3536abdb11 | ||
|
|
8d76564db6 | ||
|
|
86b7f399ba | ||
|
|
28e8874cb4 | ||
|
|
723f0fabcf | ||
|
|
81742a513c | ||
|
|
369fbf83b4 | ||
|
|
465882cad6 | ||
|
|
ece5895330 | ||
|
|
1be3ce4c17 | ||
|
|
6891c67dc7 | ||
|
|
6bcb1b3f6a | ||
|
|
cfb6e5436d | ||
|
|
c9d5f416e6 | ||
|
|
41a6830338 | ||
|
|
a8d4af21e8 | ||
|
|
9d1e1387fa | ||
|
|
86acd819a4 | ||
|
|
457f44d28b | ||
|
|
63599d05ec | ||
|
|
5df01bceb6 | ||
|
|
20e70f1e49 | ||
|
|
8deaa510f0 | ||
|
|
9911440dba | ||
|
|
30b102ea55 | ||
|
|
236520bc6d | ||
|
|
0bbc7aa3fe | ||
|
|
143c407f8c | ||
|
|
43b70aeab4 | ||
|
|
ba5ae25730 | ||
|
|
4d752dbe46 | ||
|
|
d546028843 | ||
|
|
e89f4b86c3 | ||
|
|
f133f0354b | ||
|
|
b6fdbb89dd | ||
|
|
856f2fd3f9 | ||
|
|
b1ca562c42 | ||
|
|
31afb4a8e6 | ||
|
|
4ad336d7e7 | ||
|
|
349281ede0 | ||
|
|
4974aa9755 | ||
|
|
aab5763204 | ||
|
|
59996652db | ||
|
|
c02541014b | ||
|
|
dcc5616f94 | ||
|
|
c66e7f5e17 | ||
|
|
b44514d8b1 | ||
|
|
d7ce590b16 | ||
|
|
92f8237356 | ||
|
|
20c01d470a | ||
|
|
ed488806dc | ||
|
|
2ca2a9b53c | ||
|
|
8372c30ed1 | ||
|
|
dbf09924f9 | ||
|
|
a563a06e24 | ||
|
|
e96af1ff8d | ||
|
|
201424f7f7 | ||
|
|
f8223c51b4 | ||
|
|
0290335f25 | ||
|
|
c438c5532c | ||
|
|
9044392d84 | ||
|
|
cd355b6e4a | ||
|
|
0485537024 | ||
|
|
416dd550d2 | ||
|
|
c8054adf8e | ||
|
|
a345688087 | ||
|
|
ac625731ba | ||
|
|
ea10a83fd0 | ||
|
|
2c5ed024f5 | ||
|
|
61337d9940 | ||
|
|
d83661f9b0 | ||
|
|
b7eba48e91 | ||
|
|
d97c576e03 | ||
|
|
ddc48fd74b | ||
|
|
35ba5eb42d | ||
|
|
d8dbd38070 | ||
|
|
1d1d8730ff | ||
|
|
38e7d7a628 | ||
|
|
adb00dd7fa | ||
|
|
a20d34351f | ||
|
|
2e8634d735 | ||
|
|
b58734ee4e | ||
|
|
87823024e6 | ||
|
|
557a387518 | ||
|
|
0437ca8982 | ||
|
|
8feb1adb9b | ||
|
|
2a3d9b7d61 | ||
|
|
2b191c3dc5 | ||
|
|
28ccd2db41 | ||
|
|
f87b34184a | ||
|
|
e598d482e7 | ||
|
|
38632c72d7 | ||
|
|
16936dc139 | ||
|
|
c2ebf7bd56 | ||
|
|
efb2418dfa | ||
|
|
d0d73fcc0f | ||
|
|
b47f0e45d2 | ||
|
|
1cd7705536 | ||
|
|
95888d14fd | ||
|
|
e506bf7de2 | ||
|
|
091995c737 | ||
|
|
4234059fa6 | ||
|
|
d2464b59c4 | ||
|
|
bbda70c670 | ||
|
|
e4f6eb117b | ||
|
|
ad37cd5302 | ||
|
|
bcddb2963b | ||
|
|
0e41cd7947 | ||
|
|
02cd4622d9 | ||
|
|
29bca265b3 | ||
|
|
b1c2bc6756 | ||
|
|
361e56c298 | ||
|
|
25c2b4ec91 | ||
|
|
c05f72c582 | ||
|
|
0392ffed05 | ||
|
|
c442b018c4 | ||
|
|
e9da355450 | ||
|
|
acee6497d3 | ||
|
|
abac8621e9 | ||
|
|
a39b4b1cac | ||
|
|
6a3cd375cb | ||
|
|
dffb4787e6 | ||
|
|
9862aa5f84 | ||
|
|
281caed006 | ||
|
|
1023ea7bcb |
No files matched your search
@@ -15,7 +15,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Cheap path gate for pull requests. PRs confined to docs/release-notes
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/cli-archive-contents.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
|
||||
@@ -900,12 +900,31 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# The page every archive carries beside the executable, built once here
|
||||
# rather than once per target: serve.FindPage looks for
|
||||
# frontend-next/dist next to the binary.
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- name: Build the page
|
||||
working-directory: desktop/frontend-next
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
|
||||
# Every target the release line claims to support, from one runner: the
|
||||
# CLI is CGO-free, so none of them needs a toolchain of its own.
|
||||
- name: Cross-compile and archive
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SHA: ${{ needs.resolve.outputs.sha }}
|
||||
PAGE_DIR: desktop/frontend-next/dist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
@@ -917,11 +936,7 @@ jobs:
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
|
||||
-o "reasonix$ext" ./cmd/reasonix
|
||||
if [ "$os" = windows ]; then
|
||||
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
|
||||
else
|
||||
tar czf "dist/reasonix-$os-$arch.tar.gz" "reasonix$ext"
|
||||
fi
|
||||
scripts/package-cli-archive.sh "$os" "$arch" "reasonix$ext" "$PAGE_DIR" dist
|
||||
rm -f "reasonix$ext"
|
||||
done
|
||||
# The digest list the remote verifies against. Resolved on the
|
||||
@@ -933,13 +948,7 @@ jobs:
|
||||
# A release that published the archives without the digest list is worse
|
||||
# than one that published neither: the routes resolve SHA256SUMS first.
|
||||
- name: Check the set is complete
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for want in reasonix-darwin-amd64.tar.gz reasonix-darwin-arm64.tar.gz \
|
||||
reasonix-linux-amd64.tar.gz reasonix-linux-arm64.tar.gz \
|
||||
reasonix-windows-amd64.zip reasonix-windows-arm64.zip SHA256SUMS; do
|
||||
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
|
||||
done
|
||||
run: python3 scripts/check-cli-archives.py dist
|
||||
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
|
||||
@@ -16,7 +16,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: studio-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
@@ -141,27 +141,111 @@ jobs:
|
||||
node perf/stickyfold.mjs
|
||||
node perf/crumb.mjs
|
||||
node perf/hoverradius.mjs
|
||||
node perf/jump-ends.mjs
|
||||
node perf/modelrows.mjs
|
||||
node perf/explorersize.mjs
|
||||
node perf/rowhit.mjs
|
||||
node perf/tab-pip.mjs
|
||||
node perf/context-card-hover.mjs
|
||||
node perf/sessquick.mjs
|
||||
node perf/sessionrows.mjs
|
||||
node perf/wheel.mjs
|
||||
node perf/branch-tooltip.mjs
|
||||
node perf/branch-menu.mjs
|
||||
node perf/image-preview.mjs
|
||||
node perf/user-images.mjs
|
||||
node perf/toast-anchor.mjs
|
||||
node perf/addfolder-rest.mjs
|
||||
node perf/scroll-jump.mjs
|
||||
node perf/add-project-selects.mjs
|
||||
node perf/setup-defer.mjs
|
||||
node perf/search-toggle.mjs
|
||||
node perf/narrow.mjs
|
||||
node perf/settings-rows.mjs
|
||||
node perf/narrow-steps.mjs
|
||||
node perf/job-names.mjs
|
||||
node perf/text-size.mjs
|
||||
node perf/fs-scale.mjs
|
||||
node perf/composer-feel.mjs
|
||||
node perf/side-panel.mjs
|
||||
node perf/card-shell.mjs
|
||||
node perf/line-cards.mjs
|
||||
node perf/detail-panel.mjs
|
||||
node perf/brand-lockup.mjs
|
||||
node perf/notice-line.mjs
|
||||
node perf/brought-line.mjs
|
||||
node perf/modal-focus.mjs
|
||||
node perf/focus-ring.mjs
|
||||
node perf/narrow-forms.mjs
|
||||
node perf/i18n-rendered.mjs
|
||||
node perf/narrow-rail.mjs
|
||||
node perf/selfcheck.mjs branch-tooltip
|
||||
|
||||
# What a hosted page does over the relay, end to end: the production bundle,
|
||||
# the real Studio host and kernel, a real Chromium; only the accounts API and
|
||||
# the relay are stand-ins (tools/relayfake). Event-transport changes are
|
||||
# judged here, because a unit test of one side cannot see the other.
|
||||
relay-harness:
|
||||
name: relay harness
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Transport changed?
|
||||
id: scope
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
paths="desktop/frontend-next internal/frontend/serve internal/platform/remotecloud internal/platform/account tools/relayfake cmd/reasonix-studio-host"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] &&
|
||||
git diff --quiet "origin/$BASE_REF...HEAD" -- $paths; then
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
name: Build the host, the stand-ins and the page
|
||||
run: |
|
||||
go build -o desktop/frontend-next/relay/bin/studio-host ./cmd/reasonix-studio-host
|
||||
go build -o desktop/frontend-next/relay/bin/relayfake ./tools/relayfake
|
||||
cd desktop/frontend-next
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec vite build --outDir dist-relay --emptyOutDir
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
name: Scenarios (hosted page over the relay; local page on a flapping network)
|
||||
working-directory: desktop/frontend-next
|
||||
env:
|
||||
RELAY_HOST_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/studio-host
|
||||
RELAY_FAKE_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/relayfake
|
||||
RELAY_DIST: ${{ github.workspace }}/desktop/frontend-next/dist-relay
|
||||
run: |
|
||||
node relay/live.mjs
|
||||
node relay/storm.mjs
|
||||
|
||||
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
|
||||
# moves the Windows and macOS legs onto an ubuntu runner with every step
|
||||
# skipped, so the check names still report without a scarce runner. A push to
|
||||
|
||||
@@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/
|
||||
# running, which on Windows is every rebuild while Studio is open.
|
||||
*.exe
|
||||
*.exe~
|
||||
/relayfake
|
||||
@@ -0,0 +1,35 @@
|
||||
# Compaction prefix probe
|
||||
|
||||
## Method
|
||||
|
||||
1. Measured on 2026-10-17 against the official `https://api.deepseek.com` OpenAI adapter, using the model returned by `/models`: `deepseek-flash`. Thinking was disabled. Inputs were synthetic; no workspace history or credentials are recorded here.
|
||||
2. Each of three text and three rich-history trials sent a main request to warm the prefix, then called the actual agent summary path, then an independent summary control. Rich history included a synthetic closed `read_file` call, result and reasoning. Warmup outputs were discarded, never executed.
|
||||
3. A summary had a 1,024-token output cap; warmups had 64. Success means a heading, preservation of the fixture's `ParseDuration` and zero-value constraint, no emitted tool calls, and `finish_reason=stop`. This checks the fixture's contract, not arbitrary-summary factual completeness.
|
||||
4. First-attempt reuse success counts successful reused summaries / six reused summaries. Fallback rate counts independent requests initiated by the reuse coordinator / six reuse trials; the six explicit independent controls are excluded. Both were 6/6 success and 0/6 fallbacks. Independent controls also succeeded 6/6.
|
||||
5. The raw result file records provider-reported usage and full stream latency, measured from adapter `Stream` until channel close. Token rows below sum only the three indicated summary requests; they exclude warmup, model listing and any earlier exploratory requests. Cache residency was uncontrolled; prior probes had warmed these synthetic prefixes.
|
||||
|
||||
## Results
|
||||
|
||||
| History | Shape | N | Prompt | Hit | Miss | Output | Median ms | Estimated USD |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| Text | Reused | 3 | 3,714 | 3,072 | 642 | 636 | 1,713 | 0.000487116 |
|
||||
| Text | Independent | 3 | 1,218 | 768 | 450 | 644 | 1,610 | 0.000456204 |
|
||||
| Rich | Reused | 3 | 3,780 | 3,072 | 708 | 742 | 1,774 | 0.000560616 |
|
||||
| Rich | Independent | 3 | 1,341 | 768 | 573 | 921 | 1,815 | 0.000640854 |
|
||||
|
||||
1. Estimates use the [official pricing](https://api-docs.deepseek.com/quick_start/pricing/) checked on the measurement date: off-peak Flash USD per million tokens, hit 0.003, miss 0.15, output 0.6. October 17 was a Saturday. Formula: `(hit*0.003 + miss*0.15 + output*0.6)/1_000_000`. These are token estimates, not account deductions.
|
||||
2. Reuse hit fractions were 82.7% (text) and 81.3% (rich), versus 63.1% and 57.3% for controls. This small fixture does **not** establish cost savings: retaining the system/catalog increased both total input and input cost. Text total cost increased 6.8%; rich total decreased 12.5% because output was shorter, not because input was cheaper.
|
||||
3. The six warmups consumed 7,032 prompt and 231 output tokens, estimated USD 0.000346680. All 18 recorded generation calls together cost an estimated USD 0.002491470. No failed-digest retry occurred in these live trials; the deterministic fallback tests establish its behavior and accounting.
|
||||
4. A rejected nonempty reused answer earns one independent-shape fallback. A separate truncation retry may enlarge the output cap, for at most three logical model calls total. Every actual attempt emits and records its own spend; returned usage is their sum. Empty output, stream errors and cancellation do not earn shape retries.
|
||||
5. These sequential, six-pair trials establish interoperability for one model, mode and short fixture. They do not estimate production failure probability, thinking-mode behavior, other models, cold-cache cost, or large-history savings.
|
||||
|
||||
## Reproduction
|
||||
|
||||
1. Provide a test key through an existing secret environment configuration. Do not put credentials in source, command arguments or captured logs. Select a model currently returned by `/models`.
|
||||
2. Run the opt-in probe; it skips without `DEEPSEEK_API_KEY`. The dedicated build tag avoids enabling unrelated live tests.
|
||||
|
||||
```sh
|
||||
REASONIX_LIVE_MODEL=deepseek-flash go test -tags live_compaction ./internal/runtime/agent -run '^TestLiveCompactionPrefix$' -v -count=1
|
||||
```
|
||||
|
||||
3. This probe exercises actual summary construction and the HTTP adapter. The hermetic `TestEffectCompactionSharesThePreviouslySentPrefix` separately exercises manual, automatic pressure and rich history through `boot.Build`, comparing actual JSON request-prefix and schema bytes. Its sparse-history case also proves an interleaved retained user turn survives the fold.
|
||||
@@ -0,0 +1,512 @@
|
||||
[
|
||||
{
|
||||
"model": "deepseek-flash",
|
||||
"rich": false,
|
||||
"trial": 1,
|
||||
"results": [
|
||||
{
|
||||
"kind": "warm",
|
||||
"milliseconds": 828,
|
||||
"digest": false,
|
||||
"grounded": false,
|
||||
"tool_calls": 1,
|
||||
"usage": {
|
||||
"PromptTokens": 1127,
|
||||
"CompletionTokens": 38,
|
||||
"TotalTokens": 1165,
|
||||
"CacheHitTokens": 896,
|
||||
"CacheMissTokens": 231,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "tool_calls",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "reuse",
|
||||
"milliseconds": 2006,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 1238,
|
||||
"CompletionTokens": 234,
|
||||
"TotalTokens": 1472,
|
||||
"CacheHitTokens": 1024,
|
||||
"CacheMissTokens": 214,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "independent",
|
||||
"milliseconds": 1817,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 406,
|
||||
"CompletionTokens": 234,
|
||||
"TotalTokens": 640,
|
||||
"CacheHitTokens": 256,
|
||||
"CacheMissTokens": 150,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"model": "deepseek-flash",
|
||||
"rich": false,
|
||||
"trial": 2,
|
||||
"results": [
|
||||
{
|
||||
"kind": "warm",
|
||||
"milliseconds": 820,
|
||||
"digest": false,
|
||||
"grounded": false,
|
||||
"tool_calls": 1,
|
||||
"usage": {
|
||||
"PromptTokens": 1127,
|
||||
"CompletionTokens": 38,
|
||||
"TotalTokens": 1165,
|
||||
"CacheHitTokens": 896,
|
||||
"CacheMissTokens": 231,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "tool_calls",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "reuse",
|
||||
"milliseconds": 1633,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 1238,
|
||||
"CompletionTokens": 187,
|
||||
"TotalTokens": 1425,
|
||||
"CacheHitTokens": 1024,
|
||||
"CacheMissTokens": 214,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "independent",
|
||||
"milliseconds": 1555,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 406,
|
||||
"CompletionTokens": 202,
|
||||
"TotalTokens": 608,
|
||||
"CacheHitTokens": 256,
|
||||
"CacheMissTokens": 150,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"model": "deepseek-flash",
|
||||
"rich": false,
|
||||
"trial": 3,
|
||||
"results": [
|
||||
{
|
||||
"kind": "warm",
|
||||
"milliseconds": 905,
|
||||
"digest": false,
|
||||
"grounded": false,
|
||||
"tool_calls": 1,
|
||||
"usage": {
|
||||
"PromptTokens": 1127,
|
||||
"CompletionTokens": 38,
|
||||
"TotalTokens": 1165,
|
||||
"CacheHitTokens": 896,
|
||||
"CacheMissTokens": 231,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "tool_calls",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "reuse",
|
||||
"milliseconds": 1713,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 1238,
|
||||
"CompletionTokens": 215,
|
||||
"TotalTokens": 1453,
|
||||
"CacheHitTokens": 1024,
|
||||
"CacheMissTokens": 214,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "independent",
|
||||
"milliseconds": 1610,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 406,
|
||||
"CompletionTokens": 208,
|
||||
"TotalTokens": 614,
|
||||
"CacheHitTokens": 256,
|
||||
"CacheMissTokens": 150,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"model": "deepseek-flash",
|
||||
"rich": true,
|
||||
"trial": 1,
|
||||
"results": [
|
||||
{
|
||||
"kind": "warm",
|
||||
"milliseconds": 561,
|
||||
"digest": false,
|
||||
"grounded": false,
|
||||
"tool_calls": 1,
|
||||
"usage": {
|
||||
"PromptTokens": 1217,
|
||||
"CompletionTokens": 39,
|
||||
"TotalTokens": 1256,
|
||||
"CacheHitTokens": 1024,
|
||||
"CacheMissTokens": 193,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "tool_calls",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "reuse",
|
||||
"milliseconds": 1774,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 1260,
|
||||
"CompletionTokens": 256,
|
||||
"TotalTokens": 1516,
|
||||
"CacheHitTokens": 1024,
|
||||
"CacheMissTokens": 236,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "independent",
|
||||
"milliseconds": 2271,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 447,
|
||||
"CompletionTokens": 381,
|
||||
"TotalTokens": 828,
|
||||
"CacheHitTokens": 256,
|
||||
"CacheMissTokens": 191,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"model": "deepseek-flash",
|
||||
"rich": true,
|
||||
"trial": 2,
|
||||
"results": [
|
||||
{
|
||||
"kind": "warm",
|
||||
"milliseconds": 610,
|
||||
"digest": false,
|
||||
"grounded": false,
|
||||
"tool_calls": 1,
|
||||
"usage": {
|
||||
"PromptTokens": 1217,
|
||||
"CompletionTokens": 39,
|
||||
"TotalTokens": 1256,
|
||||
"CacheHitTokens": 1024,
|
||||
"CacheMissTokens": 193,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "tool_calls",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "reuse",
|
||||
"milliseconds": 2263,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 1260,
|
||||
"CompletionTokens": 276,
|
||||
"TotalTokens": 1536,
|
||||
"CacheHitTokens": 1024,
|
||||
"CacheMissTokens": 236,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "independent",
|
||||
"milliseconds": 1783,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 447,
|
||||
"CompletionTokens": 242,
|
||||
"TotalTokens": 689,
|
||||
"CacheHitTokens": 256,
|
||||
"CacheMissTokens": 191,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"model": "deepseek-flash",
|
||||
"rich": true,
|
||||
"trial": 3,
|
||||
"results": [
|
||||
{
|
||||
"kind": "warm",
|
||||
"milliseconds": 834,
|
||||
"digest": false,
|
||||
"grounded": false,
|
||||
"tool_calls": 1,
|
||||
"usage": {
|
||||
"PromptTokens": 1217,
|
||||
"CompletionTokens": 39,
|
||||
"TotalTokens": 1256,
|
||||
"CacheHitTokens": 1024,
|
||||
"CacheMissTokens": 193,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "tool_calls",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "reuse",
|
||||
"milliseconds": 1575,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 1260,
|
||||
"CompletionTokens": 210,
|
||||
"TotalTokens": 1470,
|
||||
"CacheHitTokens": 1024,
|
||||
"CacheMissTokens": 236,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "independent",
|
||||
"milliseconds": 1815,
|
||||
"digest": true,
|
||||
"grounded": true,
|
||||
"tool_calls": 0,
|
||||
"usage": {
|
||||
"PromptTokens": 447,
|
||||
"CompletionTokens": 298,
|
||||
"TotalTokens": 745,
|
||||
"CacheHitTokens": 256,
|
||||
"CacheMissTokens": 191,
|
||||
"CacheWriteTokens": 0,
|
||||
"CacheWriteBilledTokens": 0,
|
||||
"ReasoningTokens": 0,
|
||||
"FinishReason": "stop",
|
||||
"Estimated": false,
|
||||
"RequestCount": 1,
|
||||
"ServerToolRequests": 0,
|
||||
"ContextPromptTokens": 0,
|
||||
"ContextCompletionTokens": 0,
|
||||
"ContextReasoningTokens": 0,
|
||||
"ContextCacheHitTokens": 0,
|
||||
"ContextCacheMissTokens": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runBlockedBatch runs one dry index batch where every arm's runOne fails
|
||||
// before any model turn: the task's own directory is a regular file, so the
|
||||
// fixture cannot be built. In production the same early return happens when the
|
||||
// fixture build, the session load, or the answer-on-disk guard fails.
|
||||
func runBlockedBatch(t *testing.T) string {
|
||||
t.Helper()
|
||||
tk := indexTasks()[0]
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, tk.ID), []byte("not a directory\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := runExperiment("run-index", experimentIndex, root, true, []contextTask{tk}); code != 0 {
|
||||
t.Fatalf("runExperiment exit = %d, want 0 (every run failed, but the batch finished)", code)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func readResultLines(t *testing.T, root string) ([]contextMetrics, []string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(root, "results.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runs []contextMetrics
|
||||
var lines []string
|
||||
for text := range strings.SplitSeq(strings.TrimSpace(string(raw)), "\n") {
|
||||
var m contextMetrics
|
||||
if err := json.Unmarshal([]byte(text), &m); err != nil {
|
||||
t.Fatalf("results.jsonl: %v", err)
|
||||
}
|
||||
runs = append(runs, m)
|
||||
lines = append(lines, text)
|
||||
}
|
||||
return runs, lines
|
||||
}
|
||||
|
||||
// A run that failed before the model turn produced no measurement. Recording it
|
||||
// as a zero-valued contextMetrics puts it in the funnel's Scored denominator,
|
||||
// in "Where it broke", and in the dose curve as a real zero.
|
||||
func TestBatchDoesNotRecordAFailedRunAsAResult(t *testing.T) {
|
||||
root := runBlockedBatch(t)
|
||||
runs, lines := readResultLines(t, root)
|
||||
if len(runs) == 0 {
|
||||
t.Fatal("no runs recorded at all")
|
||||
}
|
||||
for i, m := range runs {
|
||||
if m.Task == "" || m.Arm == "" || m.FailureStage == "" {
|
||||
t.Fatalf("results.jsonl line %d records a run that never ran as a result "+
|
||||
"(task=%q arm=%q failure_stage=%q):\n %s", i+1, m.Task, m.Arm, m.FailureStage, lines[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest is the artefact that says which runs counted, so a failed run
|
||||
// must not be added to CompletedRuns or to the task id list.
|
||||
func TestBatchManifestDoesNotReportFailedRunsAsCompleted(t *testing.T) {
|
||||
root := runBlockedBatch(t)
|
||||
var manifest batchManifest
|
||||
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range manifest.TaskIDs {
|
||||
if id == "" {
|
||||
t.Fatalf("manifest lists an empty task id: task_ids=%q (completed=%d planned=%d)",
|
||||
manifest.TaskIDs, manifest.CompletedRuns, manifest.PlannedRuns)
|
||||
}
|
||||
}
|
||||
if manifest.CompletedRuns == manifest.PlannedRuns {
|
||||
t.Fatalf("manifest reports every planned run completed although none produced evidence: "+
|
||||
"completed=%d planned=%d task_ids=%q", manifest.CompletedRuns, manifest.PlannedRuns, manifest.TaskIDs)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ type batchManifest struct {
|
||||
Arms []string `json:"arms"`
|
||||
PlannedRuns int `json:"planned_runs"`
|
||||
CompletedRuns int `json:"completed_runs"`
|
||||
// FailedRuns counts the planned runs that ended before a transcript existed.
|
||||
// They are rows in results.jsonl, never part of CompletedRuns, and the field
|
||||
// is absent when there were none so an intact batch is unchanged.
|
||||
FailedRuns int `json:"failed_runs,omitempty"`
|
||||
// Usage totals are absent when nothing reported any: an unmeasured cost must
|
||||
// not read as a measured zero. UsageReportedRuns stays, because zero there
|
||||
// is a fact about the batch.
|
||||
@@ -98,9 +102,15 @@ func sortedKeys(set map[string]bool) []string {
|
||||
// last, so its digests are of what is already on disk.
|
||||
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
|
||||
ids, arms := map[string]bool{}, map[string]bool{}
|
||||
var prompt, completion, cacheHit, cacheMiss, requests, reported int
|
||||
var prompt, completion, cacheHit, cacheMiss, requests, reported, failed int
|
||||
estimated := false
|
||||
for _, m := range all {
|
||||
// A failed run has no task, arm or evidence to contribute: it is counted
|
||||
// as a failure below and is not part of what the batch measured.
|
||||
if m.Failed {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
ids[m.Task] = true
|
||||
arms[m.Arm] = true
|
||||
if m.UsageReportedRequests > 0 {
|
||||
@@ -125,7 +135,7 @@ func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error
|
||||
GitCommit: commit, GitDirty: dirty,
|
||||
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
|
||||
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
|
||||
PlannedRuns: info.Planned, CompletedRuns: len(all),
|
||||
PlannedRuns: info.Planned, CompletedRuns: len(all) - failed, FailedRuns: failed,
|
||||
UsageReportedRuns: reported, UsageReportedRequests: requests,
|
||||
PromptTokens: prompt, CompletionTokens: completion,
|
||||
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// boundaryRuns builds the input reportBoundaries reads: one scored run for an
|
||||
// index task's cue side, and one failed run for another index task's cue side.
|
||||
// The runs are constructed because the report is a pure function of the slice;
|
||||
// the batch path that produces them is covered by the failed-batch tests.
|
||||
func boundaryRuns(t *testing.T) (all []contextMetrics, scoredTask, failedTask string) {
|
||||
t.Helper()
|
||||
index := indexTasks()
|
||||
if len(index) < 2 {
|
||||
t.Fatalf("need at least two index tasks, have %d", len(index))
|
||||
}
|
||||
scored, failed := index[0], index[1]
|
||||
cue, noCue := boundaryPair(scored.CueTier)
|
||||
if cue == "" || noCue == "" {
|
||||
t.Fatalf("task %s has an empty boundary pair (%q, %q)", scored.ID, cue, noCue)
|
||||
}
|
||||
failedCue, _ := boundaryPair(failed.CueTier)
|
||||
if failedCue == "" {
|
||||
t.Fatalf("task %s has no cue side", failed.ID)
|
||||
}
|
||||
all = []contextMetrics{
|
||||
{Task: scored.ID, Arm: "index-" + cue, AnswerRecovered: true, SearchCalls: 1, RecallReturnedTokens: 318},
|
||||
{Task: scored.ID, Arm: "index-" + noCue, AnswerRecovered: false},
|
||||
{Task: failed.ID, Arm: "index-" + failedCue, Failed: true, FailureStage: stageNoEvidence},
|
||||
}
|
||||
return all, scored.ID, failed.ID
|
||||
}
|
||||
|
||||
// TestBoundaryReportExcludesFailedRuns: a run that never produced a transcript
|
||||
// must not appear in the cue-boundary table, which was the last reporting exit
|
||||
// that still folded it in as a zero measurement.
|
||||
func TestBoundaryReportExcludesFailedRuns(t *testing.T) {
|
||||
all, scoredTask, failedTask := boundaryRuns(t)
|
||||
out := captureStdout(t, func() { reportBoundaries(all) })
|
||||
if strings.Contains(out, failedTask) {
|
||||
t.Fatalf("the boundary table printed the failed task %q as a measurement:\n%s", failedTask, out)
|
||||
}
|
||||
if !strings.Contains(out, scoredTask) {
|
||||
t.Fatalf("the boundary table dropped the scored task %q, so the exclusion is not specific to failures:\n%s", scoredTask, out)
|
||||
}
|
||||
if !strings.Contains(out, "cue-present") || !strings.Contains(out, "cue-absent") {
|
||||
t.Fatalf("the pooled comparison disappeared:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed: with no scored run
|
||||
// the report must say so, rather than printing a comparison of zeros.
|
||||
func TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed(t *testing.T) {
|
||||
all, _, _ := boundaryRuns(t)
|
||||
failedOnly := make([]contextMetrics, 0, len(all))
|
||||
for _, m := range all {
|
||||
m.Failed = true
|
||||
m.FailureStage = stageNoEvidence
|
||||
failedOnly = append(failedOnly, m)
|
||||
}
|
||||
out := captureStdout(t, func() { reportBoundaries(failedOnly) })
|
||||
if !strings.Contains(out, "scored=0") || !strings.Contains(out, "failed=") {
|
||||
t.Fatalf("an all-failed batch did not report scored=0 with a failed count:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "cue-present") || strings.Contains(out, "cue-absent") {
|
||||
t.Fatalf("an all-failed batch still printed a pooled comparison of zeros:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
// canonical position, and never the model's prose. "I will search the history"
|
||||
// is not a search; a recall call with a query is.
|
||||
|
||||
// failure stages, in funnel order. Exactly one is reported per run.
|
||||
// failure stages, in funnel order. Exactly one is reported per run, except for
|
||||
// NoEvidence: that stage belongs to a run with no measurement at all.
|
||||
const (
|
||||
stageReadMissed = "NoSearch" // never asked, and never read the target either
|
||||
stageSearchMiss = "SearchMiss" // searched, target never came back
|
||||
@@ -26,6 +27,7 @@ const (
|
||||
stageAnswerWrong = "ReadButAnswerWrong" // opened it and still answered wrong
|
||||
stageRecovered = "Recovered"
|
||||
stageContaminated = "Contaminated"
|
||||
stageNoEvidence = "NoEvidence"
|
||||
|
||||
// Routing is judged by model round, not by the linear order of tool events:
|
||||
// two calls in one round are one parallel fan-out, and calling that
|
||||
@@ -116,6 +118,12 @@ type contextMetrics struct {
|
||||
FailureStage string `json:"failure_stage"`
|
||||
TimeoutReason string `json:"timeout_reason,omitempty"`
|
||||
|
||||
// Failed marks a run that ended before a transcript existed: it has no score
|
||||
// and no place in the statistics. The row stays in results.jsonl with its
|
||||
// task, arm and stage, so a failure is counted rather than dropped.
|
||||
Failed bool `json:"failed,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
|
||||
// UnexpectedWorkTools are investigation tools used instead of recall.
|
||||
// Reaching for the workspace is behaviour, not noise: how a model routes
|
||||
// its uncertainty when memory is missing is part of what is measured.
|
||||
@@ -462,6 +470,12 @@ type funnel struct {
|
||||
func summarize(arm string, runs []contextMetrics) funnel {
|
||||
f := funnel{Arm: arm, Runs: len(runs), Stages: map[string]int{}, Routes: map[string]int{}}
|
||||
for _, r := range runs {
|
||||
// A run that never produced a transcript is not a scored run and has no
|
||||
// stage to place: counting it would put an empty name in the funnel, the
|
||||
// stage table and the routes.
|
||||
if r.Failed {
|
||||
continue
|
||||
}
|
||||
if r.Contaminated {
|
||||
f.Contaminated++
|
||||
f.Stages[r.FailureStage]++
|
||||
@@ -631,7 +645,7 @@ func reportStopping(all []contextMetrics) string {
|
||||
var extraRounds, extraTokens []float64
|
||||
sufficient, answeredNext := 0, 0
|
||||
for _, m := range all {
|
||||
if m.Contaminated {
|
||||
if m.Failed || m.Contaminated {
|
||||
continue
|
||||
}
|
||||
classes[m.StoppingClass]++
|
||||
|
||||
@@ -121,7 +121,7 @@ var doseMetrics = []struct {
|
||||
func reportDoseResponse(all []contextMetrics) string {
|
||||
valid := map[string]map[string]contextMetrics{}
|
||||
for _, m := range all {
|
||||
if m.Contaminated || m.FailureStage == "RunError" {
|
||||
if m.Failed || m.Contaminated || m.FailureStage == "RunError" {
|
||||
continue
|
||||
}
|
||||
scale := strings.TrimPrefix(m.Arm, "index-")
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// blockTask makes one task fail before its first model turn: its own directory
|
||||
// is a regular file, so buildFixture cannot create the session under it. It is
|
||||
// the same early exit as a failed fixture build, session load, or
|
||||
// answer-on-disk guard, and it needs no provider.
|
||||
func blockTask(t *testing.T, root, id string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(root, id), []byte("not a directory\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mixedTaskPair is one task whose runs all fail and one whose runs all succeed.
|
||||
// Both are drawn from the corpus, so the batch is the real pipeline.
|
||||
func mixedTaskPair() (blocked, ok contextTask) {
|
||||
blocked, ok = indexTasks()[0], indexTasks()[1]
|
||||
for _, t := range indexTasks() {
|
||||
if t.Role == roleEfficiency && len(armsFor(t)) == 2 {
|
||||
ok = t
|
||||
break
|
||||
}
|
||||
}
|
||||
return blocked, ok
|
||||
}
|
||||
|
||||
// runBlockedExperiment runs one dry index batch over the given tasks, with the
|
||||
// named task failing before any model turn, and returns the batch directory, the
|
||||
// runs, the manifest, and everything the run printed.
|
||||
func runBlockedExperiment(t *testing.T, tasks []contextTask, blocked ...string) (string, []contextMetrics, batchManifest, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, id := range blocked {
|
||||
blockTask(t, root, id)
|
||||
}
|
||||
out := captureStdout(t, func() {
|
||||
if code := runExperiment("run-index", experimentIndex, root, true, tasks); code != 0 {
|
||||
t.Errorf("runExperiment exit = %d, want 0 (the batch finished)", code)
|
||||
}
|
||||
})
|
||||
runs, _ := readResultLines(t, root)
|
||||
manifest, _ := readManifest(t, root)
|
||||
return root, runs, manifest, out
|
||||
}
|
||||
|
||||
// captureStdout reads everything the function prints. os.Pipe is unbuffered, so
|
||||
// the reader must run beside the call.
|
||||
func captureStdout(t *testing.T, f func()) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := os.Stdout
|
||||
os.Stdout = w
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
var b strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
b.Write(buf[:n])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
done <- b.String()
|
||||
}()
|
||||
f()
|
||||
os.Stdout = old
|
||||
w.Close()
|
||||
text := <-done
|
||||
r.Close()
|
||||
return text
|
||||
}
|
||||
|
||||
// funnelScored reads the "runs=N" column of the named arm's funnel line out of
|
||||
// the printed tables, so the assertion is on the number a reader sees.
|
||||
func funnelScored(t *testing.T, out, arm string) int {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || fields[0] != arm || !strings.HasPrefix(fields[1], "runs=") {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(fields[1], "runs="))
|
||||
if err != nil {
|
||||
t.Fatalf("funnel line for %s: %v\n%s", arm, err, line)
|
||||
}
|
||||
return n
|
||||
}
|
||||
t.Fatalf("no funnel line for arm %s:\n%s", arm, out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// The batch report states both counts on one line, so a reader of the scrollback
|
||||
// sees the scored set and the failures without counting funnel rows.
|
||||
func reportCounts(t *testing.T, out string) (scored, failed int) {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
if !strings.Contains(line, " scored=") || !strings.Contains(line, " failed=") {
|
||||
continue
|
||||
}
|
||||
for f := range strings.FieldsSeq(line) {
|
||||
switch {
|
||||
case strings.HasPrefix(f, "scored="):
|
||||
scored, _ = strconv.Atoi(strings.TrimPrefix(f, "scored="))
|
||||
case strings.HasPrefix(f, "failed="):
|
||||
failed, _ = strconv.Atoi(strings.TrimPrefix(f, "failed="))
|
||||
}
|
||||
}
|
||||
return scored, failed
|
||||
}
|
||||
t.Fatalf("no line reports scored= and failed=:\n%s", out)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// A batch that mixes a task that never ran with one that did must read as its
|
||||
// successful runs only: the failed task leaves Scored, the funnel, and the
|
||||
// completed count, and is reported as a failure with its identity instead.
|
||||
// Every assertion here is a number, not the absence of an empty row.
|
||||
func TestMixedBatchCountsOnlyTheRunThatProducedEvidence(t *testing.T) {
|
||||
blocked, ok := mixedTaskPair()
|
||||
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked, ok}, blocked.ID)
|
||||
|
||||
wantScored, wantFailed := len(armsFor(ok)), len(armsFor(blocked))
|
||||
if manifest.CompletedRuns != wantScored || manifest.FailedRuns != wantFailed {
|
||||
t.Fatalf("manifest completed=%d failed=%d, want %d and %d (planned=%d)",
|
||||
manifest.CompletedRuns, manifest.FailedRuns, wantScored, wantFailed, manifest.PlannedRuns)
|
||||
}
|
||||
if len(runs) != manifest.CompletedRuns+manifest.FailedRuns {
|
||||
t.Fatalf("results.jsonl has %d rows but completed=%d failed=%d",
|
||||
len(runs), manifest.CompletedRuns, manifest.FailedRuns)
|
||||
}
|
||||
for _, id := range manifest.TaskIDs {
|
||||
if id == blocked.ID {
|
||||
t.Fatalf("task_ids lists %s, which produced no evidence: %v", blocked.ID, manifest.TaskIDs)
|
||||
}
|
||||
}
|
||||
scored, failures := 0, 0
|
||||
for _, m := range runs {
|
||||
if m.Failed {
|
||||
failures++
|
||||
if m.Task != blocked.ID || m.FailureStage == "" || m.Reason == "" {
|
||||
t.Fatalf("failed row lost its identity or stage: %+v", m)
|
||||
}
|
||||
continue
|
||||
}
|
||||
scored++
|
||||
if m.Task != ok.ID {
|
||||
t.Fatalf("a run scored from the wrong task: %+v", m)
|
||||
}
|
||||
}
|
||||
if scored != wantScored || failures != wantFailed {
|
||||
t.Fatalf("rows scored=%d failed=%d, want %d and %d", scored, failures, wantScored, wantFailed)
|
||||
}
|
||||
// Each arm's funnel holds exactly the successful task's runs, and the two
|
||||
// scored counts agree with each other and with the manifest.
|
||||
total := 0
|
||||
for _, arm := range armsFor(ok) {
|
||||
total += funnelScored(t, out, arm.name)
|
||||
}
|
||||
if total != manifest.CompletedRuns {
|
||||
t.Fatalf("funnels scored %d runs, manifest completed_runs = %d:\n%s", total, manifest.CompletedRuns, out)
|
||||
}
|
||||
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != manifest.FailedRuns {
|
||||
t.Fatalf("report says scored=%d failed=%d, manifest says %d and %d",
|
||||
gotScored, gotFailed, manifest.CompletedRuns, manifest.FailedRuns)
|
||||
}
|
||||
if strings.Contains(out, "## Where it broke") && strings.Contains(out, "=-\n") {
|
||||
t.Fatalf("the stage table kept an empty stage:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A batch where every run fails must not read as an empty success: zero scored
|
||||
// runs, a nonzero failed count, and both stated.
|
||||
func TestAllFailedBatchReportsZeroScoredAndNonzeroFailed(t *testing.T) {
|
||||
blocked, _ := mixedTaskPair()
|
||||
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked}, blocked.ID)
|
||||
|
||||
if manifest.CompletedRuns != 0 {
|
||||
t.Fatalf("completed_runs = %d, want 0 (nothing produced evidence)", manifest.CompletedRuns)
|
||||
}
|
||||
if manifest.FailedRuns != manifest.PlannedRuns || manifest.FailedRuns == 0 {
|
||||
t.Fatalf("failed_runs = %d, planned_runs = %d: every planned run failed",
|
||||
manifest.FailedRuns, manifest.PlannedRuns)
|
||||
}
|
||||
if len(manifest.TaskIDs) != 0 {
|
||||
t.Fatalf("task_ids = %v, want none", manifest.TaskIDs)
|
||||
}
|
||||
for i, m := range runs {
|
||||
if !m.Failed || m.FailureStage == "" || m.Task != blocked.ID {
|
||||
t.Fatalf("results.jsonl line %d is not a marked failure: %+v", i+1, m)
|
||||
}
|
||||
}
|
||||
total := 0
|
||||
for _, arm := range armsFor(blocked) {
|
||||
total += funnelScored(t, out, arm.name)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("funnels scored %d runs although nothing ran:\n%s", total, out)
|
||||
}
|
||||
if gotScored, gotFailed := reportCounts(t, out); gotScored != 0 || gotFailed != manifest.FailedRuns {
|
||||
t.Fatalf("report says scored=%d failed=%d, want 0 and %d", gotScored, gotFailed, manifest.FailedRuns)
|
||||
}
|
||||
}
|
||||
|
||||
// The control: with nothing blocked, a batch is what it always was. Every run
|
||||
// is completed with a stage, two identical batches write byte-identical results
|
||||
// and the same manifest header, and the new failure field stays out of it.
|
||||
func TestAllSuccessfulBatchKeepsItsArtifacts(t *testing.T) {
|
||||
tasks := indexTasks()
|
||||
first, runs, manifest, out := runBlockedExperiment(t, tasks)
|
||||
second, again, repeat, _ := runBlockedExperiment(t, tasks)
|
||||
if manifest.CompletedRuns != len(runs) || manifest.FailedRuns != 0 {
|
||||
t.Fatalf("completed=%d failed=%d rows=%d, want every row completed",
|
||||
manifest.CompletedRuns, manifest.FailedRuns, len(runs))
|
||||
}
|
||||
if manifest.CompletedRuns != manifest.PlannedRuns {
|
||||
t.Fatalf("completed=%d planned=%d, want all planned", manifest.CompletedRuns, manifest.PlannedRuns)
|
||||
}
|
||||
for i, m := range runs {
|
||||
if m.Failed || m.Task == "" || m.Arm == "" || m.FailureStage == "" {
|
||||
t.Fatalf("results.jsonl line %d is not a completed run: %+v", i+1, m)
|
||||
}
|
||||
}
|
||||
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != 0 {
|
||||
t.Fatalf("report says scored=%d failed=%d, want %d and 0", gotScored, gotFailed, manifest.CompletedRuns)
|
||||
}
|
||||
if !reflect.DeepEqual(runs, again) {
|
||||
t.Fatal("two identical successful batches scored differently")
|
||||
}
|
||||
// Byte-for-byte on the runs. Trajectories carry per-message wall-clock
|
||||
// stamps, so they are compared field by field instead.
|
||||
if got, want := readFile(t, second, "results.jsonl"), readFile(t, first, "results.jsonl"); got != want {
|
||||
t.Fatalf("an unchanged batch wrote different results.jsonl:\n%s\n---\n%s", want, got)
|
||||
}
|
||||
gotTraj, wantTraj := withoutStamps(t, readFile(t, second, "trajectories.jsonl")),
|
||||
withoutStamps(t, readFile(t, first, "trajectories.jsonl"))
|
||||
if gotTraj != wantTraj {
|
||||
t.Fatalf("an unchanged batch wrote different trajectories:\n%s\n---\n%s", wantTraj, gotTraj)
|
||||
}
|
||||
repeat.StartedAt, repeat.FinishedAt = manifest.StartedAt, manifest.FinishedAt
|
||||
repeat.TrajectoriesSHA256 = manifest.TrajectoriesSHA256
|
||||
if !reflect.DeepEqual(repeat, manifest) {
|
||||
t.Fatalf("an unchanged batch wrote a different manifest:\n%+v\n---\n%+v", manifest, repeat)
|
||||
}
|
||||
if repeat.FailedRuns != 0 {
|
||||
t.Fatalf("a batch with no failure reports failed_runs=%d", repeat.FailedRuns)
|
||||
}
|
||||
}
|
||||
|
||||
// withoutStamps drops the createdAt stamp every message carries, which is the
|
||||
// one field a dry batch cannot reproduce between two runs.
|
||||
func withoutStamps(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(text), "\n") {
|
||||
var row map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stripStamp(row)
|
||||
out, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Write(out)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func stripStamp(v any) {
|
||||
switch typed := v.(type) {
|
||||
case map[string]any:
|
||||
delete(typed, "createdAt")
|
||||
delete(typed, "workDurationMs")
|
||||
for _, child := range typed {
|
||||
stripStamp(child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
stripStamp(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, root, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ type queryAudit struct {
|
||||
func auditQueries(runs []contextMetrics) queryAudit {
|
||||
a := queryAudit{Reformulations: map[string]int{}, MissedProbeTerms: map[string]int{}}
|
||||
for _, r := range runs {
|
||||
if r.Contaminated {
|
||||
if r.Failed || r.Contaminated {
|
||||
continue
|
||||
}
|
||||
a.Runs++
|
||||
|
||||
@@ -85,6 +85,7 @@ func runAdversarial(root string) int {
|
||||
adversarialPrompts[t.Experiment])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", id, err)
|
||||
m = failedMetrics(t, "adversarial", err)
|
||||
}
|
||||
verdict := "isolated"
|
||||
if m.EscapeFoundAnswer {
|
||||
@@ -173,6 +174,16 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
|
||||
return m, runErr
|
||||
}
|
||||
|
||||
// failedMetrics is what a run that never reached a transcript leaves behind. It
|
||||
// carries the run's identity and the stage that ended it, so the failure stays a
|
||||
// countable fact; Failed keeps it out of every statistic.
|
||||
func failedMetrics(t contextTask, armName string, err error) contextMetrics {
|
||||
return contextMetrics{
|
||||
Task: t.ID, Arm: armName, Failed: true,
|
||||
FailureStage: stageNoEvidence, Reason: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// finalAnswer is the last assistant text the turn produced.
|
||||
func finalAnswer(msgs []provider.Message) string {
|
||||
for _, m := range slices.Backward(msgs) {
|
||||
@@ -250,6 +261,7 @@ func runBoundaries(mode, root string) int {
|
||||
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
|
||||
m = failedMetrics(t, name, err)
|
||||
}
|
||||
all = append(all, m)
|
||||
if side.has {
|
||||
@@ -349,6 +361,7 @@ func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask)
|
||||
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
|
||||
m = failedMetrics(t, arm.name, err)
|
||||
}
|
||||
byArm[arm.name] = append(byArm[arm.name], m)
|
||||
all = append(all, m)
|
||||
@@ -430,11 +443,17 @@ func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
return 2
|
||||
}
|
||||
defer f.Close()
|
||||
scored, failed := 0, 0
|
||||
for _, m := range all {
|
||||
if err := writeJSONLine(f, m); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
if m.Failed {
|
||||
failed++
|
||||
} else {
|
||||
scored++
|
||||
}
|
||||
}
|
||||
if err := writeTrajectories(root, all); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write trajectories:", err)
|
||||
@@ -444,7 +463,10 @@ func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
|
||||
// Both counts are named: a reader who sees only the batch size cannot tell
|
||||
// measurements from attempts.
|
||||
fmt.Printf("\n%d runs written to %s: scored=%d failed=%d (with trajectories.jsonl and batch.json)\n",
|
||||
len(all), path, scored, failed)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -453,9 +475,21 @@ func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
// what a four-arm average cannot do.
|
||||
func reportBoundaries(all []contextMetrics) {
|
||||
byKey := map[string]contextMetrics{}
|
||||
scored, failed := 0, 0
|
||||
for _, m := range all {
|
||||
if m.Failed {
|
||||
// No transcript, no boundary to compare: count the failure instead of
|
||||
// printing the zero it left behind as a measurement.
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
scored++
|
||||
byKey[m.Task+"|"+m.Arm] = m
|
||||
}
|
||||
if scored == 0 {
|
||||
fmt.Printf("\n## Across each task's cue boundary\n no scored runs: scored=%d failed=%d (nothing to compare)\n", scored, failed)
|
||||
return
|
||||
}
|
||||
var cueSide, noCueSide []contextMetrics
|
||||
fmt.Println("\n## Across each task's cue boundary")
|
||||
fmt.Printf(" %-24s %-16s %-4s %-10s %-9s %-12s %-11s %s\n",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// writeKilledAgentStub writes a stand-in for the reasonix binary that leaves
|
||||
// exactly what a killed child leaves behind: no final metrics record, but the
|
||||
// in-flight snapshot the metrics sink writes every two seconds.
|
||||
func writeKilledAgentStub(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "stub-agent.sh")
|
||||
script := "#!/bin/sh\n" +
|
||||
"metrics=\"\"\n" +
|
||||
"prev=\"\"\n" +
|
||||
"for arg in \"$@\"; do\n" +
|
||||
" if [ \"$prev\" = \"--metrics\" ]; then metrics=\"$arg\"; fi\n" +
|
||||
" prev=\"$arg\"\n" +
|
||||
"done\n" +
|
||||
"printf '%s' '{\"prompt_tokens\":1200,\"completion_tokens\":80,\"steps\":9," +
|
||||
"\"cost\":0.0123,\"currency\":\"USD\",\"complete\":false}' > \"$metrics.partial\"\n" +
|
||||
"exit 137\n"
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write stub: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func killedRun(t *testing.T) result {
|
||||
t.Helper()
|
||||
requireShellStub(t)
|
||||
work := testenv.TempDir(t)
|
||||
tk := task{ID: "stub-task", Prompt: "do the thing"}
|
||||
r := result{task: tk}
|
||||
cfg := suiteConfig{bin: writeKilledAgentStub(t, work)}
|
||||
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err == nil {
|
||||
t.Fatal("the stub exits 137; a leg that died must be reported as failed")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// writeSilentAgentStub writes a stand-in that leaves neither a final record nor
|
||||
// a snapshot: what it spent is simply gone, so nothing can bound it.
|
||||
func writeSilentAgentStub(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
requireShellStub(t)
|
||||
path := filepath.Join(dir, "silent-agent.sh")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write stub: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// A run recovered from its snapshot is still accounted: its numbers are real and
|
||||
// stop at the last write, which is exactly what Partial says. Marking it
|
||||
// Unaccounted would drop them from every total and hide the lower bound.
|
||||
func TestSuiteSidecarRecoveredRunStaysAccountedAsALowerBound(t *testing.T) {
|
||||
r := killedRun(t)
|
||||
if r.Unaccounted || !r.Partial {
|
||||
t.Fatalf("a run recovered from its snapshot was reported as unmeasured:\n"+
|
||||
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
|
||||
}
|
||||
stats := gatherSuiteStats([]result{r})
|
||||
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 1 {
|
||||
t.Fatalf("a recovered run changed category:\n"+
|
||||
" unaccounted=%d accounted=%d partial=%d", stats.unaccounted, stats.accounted, stats.partial)
|
||||
}
|
||||
if stats.pTok != 1200 || stats.cTok != 80 || stats.cost != 0.0123 {
|
||||
t.Fatalf("the recovered numbers never reached the aggregates:\n"+
|
||||
" tokens=%d/%d cost=%.4f, want 1200/80 at 0.0123", stats.pTok, stats.cTok, stats.cost)
|
||||
}
|
||||
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
|
||||
t.Fatalf("the lower-bound hint is unreachable:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A leg that leaves neither record has no surviving evidence at all, so the run
|
||||
// is unmeasured and must stay out of the totals rather than count as zero.
|
||||
func TestSuiteRunWithNoRecordAtAllIsUnaccounted(t *testing.T) {
|
||||
work := testenv.TempDir(t)
|
||||
tk := task{ID: "silent-task", Prompt: "do the thing"}
|
||||
r := result{task: tk}
|
||||
cfg := suiteConfig{bin: writeSilentAgentStub(t, work)}
|
||||
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
|
||||
t.Fatalf("the stub exits 0; a silent leg must not fail the run: %v", err)
|
||||
}
|
||||
if !r.Unaccounted || r.Partial {
|
||||
t.Fatalf("a run that left no record at all was not marked unmeasured:\n"+
|
||||
" partial=%v unaccounted=%v, want partial=false unaccounted=true", r.Partial, r.Unaccounted)
|
||||
}
|
||||
stats := gatherSuiteStats([]result{r})
|
||||
if stats.unaccounted != 1 || stats.accounted != 0 {
|
||||
t.Fatalf("a record-less run was counted as accounted:\n"+
|
||||
" unaccounted=%d accounted=%d", stats.unaccounted, stats.accounted)
|
||||
}
|
||||
if stats.pTok != 0 || stats.cTok != 0 || stats.cost != 0 {
|
||||
t.Fatalf("a record-less run contributed numbers: tokens=%d/%d cost=%.4f",
|
||||
stats.pTok, stats.cTok, stats.cost)
|
||||
}
|
||||
if out := renderBody([]result{r}); !strings.Contains(out, "Accounting incomplete for 1 of 1 instances") {
|
||||
t.Fatalf("the record-less run was not disclosed as unaccounted:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The killed child did leave its numbers: the metrics sink snapshots in flight
|
||||
// into <metrics>.partial. Swebench mode reads that sidecar; the suite path must
|
||||
// do the same rather than let the directory removal destroy the evidence.
|
||||
func TestSuiteRunRecoversTheInFlightSnapshotItsChildLeft(t *testing.T) {
|
||||
r := killedRun(t)
|
||||
if r.PromptTokens != 1200 || r.CompletionTokens != 80 {
|
||||
t.Fatalf("the child's in-flight snapshot was dropped:\n"+
|
||||
" prompt_tokens=%d completion_tokens=%d cost=%.4f partial=%v unaccounted=%v\n"+
|
||||
" want the 1200/80 the <metrics>.partial sidecar carries",
|
||||
r.PromptTokens, r.CompletionTokens, r.Cost, r.Partial, r.Unaccounted)
|
||||
}
|
||||
}
|
||||
|
||||
// The meter-vs-harness divergence line divides one side by the other. Whether a
|
||||
// run has a harness side is the suite's Unaccounted state, not the token values:
|
||||
// a run with no record at all has no side, and pricing it against the meter
|
||||
// invents a divergence and blames the harness for it.
|
||||
func TestMeterDivergenceDoesNotPriceAMetricslessRun(t *testing.T) {
|
||||
r := result{task: task{ID: "killed"}, Unaccounted: true,
|
||||
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
|
||||
got := renderMeterAccounting([]result{r})
|
||||
if strings.Contains(got, "self-report divergence") {
|
||||
t.Fatalf("a run with no harness-side number was priced as a self-report divergence:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A persisted record that says zero tokens is a measurement of zero, not an
|
||||
// absence: the harness side exists, so it belongs on both sides of the ratio
|
||||
// even when the number it reports disagrees with the meter.
|
||||
func TestMeterAccountingKeepsAPersistedZeroOnBothSides(t *testing.T) {
|
||||
r := result{task: task{ID: "zero"}, Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
|
||||
got := renderMeterAccounting([]result{r})
|
||||
if !strings.Contains(got, "-100.0% (harness 0 vs meter 1,280 over 1 runs)") {
|
||||
t.Fatalf("a real zero-token measurement was dropped as if it were missing:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -172,10 +172,11 @@ func scanMemoryRecall(path string, markers []string, markersInPrefix bool) memor
|
||||
defer f.Close()
|
||||
type record struct {
|
||||
MemoryRecall *struct {
|
||||
Hits []struct{ ID string } `json:"hits"`
|
||||
UsedChars int `json:"used_chars"`
|
||||
Suppressed string `json:"suppressed"`
|
||||
ShadowHits []struct{ ID string } `json:"shadow_hits"`
|
||||
Hits []struct{ ID string } `json:"hits"`
|
||||
UsedChars int `json:"used_chars"`
|
||||
Suppressed string `json:"suppressed"`
|
||||
SuppressionCode string `json:"suppression_code"`
|
||||
ShadowHits []struct{ ID string } `json:"shadow_hits"`
|
||||
} `json:"memory_recall"`
|
||||
Event *struct {
|
||||
Kind string `json:"kind"`
|
||||
@@ -205,7 +206,10 @@ func scanMemoryRecall(path string, markers []string, markersInPrefix bool) memor
|
||||
if len(mr.ShadowHits) > 0 && mr.ShadowHits[0].ID == mr.Hits[0].ID {
|
||||
stats.ShadowAgree++
|
||||
}
|
||||
} else if mr.Suppressed != "" {
|
||||
} else if mr.SuppressionCode != "" || mr.Suppressed != "" {
|
||||
// The typed code is the identity. A record written before the
|
||||
// code existed carries only the sentence, whose presence still
|
||||
// means recall stayed silent.
|
||||
stats.Suppressed++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,16 @@ func TestScanMemoryRecallCountsAndPointOfUse(t *testing.T) {
|
||||
`{"seq":1,"event":{"kind":"tool_result","tool":{"args":"{\"command\":\"make check-fast --tag=MEMKEY-EARLY\"}"}}}`,
|
||||
`{"seq":2,"memory_recall":{"hits":[{"id":"a"},{"id":"b"}],"used_chars":420}}`,
|
||||
`{"seq":3,"event":{"kind":"tool_result","tool":{"args":"{\"path\":\"answer.txt\",\"content\":\"make check-fast --tag=MEMKEY-USED\"}"}}}`,
|
||||
`{"seq":4,"memory_recall":{"suppressed":"generic user turn"}}`,
|
||||
`{"seq":4,"memory_recall":{"suppression_code":"generic-turn","suppressed":"generic-turn: generic user turn"}}`,
|
||||
`{"seq":5,"event":{"kind":"text","text":"done, MEMKEY-TEXT too"}}`,
|
||||
`{"seq":6,"memory_recall":{"suppressed":"generic user turn"}}`,
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats := scanMemoryRecall(path, []string{"MEMKEY-USED", "MEMKEY-TEXT", "MEMKEY-EARLY", "MEMKEY-NEVER"}, false)
|
||||
if stats.RecallEvents != 1 || stats.RecallHits != 2 || stats.RecallChars != 420 || stats.Suppressed != 1 {
|
||||
t.Fatalf("stats = %+v, want 1 event / 2 hits / 420 chars / 1 suppressed", stats)
|
||||
if stats.RecallEvents != 1 || stats.RecallHits != 2 || stats.RecallChars != 420 || stats.Suppressed != 2 {
|
||||
t.Fatalf("stats = %+v, want 1 event / 2 hits / 420 chars / 2 suppressed", stats)
|
||||
}
|
||||
// MEMKEY-EARLY appears only BEFORE the recall: not point-of-use evidence.
|
||||
if stats.MarkersUsed != 2 {
|
||||
|
||||
@@ -116,14 +116,15 @@ func renderMeterAccounting(results []result) string {
|
||||
miss += r.Meter.CacheMissTokens
|
||||
unmeasured += r.Meter.WithoutUsage
|
||||
injected += r.Meter.Injected
|
||||
// A run whose metrics file never landed has no harness number, so it
|
||||
// stays out of both sides of the comparison; folding its meter tokens
|
||||
// against a silent zero would invent a divergence.
|
||||
if !r.Unaccounted {
|
||||
selfReported++
|
||||
harnessTokens += r.PromptTokens + r.CompletionTokens
|
||||
comparableTokens += r.Meter.PromptTokens + r.Meter.CompletionTokens
|
||||
// Whether the harness has a side to compare is the suite's state, not a
|
||||
// token count: Unaccounted has no record and Partial stops at its last
|
||||
// snapshot, so neither side is whole. A persisted zero is a measurement.
|
||||
if r.Unaccounted || r.Partial {
|
||||
continue
|
||||
}
|
||||
selfReported++
|
||||
harnessTokens += r.PromptTokens + r.CompletionTokens
|
||||
comparableTokens += r.Meter.PromptTokens + r.Meter.CompletionTokens
|
||||
}
|
||||
if metered == 0 {
|
||||
return ""
|
||||
|
||||
@@ -21,10 +21,17 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
|
||||
// call on the harness's own token counts, and another found this directory
|
||||
// by grepping the temp root for its task — moving it only helps unnamed.
|
||||
metricsDir, err := os.MkdirTemp("", "e2ebench-metrics-")
|
||||
// A snapshot recovered from the sidecar is the only surviving evidence of
|
||||
// what that leg spent, so its directory is kept instead of removed.
|
||||
keepMetrics := false
|
||||
if err != nil {
|
||||
metricsDir = work
|
||||
} else {
|
||||
defer func() { _ = os.RemoveAll(metricsDir) }()
|
||||
defer func() {
|
||||
if !keepMetrics {
|
||||
_ = os.RemoveAll(metricsDir)
|
||||
}
|
||||
}()
|
||||
}
|
||||
var runErr error
|
||||
for _, seg := range segs {
|
||||
@@ -42,7 +49,17 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
runErr = cmd.Run()
|
||||
|
||||
if m, err := readMetrics(metricsPath); err == nil {
|
||||
m, partial, err := legMetrics(metricsPath)
|
||||
if err != nil {
|
||||
// The leg left no record at all: nothing survives to say what it
|
||||
// spent, so the run is unmeasured rather than priced at zero.
|
||||
r.Unaccounted = true
|
||||
} else {
|
||||
// A snapshot is real evidence, so the run stays accounted and only
|
||||
// Partial says its numbers stop at the last write. It is sticky: a
|
||||
// complete later leg cannot restore the tail an earlier one lost.
|
||||
r.Partial = r.Partial || partial || !m.Complete
|
||||
keepMetrics = keepMetrics || partial
|
||||
foldSegmentMetrics(r, m, seg.index)
|
||||
}
|
||||
// A leg that died takes the run with it: resuming a session the child
|
||||
@@ -55,6 +72,19 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
|
||||
return runErr
|
||||
}
|
||||
|
||||
// legMetrics reads one leg's accounting. The final record is authoritative and
|
||||
// the in-flight snapshot is only read when the final one is absent, so the two
|
||||
// can never be counted together.
|
||||
func legMetrics(path string) (m runMetrics, partial bool, err error) {
|
||||
if m, err = readMetrics(path); err == nil {
|
||||
return m, false, nil
|
||||
}
|
||||
if m, err = readMetrics(path + ".partial"); err == nil {
|
||||
return m, true, nil
|
||||
}
|
||||
return m, false, err
|
||||
}
|
||||
|
||||
// segmentTrajectoryPath keeps one file per leg so a resumed leg cannot truncate
|
||||
// the record of the one before it.
|
||||
func segmentTrajectoryPath(dir, id string, seg segment, total int) string {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// stubScript builds a stand-in agent that records the --metrics path it was
|
||||
// given, writes the metrics body it was built with to that path plus suffix,
|
||||
// and exits with code. A non-empty suffix is what a killed child leaves: the
|
||||
// in-flight snapshot and no final record.
|
||||
func stubScript(t *testing.T, name, body, suffix string, code int) string {
|
||||
t.Helper()
|
||||
requireShellStub(t)
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
metrics=""
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
if [ "$prev" = "--metrics" ]; then metrics="$arg"; fi
|
||||
prev="$arg"
|
||||
done
|
||||
printf '%%s' "$metrics" > metrics-path.txt
|
||||
printf '%%s' '%s' > "$metrics%s"
|
||||
exit %d
|
||||
`, body, suffix, code)
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write stub: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// recordedMetricsPath reads back the --metrics path the stub was handed.
|
||||
func recordedMetricsPath(t *testing.T, work string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(work, "metrics-path.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("the stub did not record its metrics path: %v", err)
|
||||
}
|
||||
got := strings.TrimSpace(string(b))
|
||||
if got == "" {
|
||||
t.Fatal("the stub recorded an empty metrics path")
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// twoLegStub writes a stand-in whose legs differ: the first leaves only the
|
||||
// in-flight snapshot, the second lands its final record. Both exit 0, which is
|
||||
// what lets the run reach the second leg after the first one stopped short.
|
||||
func twoLegStub(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
requireShellStub(t)
|
||||
script := `#!/bin/sh
|
||||
metrics=""
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
if [ "$prev" = "--metrics" ]; then metrics="$arg"; fi
|
||||
prev="$arg"
|
||||
done
|
||||
case "$metrics" in
|
||||
*-1.json)
|
||||
printf '%s' '{"prompt_tokens":1200,"completion_tokens":80,"steps":9,"cost":0.0123,"currency":"USD","complete":false}' > "$metrics.partial"
|
||||
;;
|
||||
*)
|
||||
printf '%s' '{"prompt_tokens":300,"completion_tokens":20,"steps":2,"cost":0.001,"currency":"USD","complete":true}' > "$metrics"
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write stub: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// A run that lands its final metrics record is untouched by the recovery
|
||||
// path: it stays accounted, keeps its real numbers and is still not partial.
|
||||
func TestSuiteHealthyRunKeepsItsFinalAccount(t *testing.T) {
|
||||
work := testenv.TempDir(t)
|
||||
tk := task{ID: "healthy-task", Prompt: "do the thing"}
|
||||
body := `{"prompt_tokens":900,"completion_tokens":60,"steps":4,"cost":0.02,` +
|
||||
`"currency":"USD","complete":true}`
|
||||
cfg := suiteConfig{bin: stubScript(t, "stub-ok.sh", body, "", 0)}
|
||||
r := result{task: tk}
|
||||
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
|
||||
t.Fatalf("a healthy leg must not fail the run: %v", err)
|
||||
}
|
||||
stats := gatherSuiteStats([]result{r})
|
||||
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 0 {
|
||||
t.Fatalf("a healthy run changed category: unaccounted=%d accounted=%d partial=%d",
|
||||
stats.unaccounted, stats.accounted, stats.partial)
|
||||
}
|
||||
if stats.pTok != 900 || stats.cTok != 60 || stats.cost != 0.02 {
|
||||
t.Fatalf("a healthy run's numbers changed: tokens=%d/%d cost=%.4f",
|
||||
stats.pTok, stats.cTok, stats.cost)
|
||||
}
|
||||
if r.Partial || r.Unaccounted {
|
||||
t.Fatalf("a healthy run was flagged: partial=%v unaccounted=%v", r.Partial, r.Unaccounted)
|
||||
}
|
||||
// The metrics directory is disposable once the final record was read.
|
||||
dir := filepath.Dir(recordedMetricsPath(t, work))
|
||||
if dirExists(dir) {
|
||||
t.Fatalf("a healthy run left its metrics directory behind: %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// The snapshot a killed child leaves is the only evidence of what it spent, so
|
||||
// recovering the numbers must not also destroy the file they came from.
|
||||
func TestSuiteRunKeepsTheSnapshotItRecovered(t *testing.T) {
|
||||
work := testenv.TempDir(t)
|
||||
tk := task{ID: "killed-task", Prompt: "do the thing"}
|
||||
body := `{"prompt_tokens":1200,"completion_tokens":80,"steps":9,"cost":0.0123,` +
|
||||
`"currency":"USD","complete":false}`
|
||||
cfg := suiteConfig{bin: stubScript(t, "stub-partial.sh", body, ".partial", 137)}
|
||||
r := result{task: tk}
|
||||
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err == nil {
|
||||
t.Fatal("the stub exits 137; a leg that died must be reported as failed")
|
||||
}
|
||||
path := recordedMetricsPath(t, work)
|
||||
if fileExists(path) {
|
||||
t.Fatalf("a killed child cannot have written a final record: %s", path)
|
||||
}
|
||||
if !fileExists(path + ".partial") {
|
||||
t.Fatalf("the recovered snapshot was deleted with the metrics directory: %s.partial", path)
|
||||
}
|
||||
if r.PromptTokens != 1200 || r.CompletionTokens != 80 {
|
||||
t.Fatalf("the snapshot was kept but not recovered: tokens=%d/%d",
|
||||
r.PromptTokens, r.CompletionTokens)
|
||||
}
|
||||
// Recovering the numbers must not also mark the run unmeasured: the snapshot
|
||||
// is evidence, so the run stays accounted and Partial marks it a lower bound.
|
||||
if r.Unaccounted || !r.Partial {
|
||||
t.Fatalf("a recovered snapshot was not reported as accounted but partial: partial=%v unaccounted=%v",
|
||||
r.Partial, r.Unaccounted)
|
||||
}
|
||||
}
|
||||
|
||||
// A lower bound belongs to the run, not to its last leg: a complete second leg
|
||||
// cannot restore the tail a killed first leg stopped short of, so once any leg
|
||||
// ends at a snapshot the whole run stays partial — and still counts.
|
||||
func TestSuitePartialFirstLegMakesTheWholeRunALowerBound(t *testing.T) {
|
||||
work := testenv.TempDir(t)
|
||||
tk := task{ID: "two-leg-partial", Prompt: "do the thing", MaxSteps: 10}
|
||||
cfg := suiteConfig{bin: twoLegStub(t, "stub-two-leg.sh"), segments: 2}
|
||||
r := result{task: tk}
|
||||
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
|
||||
t.Fatalf("both legs exit 0; the run must not fail: %v", err)
|
||||
}
|
||||
if r.Segments != 2 {
|
||||
t.Fatalf("the run did not take both legs: segments=%d", r.Segments)
|
||||
}
|
||||
// The second leg's completeness says nothing about the first leg's tail.
|
||||
if !r.Partial || r.Unaccounted {
|
||||
t.Fatalf("a complete last leg erased the first leg's lower bound:\n"+
|
||||
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
|
||||
}
|
||||
// Partial bounds the account; it does not exclude it. Both legs are counted.
|
||||
if r.PromptTokens != 1500 || r.CompletionTokens != 100 {
|
||||
t.Fatalf("the legs' spend was not folded together: tokens=%d/%d, want 1500/100",
|
||||
r.PromptTokens, r.CompletionTokens)
|
||||
}
|
||||
stats := gatherSuiteStats([]result{r})
|
||||
if stats.accounted != 1 || stats.unaccounted != 0 || stats.partial != 1 || stats.pTok != 1500 {
|
||||
t.Fatalf("a two-leg partial run changed category:\n"+
|
||||
" accounted=%d unaccounted=%d partial=%d pTok=%d, want 1/0/1/1500",
|
||||
stats.accounted, stats.unaccounted, stats.partial, stats.pTok)
|
||||
}
|
||||
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
|
||||
t.Fatalf("the run was counted but not disclosed as a lower bound:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMeterAccountingSkipsAnUnaccountedRunWithTokens pins the guard: a run the
|
||||
// suite marked unmeasured stays out of the ratio even when a harness number is
|
||||
// present, so a run with no record is not priced.
|
||||
func TestMeterAccountingSkipsAnUnaccountedRunWithTokens(t *testing.T) {
|
||||
r := result{
|
||||
task: task{ID: "killed"},
|
||||
runMetrics: runMetrics{PromptTokens: 100, CompletionTokens: 10},
|
||||
Unaccounted: true,
|
||||
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80},
|
||||
}
|
||||
got := renderMeterAccounting([]result{r})
|
||||
if strings.Contains(got, "self-report divergence") {
|
||||
t.Fatalf("an unaccounted run with a harness number was priced as a self-report divergence:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The same guard has to cover a lower bound: a Partial run's harness side stops
|
||||
// at its last snapshot while the meter saw the whole leg, so pricing it reads as
|
||||
// a divergence that is only the missing tail.
|
||||
func TestMeterAccountingSkipsAPartialRunWithTokens(t *testing.T) {
|
||||
r := result{
|
||||
task: task{ID: "lower-bound"},
|
||||
runMetrics: runMetrics{PromptTokens: 100, CompletionTokens: 10},
|
||||
Partial: true,
|
||||
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80},
|
||||
}
|
||||
got := renderMeterAccounting([]result{r})
|
||||
if strings.Contains(got, "self-report divergence") {
|
||||
t.Fatalf("a partial run's missing tail was priced as a self-report divergence:\n%s", got)
|
||||
}
|
||||
// Its meter tokens are still a real measurement, so the totals keep them.
|
||||
if !strings.Contains(got, "tokens 1,280") {
|
||||
t.Fatalf("skipping the ratio also dropped the run's metered tokens:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -156,14 +157,47 @@ func main() {
|
||||
}
|
||||
|
||||
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
|
||||
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
|
||||
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell, driver)
|
||||
releaseFatalLog()
|
||||
releaseFatalLog, exitedFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
|
||||
logs := &lastLine{w: os.Stderr}
|
||||
code := run(parentLease(os.Stdin), os.Stdout, logs, *page, shell, driver)
|
||||
if code == 0 {
|
||||
releaseFatalLog()
|
||||
} else {
|
||||
exitedFatalLog(code, logs.last())
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// logsDirOf is the shell's logs directory: the crash directory it passed is
|
||||
// the "crash" folder inside it.
|
||||
// lastLine passes everything through and remembers the last line written, which
|
||||
// is where run says why it is returning a failure code.
|
||||
type lastLine struct {
|
||||
w io.Writer
|
||||
mu sync.Mutex
|
||||
line string
|
||||
}
|
||||
|
||||
func (l *lastLine) Write(p []byte) (int, error) {
|
||||
for line := range strings.SplitSeq(strings.TrimRight(string(p), "\r\n"), "\n") {
|
||||
if strings.HasPrefix(line, "reasonix-studio-host:") {
|
||||
l.mu.Lock()
|
||||
l.line = strings.TrimSpace(line)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
return l.w.Write(p)
|
||||
}
|
||||
|
||||
func (l *lastLine) last() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.line == "" {
|
||||
return "no reason was reported"
|
||||
}
|
||||
return l.line
|
||||
}
|
||||
|
||||
func logsDirOf(crashDir string) string {
|
||||
if strings.TrimSpace(crashDir) == "" {
|
||||
return ""
|
||||
@@ -448,6 +482,7 @@ func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startu
|
||||
OnPhase: phases.boot,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
AccountsURL: os.Getenv("REASONIX_ACCOUNTS_URL"),
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"build:dist": "electron-builder",
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"focus-live": "electron test/focus_live.js",
|
||||
"freeze-live": "electron test/freeze_live.js",
|
||||
"windowstate-live": "electron test/windowstate_live.js",
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
|
||||
@@ -3,6 +3,7 @@ const crypto = require("node:crypto");
|
||||
const { WebContentsView, session } = require("electron");
|
||||
const { installPaneReload } = require("./reload");
|
||||
const { guestNavigationAllowed, typed } = require("./browserguard");
|
||||
const { KeyboardKeeper } = require("./keyboard");
|
||||
|
||||
// The size a page lays out at while nobody is looking at it: the kernel drives
|
||||
// pages whether or not the panel is open, and a page sized to nothing reads as
|
||||
@@ -30,6 +31,7 @@ class BrowserViews {
|
||||
// for this run only: a restart, or a different certificate, asks again.
|
||||
this.trusted = new Set();
|
||||
this.shown = "";
|
||||
this.keyboard = new KeyboardKeeper({ win });
|
||||
// Moves with every show and hide, so a freeze that finishes after one does
|
||||
// not put away a page shown since.
|
||||
this.placed = 0;
|
||||
@@ -106,7 +108,8 @@ class BrowserViews {
|
||||
}
|
||||
onEvent(method, params);
|
||||
});
|
||||
const entry = { view, onDownload, typed: 0 };
|
||||
const entry = { view, onDownload, typed: 0, synthUntil: 0 };
|
||||
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
|
||||
this.watchLoads(targetId, entry);
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
@@ -125,7 +128,10 @@ class BrowserViews {
|
||||
void contents.loadURL(url).catch(() => {});
|
||||
return {
|
||||
targetId,
|
||||
send: (method, params) => contents.debugger.sendCommand(method, params),
|
||||
send: (method, params) => {
|
||||
if (method.startsWith("Input.")) entry.synthUntil = Date.now() + 250;
|
||||
return contents.debugger.sendCommand(method, params);
|
||||
},
|
||||
close,
|
||||
mainFrame: () => mainFrame,
|
||||
};
|
||||
@@ -159,6 +165,14 @@ class BrowserViews {
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
this.keepKeyboard();
|
||||
}
|
||||
|
||||
// Attaching a view can leave nobody holding the keyboard, and the platform
|
||||
// settles that a moment later, so the page is handed it back twice.
|
||||
keepKeyboard() {
|
||||
this.keyboard.reclaim();
|
||||
setTimeout(() => this.keyboard.reclaim(), 150);
|
||||
}
|
||||
|
||||
reattach() {
|
||||
@@ -168,6 +182,7 @@ class BrowserViews {
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
if (id !== this.shown) this.putAway(entry.view, entry);
|
||||
}
|
||||
this.keepKeyboard();
|
||||
}
|
||||
|
||||
// freeze answers with a picture of the page on screen, taken before it is put
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
// A person needs longer than this to read a panel and choose in it. A panel
|
||||
// that closes sooner was never drawn: on Linux a missing file-chooser portal
|
||||
// answers the same way as a dismissal, so the page would be told the person
|
||||
// declined and would offer nothing else.
|
||||
const UNDRAWN_MS = 400;
|
||||
|
||||
// folderAnswer is what the page is told about a folder panel: the path chosen,
|
||||
// "" for a panel the person closed, and null for a panel that could not be shown,
|
||||
// which the page answers by asking for the path in words.
|
||||
function folderAnswer(result, elapsedMs, platform) {
|
||||
if (!result.canceled && result.filePaths.length > 0) return result.filePaths[0];
|
||||
return platform === "linux" && elapsedMs < UNDRAWN_MS ? null : "";
|
||||
}
|
||||
|
||||
module.exports = { folderAnswer, UNDRAWN_MS };
|
||||
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
|
||||
// Who owns the keyboard while the agent drives a page. A person typing in the
|
||||
// app page keeps typing there: a page the agent opens, navigates or puts on
|
||||
// screen is given the keyboard by the platform, and nothing the agent does is a
|
||||
// reason for it to have it. The one reason that counts is the person's own: a
|
||||
// click or a key that reached the page from the window, which is theirs to
|
||||
// have chosen. The agent's own input arrives over the debugging protocol and
|
||||
// is recognised as such, because it reaches the page the same way a click does.
|
||||
|
||||
const PERSON_MS = 400;
|
||||
|
||||
class KeyboardKeeper {
|
||||
constructor({ win, now = Date.now }) {
|
||||
this.win = win;
|
||||
this.now = now;
|
||||
this.owner = win.webContents.isFocused() ? "app" : "none";
|
||||
win.webContents.on("focus", () => {
|
||||
this.owner = "app";
|
||||
});
|
||||
}
|
||||
|
||||
// watch follows one page. synthetic() says whether input reaching it right now
|
||||
// is the agent's own.
|
||||
watch(contents, synthetic = () => false) {
|
||||
let personAt = -Infinity;
|
||||
contents.on("before-input-event", () => {
|
||||
personAt = this.now();
|
||||
});
|
||||
contents.on("input-event", (_event, input) => {
|
||||
if (input && input.type === "mouseDown" && !synthetic()) personAt = this.now();
|
||||
});
|
||||
contents.on("focus", () => {
|
||||
if (this.now() - personAt < PERSON_MS) {
|
||||
this.owner = "view";
|
||||
return;
|
||||
}
|
||||
if (this.owner !== "app" || !this.live()) {
|
||||
this.owner = "view";
|
||||
return;
|
||||
}
|
||||
// The platform is still handing the page the keyboard while this event is
|
||||
// delivered, and a focus asked for from inside it is lost; one a turn later
|
||||
// is not, as long as nothing of the person's has arrived by then.
|
||||
setTimeout(() => {
|
||||
if (this.now() - personAt < PERSON_MS || this.owner !== "app" || !this.live()) return;
|
||||
if (contents.isFocused()) this.win.webContents.focus();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// reclaim gives the keyboard back to the app page when attaching or moving a
|
||||
// view left nobody holding it, while the window itself is the one in front.
|
||||
reclaim() {
|
||||
if (this.owner === "app" && this.live() && !this.win.webContents.isFocused()) this.win.webContents.focus();
|
||||
}
|
||||
|
||||
live() {
|
||||
return !this.win.isDestroyed() && this.win.isFocused();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { KeyboardKeeper, PERSON_MS };
|
||||
@@ -22,6 +22,7 @@ const { reveal, revealWorkspace, revealLogs, openWorkspace, openFile } = require
|
||||
const { appIcon } = require("./appicon");
|
||||
const { setUnreadBadge } = require("./badge");
|
||||
const layout = require("./layout");
|
||||
const { folderAnswer } = require("./folderpanel");
|
||||
const { offerCleanup } = require("./legacy");
|
||||
const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
|
||||
const { BrowserProtocol } = require("./browserprotocol");
|
||||
@@ -472,17 +473,18 @@ ipcMain.handle("dialog:save-bytes", (event, name, bytes) =>
|
||||
// createDirectory is the half that carries meaning: a panel that can only open
|
||||
// what exists reads as an app that cannot start a project, which is what the
|
||||
// Wails picker was reported as before it said so. A dismissed panel answers ""
|
||||
// like the save dialogs, and startIn is dropped when it names nothing.
|
||||
// like the save dialogs, a panel that could not be drawn answers null, and startIn
|
||||
// is dropped when it names nothing.
|
||||
ipcMain.handle("dialog:pick-folder", async (event, startIn) => {
|
||||
const target = fromWindow(event);
|
||||
if (!target) return "";
|
||||
const shownAt = Date.now();
|
||||
const picked = await dialog.showOpenDialog(target, {
|
||||
defaultPath: startIn || undefined,
|
||||
// Tools keep their working folders behind a leading dot, so those are listed too.
|
||||
properties: ["openDirectory", "createDirectory", "showHiddenFiles"],
|
||||
});
|
||||
if (picked.canceled || !picked.filePaths.length) return "";
|
||||
return picked.filePaths[0];
|
||||
return folderAnswer(picked, Date.now() - shownAt, process.platform);
|
||||
});
|
||||
|
||||
// Named before any path is derived from it: userData hangs off the app name,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use strict";
|
||||
// Who owns the keyboard while the agent drives its browser. A person typing in
|
||||
// the app page must still be typing there after the agent opens a page, shows
|
||||
// it, clicks in it, types into it or asks for it to come to the front: the
|
||||
// agent's commands arrive over the debugging protocol, and nothing it sends may
|
||||
// move the focus out of the page the person is in. Run with `pnpm focus-live`;
|
||||
// it needs no network and no kernel.
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { BrowserProtocol } = require("../src/browserprotocol");
|
||||
const { BrowserViews } = require("../src/browserviews");
|
||||
const { checker, listen, wait } = require("./livekit");
|
||||
|
||||
const APP = `<!doctype html><title>app</title><textarea id="compose" autofocus></textarea>`;
|
||||
const PAGE = `<!doctype html><title>page</title><input id="name"><button id="go" onclick="document.title='clicked'">Go</button>`;
|
||||
|
||||
const { check, failures } = checker();
|
||||
|
||||
async function main() {
|
||||
await app.whenReady();
|
||||
const page = await listen((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(PAGE);
|
||||
});
|
||||
const win = new BrowserWindow({ width: 1000, height: 700, show: true, webPreferences: { contextIsolation: true } });
|
||||
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(APP));
|
||||
win.show();
|
||||
app.focus({ steal: true });
|
||||
win.focus();
|
||||
win.webContents.focus();
|
||||
await wait(800);
|
||||
if (!win.webContents.isFocused()) {
|
||||
process.stdout.write(" SKIP this desktop would not give the test window the keyboard, so there is nothing to keep\n");
|
||||
app.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const frames = [];
|
||||
const waiting = new Map();
|
||||
const protocol = new BrowserProtocol({
|
||||
createView: (spec) => views.create(spec),
|
||||
post: (frame) => {
|
||||
frames.push(frame);
|
||||
const id = frame.message && frame.message.id;
|
||||
if (id !== undefined && waiting.has(id)) waiting.get(id)(frame.message);
|
||||
},
|
||||
});
|
||||
|
||||
let n = 0;
|
||||
const send = (message, sessionId) => new Promise((resolve) => {
|
||||
const id = ++n;
|
||||
waiting.set(id, resolve);
|
||||
protocol.receive({ conn: "c", message: JSON.stringify({ id, ...message, ...(sessionId ? { sessionId } : {}) }) });
|
||||
setTimeout(() => resolve({ timedOut: true }), 4000);
|
||||
});
|
||||
|
||||
const where = async () => ({
|
||||
app: await win.webContents.executeJavaScript("document.activeElement && document.activeElement.id"),
|
||||
appFocused: win.webContents.isFocused(),
|
||||
viewFocused: [...views.entries.values()].some((e) => e.view.webContents.isFocused()),
|
||||
});
|
||||
const keepsTheKeyboard = async (what) => {
|
||||
const w = await where();
|
||||
check(`${what}: the composer keeps the keyboard`, w.app === "compose" && w.appFocused && !w.viewFocused, w);
|
||||
};
|
||||
|
||||
protocol.receive({ conn: "c", open: "focus-live" });
|
||||
await keepsTheKeyboard("before the agent does anything");
|
||||
|
||||
const created = await send({ method: "Target.createTarget", params: { url: page.url } });
|
||||
const targetId = created.result.targetId;
|
||||
const attached = await send({ method: "Target.attachToTarget", params: { targetId } });
|
||||
const session = attached.result.sessionId;
|
||||
await wait(1200);
|
||||
await keepsTheKeyboard("opening a page");
|
||||
|
||||
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
|
||||
await wait(500);
|
||||
await keepsTheKeyboard("showing it over the page");
|
||||
|
||||
await send({ method: "Page.bringToFront" }, session);
|
||||
await wait(300);
|
||||
await keepsTheKeyboard("asking for the page to come to the front");
|
||||
|
||||
await send({ method: "Input.dispatchMouseEvent", params: { type: "mousePressed", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
|
||||
await send({ method: "Input.dispatchMouseEvent", params: { type: "mouseReleased", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
|
||||
await wait(300);
|
||||
await keepsTheKeyboard("a click inside the agent's page");
|
||||
|
||||
await send({ method: "Input.insertText", params: { text: "hello" } }, session);
|
||||
await send({ method: "Input.dispatchKeyEvent", params: { type: "keyDown", key: "a", text: "a" } }, session);
|
||||
await send({ method: "Input.dispatchKeyEvent", params: { type: "keyUp", key: "a" } }, session);
|
||||
await wait(300);
|
||||
await keepsTheKeyboard("typing into the agent's page");
|
||||
|
||||
await send({ method: "Runtime.evaluate", params: { expression: "document.querySelector('#name').focus()" } }, session);
|
||||
await wait(300);
|
||||
await keepsTheKeyboard("the page focusing one of its own fields");
|
||||
|
||||
await send({ method: "Page.navigate", params: { url: page.url + "/next" } }, session);
|
||||
await wait(1200);
|
||||
await keepsTheKeyboard("navigating the agent's page");
|
||||
|
||||
views.hide();
|
||||
await wait(300);
|
||||
await keepsTheKeyboard("putting the page away");
|
||||
|
||||
// The other half: a person who clicks into the agent's page is typing there,
|
||||
// and the agent navigating it again does not take that away from them.
|
||||
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
|
||||
await wait(500);
|
||||
const theirs = [...views.entries.values()][0].view.webContents;
|
||||
// A click that reaches the page from the window, and the focus the platform
|
||||
// gives the page because of it; sendInputEvent alone does not move focus.
|
||||
theirs.sendInputEvent({ type: "mouseDown", x: 40, y: 20, button: "left", clickCount: 1 });
|
||||
theirs.focus();
|
||||
theirs.sendInputEvent({ type: "mouseUp", x: 40, y: 20, button: "left", clickCount: 1 });
|
||||
await wait(500);
|
||||
const chosen = await where();
|
||||
check("a click of the person's own into the agent's page gives it the keyboard", chosen.viewFocused && !chosen.appFocused, chosen);
|
||||
await send({ method: "Page.navigate", params: { url: page.url + "/again" } }, session);
|
||||
await wait(1200);
|
||||
const stays = await where();
|
||||
check("and the agent navigating it again does not pull the keyboard back", stays.viewFocused && !stays.appFocused, stays);
|
||||
|
||||
win.webContents.focus();
|
||||
await wait(300);
|
||||
win.minimize();
|
||||
await wait(400);
|
||||
win.restore();
|
||||
await wait(600);
|
||||
await keepsTheKeyboard("the window being minimized and restored");
|
||||
|
||||
page.server.close();
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stdout.write(` FAIL focus-live crashed — ${err && err.stack}\n`);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const { externalTarget } = require("../src/links.js");
|
||||
const { offerCleanup, ownBundle } = require("../src/legacy.js");
|
||||
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
|
||||
const { groundFor, themeSourceFor, GROUND } = require("../src/ground.js");
|
||||
const { folderAnswer } = require("../src/folderpanel.js");
|
||||
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
|
||||
|
||||
const TOKEN = "a".repeat(64);
|
||||
@@ -2259,6 +2260,8 @@ test("a parked page is attached again when it is shown, and a page already on sc
|
||||
const win = {
|
||||
on() {},
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => false,
|
||||
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
|
||||
getContentBounds: () => ({ width: 1200, height: 800 }),
|
||||
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
|
||||
};
|
||||
@@ -2441,3 +2444,130 @@ test("the window capture is scaled down past the upload-friendly width and refus
|
||||
const blank = captureHandler(() => ({ webContents: { capturePage: async () => fakeImage(0, { empty: true }) } }));
|
||||
assert.equal(await blank({}), null);
|
||||
});
|
||||
|
||||
test("a folder panel that closes before anyone could read it is reported as never drawn on Linux", () => {
|
||||
const closed = { canceled: true, filePaths: [] };
|
||||
assert.equal(folderAnswer(closed, 20, "linux"), null);
|
||||
assert.equal(folderAnswer(closed, 5000, "linux"), "");
|
||||
assert.equal(folderAnswer(closed, 20, "darwin"), "");
|
||||
assert.equal(folderAnswer(closed, 20, "win32"), "");
|
||||
assert.equal(folderAnswer({ canceled: false, filePaths: ["/home/a/p"] }, 20, "linux"), "/home/a/p");
|
||||
});
|
||||
|
||||
// Who keeps the keyboard while the agent drives a page.
|
||||
const { KeyboardKeeper, PERSON_MS } = require("../src/keyboard.js");
|
||||
|
||||
function keyboardWindow({ appFocused = true, windowFocused = true } = {}) {
|
||||
const contents = Object.assign(new EventEmitter(), { focused: appFocused, focus() { this.focused = true; this.emit("focus"); }, isFocused() { return this.focused; } });
|
||||
const win = { webContents: contents, isDestroyed: () => false, isFocused: () => windowFocused };
|
||||
return { win, contents };
|
||||
}
|
||||
function keyboardPage() {
|
||||
return new EventEmitter();
|
||||
}
|
||||
|
||||
const turn = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
test("a page the agent opens does not take the keyboard from the app page", async () => {
|
||||
const { win, contents } = keyboardWindow();
|
||||
const keeper = new KeyboardKeeper({ win });
|
||||
const page = keyboardPage();
|
||||
page.isFocused = () => true;
|
||||
keeper.watch(page);
|
||||
contents.focused = false;
|
||||
page.emit("focus");
|
||||
assert.equal(contents.focused, false, "not from inside the platform's own focus event");
|
||||
await turn();
|
||||
assert.equal(contents.focused, true);
|
||||
assert.equal(keeper.owner, "app");
|
||||
});
|
||||
|
||||
test("a click or a key from the person into the page is theirs to have chosen", () => {
|
||||
for (const arrive of [(page) => page.emit("before-input-event", {}, { type: "keyDown" }), (page) => page.emit("input-event", {}, { type: "mouseDown" })]) {
|
||||
const { win, contents } = keyboardWindow();
|
||||
const keeper = new KeyboardKeeper({ win });
|
||||
const page = keyboardPage();
|
||||
keeper.watch(page);
|
||||
arrive(page);
|
||||
contents.focused = false;
|
||||
page.emit("focus");
|
||||
assert.equal(contents.focused, false);
|
||||
assert.equal(keeper.owner, "view");
|
||||
}
|
||||
});
|
||||
|
||||
test("a click the agent itself sent over the protocol is not the person's", async () => {
|
||||
const { win, contents } = keyboardWindow();
|
||||
const keeper = new KeyboardKeeper({ win });
|
||||
const page = keyboardPage();
|
||||
page.isFocused = () => true;
|
||||
keeper.watch(page, () => true);
|
||||
page.emit("input-event", {}, { type: "mouseDown" });
|
||||
contents.focused = false;
|
||||
page.emit("focus");
|
||||
await turn();
|
||||
assert.equal(contents.focused, true);
|
||||
});
|
||||
|
||||
test("the person's choice stands until they go back to the app page", async () => {
|
||||
const { win, contents } = keyboardWindow();
|
||||
const keeper = new KeyboardKeeper({ win });
|
||||
const page = keyboardPage();
|
||||
page.isFocused = () => true;
|
||||
keeper.watch(page);
|
||||
page.emit("input-event", {}, { type: "mouseDown" });
|
||||
page.emit("focus");
|
||||
assert.equal(keeper.owner, "view");
|
||||
contents.focused = false;
|
||||
page.emit("focus");
|
||||
await turn();
|
||||
assert.equal(contents.focused, false, "the agent navigating again does not pull it back");
|
||||
contents.focus();
|
||||
assert.equal(keeper.owner, "app");
|
||||
});
|
||||
|
||||
test("nothing is taken back when the app page did not have the keyboard, or the window is not in front", async () => {
|
||||
let { win, contents } = keyboardWindow({ appFocused: false });
|
||||
let keeper = new KeyboardKeeper({ win });
|
||||
let page = keyboardPage();
|
||||
page.isFocused = () => true;
|
||||
keeper.watch(page);
|
||||
page.emit("focus");
|
||||
await turn();
|
||||
assert.equal(contents.focused, false);
|
||||
({ win, contents } = keyboardWindow({ windowFocused: false }));
|
||||
keeper = new KeyboardKeeper({ win });
|
||||
page = keyboardPage();
|
||||
page.isFocused = () => true;
|
||||
keeper.watch(page);
|
||||
contents.focused = false;
|
||||
page.emit("focus");
|
||||
await turn();
|
||||
assert.equal(contents.focused, false);
|
||||
({ win, contents } = keyboardWindow());
|
||||
keeper = new KeyboardKeeper({ win });
|
||||
page = keyboardPage();
|
||||
page.isFocused = () => true;
|
||||
keeper.watch(page);
|
||||
contents.focused = false;
|
||||
page.emit("focus");
|
||||
page.emit("before-input-event", {}, { type: "keyDown" });
|
||||
await turn();
|
||||
assert.equal(contents.focused, false, "a key from the person in the meantime is theirs");
|
||||
});
|
||||
|
||||
test("reclaim hands the keyboard back only when nobody holds it and the app page owned it", () => {
|
||||
const { win, contents } = keyboardWindow();
|
||||
const keeper = new KeyboardKeeper({ win });
|
||||
contents.focused = false;
|
||||
keeper.reclaim();
|
||||
assert.equal(contents.focused, true);
|
||||
keeper.owner = "view";
|
||||
contents.focused = false;
|
||||
keeper.reclaim();
|
||||
assert.equal(contents.focused, false);
|
||||
});
|
||||
|
||||
test("a person's click is only a click for a moment", () => {
|
||||
assert.ok(PERSON_MS > 0 && PERSON_MS < 2000);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-perf
|
||||
dist-perf*
|
||||
*.png
|
||||
!public/icons/*.png
|
||||
tsconfig.tsbuildinfo
|
||||
dist-relay*
|
||||
relay/bin
|
||||
@@ -78,6 +78,7 @@ node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏
|
||||
node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶部、有自己的底(浅/深)
|
||||
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
|
||||
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
|
||||
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
|
||||
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
|
||||
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// The product mark in the sidebar's top-left is one row: the wordmark and a bordered uppercase badge, vertically centred on
|
||||
// each other. Where the badge was hidden before it stays hidden.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
let seen = null;
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1280, 700]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height: 800 }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".studio-brand-lockup");
|
||||
await page.waitForTimeout(500);
|
||||
const m = await page.evaluate(() => {
|
||||
const logo = document.querySelector(".studio-brand-logo").getBoundingClientRect();
|
||||
const sub = document.querySelector(".studio-brand-sub");
|
||||
const r = sub.getBoundingClientRect();
|
||||
const cs = getComputedStyle(sub);
|
||||
return {
|
||||
shown: r.width > 0,
|
||||
left: r.left,
|
||||
top: r.top,
|
||||
dy: Math.abs(logo.top + logo.height / 2 - (r.top + r.height / 2)),
|
||||
after: r.left >= logo.right - 1,
|
||||
fill: cs.backgroundColor, ink: cs.color, radius: cs.borderTopLeftRadius, upper: cs.textTransform, text: sub.textContent,
|
||||
};
|
||||
});
|
||||
const tag = `${scheme} ${width}px`;
|
||||
if (width >= 760) {
|
||||
check(`${tag}: the badge is on the wordmark's row, vertically centred on it`, m.shown && m.dy <= 1.5 && m.after, JSON.stringify(m));
|
||||
check(`${tag}: it is a filled uppercase badge in both themes and the text is untouched`, m.fill !== "rgba(0, 0, 0, 0)" && m.fill !== m.ink && m.radius !== "0px" && m.upper === "uppercase" && m.text === "studio", JSON.stringify(m));
|
||||
if (!seen) seen = { left: m.left, top: m.top };
|
||||
check(`${tag}: it sits in the same place whichever theme is on`, Math.abs(m.left - seen.left) <= 0.5 && Math.abs(m.top - seen.top) <= 0.5, `${m.left},${m.top} vs ${seen.left},${seen.top}`);
|
||||
} else {
|
||||
check(`${tag}: the badge stays hidden on a narrow window`, !m.shown);
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,57 @@
|
||||
// A call brought to the end of the transcript is the same row it is where it happened, drawn open: no box, no icon
|
||||
// square, no vertical track — the status glyph, the title, the subject and the meta on one row over a hairline.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1100, height: 900 }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "run" });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}' } });
|
||||
window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}', output: "ok\n", durationMs: 900 } });
|
||||
window.__feed({ kind: "text", text: "done" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
await page.evaluate(() => [...document.querySelectorAll('[data-action="tool.bring"]')].pop()?.click());
|
||||
await page.waitForSelector(".brought-item .call");
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(200);
|
||||
const m = await page.evaluate(() => {
|
||||
const call = document.querySelector(".brought-item .call");
|
||||
const g = call.querySelector(":scope > .g");
|
||||
if (!call.closest(".flow")) return { iconColumn: "outside .flow" };
|
||||
const c = call.querySelector(":scope > .c");
|
||||
const cs = getComputedStyle(c);
|
||||
return {
|
||||
iconColumn: g ? getComputedStyle(g).display : "absent",
|
||||
sides: [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth].join(","),
|
||||
radius: cs.borderTopLeftRadius,
|
||||
bg: cs.backgroundColor,
|
||||
caption: !!document.querySelector(".brought-bar span"),
|
||||
open: !!call.querySelector("details[open]"),
|
||||
};
|
||||
});
|
||||
check(`${scheme}: no icon column or vertical track`, m.iconColumn === "none" || m.iconColumn === "absent", m.iconColumn);
|
||||
check(`${scheme}: no box around the row`, m.sides === "0px,0px,0px" && m.radius === "0px" && (m.bg === "rgba(0, 0, 0, 0)" || m.bg === "transparent"), JSON.stringify(m));
|
||||
check(`${scheme}: opened, with its caption above`, m.open && m.caption);
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,64 @@
|
||||
// Cards the reader reads or acts on share one shell: the same radius, border and ground, and a header with no fill of its
|
||||
// own. A kind that draws its own (a different radius, a state-tinted border, a filled strip) reads as another family.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
|
||||
const f = window.__feed;
|
||||
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "跑一遍" });
|
||||
f({ kind: "text", text: "我来处理。" });
|
||||
f({ kind: "tool_dispatch", tool: { id: "rm", name: "remember", args: '{}' } });
|
||||
f({ kind: "tool_result", tool: { id: "rm", name: "remember", output: "saved", args: JSON.stringify({ name: "n", title: "标题", description: "一句话", scope: "project", activation: "always", body: "正文" }) } });
|
||||
f({ kind: "guardian_assessment", guardian: { id: "gd", tool: "bash", subject: "rm -rf /", outcome: "deny", risk_level: "critical", user_authorization: "unknown", rationale: "越界", duration_ms: 900 } });
|
||||
f({ kind: "compaction_done", compaction: { trigger: "capacity", messages: 40, summary: "折叠了前 40 条。", sourceTokens: 90000, projectionTokens: 6000, coverageRequired: 6, coverageMissing: 2, boundary: "capacity", triggerTokens: 90000 } });
|
||||
f({ kind: "extension_surface", extension: { pluginId: "cov", surfaceId: "c", kind: "card", card: { title: "覆盖率", text: "78.4%", fields: [{ key: "语句", value: "78.4%" }], progress: 0.78, actions: [{ actionId: "o", label: "打开" }] } } });
|
||||
f({ kind: "turn_done", receipt: { verdict: "partial", changes: [{ path: "a.go", reviewed: true }], verifications: [{ command: "go test ./...", passed: false, stale: true }], gaps: [{ kind: "unverified_mutation", detail: "改了没跑" }], risks: [], unverified: [], saysSomething: true } });
|
||||
f({ kind: "turn_started" });
|
||||
f({ kind: "approval_request", approval: { id: "a1", tool: "bash", subject: "rm -rf build && make" } });
|
||||
f({ kind: "ask_request", ask: { id: "q1", questions: [{ id: "q", header: "选一个", prompt: "走哪条路", options: [{ label: "方案 A" }, { label: "方案 B" }] }] } });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
await page.mouse.move(995, 5);
|
||||
const rows = await page.evaluate(() =>
|
||||
[...document.querySelectorAll(".flow .call")]
|
||||
.filter((c) => ["mem", "host", "extension"].includes(c.getAttribute("data-k")) || c.hasAttribute("data-prompt"))
|
||||
.map((c) => {
|
||||
const cs = getComputedStyle(c.querySelector(":scope > .c"));
|
||||
const hl = c.querySelector(":scope > .c > .hl");
|
||||
return {
|
||||
kind: c.getAttribute("data-k"),
|
||||
radius: cs.borderTopLeftRadius,
|
||||
border: cs.borderTopColor,
|
||||
bg: cs.backgroundColor,
|
||||
head: hl ? getComputedStyle(hl).backgroundColor : "rgba(0, 0, 0, 0)",
|
||||
};
|
||||
}));
|
||||
check(`${scheme}: four kinds of card found`, rows.length === 4, rows.map((r) => r.kind).join(","));
|
||||
const same = (key) => new Set(rows.map((r) => r[key])).size === 1;
|
||||
check(`${scheme}: one radius`, rows.length > 0 && same("radius"), [...new Set(rows.map((r) => r.radius))].join(" / "));
|
||||
check(`${scheme}: one border colour, whatever the state`, rows.length > 0 && same("border"), [...new Set(rows.map((r) => r.border))].join(" / "));
|
||||
check(`${scheme}: one ground`, rows.length > 0 && same("bg"), [...new Set(rows.map((r) => r.bg))].join(" / "));
|
||||
check(`${scheme}: no header fill`, rows.length > 0 && rows.every((r) => r.head === "rgba(0, 0, 0, 0)"), [...new Set(rows.map((r) => r.head))].join(" / "));
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -101,6 +101,12 @@ check("回执没有重复根容器", await page.locator(".rc > .rc").count() ===
|
||||
// between any two glyphs, so it is the one that must not give up its width.
|
||||
const rc = await browser.newPage({ viewport: { width: 1440, height: 900 }, locale: "zh-CN", colorScheme: "dark" });
|
||||
rc.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await rc.addInitScript(() => {
|
||||
try {
|
||||
localStorage.setItem("rx-turn-receipt", "on");
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
await rc.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await rc.waitForSelector(".compose");
|
||||
await rc.evaluate(() => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// The composer keeps the 1.x feel: a ring of light sweeps its border once when
|
||||
// it is hovered or focused, nothing sweeps while a turn runs or when motion is
|
||||
// reduced, and the run state is announced rather than printed inside the box.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const shotDir = process.env.SHOTS;
|
||||
const sweep = (page) => page.evaluate(() => getComputedStyle(document.querySelector(".compose"), "::before").animationName);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const running of [false, true]) {
|
||||
const label = `${scheme} ${running ? "running" : "idle"}`;
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(BASE + (running ? "&running=1" : ""), { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
if (running) {
|
||||
await page.waitForFunction(() => typeof window.__feed === "function");
|
||||
await page.waitForTimeout(400);
|
||||
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
|
||||
}
|
||||
await page.waitForSelector(running ? ".studio-runstate[data-running]" : ".studio-runstate[data-idle]");
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const state = await page.evaluate(() => {
|
||||
const status = document.querySelector(".compose .studio-runstate");
|
||||
const word = status?.querySelector(".studio-runlabel > span.sr-only");
|
||||
const r = word?.getBoundingClientRect();
|
||||
const clock = status?.querySelector(".studio-runclock");
|
||||
return {
|
||||
role: status?.getAttribute("role"),
|
||||
live: status?.getAttribute("aria-live"),
|
||||
said: word?.textContent ?? "",
|
||||
wordBox: r ? [Math.round(r.width), Math.round(r.height)] : null,
|
||||
clockShown: !!clock && clock.getBoundingClientRect().width > 0,
|
||||
mark: !!status?.querySelector(".rmark"),
|
||||
};
|
||||
});
|
||||
check(`${label}: the state is announced`, state.role === "status" && state.live === "polite" && state.said.length > 0, state.said);
|
||||
check(`${label}: the word is not printed in the box`, state.wordBox && state.wordBox[0] <= 2 && state.wordBox[1] <= 2, JSON.stringify(state.wordBox));
|
||||
check(`${label}: the mark stays`, state.mark);
|
||||
if (running) check(`${label}: the clock stays`, state.clockShown);
|
||||
|
||||
check(`${label}: nothing sweeps at rest`, (await sweep(page)) === "none");
|
||||
await page.hover(".compose textarea");
|
||||
await page.waitForTimeout(300);
|
||||
const hovered = await sweep(page);
|
||||
check(`${label}: ${running ? "no sweep on hover while running" : "a sweep on hover"}`, running ? hovered === "none" : hovered === "compose-sweep", hovered);
|
||||
if (shotDir && !running) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-hover.png` });
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(100);
|
||||
await page.focus(".compose textarea");
|
||||
await page.waitForTimeout(300);
|
||||
const focused = await sweep(page);
|
||||
check(`${label}: ${running ? "no sweep on focus while running" : "a sweep on focus"}`, running ? focused === "none" : focused === "compose-sweep", focused);
|
||||
if (shotDir) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-${running ? "running" : "idle"}.png` });
|
||||
await page.close();
|
||||
}
|
||||
const calm = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
await calm.goto(BASE, { waitUntil: "networkidle" });
|
||||
await calm.waitForSelector(".compose");
|
||||
await calm.hover(".compose textarea");
|
||||
await calm.focus(".compose textarea");
|
||||
await calm.waitForTimeout(300);
|
||||
check(`${scheme}: no sweep when motion is reduced`, (await sweep(calm)) === "none");
|
||||
await calm.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// The context card opens on hover and holds buttons (compress now, manage MCP).
|
||||
// Reaching them means crossing the gap between the meter and the card; a card
|
||||
// that closes while the pointer is in that gap can never be used. Hover is a
|
||||
// layout question, so it is asked of a real browser, with the pointer moving
|
||||
// the way a hand does: in steps, through the gap.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 }, colorScheme: scheme, locale: "zh-CN" });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".studio-meter-context", { timeout: 20000 });
|
||||
const trigger = page.locator(".studio-meter-context").first();
|
||||
const tb = await trigger.boundingBox();
|
||||
await page.mouse.move(tb.x + tb.width / 2, tb.y + tb.height / 2);
|
||||
await page.waitForTimeout(250);
|
||||
const card = page.locator(".studio-context-anchor .chrome-context-card");
|
||||
check(`${scheme}: the card opens while the pointer is on the meter`, await card.isVisible());
|
||||
const target = card.locator("button, a").first();
|
||||
const cb = await target.boundingBox();
|
||||
// Up through the gap in small steps, then onto the card's own control.
|
||||
await page.mouse.move(cb.x + cb.width / 2, cb.y + cb.height / 2, { steps: 40 });
|
||||
await page.waitForTimeout(250);
|
||||
check(`${scheme}: the card is still open after crossing the gap`, await card.isVisible());
|
||||
const hit = await target.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return at === el || el.contains(at);
|
||||
});
|
||||
check(`${scheme}: its first control takes the pointer`, hit);
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,46 @@
|
||||
// An opened step shows its detail as a panel in place, bordered on all four sides by one hairline. A rule on one side only
|
||||
// is a rail, and the transcript has no rails.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
const f = window.__feed;
|
||||
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
|
||||
f({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}' } });
|
||||
f({ kind: "tool_result", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}', output: "ok\n", durationMs: 900 } });
|
||||
f({ kind: "text", text: "done" });
|
||||
f({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => document.querySelectorAll("details").forEach((d) => (d.open = true)));
|
||||
await page.waitForTimeout(400);
|
||||
await page.mouse.move(995, 5);
|
||||
const m = await page.evaluate(() =>
|
||||
[...document.querySelectorAll(".flow .tool-disclosure > .out")].map((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return { sides: [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth].join(","), color: [cs.borderTopColor, cs.borderRightColor, cs.borderBottomColor, cs.borderLeftColor] };
|
||||
}));
|
||||
check(`${scheme}: an opened step has a detail panel`, m.length > 0, `${m.length} found`);
|
||||
check(`${scheme}: it is bordered on all four sides`, m.length > 0 && m.every((x) => x.sides === "1px,1px,1px,1px" && new Set(x.color).size === 1), JSON.stringify(m[0] ?? {}));
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -1,4 +1,4 @@
|
||||
// A docked browser column starts at the top of the pane, beside the navigation row, and gives the rows back when it
|
||||
// A docked browser column starts at the top of the pane, and gives the rows back when it
|
||||
// is undocked. Where things start is a layout question, so it is asked of a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
@@ -19,7 +19,7 @@ const geometry = (page) =>
|
||||
};
|
||||
const card = document.querySelector('.pane > .scroll[data-pane="browser"] > .workbench');
|
||||
const divider = card ? parseFloat(getComputedStyle(card).borderLeftWidth) : 0;
|
||||
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody"), strip: box(".workbench-tabs"), divider };
|
||||
return { pane: box(".pane"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody"), strip: box(".workbench-tabs"), divider };
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
@@ -40,26 +40,24 @@ for (const scheme of ["light", "dark"]) {
|
||||
check(`${tag} ${round}: the browser column is drawn`, !!open.side);
|
||||
if (open.side) {
|
||||
if (label === "wide") {
|
||||
check(`${tag} ${round}: it starts at the top of the pane, beside the navigation row`, Math.abs(open.side.top - open.pane.top) <= 1 && Math.abs(open.tabs.top - open.pane.top) <= 1, `pane ${open.pane.top} tabs ${open.tabs.top} column ${open.side.top}`);
|
||||
check(`${tag} ${round}: it starts at the top of the pane`, Math.abs(open.side.top - open.pane.top) <= 1, `pane ${open.pane.top} column ${open.side.top}`);
|
||||
check(`${tag} ${round}: it runs to the bottom of the pane`, Math.abs(open.side.bottom - open.pane.bottom) <= 1, `${open.side.bottom} against ${open.pane.bottom}`);
|
||||
check(`${tag} ${round}: the navigation row stays left of it`, open.tabs.right <= open.side.left + 1, `${open.tabs.right} / ${open.side.left}`);
|
||||
check(`${tag} ${round}: its strip ends on the navigation row's line, so the two rows read as one`, !!open.strip && Math.abs(open.strip.bottom - open.tabs.bottom) <= 1, JSON.stringify([open.tabs, open.strip]));
|
||||
check(`${tag} ${round}: the workbench card's own edge separates it from the conversation`, open.divider >= 1, String(open.divider));
|
||||
} else {
|
||||
check(`${tag} ${round}: the navigation row stays above the page`, !!open.tabs && open.side.top >= open.tabs.bottom - 1, JSON.stringify([open.tabs, open.side]));
|
||||
check(`${tag} ${round}: the page takes the pane's full width when it stacks`, open.side.left <= open.pane.left + 1 && open.side.right >= open.pane.right - 1, JSON.stringify([open.pane, open.side]));
|
||||
}
|
||||
}
|
||||
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
|
||||
}
|
||||
await page.locator('[data-action="pane.view"][data-value="flow"]').first().click();
|
||||
await page.locator('[data-action="pane.view"][data-value="browser"][aria-pressed="true"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const shut = await geometry(page);
|
||||
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
|
||||
check(`${tag} undocked: the column is gone`, !shut.side || shut.side.left >= shut.pane.right - 1, JSON.stringify([shut.side, shut.pane]));
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
// Every room between phone and wide, not two samples: the left column shrinks with the window, and the tabs it holds
|
||||
// must still fit and stay clear of the browser column.
|
||||
// Every room between phone and wide, not two samples: the conversation shrinks with the window, and it must
|
||||
// stay clear of the browser column, which stacks over it once the window is narrow.
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 800 } });
|
||||
const bad = [];
|
||||
@@ -70,22 +68,15 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const g = await page.evaluate(() => {
|
||||
const tabs = document.querySelector(".pane > .tabs");
|
||||
const side = document.querySelector('.pane > .scroll[data-pane="browser"]');
|
||||
const t = tabs.getBoundingClientRect();
|
||||
const last = [...tabs.querySelectorAll(".tab")].at(-1)?.getBoundingClientRect();
|
||||
const s = side?.getBoundingClientRect();
|
||||
const stacked = !!s && s.top >= t.bottom - 1;
|
||||
const first = tabs.querySelector(".tab")?.getBoundingClientRect();
|
||||
const pad = parseFloat(getComputedStyle(tabs).paddingLeft) * 2;
|
||||
const need = last && first ? last.right - first.left + pad : 0;
|
||||
const pane = document.querySelector(".pane").getBoundingClientRect().width;
|
||||
return { fits: tabs.scrollWidth <= tabs.clientWidth + 1, clear: stacked || !s || !last || last.right <= s.left + 1, stacked, need, pane };
|
||||
const side = document.querySelector('.pane > .scroll[data-pane="browser"]')?.getBoundingClientRect();
|
||||
const body = document.querySelector(".pane > .pbody")?.getBoundingClientRect();
|
||||
const pane = document.querySelector(".pane").getBoundingClientRect();
|
||||
const stacked = !!side && side.left <= pane.left + 40;
|
||||
return { stacked, clear: stacked || !side || !body || body.right <= side.left + 1 };
|
||||
});
|
||||
const should = g.pane < g.need + 320 || width <= 640;
|
||||
if (!g.fits || !g.clear || g.stacked !== should) bad.push(`${width}:${g.fits ? "" : "overflow "}${g.clear ? "" : "overlap "}${g.stacked !== should ? `stacked=${g.stacked} pane=${Math.round(g.pane)} need=${Math.round(g.need)}` : ""}`);
|
||||
if (!g.clear || (width <= 640 && !g.stacked) || (width >= 1000 && g.stacked)) bad.push(`${width}:${g.clear ? "" : "overlap "}stacked=${g.stacked}`);
|
||||
}
|
||||
check("from 560 to 1440 the tabs fit their row, stay clear of the browser column, and the column stacks exactly when tabs plus its 320px floor no longer fit", bad.length === 0, bad.join(" "));
|
||||
check("from 560 to 1440 the conversation stays clear of the browser column, which stacks only in a narrow window", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Keyboard focus has to be seen. Every button the keyboard reaches in the workbench must look different when focused for a
|
||||
// reason other than fading in: an outline, a shadow, a border or a background. A control that only changes opacity is
|
||||
// invisible to anyone who cannot tell .45 from 1.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 900 }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(700);
|
||||
await page.mouse.move(1270, 5);
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
const look = (el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return [cs.outlineStyle === "none" ? "" : `${cs.outlineWidth} ${cs.outlineColor}`, cs.boxShadow, cs.borderColor, cs.backgroundColor, cs.textDecorationLine].join("|");
|
||||
};
|
||||
const seen = new Set();
|
||||
let stops = 0;
|
||||
const bad = [];
|
||||
for (let i = 0; i < 90; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
const r = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
if (!el || el === document.body || el.tagName !== "BUTTON") return null;
|
||||
const cs = getComputedStyle(el);
|
||||
const focused = [cs.outlineStyle === "none" ? "" : `${cs.outlineWidth} ${cs.outlineColor}`, cs.boxShadow, cs.borderColor, cs.backgroundColor, cs.textDecorationLine].join("|");
|
||||
const id = (el.getAttribute("aria-label") || el.textContent || el.getAttribute("data-action") || "button").trim().slice(0, 40);
|
||||
el.blur();
|
||||
const css2 = getComputedStyle(el);
|
||||
const plain = [css2.outlineStyle === "none" ? "" : `${css2.outlineWidth} ${css2.outlineColor}`, css2.boxShadow, css2.borderColor, css2.backgroundColor, css2.textDecorationLine].join("|");
|
||||
const r = el.getBoundingClientRect();
|
||||
el.focus({ focusVisible: true });
|
||||
return { id, key: id + Math.round(r.left) + Math.round(r.top), same: focused === plain, shown: r.width > 0 && r.height > 0 };
|
||||
});
|
||||
if (!r) continue;
|
||||
if (seen.has(r.key)) break;
|
||||
seen.add(r.key);
|
||||
stops++;
|
||||
if (r.shown && r.same) bad.push(r.id);
|
||||
}
|
||||
console.log(`${bad.length ? "FAIL" : " ok"} ${scheme}: ${stops} buttons reached by Tab${bad.length ? ", no visible focus on: " + bad.join("; ") : ""}`);
|
||||
if (bad.length) fails.push(`${scheme}: ${bad.join("; ")}`);
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,90 @@
|
||||
// Interface text has one size dial, --fs-scale, and the transcript has its own,
|
||||
// --read. Every piece of text must answer to one of them: text that moves with
|
||||
// neither is a size written into a stylesheet as a bare pixel count, which the
|
||||
// text-size setting could never reach.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const SCENES = [
|
||||
{ name: "conversation", q: "?pref=zh&ws=2&sess=6&turns=3" },
|
||||
{ name: "files panel", q: "?pref=zh&ws=2&sess=6&turns=2", act: async (p) => { await p.locator('.navbtn[aria-label="文件"]').first().click(); await p.waitForSelector(".workbench-filelist button"); } },
|
||||
{ name: "side panel", q: "?pref=zh&turns=2", act: async (p) => { await p.locator('[data-action="side.toggle"]').click(); await p.waitForSelector(".sidepanel"); } },
|
||||
{ name: "narrow", q: "?pref=zh&ws=2&sess=6&turns=2", w: 390 },
|
||||
{ name: "settings", q: "?pref=zh&turns=2", tabs: true },
|
||||
];
|
||||
|
||||
const sizes = (page) => page.evaluate(() => {
|
||||
window.__texts ??= (() => {
|
||||
const out = [];
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
let n;
|
||||
while ((n = walker.nextNode())) {
|
||||
if (![...n.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim())) continue;
|
||||
const r = n.getBoundingClientRect();
|
||||
if (r.width < 2 || r.height < 2 || !n.checkVisibility({ checkVisibilityCSS: true, contentVisibilityAuto: true })) continue;
|
||||
out.push(n);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
return window.__texts.map((el) => parseFloat(getComputedStyle(el).fontSize));
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const sc of SCENES) {
|
||||
const page = await browser.newPage({ viewport: { width: sc.w ?? 1400, height: 900 }, locale: "zh-CN", reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(BASE + sc.q, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const views = [];
|
||||
if (sc.tabs) {
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
const n = await page.evaluate(() => document.querySelectorAll(".prefs-nav button").length);
|
||||
for (let i = 0; i < n; i++) views.push(async () => { await page.evaluate((k) => document.querySelectorAll(".prefs-nav button")[k].click(), i); });
|
||||
} else views.push(async () => { await sc.act?.(page); });
|
||||
let total = 0;
|
||||
const holes = new Map();
|
||||
for (const show of views) {
|
||||
await show();
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => { window.__texts = undefined; document.documentElement.style.setProperty("--fs-scale", "1"); });
|
||||
const base = await sizes(page);
|
||||
await page.evaluate(() => document.documentElement.style.setProperty("--fs-scale", "1.25"));
|
||||
await page.waitForTimeout(80);
|
||||
const scaled = await sizes(page);
|
||||
await page.evaluate(() => {
|
||||
const root = document.documentElement.style;
|
||||
window.__read = root.getPropertyValue("--read");
|
||||
root.setProperty("--fs-scale", "1");
|
||||
root.setProperty("--read", `${parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--read")) * 1.25}px`);
|
||||
});
|
||||
await page.waitForTimeout(80);
|
||||
const reading = await sizes(page);
|
||||
await page.evaluate(() => {
|
||||
const root = document.documentElement.style;
|
||||
if (window.__read) root.setProperty("--read", window.__read);
|
||||
else root.removeProperty("--read");
|
||||
});
|
||||
const names = await page.evaluate(() => window.__texts.map((el) => (el.className && typeof el.className === "string" ? el.className.trim().split(/\s+/).slice(0, 2).join(".") : el.tagName.toLowerCase()) + " " + el.textContent.trim().slice(0, 10)));
|
||||
base.forEach((b, i) => {
|
||||
if (b <= 0) return;
|
||||
total++;
|
||||
const ok = Math.abs(scaled[i] / b - 1.25) < 0.03 || Math.abs(reading[i] / b - 1.25) < 0.03;
|
||||
if (!ok) holes.set(`${names[i]} (${b}px)`, (holes.get(`${names[i]} (${b}px)`) ?? 0) + 1);
|
||||
});
|
||||
}
|
||||
check(`${sc.name}: every piece of text answers to the text size or the reading size`, holes.size === 0, `${total} text nodes${holes.size ? "; stuck: " + [...holes.keys()].slice(0, 6).join(", ") : ""}`);
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import "../src/styles/app.css";
|
||||
import "../src/styles/dock.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/changedpill.css";
|
||||
import "../src/styles/askskip.css";
|
||||
import "../src/styles/pastekeep.css";
|
||||
import "../src/styles/ctxitems.css";
|
||||
import "../src/styles/sidepanel.css";
|
||||
import "../src/styles/contentmatches.css";
|
||||
import "../src/styles/scrolljump.css";
|
||||
import "../src/styles/chart.css";
|
||||
import "../src/styles/community.css";
|
||||
@@ -25,9 +27,12 @@ import "../src/styles/dialogcenter.css";
|
||||
import "../src/styles/treerows.css";
|
||||
import "../src/styles/workbench-changes.css";
|
||||
import "../src/styles/gitgraph.css";
|
||||
import "../src/styles/vthumb.css";
|
||||
import "../src/styles/tips.css";
|
||||
import "../src/styles/waitnote.css";
|
||||
import "../src/styles/bring.css";
|
||||
import "../src/styles/line.css";
|
||||
import "../src/styles/notice.css";
|
||||
import "../src/styles/peers.css";
|
||||
import "../src/styles/jsontext.css";
|
||||
import "../src/styles/skillpin.css";
|
||||
import "../src/styles/narrow.css";
|
||||
@@ -36,13 +41,16 @@ import "../src/styles/workbench-launcher.css";
|
||||
import "../src/styles/feedback.css";
|
||||
import "../src/styles/toasts.css";
|
||||
import "../src/styles/modelmenu.css";
|
||||
import "../src/styles/composer-menu.css";
|
||||
import "../src/styles/runsignals.css";
|
||||
import "../src/styles/overview.css";
|
||||
import "../src/styles/themes.css";
|
||||
import { App } from "../src/ui/App";
|
||||
import { MockHub } from "../src/port/mock_hub";
|
||||
import { MockPort } from "../src/port/mock";
|
||||
import { fromHistory } from "../src/state/session";
|
||||
import type { AgentPort, Appearance, HistoryMessage } from "../src/port/port";
|
||||
import type { UpdateProgress } from "../src/port/version";
|
||||
import type { ThemePack } from "../src/port/look";
|
||||
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
|
||||
import type { WireEvent } from "../src/port/wire";
|
||||
@@ -86,18 +94,35 @@ function benchPack(): ThemePack | null {
|
||||
}
|
||||
|
||||
class BenchPort extends MockPort {
|
||||
// ?checkfail=<name> makes a plain test of that source answer 401.
|
||||
protected async answerCheck(name: string) {
|
||||
if (query.get("checkfail") !== name) return super.answerCheck(name);
|
||||
return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
|
||||
}
|
||||
|
||||
async browserTabs() {
|
||||
if (!query.has("agentpage")) return super.browserTabs();
|
||||
return [{ id: "t1", target: "view-a", url: "https://example.com/docs/install", title: "Install guide", active: true }];
|
||||
}
|
||||
|
||||
async workspaceFile(path: string) {
|
||||
// ?grant=1 grants the workspace a folder, /library, holding one document.
|
||||
async workspaceRoots() {
|
||||
const roots = await super.workspaceRoots();
|
||||
return query.has("grant") ? { ...roots, extra: ["/library"] } : roots;
|
||||
}
|
||||
|
||||
async workspaceFile(path: string, root?: string) {
|
||||
if (root) {
|
||||
const content = "# Release plan\n\nThe plan lives in the library folder you granted this project.\n\n- freeze on Friday\n- tag after the checks are green\n- announce the next morning\n";
|
||||
return { path, content, revision: "r1", root, readOnly: true };
|
||||
}
|
||||
if (!query.has("doc") || !path.endsWith(".md")) return super.workspaceFile(path);
|
||||
const content = "# Install guide\n\nDownload the package for your system, then run the installer. It places the program in your user folder and does not need administrator rights.\n\nAfter the first start, open Settings and add a model service before you begin a conversation.";
|
||||
return { path, content, revision: "r1" };
|
||||
}
|
||||
|
||||
async versions() {
|
||||
if (query.has("update")) return { current: "2.42.0", pinned: "", stalePin: false, latest: "2.43.0", newer: true, versions: [] };
|
||||
if (!query.has("versions")) return super.versions();
|
||||
const names = ["2.38.0", "2.37.0", "2.36.0", "2.35.0", "2.34.0", "2.33.0", "2.32.0", "2.31.0", "2.30.0", "2.29.0"];
|
||||
return {
|
||||
@@ -135,6 +160,15 @@ class BenchPort extends MockPort {
|
||||
return ONBOARDING ? { required: true, noProvider: true, provider: "", model: "" } : null;
|
||||
}
|
||||
|
||||
async peers() {
|
||||
if (!query.has("peers")) return super.peers();
|
||||
return {
|
||||
enabled: true,
|
||||
peers: [{ key: "s-1a2b3c4d", title: "拾音", project: "rx-archive" }, { key: "s-5e6f7a8b", title: "文档整理", project: "docs" }],
|
||||
held: [{ id: "m-1", fromSession: "s-1a2b3c4d", fromTitle: "拾音", fromProject: "rx-archive", sentAt: "2026-10-16T12:00:00Z", body: "迁移脚本跑完了,结果在 out/report.md,请你核对第三节再继续。" }],
|
||||
};
|
||||
}
|
||||
|
||||
async foldWorkSettings() {
|
||||
return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
|
||||
}
|
||||
@@ -210,19 +244,42 @@ class BenchPort extends MockPort {
|
||||
private readonly jobsSince = Date.now() - 739_000;
|
||||
|
||||
async status() {
|
||||
const st = await super.status();
|
||||
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}) };
|
||||
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
|
||||
if (!JOBS) return st;
|
||||
const jobs = Array.from({ length: JOBS }, (_, i) => ({
|
||||
id: `job-${i}`,
|
||||
kind: "bash",
|
||||
label: i === 0 ? "npm run tauri:test 2>&1 | Select-Object -Last 40" : `go test ./internal/... -run Case${i}`,
|
||||
// ?jobnames=1 gives each job the name its caller wrote and the full command.
|
||||
...(query.has("jobnames") ? {
|
||||
description: i === 0 ? "运行桌面端测试并只看最后 40 行输出" : `跑第 ${i} 组内核测试`,
|
||||
detail: i === 0 ? "npm run tauri:test 2>&1 | Select-Object -Last 40" : `go test ./internal/... -run Case${i} -count=1 -timeout 10m`,
|
||||
} : {}),
|
||||
status: this.stopped.has(`job-${i}`) ? "killed" : "running",
|
||||
startedAt: this.jobsSince,
|
||||
}));
|
||||
return { ...st, jobs };
|
||||
}
|
||||
|
||||
async switchBranch(name: string) {
|
||||
if (SWITCH_MS > 0) await new Promise((r) => setTimeout(r, SWITCH_MS));
|
||||
return super.switchBranch(name);
|
||||
}
|
||||
|
||||
async goToVersion() {}
|
||||
|
||||
onUpdateProgress(cb?: (p: UpdateProgress) => void) {
|
||||
if (cb && query.has("update")) queueMicrotask(() => cb({ version: "2.43.0", phase: "ready", received: 0, total: 0 }));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async jobOutput(jobId: string) {
|
||||
if (!JOBS) return super.jobOutput(jobId);
|
||||
const lines = Array.from({ length: 28 }, (_, i) => `[${String(i).padStart(2, "0")}] compiling module ${i * 7 + 3} … ok`);
|
||||
return { text: lines.join("\n") + "\nwatching for changes\n", status: this.stopped.has(jobId) ? "killed" : "running", bytes: 1100, truncated: false };
|
||||
}
|
||||
|
||||
async cancelJob(jobId: string) {
|
||||
if (!JOBS) return super.cancelJob(jobId);
|
||||
this.stopped.add(jobId);
|
||||
@@ -261,6 +318,7 @@ const SESSIONS = Number(query.get("sess") ?? 0);
|
||||
// ?turns= is how long the conversation being opened already is.
|
||||
const TURNS = Number(query.get("turns") ?? 0);
|
||||
const STATUS_MS = Number(query.get("statusms") ?? 0);
|
||||
const SWITCH_MS = Number(query.get("switchms") ?? 0);
|
||||
// ?queue= is how many lines are already waiting when the window opens.
|
||||
const QUEUE = Number(query.get("queue") ?? 0);
|
||||
const QUEUE_BODY = query.has("queuebody");
|
||||
@@ -274,6 +332,16 @@ const NO_PANES = PANES === 0;
|
||||
|
||||
class BenchHub extends MockHub {
|
||||
readonly feeds = new Map<string, BenchPort>();
|
||||
|
||||
// ?search=1 answers every query with two conversations that say it.
|
||||
async searchHistory(q: string) {
|
||||
if (!query.has("search")) return super.searchHistory(q);
|
||||
return [
|
||||
{ path: "/sessions/mock.jsonl", id: "mock", snippet: `…上周讨论过要不要把 ${q} 的轮换放进发布流程,结论是每月一次…`, role: "assistant", messageIndex: 4, score: 2 },
|
||||
{ path: "/sessions/older.jsonl", id: "older", snippet: `怎么把 ${q} 配到预发环境里?`, role: "user", messageIndex: 0, score: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
private readonly settingsPort = new BenchPort();
|
||||
|
||||
machinePort(): AgentPort {
|
||||
|
||||
@@ -14,10 +14,15 @@ const CONTROLS = [".studio-mode-picker", ".studio-branch", ".model-picker", ".co
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const scheme of ["light", "dark"]) for (const turns of [2, 0]) {
|
||||
const context = `${scheme}, ${turns ? "existing conversation" : "new conversation"}`;
|
||||
const url = new URL(BASE);
|
||||
url.searchParams.set("turns", String(turns));
|
||||
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1100, height: 760 } });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose .studio-branch");
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(turns ? ".compose .studio-branch" : ".compose .worktree-trigger");
|
||||
const inline = await page.locator(".compose .studio-branch").first().evaluate((e) => !!e.closest(".turntools"));
|
||||
check(`${context}: workspace control stays in the toolbar`, inline);
|
||||
const seen = [];
|
||||
for (const sel of CONTROLS) {
|
||||
const el = page.locator(`.compose ${sel}`.replace(".compose .compose", ".compose")).first();
|
||||
@@ -26,11 +31,16 @@ try {
|
||||
await page.waitForTimeout(300);
|
||||
seen.push([sel, await el.evaluate((e) => ({ radius: getComputedStyle(e).borderRadius, bg: getComputedStyle(e).backgroundColor }))]);
|
||||
}
|
||||
check(`${scheme}: the hovered controls were all found`, seen.length === CONTROLS.length, `${seen.length} of ${CONTROLS.length}`);
|
||||
check(`${context}: the hovered controls were all found`, seen.length === CONTROLS.length, `${seen.length} of ${CONTROLS.length}`);
|
||||
const radii = new Set(seen.map(([, v]) => v.radius));
|
||||
const grounds = new Set(seen.map(([, v]) => v.bg));
|
||||
check(`${scheme}: one corner radius across the row`, radii.size === 1, [...radii].join(" / "));
|
||||
check(`${scheme}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
|
||||
check(`${context}: one corner radius across the row`, radii.size === 1, [...radii].join(" / "));
|
||||
check(`${context}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
const chip = page.locator(turns ? ".compose .studio-branch" : ".compose .worktree-trigger").first();
|
||||
await chip.click();
|
||||
check(`${context}: a narrow-pane pointer click opens the workspace menu`, await chip.getAttribute("aria-expanded") === "true");
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Interface wording the catalogue test cannot see: a Chinese label kept in a table and drawn without t() passes every
|
||||
// source scan and still shows up in the English window. This opens each settings section in English, fires the probes
|
||||
// that fill in more of the page, and fails on any Chinese the reader would see that is not on the fixture list below.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
// Names the demo data itself carries, and names that are written in Chinese on purpose.
|
||||
const FIXTURE = [
|
||||
/并行会话演示/, /上一次的会话/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
|
||||
/缓存命中率为什么会掉/, /Co-Authored-By/, /Generated with/, /^回复用中文$/, /^跟着用户每条消息的语言走$/, /v2 是从零重写的 Go 内核/,
|
||||
/没有 web,桌面端重做/, /问题里提到了 main-v2/, /构建用 -tags legacy/, /^迁移前的构建方式$/,
|
||||
];
|
||||
const fails = [];
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ locale: "en-US", viewport: { width: 1280, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const scan = () => {
|
||||
const out = [];
|
||||
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
||||
const text = n.textContent.trim();
|
||||
if (text && /[一-鿿]/.test(text) && !["SCRIPT", "STYLE"].includes(n.parentElement?.tagName)) out.push(text);
|
||||
}
|
||||
for (const el of document.querySelectorAll("[aria-label],[title],[placeholder],[alt]"))
|
||||
for (const a of ["aria-label", "title", "placeholder", "alt"]) {
|
||||
const v = el.getAttribute(a);
|
||||
if (v && /[一-鿿]/.test(v)) out.push(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const seen = new Map();
|
||||
const take = async (where) => {
|
||||
for (const text of await page.evaluate(scan)) if (!FIXTURE.some((re) => re.test(text)) && !seen.has(text)) seen.set(text, where);
|
||||
};
|
||||
|
||||
await take("workbench");
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /Back up|repair/i.test(x.textContent))?.click());
|
||||
await page.waitForTimeout(400);
|
||||
const sections = await page.evaluate(() => [...new Set([...document.querySelectorAll('[data-action="settings.section"]')].map((b) => b.getAttribute("data-value")))]);
|
||||
if (sections.length < 8) fails.push(`only ${sections.length} settings sections found; the guard is looking at the wrong page`);
|
||||
for (const value of sections) {
|
||||
await page.evaluate((v) => document.querySelector(`[data-action="settings.section"][data-value="${v}"]`)?.click(), value);
|
||||
await page.waitForTimeout(450);
|
||||
await page.evaluate(() => document.querySelector('[data-action="network.diagnose"]')?.click());
|
||||
await page.evaluate(() => document.querySelectorAll("details").forEach((d) => (d.open = true)));
|
||||
await page.waitForTimeout(500);
|
||||
await take(`settings:${value}`);
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
for (const [text, where] of seen) fails.push(`${where}: ${text.slice(0, 80)}`);
|
||||
if (fails.length) {
|
||||
console.log("Chinese in the English window:\n " + fails.join("\n "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`ok ${sections.length} settings sections, no unlisted Chinese in the English window`);
|
||||
@@ -0,0 +1,52 @@
|
||||
// A background job is named for a person: the list shows the name its caller gave it, and the whole name and the full command
|
||||
// appear in a tip that stays inside the window, can be selected, and opens from the keyboard as well as the pointer.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, height] of [[1280, 860], [400, 760]]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?jobs=3&jobnames=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(800);
|
||||
await page.evaluate(() => [...document.querySelectorAll("button")].find((b) => /后台任务/.test(b.textContent))?.click());
|
||||
await page.waitForSelector(".jobs .job .cmd");
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const shown = await page.evaluate(() => [...document.querySelectorAll(".jobs .job .cmd")].map((e) => e.textContent));
|
||||
check(`${tag}: the list shows the names, not the commands`, shown.length === 3 && shown.every((t) => !/go test|npm run/.test(t)), shown.join(" | "));
|
||||
await page.locator(".jobs .job .cmd").first().hover();
|
||||
await page.waitForSelector(".job-tip");
|
||||
const tip = await page.evaluate(() => {
|
||||
const el = document.querySelector(".job-tip");
|
||||
const r = el.getBoundingClientRect();
|
||||
return { inside: r.left >= 0 && r.top >= 0 && r.right <= innerWidth + 1 && r.bottom <= innerHeight + 1, lines: el.children.length, select: getComputedStyle(el).userSelect, cmd: el.querySelector(".jt-cmd")?.textContent ?? "" };
|
||||
});
|
||||
check(`${tag}: the tip has the name and the full command, inside the window`, tip.inside && tip.lines === 2 && /npm run tauri:test/.test(tip.cmd), JSON.stringify(tip));
|
||||
check(`${tag}: its text can be selected`, tip.select === "text", tip.select);
|
||||
await page.mouse.move(width - 5, 5);
|
||||
await page.waitForTimeout(200);
|
||||
check(`${tag}: it closes when the pointer leaves`, (await page.locator(".job-tip").count()) === 0);
|
||||
await page.locator(".jobs .job .cmd").nth(1).focus();
|
||||
await page.waitForSelector(".job-tip");
|
||||
check(`${tag}: focus from the keyboard opens it too`, /内核测试/.test(await page.locator(".job-tip .jt-name").textContent()));
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(150);
|
||||
check(`${tag}: Escape closes it`, (await page.locator(".job-tip").count()) === 0);
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,46 @@
|
||||
// The two floating buttons are named for the ends they go to ("back to top", "jump to latest"),
|
||||
// so one click has to arrive there, whatever expanded blocks lie between. Where a scroller ends up
|
||||
// is a property of the render, so it is read from the browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=40";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 760 } });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector('[data-pane="flow"]');
|
||||
const geometry = () => page.locator('[data-pane="flow"]').evaluate((el) => ({ top: Math.round(el.scrollTop), room: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop) }));
|
||||
await page.locator('[data-pane="flow"]').evaluate((el) => { el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2); });
|
||||
await page.waitForSelector('.scrolljump button[data-value="up"]');
|
||||
await page.waitForSelector('.scrolljump button[data-value="down"]');
|
||||
await page.evaluate(() => document.querySelectorAll("details.tool-disclosure, details.think").forEach((d) => { d.open = true; }));
|
||||
await page.waitForTimeout(200);
|
||||
const open = await page.locator("details.tool-disclosure[open], details.think[open]").count();
|
||||
check("there are expanded blocks between the middle and each end", open >= 2, `${open} expanded blocks`);
|
||||
check("the conversation is long enough to scroll", (await geometry()).top > 0 && (await geometry()).room > 0);
|
||||
|
||||
await page.click('.scrolljump button[data-value="up"]');
|
||||
await page.waitForTimeout(400);
|
||||
const atTop = await geometry();
|
||||
check("one click on the top button reaches the top", atTop.top <= 1, `scrollTop ${atTop.top}`);
|
||||
|
||||
await page.locator('[data-pane="flow"]').evaluate((el) => { el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2); });
|
||||
await page.waitForSelector('.scrolljump button[data-value="down"]');
|
||||
await page.click('.scrolljump button[data-value="down"]');
|
||||
await page.waitForTimeout(800);
|
||||
const atEnd = await geometry();
|
||||
check("one click on the latest button reaches the end", atEnd.room <= 2, `${atEnd.room}px from the end`);
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// An allowed guardian review at low risk and a remembered fact are Lines, not cards: one row over a hairline with no box.
|
||||
// A refusal, or an allow that carried real risk, keeps the card.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
const f = window.__feed;
|
||||
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
|
||||
f({ kind: "tool_dispatch", tool: { id: "rm", name: "remember", args: "{}" } });
|
||||
f({ kind: "tool_result", tool: { id: "rm", name: "remember", output: "saved", args: JSON.stringify({ name: "n", title: "A fact", description: "why", scope: "project", activation: "always", body: "b" }) } });
|
||||
f({ kind: "guardian_assessment", guardian: { id: "g1", tool: "bash", subject: "go test", outcome: "allow", risk_level: "low", user_authorization: "high", rationale: "read only", duration_ms: 5 } });
|
||||
f({ kind: "guardian_assessment", guardian: { id: "g2", tool: "bash", subject: "git push -f", outcome: "allow", risk_level: "high", user_authorization: "low", rationale: "rewrites history", duration_ms: 5 } });
|
||||
f({ kind: "guardian_assessment", guardian: { id: "g3", tool: "bash", subject: "rm -rf /", outcome: "deny", risk_level: "critical", user_authorization: "unknown", rationale: "no", duration_ms: 5 } });
|
||||
f({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
await page.mouse.move(995, 5);
|
||||
const m = await page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll(".flow .line-row")];
|
||||
const box = (el) => { const cs = getComputedStyle(el); return [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth].join(","); };
|
||||
return {
|
||||
lines: rows.length,
|
||||
boxes: rows.map(box),
|
||||
radius: rows.map((r) => getComputedStyle(r).borderTopLeftRadius),
|
||||
cards: [...document.querySelectorAll('.flow .call[data-k="host"]')].length,
|
||||
forget: !!document.querySelector('.flow .line-row [data-action="memory.forget"]'),
|
||||
oldMem: document.querySelectorAll('.flow .call[data-k="mem"]').length,
|
||||
};
|
||||
});
|
||||
check(`${scheme}: the remembered fact and the low-risk allow are rows`, m.lines === 2, `${m.lines} rows`);
|
||||
check(`${scheme}: rows carry no box`, m.lines > 0 && m.boxes.every((b) => b === "0px,0px,0px") && m.radius.every((r) => r === "0px"), JSON.stringify(m));
|
||||
check(`${scheme}: the high-risk allow and the refusal keep their cards`, m.cards === 2, `${m.cards} cards`);
|
||||
check(`${scheme}: the take-back sits in the row, and no memory card remains`, m.forget && m.oldMem === 0);
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,58 @@
|
||||
// A dialog that declares itself modal keeps the keyboard inside it: Tab from the last control goes back to the first, and
|
||||
// never reaches the sidebar, the transcript or the composer the sheet is covering.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 900 }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const outside = async (selector, presses) => {
|
||||
const out = [];
|
||||
for (let i = 0; i < presses; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
const where = await page.evaluate((sel) => {
|
||||
const el = document.activeElement;
|
||||
return el && el !== document.body && !el.closest(sel) ? (el.getAttribute("aria-label") || el.textContent || el.tagName).trim().slice(0, 30) : "";
|
||||
}, selector);
|
||||
if (where) out.push(where);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-sheet");
|
||||
await page.waitForTimeout(400);
|
||||
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
const settingsOut = await outside(".prefs-sheet", 60);
|
||||
check(`${scheme}: Tab never leaves the settings sheet`, settingsOut.length === 0, settingsOut.slice(0, 3).join("; "));
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
await page.evaluate(() => document.querySelector('[data-action="workspace.search"]')?.click());
|
||||
await page.waitForSelector(".palette");
|
||||
await page.waitForTimeout(300);
|
||||
const paletteOut = await outside(".palette", 30);
|
||||
check(`${scheme}: Tab never leaves the command palette`, paletteOut.length === 0, paletteOut.slice(0, 3).join("; "));
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,59 @@
|
||||
// The connection form is the widest thing in settings. At phone widths its headings stay on one line, rows that cannot fit
|
||||
// wrap instead of overlapping, and nothing passes the right edge of the window.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [360, 390, 430]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height: 844 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
|
||||
await page.waitForTimeout(400);
|
||||
await page.evaluate(() => [...document.querySelectorAll('[data-action="settings.section"]')].find((x) => x.textContent.includes("模型服务"))?.click());
|
||||
await page.waitForSelector(".pdetail", { state: "attached" });
|
||||
await page.evaluate(() => [...document.querySelectorAll("button,li,a")].find((x) => /myrelay-work/.test(x.textContent) && x.textContent.length < 80)?.click());
|
||||
await page.waitForSelector(".pdetail .mlhead", { state: "attached" });
|
||||
const m = await page.evaluate(() => {
|
||||
const live = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden" ? r : null;
|
||||
};
|
||||
const head = document.querySelector(".pdetail .mlhead");
|
||||
const kids = [...head.children].map((el) => [el, live(el)]).filter(([, r]) => r);
|
||||
const overlaps = [];
|
||||
for (let i = 0; i < kids.length; i++)
|
||||
for (let j = i + 1; j < kids.length; j++) {
|
||||
const a = kids[i][1], b = kids[j][1];
|
||||
if (Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1) overlaps.push(i + "/" + j);
|
||||
}
|
||||
const ttl = head.querySelector(".ttl");
|
||||
const lineH = parseFloat(getComputedStyle(ttl).lineHeight) || parseFloat(getComputedStyle(ttl).fontSize) * 1.4;
|
||||
const out = [...document.querySelectorAll(".pdetail *")].filter((el) => live(el) && live(el).right > innerWidth + 1 && !el.closest("[hidden]")).map((el) => String(el.className).split(" ")[0] || el.tagName).slice(0, 4);
|
||||
return { ttlLines: Math.round(ttl.getBoundingClientRect().height / lineH), overlaps, out, hscroll: document.documentElement.scrollWidth > innerWidth + 1 };
|
||||
});
|
||||
check(`${scheme} ${width}px: the model list heading stays on one line`, m.ttlLines === 1, JSON.stringify(m));
|
||||
check(`${scheme} ${width}px: the model list header wraps instead of overlapping`, m.overlaps.length === 0, m.overlaps.join(", "));
|
||||
check(`${scheme} ${width}px: nothing in the connection form passes the window edge`, m.out.length === 0 && !m.hscroll, m.out.join(", "));
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -43,7 +43,6 @@ const probe = (page) =>
|
||||
}
|
||||
return hits;
|
||||
};
|
||||
const tabbar = document.querySelector(".tabs");
|
||||
const bar = document.querySelector(".chrome");
|
||||
const row = document.querySelector(".compose .row");
|
||||
const tools = document.querySelector(".compose .turntools");
|
||||
@@ -54,9 +53,11 @@ const probe = (page) =>
|
||||
return {
|
||||
bar: { overlaps: overlaps(bar).concat(overlaps(bar.querySelector(".r"))), outside: outside(bar).concat(outside(bar.querySelector(".r"))) },
|
||||
row: { overlaps: overlaps(row), outside: outside(row) },
|
||||
tabs: flatOverlaps([...tabbar.querySelectorAll(".vtabs > .tab, .plan-peek, .sidetoggle")]),
|
||||
tabsOutside: [...tabbar.querySelectorAll(".vtabs > .tab, .plan-peek, .sidetoggle")].filter((el) => live(el).bottom > tabbar.getBoundingClientRect().bottom + 1).map(name),
|
||||
viewSwitch: [...bar.querySelectorAll(".view-action")].map((el) => el.getAttribute("data-value")),
|
||||
sideAsk: (() => { const el = tools.querySelector('[data-action="side.toggle"]'); const r = el && live(el); return r ? { w: r.width, h: r.height } : null; })(),
|
||||
toolsOverlaps: flatOverlaps([...tools.children]),
|
||||
toolsOverflow: [...tools.children].filter((el) => live(el) && live(el).right > tools.getBoundingClientRect().right + 1).map(name),
|
||||
toolsOverlap: flatOverlaps([...tools.querySelectorAll("button")]),
|
||||
titleShown: t.width,
|
||||
titleOutsideCrumb: t.width > 0 && (t.left < clip.left - 1 || t.right > clip.right + 1),
|
||||
crumbWidth: clip.width,
|
||||
@@ -64,7 +65,7 @@ const probe = (page) =>
|
||||
};
|
||||
});
|
||||
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action"];
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".view-action"];
|
||||
|
||||
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
|
||||
async function reachable(page) {
|
||||
@@ -107,8 +108,30 @@ const browser = await chromium.launch();
|
||||
check("at every room from 190 to 460 the title keeps its 4em floor", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
// A running turn swaps Send for Stop, which is wider; the toolbar must still sit on one line at every column width.
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto(`${BASE}?pref=zh&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.fill('textarea[role="combobox"]', "hello");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(".btn.stop");
|
||||
const bad = [];
|
||||
for (let room = 420; room <= 760; room += 20) {
|
||||
await page.setViewportSize({ width: room, height: 800 });
|
||||
await page.waitForTimeout(150);
|
||||
const lines = await page.evaluate(() => {
|
||||
const seen = [...document.querySelectorAll(".compose .row button")].filter((el) => el.getBoundingClientRect().width > 0 && el.checkVisibility());
|
||||
const mids = seen.map((el) => { const r = el.getBoundingClientRect(); return r.top + r.height / 2; }).sort((a, b) => a - b);
|
||||
return mids.filter((m, i) => i === 0 || m - mids[i - 1] > 6).length;
|
||||
});
|
||||
if (lines !== 1) bad.push(`${room}:${lines}`);
|
||||
}
|
||||
check("while a turn runs, the composer toolbar stays on one line from 420 to 760", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [320, 1], [1280, 1]]) {
|
||||
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [390, 1], [320, 1.8], [320, 1], [1280, 1]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
@@ -120,10 +143,12 @@ for (const scheme of ["light", "dark"]) {
|
||||
const m = await probe(page);
|
||||
check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", "));
|
||||
check(`${tag}: title bar stays inside the window`, m.bar.outside.length === 0, m.bar.outside.join(", "));
|
||||
check(`${tag}: view tabs and their neighbours do not overlap`, m.tabs.length === 0, m.tabs.join(", "));
|
||||
check(`${tag}: the tab bar grows to hold a wrapped row`, m.tabsOutside.length === 0, m.tabsOutside.join(", "));
|
||||
check(`${tag}: the workbench switch is on the bar`, m.viewSwitch.includes("browser"), JSON.stringify(m.viewSwitch));
|
||||
check(`${tag}: composer row does not overlap`, m.row.overlaps.length === 0, m.row.overlaps.join(", "));
|
||||
check(`${tag}: the side question icon is drawn in the toolbar`, !!m.sideAsk && m.sideAsk.w > 0 && m.sideAsk.h > 0, JSON.stringify(m.sideAsk));
|
||||
check(`${tag}: composer toolbar controls do not overlap`, m.toolsOverlaps.length === 0, m.toolsOverlaps.join(", "));
|
||||
check(`${tag}: composer toolbar controls stay in the toolbar`, m.toolsOverflow.length === 0, m.toolsOverflow.join(", "));
|
||||
check(`${tag}: composer toolbar buttons do not overlap`, m.toolsOverlap.length === 0, m.toolsOverlap.join(", "));
|
||||
check(`${tag}: the title keeps its 4em floor inside its own box`, m.crumbWidth >= 48 && !m.titleOutsideCrumb, `crumb ${Math.round(m.crumbWidth)}px`);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-${scheme}-${width}-z${zoom}.png` });
|
||||
const hint = await page.evaluate(() => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// A host notice is a bar, not a card: one sentence with a glyph, no header strip, no box and no state wash. Severity is
|
||||
// the glyph's ink, so warn and error differ only there.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 760 }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
|
||||
for (const level of ["info", "warn", "error"]) window.__feed({ kind: "notice", level, text: `a ${level} notice`, detail: "the diagnostic" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.mouse.move(990, 10);
|
||||
const m = await page.evaluate(() => {
|
||||
const read = (el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
const g = el.querySelector(".nb-ic, .nb-dot");
|
||||
return {
|
||||
border: [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth, cs.borderBottomWidth].join(","),
|
||||
bg: cs.backgroundColor,
|
||||
header: !!el.querySelector(".hl, .tag"),
|
||||
glyph: g ? getComputedStyle(g).color : "",
|
||||
};
|
||||
};
|
||||
return [...document.querySelectorAll(".notice-bar")].map(read);
|
||||
});
|
||||
check(`${scheme}: three notices drawn as bars`, m.length === 3, `${m.length} found`);
|
||||
check(`${scheme}: no box, wash or header strip`, m.length > 0 && m.every((x) => x.border === "0px,0px,0px,0px" && (x.bg === "rgba(0, 0, 0, 0)" || x.bg === "transparent") && !x.header), JSON.stringify(m[0] ?? {}));
|
||||
check(`${scheme}: severity is the glyph ink alone`, m.length === 3 && new Set(m.map((x) => x.glyph)).size === 3, m.map((x) => x.glyph).join(" / "));
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,75 @@
|
||||
// Tab-switch and sidebar-filter cost with 1/2/4/8 panes that each hold a long transcript. A measurement, not a guard:
|
||||
// the numbers depend on the machine, so it prints a table instead of failing. CPU=4 emulates a slow laptop.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const CPU = Number(process.env.CPU ?? 4);
|
||||
const TURNS = Number(process.env.TURNS ?? 60);
|
||||
const REPS = Number(process.env.REPS ?? 3);
|
||||
const b = await chromium.launch();
|
||||
const rows = [];
|
||||
const pct = (a, p) => { const s = [...a].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor(s.length * p))]; };
|
||||
for (const n of [1, 2, 4, 8]) {
|
||||
const ctx = await b.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
await page.goto(`${process.env.PERF_URL ?? "http://localhost:4399/perf.html"}?panes=${n}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app"); await page.waitForTimeout(800);
|
||||
await page.evaluate(async (k) => {
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
for (let i = 0; i < k; i++) {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
const id = `t${i}`;
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id, name: "bash", args: JSON.stringify({ command: `go test ./pkg${i}/...` }) } });
|
||||
window.__feed({ kind: "tool_result", tool: { id, name: "bash", args: JSON.stringify({ command: `go test ./pkg${i}/...` }), output: "ok\n".repeat(20) } });
|
||||
window.__feed({ kind: "text", text: `第 ${i} 段回答。\n\n- 要点一\n- 要点二\n\n\`\`\`go\nfunc f${i}() {}\n\`\`\`\n` });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
if (i % 10 === 9) await frame();
|
||||
}
|
||||
await frame();
|
||||
}, TURNS);
|
||||
await page.waitForTimeout(800);
|
||||
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU });
|
||||
await cdp.send("Performance.enable");
|
||||
const nodes = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "Nodes")?.value;
|
||||
// tab switch: click each session row in turn, time until two frames have painted
|
||||
const switches = [];
|
||||
const m0 = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
|
||||
for (let r = 0; r < REPS; r++) {
|
||||
const count = await page.locator(".sessrow").count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ms = await page.evaluate(async (i) => {
|
||||
const el = document.querySelectorAll(".sessrow")[i];
|
||||
const t0 = performance.now();
|
||||
el.click();
|
||||
await new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res)));
|
||||
return performance.now() - t0;
|
||||
}, i);
|
||||
switches.push(ms);
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
}
|
||||
const m1 = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
|
||||
const per = (k) => (((m1[k] - m0[k]) * 1000) / switches.length).toFixed(0);
|
||||
// sidebar: type into the rail find box and measure each keystroke to paint
|
||||
const keys = [];
|
||||
const box = page.locator(".wsfind input");
|
||||
await page.evaluate(() => document.querySelector(".wsfind input")?.focus());
|
||||
for (const ch of "perf") {
|
||||
const ms = await page.evaluate(async (ch) => {
|
||||
const input = document.querySelector(".wsfind input");
|
||||
const t0 = performance.now();
|
||||
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
|
||||
set.call(input, input.value + ch);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res)));
|
||||
return performance.now() - t0;
|
||||
}, ch);
|
||||
keys.push(ms);
|
||||
}
|
||||
rows.push({ N: n, 节点: nodes, "切换 p50": pct(switches, 0.5).toFixed(0), "切换 p95": pct(switches, 0.95).toFixed(0), "切换 max": Math.max(...switches).toFixed(0), "脚本/次": per("ScriptDuration"), "样式/次": per("RecalcStyleDuration"), "布局/次": per("LayoutDuration"), "侧栏筛选 p50": pct(keys, 0.5).toFixed(0), "侧栏筛选 max": Math.max(...keys).toFixed(0) });
|
||||
await ctx.close();
|
||||
}
|
||||
await b.close();
|
||||
console.log(`CPU 降频 ${CPU}x,每窗格 ${TURNS} 轮历史(含 bash 工具卡),切换 ${REPS} 轮;单位 ms(点击到两帧之后)\n`);
|
||||
console.table(rows);
|
||||
@@ -1,33 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
// The small magnifier beside the session filters opens the rail's find box and
|
||||
// closes it again: a second press must not reopen it.
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, colorScheme: theme, reducedMotion: "reduce" });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(`${base}?pref=zh`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".studio-filter-search");
|
||||
const shown = () => page.evaluate(() => Number(getComputedStyle(document.querySelector(".rail .wsfind")).opacity) > 0.5);
|
||||
const press = () => page.locator(".studio-filter-search").click();
|
||||
assert.equal(await shown(), false, `${theme}: the find box starts hidden`);
|
||||
await press();
|
||||
await page.waitForTimeout(250);
|
||||
assert.equal(await shown(), true, `${theme}: the first press shows the find box`);
|
||||
await press();
|
||||
await page.waitForTimeout(250);
|
||||
assert.equal(await shown(), false, `${theme}: a second press on the magnifier must hide the find box, not show it again`);
|
||||
await press();
|
||||
await page.waitForTimeout(250);
|
||||
assert.equal(await shown(), true, `${theme}: the third press shows it again`);
|
||||
assert.deepEqual(errors, []);
|
||||
await page.close();
|
||||
}
|
||||
console.log("ok search-toggle");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// The side question opens from the button in the composer, above the composer
|
||||
// and inside the conversation's own column — never over what sits beside or
|
||||
// above it, whether the file panel is open or not and at any width.
|
||||
import { chromium } from "playwright";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const shotDir = process.env.SHOTS;
|
||||
const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null);
|
||||
|
||||
if (shotDir) await mkdir(shotDir, { recursive: true });
|
||||
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL });
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, files, height = 800, zoom = 1] of [[1400, false], [1400, true], [760, false], [760, true], [390, false, 700], [900, false, 480], [760, false, 800, 1.3]]) {
|
||||
const label = `${scheme} ${width}x${height} zoom ${zoom}${files ? " with the file panel" : ""}`;
|
||||
const page = await browser.newPage({ viewport: { width, height }, locale: "zh-CN", colorScheme: scheme });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${PAGE}&zoom=${zoom}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
if (files) {
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
await page.waitForSelector(".workbench-filelist button");
|
||||
}
|
||||
await page.locator('[data-action="side.toggle"]').click();
|
||||
await page.waitForSelector(".sidepanel");
|
||||
await page.waitForTimeout(250);
|
||||
const r = await page.evaluate(() => {
|
||||
const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null);
|
||||
const panel = document.querySelector(".sidepanel");
|
||||
const compose = panel.closest(".compose");
|
||||
const pane = compose.closest(".pane");
|
||||
const beside = [...document.querySelectorAll(".workbench-filelist, .workbench-explorer-head")].filter((el) => !pane.contains(el)).map(box);
|
||||
return { panel: box(panel), compose: box(compose), pane: box(pane), beside, vw: innerWidth, vh: innerHeight, button: box(document.querySelector('[data-action="side.toggle"]')) };
|
||||
});
|
||||
const p = r.panel;
|
||||
check(`${label}: it sits above the composer`, p.bottom <= r.compose.top + 1 && r.compose.top - p.bottom < 24, `bottom ${Math.round(p.bottom)}, composer top ${Math.round(r.compose.top)}`);
|
||||
check(`${label}: it stays inside its pane`, p.left >= r.pane.left - 1 && p.right <= r.pane.right + 1 && p.top >= r.pane.top - 1, `panel ${Math.round(p.left)}–${Math.round(p.right)}, pane ${Math.round(r.pane.left)}–${Math.round(r.pane.right)}`);
|
||||
check(`${label}: it stays inside the window`, p.left >= 0 && p.right <= r.vw && p.top >= 0 && p.bottom <= r.vh);
|
||||
check(`${label}: it does not cover the file panel`, r.beside.every((b) => p.right <= b.left || p.left >= b.right || p.bottom <= b.top || p.top >= b.bottom), files ? `${r.beside.length} file panel parts` : "no file panel");
|
||||
check(`${label}: it opens above the button that asked`, p.bottom <= r.button.top && p.left <= r.button.left + 1, `button ${Math.round(r.button.left)}`);
|
||||
const draft = page.locator('[data-action-change="side.draft"]');
|
||||
await draft.fill("解释这个函数如何处理错误。".repeat(50));
|
||||
await page.locator('[data-action="side.send"]').click();
|
||||
await page.locator('.sideturn[data-role="side"]').waitFor();
|
||||
await draft.fill("keep this draft");
|
||||
const collapse = page.locator('[data-action="side.collapse"]');
|
||||
await collapse.click();
|
||||
const hidden = await draft.isHidden();
|
||||
const compact = await page.locator(".sidepanel").evaluate((el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
const button = el.querySelector('[data-action="side.collapse"]');
|
||||
const c = button.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(c.x + c.width / 2, c.y + c.height / 2);
|
||||
const flow = el.closest(".pane").querySelector('[data-pane="flow"]').getBoundingClientRect();
|
||||
return { height: b.height, hit: hit === button || button.contains(hit), readable: b.top - flow.top, flowHeight: flow.height };
|
||||
});
|
||||
check(`${label}: collapse leaves a reachable header and hides inputs`, hidden && compact.hit && compact.height < 72, JSON.stringify(compact));
|
||||
check(`${label}: collapse exposes the transcript`, compact.readable > 100 && compact.readable > compact.flowHeight / 2, JSON.stringify(compact));
|
||||
await collapse.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
check(`${label}: Tab skips the collapsed controls`, await page.locator('[data-action="side.close"]').evaluate((el) => el === document.activeElement));
|
||||
if (shotDir && [1400, 390].includes(width) && !files) await page.screenshot({ path: `${shotDir}/${scheme}-${width}.png` });
|
||||
await collapse.click();
|
||||
await draft.waitFor({ state: "visible" });
|
||||
check(`${label}: expansion keeps the draft`, await draft.inputValue() === "keep this draft");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator(".sidepanel").waitFor({ state: "detached" });
|
||||
check(`${label}: Escape returns focus`, await page.locator('[data-action="side.toggle"]').evaluate((el) => el === document.activeElement));
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// The session tab's dot is the only at-a-glance sign of a working session: it
|
||||
// breathes while the run is live and stays still in every other state, and a
|
||||
// person who asked for reduced motion gets it still. Only the browser computes it.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const [label, reduced] of [["normal", "no-preference"], ["reduced motion", "reduce"]]) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1200, height: 760 }, reducedMotion: reduced });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${BASE}?pack=photo`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const names = await page.evaluate(() => {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "panetabs";
|
||||
bar.style.cssText = "position:fixed;left:0;top:0;opacity:0;pointer-events:none";
|
||||
bar.innerHTML = ["idle", "running", "halt", "done"].map((r) => `<div class="ptab" data-run="${r}"><i class="pip"></i></div>`).join("");
|
||||
bar.innerHTML += `<div class="ptab" data-run="done"><i class="pip" data-unread></i></div>`;
|
||||
document.body.appendChild(bar);
|
||||
const out = {};
|
||||
for (const r of ["idle", "running", "halt", "done"]) out[r] = getComputedStyle(bar.querySelector(`[data-run="${r}"] .pip`)).animationName;
|
||||
const read = getComputedStyle(bar.querySelector('[data-run="done"] .pip:not([data-unread])'));
|
||||
const unread = getComputedStyle(bar.querySelector('[data-run="done"] .pip[data-unread]'));
|
||||
out.unreadDiffers = unread.backgroundColor !== read.backgroundColor && unread.backgroundColor !== "rgba(0, 0, 0, 0)";
|
||||
return out;
|
||||
});
|
||||
check(`${label}: a running session's dot ${reduced === "reduce" ? "does not move" : "breathes"}`, reduced === "reduce" ? names.running === "none" : names.running === "breathe", names.running);
|
||||
check(`${label}: idle, halted and finished dots stay still`, names.idle === "none" && names.halt === "none" && names.done === "none", JSON.stringify(names));
|
||||
check(`${label}: an unread finished session is told apart by its own dot, not an extra one`, names.unreadDiffers === true);
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,59 @@
|
||||
// The interface text size enlarges type and nothing else: the window keeps its
|
||||
// layout, no column spills sideways at the largest step, and the control that
|
||||
// sets it reaches the page.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const shotDir = process.env.SHOTS;
|
||||
const STEPS = [["标准", 1], ["大", 1.15], ["更大", 1.3]];
|
||||
const measure = (page) => page.evaluate(() => {
|
||||
const px = (sel, prop = "fontSize") => parseFloat(getComputedStyle(document.querySelector(sel))[prop]);
|
||||
const box = (sel) => document.querySelector(sel).getBoundingClientRect();
|
||||
const spill = [".rail", ".compose", ".panetabs", ".chrome"].map((s) => document.querySelector(s)).filter(Boolean).filter((el) => el.scrollWidth > el.clientWidth + 1).map((el) => el.className.split(" ")[0]);
|
||||
return {
|
||||
sessionTitle: px(".sesstitle"),
|
||||
newTask: px(".studio-new-task b"),
|
||||
railWidth: Math.round(box(".rail").width),
|
||||
composeWidth: Math.round(box(".compose").width),
|
||||
zoom: getComputedStyle(document.documentElement).getPropertyValue("--zoom"),
|
||||
scale: document.documentElement.style.getPropertyValue("--fs-scale"),
|
||||
spill,
|
||||
};
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".sesstitle");
|
||||
const base = await measure(page);
|
||||
for (const [name, scale] of STEPS) {
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.locator(".prefs-nav button", { hasText: "外观" }).first().click();
|
||||
await page.locator('[data-action="appearance.text-size"]', { hasText: new RegExp(`^${name}$`) }).click();
|
||||
await page.waitForTimeout(200);
|
||||
if (shotDir && scale === 1.3) await page.screenshot({ path: `${shotDir}/${scheme}-settings-1.3.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(300);
|
||||
const m = await measure(page);
|
||||
const tag = `${scheme} ${name}`;
|
||||
check(`${tag}: the control reaches the page`, scale === 1 ? m.scale === "" : Number(m.scale) === scale, `--fs-scale "${m.scale}"`);
|
||||
check(`${tag}: type grows by the step`, Math.abs(m.sessionTitle / base.sessionTitle - scale) < 0.03 && Math.abs(m.newTask / base.newTask - scale) < 0.03, `${base.sessionTitle}→${m.sessionTitle}, ${base.newTask}→${m.newTask}`);
|
||||
check(`${tag}: the layout keeps its room`, m.railWidth === base.railWidth && m.composeWidth === base.composeWidth && m.zoom === base.zoom, `rail ${m.railWidth}/${base.railWidth}, composer ${m.composeWidth}/${base.composeWidth}`);
|
||||
check(`${tag}: nothing spills sideways`, m.spill.length === 0, m.spill.join(","));
|
||||
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-${scale}.png` });
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// A picture sent with a message is a 240x160 thumbnail whatever its shape. A
|
||||
// tall phone screenshot used to keep its own aspect ratio at the thumbnail's
|
||||
// width and spill far below its box, over the message text.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?ws=1&sess=1&turns=1&pref=zh";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const shape = { tall: [390, 700], wide: [1600, 400], square: [500, 500], small: [60, 40] };
|
||||
const svg = (w, h) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#e8eef5"/></svg>`;
|
||||
const text = "@.reasonix/attachments/clipboard-tall.png\[email protected]/attachments/clipboard-wide.png\n管理员长按成员头像弹出的踢出弹窗,成员名字下方修改为岗位\n移除成员界面成员也要显示岗位";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1400, 520]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.route(/fixture/, (r) => {
|
||||
const name = Object.keys(shape).find((k) => r.request().url().includes(k)) ?? "square";
|
||||
return r.fulfill({ contentType: "image/svg+xml", body: svg(...shape[name]) });
|
||||
});
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForFunction(() => window.__panes?.() > 0);
|
||||
await page.evaluate((text) => window.__feed({
|
||||
kind: "turn_started", authoredTurn: 9, msgIndex: 90, text,
|
||||
attachments: ["tall", "wide", "square", "small"].map((n) => ({ path: `.reasonix/attachments/${n}.png` })),
|
||||
}), text);
|
||||
await page.locator(".call[data-k='me'] .user-image img").last().waitFor();
|
||||
await page.waitForTimeout(400);
|
||||
const rows = await page.evaluate(() => {
|
||||
const me = [...document.querySelectorAll(".call[data-k='me']")].at(-1);
|
||||
const rect = (e) => { const b = e.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom, w: b.width, h: b.height }; };
|
||||
const words = [...me.querySelectorAll(".out, .txt, .bubble, p, div")].filter((e) => !e.closest(".user-images") && e.textContent.includes("管理员"));
|
||||
const bubble = words.at(-1);
|
||||
return { imgs: [...me.querySelectorAll(".user-image img")].map((i) => ({ box: rect(i.closest(".user-image")), img: rect(i) })), text: bubble ? rect(bubble) : null };
|
||||
});
|
||||
const tag = `${scheme} ${width}px`;
|
||||
check(`${tag}: every picture stays inside a 240x160 thumbnail`, rows.imgs.length === 4 && rows.imgs.every(({ img }) => img.w <= 241 && img.h <= 161), JSON.stringify(rows.imgs.map((x) => [Math.round(x.img.w), Math.round(x.img.h)])));
|
||||
check(`${tag}: no picture leaves its own box`, rows.imgs.every(({ box, img }) => img.t >= box.t - 1 && img.b <= box.b + 1 && img.l >= box.l - 1 && img.r <= box.r + 1));
|
||||
check(`${tag}: the message text is not covered by a picture`, rows.text !== null && rows.imgs.every(({ img }) => img.b <= rows.text.t + 1 || img.t >= rows.text.b - 1 || img.r <= rows.text.l + 1 || img.l >= rows.text.r - 1));
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -123,7 +123,7 @@ await page.waitForTimeout(400);
|
||||
const trajRows = await page.locator(".run-analysis").count();
|
||||
check("运行分析页有内容", trajRows > 0, `${trajRows} 个`);
|
||||
await page.screenshot({ path: `${SHOTS}/3-运行分析页.png` });
|
||||
await page.locator('[role="tab"]').nth(0).click();
|
||||
await page.locator('[data-action="pane.view"][aria-pressed="true"]').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const after = await page.locator('[data-pane="flow"] .call').count();
|
||||
check("切回后卡片仍在", after > 0, `${after} 张`);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// The pane's view tabs share one highlight that moves to the chosen tab, so a
|
||||
// switch reads as one motion. Where it lands is geometry, asked of a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en&turns=5`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".pane > .tabs [role=tab]");
|
||||
const tabs = page.locator(".pane > .tabs [role=tab]");
|
||||
const count = await tabs.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
await tabs.nth(i).click();
|
||||
await page.waitForTimeout(400);
|
||||
const g = await page.evaluate(() => {
|
||||
const thumbs = document.querySelectorAll(".pane:not([data-off]) > .tabs .vthumb");
|
||||
const on = document.querySelector('.pane:not([data-off]) > .tabs [role=tab][aria-selected="true"]');
|
||||
const a = thumbs[0]?.getBoundingClientRect();
|
||||
const c = on?.getBoundingClientRect();
|
||||
return { thumbs: thumbs.length, thumb: a && [Math.round(a.left), Math.round(a.width), Math.round(a.top), Math.round(a.height)], tab: c && [Math.round(c.left), Math.round(c.width), Math.round(c.top), Math.round(c.height)], tabBg: on && getComputedStyle(on).backgroundColor };
|
||||
});
|
||||
check(`${scheme} tab ${i}: one highlight, lying exactly under the chosen tab`, g.thumbs === 1 && JSON.stringify(g.thumb) === JSON.stringify(g.tab), JSON.stringify(g));
|
||||
check(`${scheme} tab ${i}: the chosen tab draws no box of its own`, g.tabBg === "rgba(0, 0, 0, 0)", g.tabBg);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -1,2 +1,12 @@
|
||||
# The shell names the build that is current, and the page's own out-of-date
|
||||
# check reads this same file over no-store. A cached or revalidated copy makes
|
||||
# an open page look behind and keeps reloading onto it. Hashed bundles under
|
||||
# /assets are the opposite: they are named by content, so they never change.
|
||||
/
|
||||
Cache-Control: no-store
|
||||
|
||||
/index.html
|
||||
Cache-Control: no-store
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-10-17
|
||||
---
|
||||
|
||||
# Relay harness
|
||||
|
||||
A hosted page driving a real Studio through a stand-in relay.
|
||||
|
||||
- Real: the production page bundle, its encrypted channel, `reasonix-studio-host` with its kernel, and Chromium.
|
||||
- Replaced: the accounts API and the message relay (`tools/relayfake`), and the model provider (a scripted stream in `stack.mjs`).
|
||||
|
||||
The page still dials `id.reasonix.io` and `remote.reasonix.io`; the browser
|
||||
context answers those two hosts with the stand-ins.
|
||||
|
||||
```
|
||||
go build -o relay/bin/studio-host ../../cmd/reasonix-studio-host
|
||||
go build -o relay/bin/relayfake ../../tools/relayfake
|
||||
pnpm exec vite build --outDir dist-relay --emptyOutDir
|
||||
RELAY_HOST_BIN=$PWD/relay/bin/studio-host RELAY_FAKE_BIN=$PWD/relay/bin/relayfake \
|
||||
RELAY_DIST=$PWD/dist-relay node relay/live.mjs
|
||||
```
|
||||
|
||||
`relay/storm.mjs` is the other scenario: a page served by the Studio itself (no relay), nine panes on the shared stream, and the browser going offline and back across three turns. Run it the same way, with `RELAY_DIST` pointing at the same build.
|
||||
|
||||
`CHROMIUM_PATH` selects a browser other than the one Playwright installed.
|
||||
A change to how events travel between a page and a kernel is judged by this
|
||||
run, not only by a unit test of one side. Add a scenario to `live.mjs` rather
|
||||
than a second harness.
|
||||
@@ -0,0 +1,61 @@
|
||||
// A hosted page driving a real Studio through a stand-in relay. What a shipped
|
||||
// page does over that link is the claim: the production bundle, the encrypted
|
||||
// channel, the Studio's own host and a real kernel are all real; only the two
|
||||
// Reasonix services between them are replaced (tools/relayfake).
|
||||
import { chromium } from "playwright";
|
||||
import { startStack, routeServices, until } from "./stack.mjs";
|
||||
|
||||
const need = (name) => {
|
||||
if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`);
|
||||
return process.env[name];
|
||||
};
|
||||
|
||||
const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST") });
|
||||
const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {});
|
||||
const failures = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`);
|
||||
if (!ok) failures.push(name);
|
||||
};
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1300, height: 850 } });
|
||||
await routeServices(context, stack);
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
await page.goto(stack.pageUrl());
|
||||
await page.evaluate(() => { window.__survivor = true; });
|
||||
await page.keyboard.press("Escape");
|
||||
const pane = await stack.api("/runtimes", "POST", { root: stack.workspace });
|
||||
check("a pane opens on the Studio", pane.status === 200, `status ${pane.status}`);
|
||||
|
||||
const composer = page.getByLabel("Task input").first();
|
||||
await composer.waitFor({ timeout: 30_000 });
|
||||
const text = () => page.evaluate(() => document.body.innerText);
|
||||
const count = async (word) => ((await text()).match(new RegExp(word, "g")) ?? []).length;
|
||||
const send = async (message) => {
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
};
|
||||
|
||||
await send("first");
|
||||
const live = await until("the reply to stream onto the page", async () => (await count("word11")) >= 1, 20_000).then(() => true, () => false);
|
||||
check("a reply streams onto the page over the relay", live, `provider saw ${stack.provider.requests.length} request(s)`);
|
||||
|
||||
const dropped = await stack.ctl("drop-controllers", "POST");
|
||||
check("the relay drops the page's socket", dropped.dropped === 1, JSON.stringify(dropped));
|
||||
await until("the page to reconnect", async () => (await stack.ctl("state")).controllers === 1, 30_000).catch(() => {});
|
||||
await send("second");
|
||||
const after = await until("a reply after the reconnect", async () => (await count("word11")) >= 2, 40_000).then(() => true, () => false);
|
||||
check("a reply streams onto the page after the relay drops the socket", after);
|
||||
|
||||
check("the page was never reloaded", await page.evaluate(() => window.__survivor === true));
|
||||
check("the page threw no error", pageErrors.length === 0, pageErrors.join(" | "));
|
||||
} catch (error) {
|
||||
check("the scenario ran to the end", false, String(error));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stack.stop();
|
||||
}
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
@@ -0,0 +1,172 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function until(what, probe, ms = 20_000) {
|
||||
const end = Date.now() + ms;
|
||||
for (;;) {
|
||||
const value = await probe();
|
||||
if (value) return value;
|
||||
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
|
||||
await wait(100);
|
||||
}
|
||||
}
|
||||
|
||||
function firstLine(child, what) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
const at = buffer.indexOf("\n");
|
||||
if (at >= 0) resolve(buffer.slice(0, at));
|
||||
});
|
||||
child.once("exit", (code) => reject(new Error(`${what} exited (${code}) before it was ready`)));
|
||||
});
|
||||
}
|
||||
|
||||
function scriptedProvider(words, gapMs) {
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
if (!req.url.includes("chat/completions")) {
|
||||
res.writeHead(404);
|
||||
res.end("{}");
|
||||
return;
|
||||
}
|
||||
requests.push({ at: Date.now(), body });
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const chunk = (delta) =>
|
||||
`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta }] })}\n\n`;
|
||||
let n = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (n < words) {
|
||||
res.write(chunk({ content: `word${n++} ` }));
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
res.write(`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 12, total_tokens: 22 } })}\n\n`);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}, gapMs);
|
||||
res.on("close", () => clearInterval(timer));
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, requests, port: server.address().port })));
|
||||
}
|
||||
|
||||
function pageServer(dist) {
|
||||
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".json": "application/json", ".webmanifest": "application/manifest+json", ".png": "image/png" };
|
||||
const server = http.createServer((req, res) => {
|
||||
const target = path.join(dist, decodeURIComponent(new URL(req.url, "http://x").pathname));
|
||||
const file = target.startsWith(dist) && fs.existsSync(target) && fs.statSync(target).isFile() ? target : path.join(dist, "index.html");
|
||||
res.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" });
|
||||
fs.createReadStream(file).pipe(res);
|
||||
});
|
||||
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, origin: `http://127.0.0.1:${server.address().port}` })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts everything between a hosted page and a Studio except the page's browser:
|
||||
* the real studio-host binary on a fresh data home, a stand-in accounts API and
|
||||
* relay, a scripted model provider, and a static server for the built page.
|
||||
*/
|
||||
export async function startStack({ hostBin, fakeBin, dist, servePage = false, words = 12, gapMs = 120 }) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "relay-stack-"));
|
||||
const home = path.join(root, "home");
|
||||
const workspace = path.join(root, "workspace");
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
const provider = await scriptedProvider(words, gapMs);
|
||||
fs.writeFileSync(path.join(home, "config.toml"), [
|
||||
'language = "en"',
|
||||
'default_model = "fake"',
|
||||
`providers = [{ name = "fake", kind = "openai", base_url = "http://127.0.0.1:${provider.port}/v1", model = "fake-m", api_key_env = "FAKE_KEY" }]`,
|
||||
"",
|
||||
].join("\n"));
|
||||
fs.writeFileSync(path.join(home, ".env"), "FAKE_KEY=fixture\n");
|
||||
|
||||
const fake = spawn(fakeBin, [], { stdio: ["ignore", "pipe", "inherit"] });
|
||||
const urls = JSON.parse(await firstLine(fake, "relayfake"));
|
||||
const ctl = async (route, method = "GET") => (await fetch(`${urls.accounts}/ctl/${route}`, { method })).json();
|
||||
|
||||
const host = spawn(hostBin, servePage ? ["-page", dist] : [], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: {
|
||||
...process.env, HOME: root, USERPROFILE: root, REASONIX_HOME: home, REASONIX_STATE_HOME: home,
|
||||
REASONIX_ACCOUNTS_URL: urls.accounts, REASONIX_REMOTE_URL: urls.relay, REASONIX_ACCOUNT_TOKEN: "fixture-token",
|
||||
FAKE_KEY: "fixture",
|
||||
},
|
||||
});
|
||||
const handshake = JSON.parse(await firstLine(host, "studio-host"));
|
||||
const state = await until("the Studio to reach the relay", async () => {
|
||||
const s = await ctl("state");
|
||||
return s.deviceId && s.deviceOnline ? s : null;
|
||||
});
|
||||
const page = await pageServer(dist);
|
||||
|
||||
const api = async (route, method = "GET", body) => {
|
||||
const response = await fetch(handshake.origin + route, {
|
||||
method,
|
||||
headers: { cookie: `reasonix_token=${handshake.token}`, origin: handshake.origin, "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: response.status, json: await response.json().catch(() => null) };
|
||||
};
|
||||
|
||||
return {
|
||||
urls, deviceId: state.deviceId, pageOrigin: page.origin, hostOrigin: handshake.origin, hostToken: handshake.token, workspace, provider, ctl, api,
|
||||
pageUrl: () => `${page.origin}/?device=${state.deviceId}`,
|
||||
async stop() {
|
||||
const gone = new Promise((resolve) => host.once("exit", resolve));
|
||||
host.stdin.end();
|
||||
host.kill();
|
||||
await gone;
|
||||
fake.kill();
|
||||
page.server.close();
|
||||
provider.server.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Points a browser context's two Reasonix services at the stand-ins. The page is
|
||||
* the production build, so it still dials id.reasonix.io and remote.reasonix.io;
|
||||
* the requests and the socket are answered here. The socket is a plain pipe:
|
||||
* the page and the Studio speak their own encrypted protocol across it.
|
||||
*/
|
||||
export async function routeServices(context, stack) {
|
||||
await context.route("https://id.reasonix.io/**", async (route) => {
|
||||
const request = route.request();
|
||||
const cors = {
|
||||
"access-control-allow-origin": request.headers().origin ?? "*",
|
||||
"access-control-allow-credentials": "true",
|
||||
"access-control-allow-headers": "content-type",
|
||||
};
|
||||
if (request.method() === "OPTIONS") {
|
||||
await route.fulfill({ status: 204, headers: cors });
|
||||
return;
|
||||
}
|
||||
const answer = await fetch(stack.urls.accounts + new URL(request.url()).pathname, {
|
||||
method: request.method(),
|
||||
headers: { "content-type": "application/json" },
|
||||
body: request.postData() ?? undefined,
|
||||
});
|
||||
await route.fulfill({ status: answer.status, headers: { ...cors, "content-type": "application/json" }, body: await answer.text() });
|
||||
});
|
||||
await context.routeWebSocket(/remote\.reasonix\.io/, (page) => {
|
||||
const upstream = new WebSocket(`${stack.urls.relay}/v1/sessions/connect?fixture=1`, "reasonix.remote.v1");
|
||||
const queued = [];
|
||||
upstream.onopen = () => queued.splice(0).forEach((message) => upstream.send(message));
|
||||
upstream.onmessage = (event) => page.send(String(event.data));
|
||||
upstream.onclose = (event) => page.close({ code: event.code >= 1000 && event.code < 5000 ? event.code : 1011, reason: event.reason });
|
||||
page.onMessage((message) => (upstream.readyState === 1 ? upstream.send(message) : queued.push(message)));
|
||||
page.onClose(() => upstream.close());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// A real page on a real Studio while its network flaps. Nine panes stream on one
|
||||
// multiplexed connection while the browser keeps going offline and back across
|
||||
// three turns, so the flaps land on turn starts and ends. What is judged is what
|
||||
// the person sees on the pane they have open, read without navigating: raw frames
|
||||
// are not the claim, because a page may recover a lost one from a snapshot.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
import { startStack, until } from "./stack.mjs";
|
||||
|
||||
const need = (name) => {
|
||||
if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`);
|
||||
return process.env[name];
|
||||
};
|
||||
const PANES = 9;
|
||||
const TURNS = 3;
|
||||
const WORDS = 15;
|
||||
|
||||
const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST"), servePage: true, words: WORDS, gapMs: 100 });
|
||||
const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {});
|
||||
const failures = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`);
|
||||
if (!ok) failures.push(name);
|
||||
};
|
||||
|
||||
try {
|
||||
const panes = [];
|
||||
for (let i = 0; i < PANES; i++) {
|
||||
const root = path.join(stack.workspace, `p${i}`);
|
||||
fs.mkdirSync(root);
|
||||
panes.push((await stack.api("/runtimes", "POST", { root })).json);
|
||||
}
|
||||
const context = await browser.newContext({ viewport: { width: 1300, height: 1400 } });
|
||||
await context.addCookies([{ name: "reasonix_token", value: stack.hostToken, url: stack.hostOrigin }]);
|
||||
await context.addInitScript(() => {
|
||||
const Native = window.EventSource;
|
||||
window.__streams = [];
|
||||
window.EventSource = class extends Native {
|
||||
constructor(url, options) {
|
||||
super(url, options);
|
||||
window.__streams.push(String(url));
|
||||
}
|
||||
};
|
||||
});
|
||||
const page = await context.newPage();
|
||||
if (process.env.RELAY_DEBUG) {
|
||||
page.on("console", (m) => console.log("[console]", m.type(), m.text().slice(0, 160)));
|
||||
page.on("pageerror", (e) => console.log("[pageerror]", e.message));
|
||||
page.on("framenavigated", (f) => console.log("[navigated]", f.url()));
|
||||
}
|
||||
await page.goto(stack.hostOrigin + "/");
|
||||
await page.keyboard.press("Escape");
|
||||
await until("the sidebar to list every project", async () => (await page.getByText(/^p\d$/).count()) >= PANES, 30_000).catch(() => {});
|
||||
|
||||
const turnOn = async (turn) => {
|
||||
await Promise.all(panes.map((p, i) => stack.api(`${p.base}/submit`, "POST", { input: `pane${i} turn ${turn}` })));
|
||||
return until(`turn ${turn} to finish on every pane`, async () => {
|
||||
const states = await Promise.all(panes.map(async (p) => (await stack.api(`${p.base}/status`)).json));
|
||||
return states.every((st) => st && st.running === false);
|
||||
}, 60_000).then(() => true, () => false);
|
||||
};
|
||||
let settled = await turnOn(0);
|
||||
await page.getByText(/^pane0 turn/).first().click({ timeout: 15_000 }).catch(async () => {
|
||||
await page.getByText("p0", { exact: true }).first().click();
|
||||
await page.getByText(/^pane0 turn/).first().click();
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
if (process.env.RELAY_SHOT) await page.screenshot({ path: process.env.RELAY_SHOT.replace('.png', '-before.png') });
|
||||
|
||||
let flapping = true;
|
||||
const flaps = (async () => {
|
||||
while (flapping) {
|
||||
await context.setOffline(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await context.setOffline(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
}
|
||||
})();
|
||||
for (let turn = 1; turn <= TURNS && settled; turn++) settled = await turnOn(turn);
|
||||
flapping = false;
|
||||
await flaps;
|
||||
await context.setOffline(false);
|
||||
check("every pane finished every turn on the Studio", settled);
|
||||
|
||||
const streams = await page.evaluate(() => window.__streams);
|
||||
check("no pane held a stream of its own", streams.every((u) => !/\/rt\/.*\/events/.test(u)), streams.filter((u) => /\/rt\//.test(u)).join(" "));
|
||||
|
||||
// The open pane is read where it stands, with no navigation: opening a pane
|
||||
// reloads it from its transcript, which would hide a live stream that stopped.
|
||||
// Recovery after the last flap has a bounded time to finish.
|
||||
const read = () => page.evaluate(() => document.body.innerText);
|
||||
const done = await until("the open pane to settle", async () => /\bIdle\b|Done/.test(await read()), 30_000).then(() => true, () => false);
|
||||
const text = await read();
|
||||
const replies = (text.match(new RegExp(`word${WORDS - 1}`, "g")) ?? []).length;
|
||||
check("the open pane shows every reply it was sent while the network flapped", replies >= TURNS + 1, `${replies} of ${TURNS + 1} replies`);
|
||||
check("the open pane returns to idle", done);
|
||||
if (process.env.RELAY_SHOT) {
|
||||
await page.mouse.move(700, 700);
|
||||
await page.waitForTimeout(2500);
|
||||
await page.screenshot({ path: process.env.RELAY_SHOT });
|
||||
}
|
||||
} catch (error) {
|
||||
check("the scenario ran to the end", false, String(error));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stack.stop();
|
||||
}
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
@@ -62,10 +62,18 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.live-grace", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.legacy-sessions", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.open-session-limit", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.live-stream-budget", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.configure", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.source-search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.source-navigate", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.source-select", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.source-open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.worktree-toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
@@ -104,6 +112,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// Closing a conversation's pane ends its runtime and keeps the transcript;
|
||||
// refused while a turn runs, so it never stops work.
|
||||
{ id: "session.close", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "session.branch", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "session.archive", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "session.copy-info", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "session.export", kind: "shell-native", target: "entity", proof: "browser" },
|
||||
@@ -131,7 +140,14 @@ export const ACTIONS: UIAction[] = [
|
||||
// live turn the first steers and the second cancels the current turn.
|
||||
{ id: "session.send", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "session.stop", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "tool.bring", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "tool.unbring", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "tool.stop", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "session.open-match", kind: "navigation", target: "entity", proof: "interaction" },
|
||||
{ id: "job.cancel", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "job.tip", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "job.view", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "job.close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.mode", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
// A rewrite is asked of the kernel but changes nothing there; taking it only
|
||||
// replaces what is in the box.
|
||||
@@ -221,6 +237,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.unquote", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -245,6 +262,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
@@ -381,6 +402,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "remote-host.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.reveal", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workspace.open-with", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.open-with-menu", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "extensions.invoke", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -481,6 +503,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-args", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "hooks.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "hooks.run-once", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -522,6 +545,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.zoom", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.reading-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.text-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.measure", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.background", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.font", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
@@ -558,6 +582,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
|
||||
{ id: "workbench.rename-tab", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.open-file", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.copy-path", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.add-to-chat", kind: "view", target: "entity", proof: "interaction" },
|
||||
@@ -598,6 +623,28 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "sandbox.add-write-root", kind: "kernel-mutation", target: "optional", proof: "authority-effect" },
|
||||
{ id: "sandbox.remove-write-root", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "size.set", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "size.keep", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "size.restore", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "tree.retry", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "update.mode", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "update.restart", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "update.later", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "skills.update-close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-overwrite", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-hidden", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-reload", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-apply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "subagent-claims.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "write-lease.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "plan-approval.auto", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.target", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "peers.text", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "peers.expand", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "peers.send", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.approve", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.deny", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// Read by the running turn at its next round, so no rebuild stands between the
|
||||
// click and the canonical change.
|
||||
{ id: "auto-archive.notice-dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -619,9 +666,6 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "stall.dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "provider.remove", kind: "destructive", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.protocol", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.web-search", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.thinking", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.continuation", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
|
||||
// ── The two side panels. Reached from the keyboard here, and from each
|
||||
// gutter's own grip, which this pass has not annotated yet.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import { STORAGE, boot } from "./index";
|
||||
import { reason } from "./kernel";
|
||||
|
||||
const refused = new HttpError(409, "config does not parse", { code: "config.unparsed", params: { path: "C:\\Users\\a\\.reasonix\\config.toml", line: 12 } });
|
||||
|
||||
describe("a settings save refused because the config file does not parse", () => {
|
||||
it("says where to see and repair it, not only that it failed", () => {
|
||||
expect(reason(refused)).toContain("设置");
|
||||
});
|
||||
|
||||
it("names the file that did not parse", () => {
|
||||
expect(reason(refused)).toContain("C:\\Users\\a\\.reasonix\\config.toml");
|
||||
});
|
||||
|
||||
it("says the same in English", () => {
|
||||
localStorage.setItem(STORAGE, "en");
|
||||
boot();
|
||||
try {
|
||||
expect(reason(refused)).toContain("Settings");
|
||||
expect(reason(refused)).toContain("config.toml");
|
||||
} finally {
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { EN_COMMUNITY } from "./en_community";
|
||||
import { EN_SPONSOR } from "./en_sponsor";
|
||||
|
||||
export const EN: Record<string, string> = {
|
||||
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
|
||||
...EN_SURFACES,
|
||||
"重命名聊天": "Rename chat",
|
||||
"保持简短且易于识别": "Keep it short and recognizable",
|
||||
@@ -62,6 +63,7 @@ export const EN: Record<string, string> = {
|
||||
"收回 {rule}": "Take back {rule}",
|
||||
"放大第 {n} 张": "Enlarge image {n}",
|
||||
"工具截图 {n}": "Tool screenshot {n}",
|
||||
"视频 {name}({detail})已原生发送给 {model}": "Video {name} ({detail}) sent to {model} as a video file",
|
||||
"查看图片 {name}": "Preview image {name}",
|
||||
"关闭图片预览": "Close image preview",
|
||||
"放大的工具截图": "The enlarged tool screenshot",
|
||||
@@ -135,6 +137,12 @@ export const EN: Record<string, string> = {
|
||||
// ── 运行状态 ─────────────────────────────────────────────────────
|
||||
"空闲": "Idle",
|
||||
"运行中": "Running",
|
||||
"只停止这一条调用": "Stop only this call",
|
||||
"把结果带到末尾": "Show this result again at the end",
|
||||
"带到末尾的结果": "Brought to the end",
|
||||
"正在搜索对话内容…": "Searching conversation text…",
|
||||
"对话内容暂时搜不了,稍后再试": "Conversation text cannot be searched right now; try again shortly",
|
||||
"对话内容匹配": "Matches in conversation text",
|
||||
"声明推理档位": "Declare reasoning levels",
|
||||
"这个端点没有报告推理档位,中转站通常不转发这项能力。将打开该来源的编辑表单,可以选择思考参数或直接填写档位。": "This endpoint reported no reasoning levels; relays often do not forward them. Opens this source’s editor, where you can pick a thinking parameter or list the levels yourself.",
|
||||
"本轮已发送 / 已接收的 token": "Tokens sent and received this turn",
|
||||
@@ -321,6 +329,7 @@ export const EN: Record<string, string> = {
|
||||
|
||||
// ── 会话树 ───────────────────────────────────────────────────────
|
||||
"新会话": "New session",
|
||||
"标签数已到上限 {n},已把「{title}」从标签栏收起,对话仍在列表里。": "The open-session limit ({n}) was reached, so \"{title}\" was folded out of the tab bar. The conversation is still in the list.",
|
||||
"打开项目文件夹": "Open project folder",
|
||||
"新会话 {n}": "New session {n}",
|
||||
"从左栏选择,或在当前文件夹新建": "Pick one on the left, or start a new one in this folder",
|
||||
@@ -352,6 +361,8 @@ export const EN: Record<string, string> = {
|
||||
"删除主题「{name}」": "Delete theme \"{name}\"",
|
||||
"删除这份恢复副本?": "Delete this recovery copy?",
|
||||
"连同其记录一并删除": "Deletes it together with its record",
|
||||
"还有 {n} 份恢复副本带着这个会话没有的内容,直接删除会保留它们": "{n} recovery copies hold content this conversation lacks; a plain delete keeps them",
|
||||
"连同 {n} 份副本删除": "Delete with {n} copies",
|
||||
"恢复副本": "Recovery",
|
||||
"早先版本 · {n} 轮": "Earlier version · {n} turns",
|
||||
"删除这个早先版本?": "Delete this earlier version?",
|
||||
@@ -427,7 +438,9 @@ export const EN: Record<string, string> = {
|
||||
"更大": "Larger",
|
||||
"小": "Small",
|
||||
"大": "Large",
|
||||
"「界面」会同时缩放边距与控件,「正文」仅调整对话中的文字大小,两者独立设置。": "Interface scales spacing and controls together; Body text changes only the text in the conversation. The two are set independently.",
|
||||
"「界面」会同时缩放边距与控件,「界面文字」只放大侧栏、设置等处的文字,「正文」仅调整对话中的文字大小,三者独立设置。": "Interface scales spacing and controls together; Interface text enlarges only the text in the sidebar, settings and the like; Body text changes only the text in the conversation. The three are set independently.",
|
||||
"界面文字": "Interface text",
|
||||
"界面文字大小": "Interface text size",
|
||||
"字体": "Typeface",
|
||||
"等宽": "Monospace",
|
||||
"清除": "Clear",
|
||||
@@ -600,6 +613,13 @@ export const EN: Record<string, string> = {
|
||||
"留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。": "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively.",
|
||||
"在代码编辑器中打开工作区": "Open the workspace in a code editor",
|
||||
"在系统文件管理器中显示工作区": "Show the workspace in the system file manager",
|
||||
"用其他程序打开项目": "Open the project in another program",
|
||||
"没有找到可用的程序": "No supported program found",
|
||||
"文件管理器": "File manager",
|
||||
"终端": "Terminal",
|
||||
"在系统文件管理器中显示此文件": "Show this file in the system file manager",
|
||||
"只读 · 来自你授权的文件夹": "Read-only · from a folder you granted",
|
||||
"重命名页签": "Rename tab",
|
||||
"显示隐藏文件": "Show hidden files",
|
||||
"在访达中显示": "Reveal in Finder",
|
||||
"在文件资源管理器中显示": "Reveal in File Explorer",
|
||||
@@ -646,6 +666,11 @@ export const EN: Record<string, string> = {
|
||||
"编辑": "Edit",
|
||||
"测试中…": "Testing…",
|
||||
"测试连接": "Test it",
|
||||
"代理": "Proxy",
|
||||
"域名解析": "Name resolution",
|
||||
"建立连接": "Connection",
|
||||
"加密握手": "TLS handshake",
|
||||
"凭据": "Credentials",
|
||||
"保存失败": "Save failed",
|
||||
"已保存,尚未生效": "Saved, not applied yet",
|
||||
"读取模型列表失败": "Could not read the model list",
|
||||
@@ -712,12 +737,16 @@ export const EN: Record<string, string> = {
|
||||
"指定一个可执行文件": "Point at an executable",
|
||||
"取消指定": "Clear it",
|
||||
"可执行文件路径": "Path to the executable",
|
||||
"启动命令": "Launch command",
|
||||
"保存的启动参数现在无法运行,已回退到自动检测。": "The saved launcher arguments no longer run, so auto-detection is in use.",
|
||||
"启动参数(可选,每行一项)": "Launcher arguments (optional, one per line)",
|
||||
"例如 wsl.exe 需要 -d <发行版> -- /bin/bash 才会进入 shell。参数按原样传给程序,不经过任何 shell;保存前会带着它们运行一次来验证。": "For example wsl.exe needs -d <distro> -- /bin/bash to reach a shell. Arguments are passed to the program as written, through no shell; they are run once to verify before saving.",
|
||||
"自行编译的 bash、MSYS2,或安装在其他位置的 pwsh 均可填写于此。保存前会实际执行一条命令进行验证,无法运行则不会写入配置。":
|
||||
"A bash you built yourself, MSYS2, a pwsh installed elsewhere — all go here. Before saving, one command is really run through it; if that fails, nothing is written to the config.",
|
||||
"切换失败": "Not changed",
|
||||
"够不着这个工作目录。": "cannot reach this working directory.",
|
||||
"本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项 —— WSL 中的 bash 不适用,它看到的是 /mnt 下的另一套路径,无法访问当前工作目录。":
|
||||
"There is no bash on this machine, so commands can only be written for PowerShell. Installing Git for Windows adds Git Bash here — the one inside WSL does not count: it sees a different set of paths under /mnt and cannot reach this working directory.",
|
||||
"本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项。要用 WSL,请在下方指定 wsl.exe 并填写启动参数;其中的路径是 Linux 写法。":
|
||||
"There is no bash on this machine, so commands can only be written for PowerShell. Installing Git for Windows adds Git Bash here. To use WSL, point at wsl.exe below and add its launcher arguments; paths inside it are Linux paths.",
|
||||
"无法读取 shell 配置。": "Could not read the shell config.",
|
||||
|
||||
// ── 卡片:批准、还原、收工 ───────────────────────────────────────
|
||||
@@ -1069,6 +1098,9 @@ export const EN: Record<string, string> = {
|
||||
"外部服务 {name} 提供的工具": "A tool provided by the external service {name}",
|
||||
"这次调用解析到的能力目录条目": "The capability catalogue entry this call resolved to",
|
||||
"在这段对话里查找": "Find in this conversation",
|
||||
"显示对话": "Show conversation",
|
||||
"显示运行分析": "Show run analysis",
|
||||
"显示工作台": "Show workbench",
|
||||
"无结果": "No matches",
|
||||
"上一处": "Previous match",
|
||||
"下一处": "Next match",
|
||||
@@ -1159,6 +1191,7 @@ export const EN: Record<string, string> = {
|
||||
// ── 回合中的通知(notices.ts 按 code 查表,扫描器看不到)──────
|
||||
"这一轮模型没有给出回答,正在让它重说一次": "The model gave no answer this round; asking it again",
|
||||
"模型直接给了答案却没动手,正在要求它先用工具": "The model answered without acting; asking it to use its tools first",
|
||||
"分支保存的模型已不可用,本次改用其他已配置的模型;可以在模型设置中重新选择": "The model saved for this branch is unavailable, so another configured model is in use; choose a model again in settings",
|
||||
"同一个检查连着几轮都报同样的结果,是否继续由你定":
|
||||
"The same check has reported the same thing for several rounds; whether to keep at it is yours to call",
|
||||
"另一个会话正在写这个工作区,等它写完会自动继续":
|
||||
@@ -1291,12 +1324,30 @@ export const EN: Record<string, string> = {
|
||||
"置顶会话": "Pin session",
|
||||
"删除会话": "Delete session",
|
||||
"会话范围": "Session scope",
|
||||
"按名称筛选会话": "Filter sessions by name",
|
||||
"运行统计": "Run metrics",
|
||||
"本轮": "This turn",
|
||||
"详情": "Details",
|
||||
"分离状态": "detached",
|
||||
"新版本 {v} 已下载。重启后使用,不会自动重启。": "Version {v} is downloaded. Restart to use it; Studio never restarts by itself.",
|
||||
"重启并更新": "Restart and update",
|
||||
"有 {n} 个会话还在运行,等它们结束后再重启。": "{n} sessions are still running; restart after they finish.",
|
||||
"仍在读取(会话很多时首次较慢)": "Still reading (the first read is slow with many sessions)",
|
||||
"界面是不是偏小?可以在这里放大,之后也能在「外观」里调整。": "Is the interface too small? You can enlarge it here and change it later under Appearance.",
|
||||
"界面已改为 {n}%。合适吗?": "The interface is now {n}%. Does that look right?",
|
||||
"恢复 {n}%": "Restore {n}%",
|
||||
"不用了": "No thanks",
|
||||
"好": "OK",
|
||||
"后台任务输出:{name}": "Background task output: {name}",
|
||||
"查看输出:{cmd}": "View output: {cmd}",
|
||||
"查看输出": "View output",
|
||||
"停止此任务": "Stop this task",
|
||||
"复制输出": "Copy output",
|
||||
"这个任务已经结束,没有可显示的输出了。": "This task has ended; there is no output left to show.",
|
||||
"只显示最后一部分(共 {total})。": "Showing only the last part ({total} in all).",
|
||||
"还没有输出": "No output yet",
|
||||
"当前 Git 分支:{branch}": "Current Git branch: {branch}",
|
||||
"正在切换到 {branch}…": "Switching to {branch}…",
|
||||
"这张图片在工作区之外,不会加载": "This picture is outside the workspace and is not loaded",
|
||||
"{n} 个变更": "{n} changes",
|
||||
"1 个文件已更改": "1 file changed",
|
||||
"{n} 个文件已更改": "{n} files changed",
|
||||
@@ -1356,8 +1407,10 @@ export const EN: Record<string, string> = {
|
||||
"复制选中内容": "Copy selected text",
|
||||
"选中内容": "Selected text",
|
||||
"重新生成": "Regenerate",
|
||||
"分支为新会话": "Branch into a new conversation",
|
||||
"创建对话分支": "Create a conversation branch",
|
||||
"分支": "branch",
|
||||
"从最近回复分叉": "Branch from the latest reply",
|
||||
"还没有已完成的回复可以分叉": "There is no finished reply to branch from yet",
|
||||
"当前回复会留在运行历史里": "The current reply stays in the run history",
|
||||
"按当前配置重试": "Retry as configured",
|
||||
"先调整模型与强度": "Adjust the model and effort first",
|
||||
@@ -1508,12 +1561,17 @@ export const EN: Record<string, string> = {
|
||||
"后台任务已结束:{name}": "Background job finished: {name}",
|
||||
"后台任务已终止:{name}": "Background job killed: {name}",
|
||||
"后台任务 {name} 失败,需要处理": "Background job {name} failed: needs attention",
|
||||
"后台任务 {name} 已有 {quiet} 没往自己的输出里写任何内容:可能是在写文件,也可能卡住了;可以用 wait 或 bash_output 查看,或用 kill_shell 结束它":
|
||||
"Background job {name} has written nothing to its own output for {quiet}: it may be writing to a file, or stuck; check it with wait or bash_output, or stop it with kill_shell",
|
||||
"被信号 {signal} 终止": "Ended by signal {signal}",
|
||||
"退出码 {code}": "Exit code {code}",
|
||||
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
|
||||
"待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送": "Sending is paused. This message is queued and goes out once you press Release",
|
||||
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
|
||||
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
|
||||
"这个会话保存的压缩结果没有被复用:它所总结的历史之后发生了变化,现在发送的是完整历史,可能超出模型容量": "This conversation's saved compaction was not reused: the history it summarised has changed since. The full history is being sent, which may exceed the model's window.",
|
||||
"这个会话保存的压缩结果是在另一个模型或工作区下生成的,与当前历史对不上,没有被复用,现在发送的是完整历史": "This conversation's saved compaction was made under another model or workspace and does not match its history, so it was not reused. The full history is being sent.",
|
||||
"这些工作区在同一个 Git 仓库 {repo} 内,同一仓库共用一个写入域;把项目移出该仓库,或在用户配置里设 write_lease = \"optimistic\",才能并行写入": "These workspaces are inside the same Git repository, {repo}, and one repository is one write domain; move the project out of it, or set write_lease = \"optimistic\" in your user config, to write in parallel",
|
||||
"配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个": "The question wait time in the config file is not valid (it must be a whole number, 0 or 10 to 86400), so questions wait for you as long as it takes; set it again in Settings.",
|
||||
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
|
||||
"扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展": "The companion background program of extension {ext} is not running, so the extension was skipped this time (at {point}). Open Tools and integrations to check and start it, or disable the extension.",
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
// The turn starts here: drafting, attachments, run posture, and the two actions
|
||||
// that remain available while an agent is live.
|
||||
export const EN_COMPOSER: Record<string, string> = {
|
||||
"配置工作目录": "Configure workspace",
|
||||
"独立 worktree · {branch}": "New worktree · {branch}",
|
||||
"当前目录 · {branch}": "Current folder · {branch}",
|
||||
"新建 worktree": "New worktree",
|
||||
"为此会话创建独立的代码目录": "Create a separate code folder for this session",
|
||||
"起始分支": "Source branch",
|
||||
"任务分支": "Task branch",
|
||||
"自动创建": "Create automatically",
|
||||
"基于所选分支的已提交版本创建,不包含未提交修改": "Start from the selected branch's committed version, without uncommitted changes",
|
||||
"使用当前目录,与该目录中的其他会话共享文件": "Use the current folder, sharing files with other sessions in it",
|
||||
"选择起始分支": "Choose source branch",
|
||||
"搜索分支": "Search branches",
|
||||
"搜索分支…": "Search branches…",
|
||||
"远程分支": "Remote branches",
|
||||
"当前分支": "Current branch",
|
||||
"没有匹配的分支": "No matching branches",
|
||||
"分支读取失败,请关闭后重试": "Could not read branches. Close and try again",
|
||||
"不思考": "No reasoning",
|
||||
"最轻量": "Minimal",
|
||||
"比快速档更轻;GLM-5.2 会跳过思考": "Lighter than Fast; GLM-5.2 skips thinking",
|
||||
@@ -43,6 +60,13 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"当前分支 · {branch}": "Current branch · {branch}",
|
||||
"非 Git 仓库": "No Git repository",
|
||||
"此工作区未受版本控制": "This workspace is not version-controlled",
|
||||
"Git 拒绝打开": "Git refused to open it",
|
||||
"Git 未响应": "Git did not respond",
|
||||
"这个文件夹有 Git 仓库,但 Git 拒绝打开它": "This folder has a Git repository, but Git refused to open it",
|
||||
"这个文件夹有 Git 仓库,但 Git 没有及时响应": "This folder has a Git repository, but Git did not respond in time",
|
||||
"Git 说:{detail}": "Git said: {detail}",
|
||||
"请在终端里运行 git status 查看原因": "Run git status in a terminal to see why",
|
||||
"分支暂时无法显示。重新打开这个文件夹,或重启 Studio 后再试": "Branches cannot be shown for now. Reopen this folder, or restart Studio, and try again",
|
||||
"这里没有 Git 仓库,因此没有分支可显示或切换": "There is no Git repository here, so there is no branch to show or switch to",
|
||||
"HEAD 分离 · {sha}": "Detached HEAD · {sha}",
|
||||
"本地分支": "Local branches",
|
||||
@@ -93,6 +117,6 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"账号:{name}": "Account: {name}",
|
||||
"目录": "folder",
|
||||
"资源": "resource",
|
||||
"Tab 补全或进入目录 · ↑↓ 后回车确认 · Esc 关闭": "Tab completes or enters a folder · ↑↓ then Enter confirms · Esc closes",
|
||||
"↑↓ 选择 · 回车引用 · Tab 补全或进入目录 · Esc 关闭": "↑↓ selects · Enter references · Tab completes or enters a folder · Esc closes",
|
||||
"↑↓ 选择 · Tab 或回车补全 · Esc 关闭": "↑↓ selects · Tab or Enter completes · Esc closes",
|
||||
};
|
||||
@@ -1,6 +1,11 @@
|
||||
// English for the in-app feedback dialog and its refusals.
|
||||
export const EN_FEEDBACK: Record<string, string> = {
|
||||
"反馈": "Feedback",
|
||||
"新回复": "New replies",
|
||||
"未结束": "Open",
|
||||
"已结束": "Closed",
|
||||
"按进展筛选": "Filter by progress",
|
||||
"这一组里没有反馈。": "No reports in this group.",
|
||||
"已完成:": "Done: ",
|
||||
"当前:": "Current: ",
|
||||
"尚未开始:": "Not yet: ",
|
||||
@@ -144,6 +149,8 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"{n} 项待查看": "{n} to review",
|
||||
"有 {n} 份反馈收到了新回复。": "{n} of your reports have new replies.",
|
||||
"回复已发送。": "Reply sent.",
|
||||
"也可以直接粘贴。PNG 或 JPEG,最多 {n} 张,每张不超过 {size} MB。": "You can also paste. PNG or JPEG, up to {n}, each up to {size} MB.",
|
||||
"截图先只发给维护者,由维护者决定是否公开。": "Screenshots go to the maintainers only, who decide whether to make them public.",
|
||||
"反馈等级": "Feedback level",
|
||||
"升级进度": "Progress to the next level",
|
||||
"新种": "New seed",
|
||||
@@ -176,4 +183,20 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"约 {n} 分钟后重置。": "It resets in about {n} min.",
|
||||
"约 {n} 小时后重置。": "It resets in about {n} h.",
|
||||
"这份反馈的回复次数已到上限,不会自动重置。如有新的情况,可以另外提交一条反馈。": "This report has reached its reply limit, and it does not reset. If something new comes up, send another report.",
|
||||
"账号关联": "Account link",
|
||||
"关联后,这台电脑的反馈等级会显示在网页账号页上。关联不会改变额度,随时可以解除。": "Once linked, this computer's feedback level shows on your account page on the web. Linking does not change your limits, and you can unlink at any time.",
|
||||
"先在设置里登录账号,才能关联。": "Sign in to your account in Settings first, then you can link.",
|
||||
"发出第一条反馈之后才能关联这台电脑。": "You can link this computer after you have sent your first report.",
|
||||
"这台电脑已关联": "This computer is linked",
|
||||
"解除关联": "Unlink",
|
||||
"把这台电脑关联到我的账号": "Link this computer to my account",
|
||||
"账号下的其他电脑": "Other computers on this account",
|
||||
"解除": "Unlink",
|
||||
"没能连上反馈服务,请稍后重试。": "Could not reach the feedback service. Please try again later.",
|
||||
"需要先登录账号。登录状态可能已过期,请在设置里重新登录。": "Sign in to your account first. Your sign-in may have expired; sign in again in Settings.",
|
||||
"这台电脑已经关联到另一个账号。请先用那个账号解除关联。": "This computer is already linked to another account. Unlink it from that account first.",
|
||||
"这个账号关联的电脑已达上限。请先解除一台不用的电脑。": "This account has reached its limit of linked computers. Unlink one you no longer use.",
|
||||
"这台电脑还没有发过反馈,发出第一条之后才能关联账号。": "This computer has not sent a report yet. You can link it to an account after the first one.",
|
||||
"账号关联暂时不可用,请稍后再试。": "Account linking is unavailable right now. Please try again later.",
|
||||
"操作没有完成,请稍后重试。": "That did not finish. Please try again later.",
|
||||
};
|
||||
@@ -3,11 +3,16 @@
|
||||
// code adds one here. i18n.test.ts is what stops one from being forgotten.
|
||||
|
||||
export const EN_KERNEL: Record<string, string> = {
|
||||
"起始分支已不存在,请重新选择分支": "The source branch no longer exists. Choose another branch",
|
||||
"会话或工作目录已改变,请新建会话后重新选择 worktree": "The session or workspace changed. Start a new session to select a worktree",
|
||||
"请先打开一个本机会话,再读取本机设置": "Open a local session to read this machine’s settings",
|
||||
"未配置文件访问策略,无法执行此文件操作。": "File access policy is not configured; this file operation cannot run.",
|
||||
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
|
||||
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
|
||||
"无法读取目标文件:没有权限访问。": "Cannot read target file: permission denied.",
|
||||
"读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。": "Reading a video needs ffmpeg (with ffprobe) installed and on the system path. Install it and try again.",
|
||||
"不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。": "This video format is not recognised. MP4/MOV, MKV/WebM, AVI, FLV, ASF, MPEG and Ogg containers are supported.",
|
||||
"视频文件太大,超过了能读取的上限。": "The video file is larger than the limit for reading.",
|
||||
"无法覆盖目标文件:没有权限读取原文件内容。": "Cannot overwrite target file: permission to read existing contents is required.",
|
||||
"内核繁忙,这次操作可能没有被收到,重试前请先确认": "The kernel is busy; the action may not have been received - check before retrying",
|
||||
"无法连接内核,这次操作可能没有送达,重试前请先确认": "The kernel cannot be reached; the action may not have been delivered - check before retrying",
|
||||
@@ -33,6 +38,9 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该会话还没有可用于命名的用户消息": "This chat has no user message to name it from",
|
||||
"未配置可用于自动命名的模型,请先配置模型": "Configure a model before using automatic naming",
|
||||
"自动命名失败,原标题未更改,请重试": "Automatic naming failed. The original title is unchanged. Please try again",
|
||||
"自动命名超时:标题模型没有及时回答,原标题未更改,请稍后重试": "Automatic naming timed out: the title model did not answer in time. The original title is unchanged. Please try again later",
|
||||
"自动命名失败:标题模型没有返回任何文字,原标题未更改": "Automatic naming failed: the title model returned no text. The original title is unchanged",
|
||||
"自动命名失败:标题模型的接口拒绝了请求(HTTP {status}){detail}": "Automatic naming failed: the title model's endpoint refused the request (HTTP {status}) {detail}",
|
||||
"会话标题保存失败,请重试": "The session title could not be saved. Please try again",
|
||||
|
||||
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
|
||||
@@ -46,6 +54,10 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
|
||||
"这份反馈现在不接收回复": "This report takes no reply right now",
|
||||
"反馈服务要求额外验证,请稍后再试": "The feedback service asks for extra verification - try again later",
|
||||
"需要先登录账号": "Sign in to your account first",
|
||||
"这台电脑已关联到另一个账号": "This computer is already linked to another account",
|
||||
"这个账号关联的电脑已达上限": "This account has reached its limit of linked computers",
|
||||
"这台电脑还没有发过反馈": "This computer has not sent a report yet",
|
||||
"截图无法清除其中的元数据,请换一张或先用截图工具重新导出": "An image could not be cleaned of its metadata - pick another or re-export it",
|
||||
"反馈内容不符合要求,请检查后重试": "That feedback could not be accepted - check the fields and try again",
|
||||
"反馈内容过大,请缩短文字或减少截图": "That feedback is too large - shorten the text or attach fewer screenshots",
|
||||
@@ -69,7 +81,14 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"开关未能保存:其存储文件无法读取或写入": "The switch was not stored: its file could not be read or written",
|
||||
// 待送达:条目、队列、这份存档各自会拒
|
||||
"该条已不在待送达队列中": "That entry is no longer in the pending queue",
|
||||
"事件流连接已经关闭,需要重新打开并重新订阅各窗格": "The event stream has closed; open a new one and subscribe each pane again",
|
||||
"这个窗格由另一台机器驱动,使用它自己的事件流": "This pane is driven from another machine and keeps its own event stream",
|
||||
"这个后台任务已经不在运行了": "That background job is no longer running",
|
||||
"这条调用已经结束了": "This call has already finished",
|
||||
"你停止了这条调用,它在外部已经做了什么无法确定": "You stopped this call; what it had already done outside the workspace is unknown",
|
||||
"请输入要搜索的内容": "Enter something to search for",
|
||||
"对话内容索引暂时无法回答,稍后再试": "The conversation index cannot answer right now; try again shortly",
|
||||
"已经请求停止这个后台任务,它的进程还没有退出,仍在等待": "A stop was already requested for this background job; its process has not exited yet, so it is still shutting down",
|
||||
"该条当前状态不允许此操作": "That entry's current state does not allow this",
|
||||
"待送达已暂停,请先恢复派发再进行操作": "The pending queue is on hold - resume it first",
|
||||
"待送达条数已达上限,请先发送部分内容": "The pending queue is full - let a few go out first",
|
||||
@@ -80,7 +99,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该待送达由更高版本写入,当前版本只能读取": "This queue was written by a newer version and is read-only here",
|
||||
"该提交标识已被使用,且当时的内容不同": "That submission key was already used, with different content",
|
||||
// 配置文件本身坏了,以及每一个写设置的面板被它挡住时
|
||||
"配置文件无法读取,因此本次未保存": "The config file does not parse, so nothing was saved",
|
||||
"配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复": "The config file {path} does not parse, so nothing was saved. Open Settings to see where it is broken and repair it",
|
||||
"这台服务器未开放配置编辑": "This server does not allow editing its config",
|
||||
"该文件需手动修改:{detail}": "This file needs an edit by hand: {detail}",
|
||||
"设置已写入,但运行时未能按新设置重建:{detail}": "The settings were written, but the runtime could not be rebuilt on them: {detail}",
|
||||
@@ -89,6 +108,14 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"沙箱设置未能保存:{detail}": "The sandbox settings were not saved: {detail}",
|
||||
"内置浏览器设置未能保存:{detail}": "The built-in browser setting was not saved: {detail}",
|
||||
"本次请求未说明开关状态,未做任何修改": "The request did not say on or off, so nothing changed",
|
||||
"这台服务器未开放子代理写入范围设置": "This server does not open the sub-agent write scope setting",
|
||||
"这台服务器未开放多会话写入设置": "This server does not open the multi-session write setting",
|
||||
"多会话写入设置未能保存:{detail}": "The multi-session write setting was not saved: {detail}",
|
||||
"这台服务器未开放计划批准设置": "This server does not open the plan approval setting",
|
||||
"计划批准设置未能保存:{detail}": "The plan approval setting was not saved: {detail}",
|
||||
"子代理写入范围设置未能保存:{detail}": "The sub-agent write scope setting was not saved: {detail}",
|
||||
"本次请求未说明选择,未做任何修改": "The request did not say which, so nothing changed",
|
||||
"这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写": "This path is in use by another parallel sub-agent or write. This write did not run; choose another path, or wait until it finishes",
|
||||
"无法解析该服务器声明:{detail}": "That server declaration could not be read: {detail}",
|
||||
"未能安装该服务器:{detail}": "That server could not be installed: {detail}",
|
||||
"未能移除该服务器:{detail}": "That server could not be removed: {detail}",
|
||||
@@ -96,6 +123,30 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该钩子未能运行:{detail}": "That hook did not run: {detail}",
|
||||
"未能删除该条记忆:{detail}": "That memory could not be removed: {detail}",
|
||||
"网络设置未能保存:{detail}": "The network settings were not saved: {detail}",
|
||||
"不走代理(已关闭)": "Not using a proxy (turned off)",
|
||||
"这个地址直连,不走代理": "This address is reached directly, without a proxy",
|
||||
"经由 {proxy}": "Through {proxy}",
|
||||
"代理配置本身不合法,先改这里": "The proxy setting itself is not valid; fix it here first",
|
||||
"读不懂这个地址:{endpoint}": "This address cannot be read: {endpoint}",
|
||||
"解析不了 {host}:{error}": "Cannot resolve {host}: {error}",
|
||||
"域名解析就失败了,通常是 DNS 或者根本没有网": "Name resolution failed: usually DNS, or no network at all",
|
||||
"连不上 {host}:{port}:{error}": "Cannot connect to {host}:{port}: {error}",
|
||||
"{host}:{port} 通了": "{host}:{port} is reachable",
|
||||
"解析得到地址但连不上代理,检查代理是不是没开、端口对不对": "The name resolved but the proxy is not reachable; check that it is running and the port is right",
|
||||
"解析得到地址但连不上,可能被防火墙挡了,或者需要走代理": "The name resolved but the connection fails; a firewall may be blocking it, or it may need a proxy",
|
||||
"握手成功 · HTTP {status}": "Handshake succeeded · HTTP {status}",
|
||||
"证书不被信任 —— 公司网络里的解密代理常有这种情况,需要把它的根证书装进系统信任": "The certificate is not trusted — common behind a company decrypting proxy; install its root certificate into the system trust store",
|
||||
"对面不像是一个 HTTPS 端点,检查地址或者代理协议是不是填错了": "The other side does not look like an HTTPS endpoint; check the address and the proxy protocol",
|
||||
"读不到配置:{error}": "Cannot read the configuration: {error}",
|
||||
"没有配置 provider,没有可以测的目标": "No provider is configured, so there is nothing to test",
|
||||
"没有可用的模型配置": "No usable model configuration",
|
||||
"没有找到 API key": "No API key was found",
|
||||
"网络这一侧是通的,缺的是 key —— 去「模型」那页填": "The network side works; the key is missing — add it on the Models page",
|
||||
"key 被拒了 — HTTP {status}": "The key was rejected — HTTP {status}",
|
||||
"网络通了,是 key 的问题,去「模型」那页换一个": "The network works and the key is the problem; replace it on the Models page",
|
||||
"对面出错了 — HTTP {status}": "The other side failed — HTTP {status}",
|
||||
"不是你的配置问题,是服务端;过一会儿再试": "This is not your configuration, it is the server; try again in a while",
|
||||
"key 可用 — HTTP {status}": "The key works — HTTP {status}",
|
||||
"shell 设置未能保存:{detail}": "The shell settings were not saved: {detail}",
|
||||
"扩展未执行该动作:{detail}": "The extension did not carry that action out: {detail}",
|
||||
"扩展未接受本次提交:{detail}": "The extension did not accept that submission: {detail}",
|
||||
@@ -159,6 +210,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个文件夹已经在当前工作区里": "This folder is already inside the current workspace.",
|
||||
"这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)": "This folder is too broad: it is a drive root or contains your home or Reasonix data folder.",
|
||||
"这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限": "This folder holds or sits inside keys, credentials or Reasonix's own data, so it cannot be granted write access.",
|
||||
"这里是 Reasonix 保存已安装技能的位置,不能授予写入权限。想让 AI 持续改进自己的技能:把技能的源文件夹用“登记”(register)方式安装,之后在源文件夹里修改。": "This is where Reasonix keeps installed skills, so it cannot be granted write access. To let the AI keep improving a skill of yours, install the skill's source folder in register mode and edit it there.",
|
||||
"这个文件夹不属于当前用户,不能授予写入权限": "This folder is not owned by the current user, so it cannot be granted write access.",
|
||||
"这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠": "This folder overlaps a location that is already writable (temp, a tool cache or another extra folder).",
|
||||
"没有这个预设": "No curated preset has this id.",
|
||||
@@ -281,7 +333,19 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个会话不支持辅助提问": "Side questions are not available in this session",
|
||||
"这个辅助提问已经关闭,请重新打开": "That side question is closed; open it again",
|
||||
"上一个问题还在回答,请稍候再问": "The previous question is still being answered; wait a moment",
|
||||
"同时打开的辅助提问太多,请先关闭几个": "Too many side questions are open; close some first",
|
||||
"同时打开的辅助提问已到你设置的上限(agent.max_side_sessions),请先关闭几个": "The side questions open at once have reached your limit (agent.max_side_sessions); close some first",
|
||||
"会话间消息未开启": "Messages between sessions are not turned on",
|
||||
"这个客户端不能收发会话间消息": "This client cannot send or decide messages between sessions",
|
||||
"本机没有叫这个名字的会话": "No session on this machine has that name",
|
||||
"有多个会话叫这个名字,请改用「项目/会话名」": "More than one session has that name; use project/session",
|
||||
"不能给自己发消息": "A session cannot message itself",
|
||||
"对方不接收消息": "That session does not accept messages",
|
||||
"发得太快了,请稍后再发": "Sending too fast; try again shortly",
|
||||
"请先写下要发的内容": "Write the message first",
|
||||
"这条消息太长": "That message is too long",
|
||||
"这条消息已经处理过了": "That message was already decided",
|
||||
"会话间消息没能完成:{detail}": "The message between sessions did not go through: {detail}",
|
||||
"地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥": "The address moved to another origin, so the saved key is not sent there; enter the key for this address",
|
||||
"请先写下要问的内容": "Write the question first",
|
||||
"辅助提问没能完成,请重试": "The side question could not be completed; try again",
|
||||
"尚未填写服务地址": "No endpoint address yet",
|
||||
@@ -364,7 +428,9 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这份备份已不存在": "That backup no longer exists",
|
||||
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
|
||||
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
|
||||
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
|
||||
"项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加": "The project list is at the limit you set (desktop.max_workspaces); remove one before adding another",
|
||||
"{model} 的价格必须是不小于 0 的数字,并选择货币": "The price for {model} must be a number not below 0, with a currency chosen",
|
||||
"{model} 不在已选模型中,未保存价格": "{model} is not one of the selected models, so no price was saved",
|
||||
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
|
||||
"不存在该预设": "There is no such preset",
|
||||
"没有找到这个模型,默认模型没有改动": "That model was not found, so the default model is unchanged",
|
||||
@@ -411,7 +477,18 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个内核没有窗口外壳,没有可以指出的日志目录": "This kernel has no window shell, so there is no log directory to point to",
|
||||
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
|
||||
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
|
||||
"这不是一个技能名": "That is not a skill name.",
|
||||
"这个技能没有安装来源的记录,无法从源文件夹更新": "This skill has no recorded source, so it cannot be updated from a folder.",
|
||||
"这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)": "This skill was not copied from a local folder, so it cannot be updated from one (reinstall a linked or remote skill).",
|
||||
"找不到来源文件夹,可能被移动或删除了": "The source folder cannot be found; it may have been moved or deleted.",
|
||||
"自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新": "The source folder or the installed copy changed after you opened the preview. Review it again before updating.",
|
||||
"已安装的副本被改过,更新会覆盖这些改动,需要你确认": "The installed copy was edited, and updating overwrites those edits; confirm to continue.",
|
||||
"这个技能太大,无法更新(文件数或总大小超过上限)": "This skill is too large to update (too many files or too many bytes).",
|
||||
"更新没有完成,已安装的副本保持原样:{detail}": "The update did not complete and the installed copy was left as it was: {detail}",
|
||||
"请先查看更新预览,再确认更新": "Open the update preview first, then confirm.",
|
||||
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
|
||||
"不支持这种更新方式,没有改动。": "That update mode is not supported; nothing was changed.",
|
||||
"更新方式未能保存:{detail}": "The update mode was not saved: {detail}",
|
||||
"这不是一个已发布的版本号": "That is not a published version number",
|
||||
"这个版本没有发布更新内容": "This version has no published notes",
|
||||
"暂时取不到更新内容,请检查网络后重试": "What's new cannot be reached right now. Check the network and try again",
|
||||
@@ -433,6 +510,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"找不到该文件,它可能已被移动或删除": "The file was not found; it may have been moved or deleted",
|
||||
"文件操作失败,请重试": "The file operation failed; try again",
|
||||
"该路径不在当前工作区内": "That path is outside the current workspace",
|
||||
"这个文件夹没有授权给当前工作区": "That folder has not been granted to this workspace",
|
||||
"写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹": "The target is outside the workspace write scope. This is the file tools' write scope, not an OS sandbox; to allow the write, add the target folder under Settings → Sandbox → Also writable",
|
||||
"无法读取工作区文件列表": "The workspace file list could not be read",
|
||||
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
|
||||
@@ -451,4 +529,42 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
|
||||
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
|
||||
"该数值超出允许范围,未做任何修改:{detail}": "That value is out of range; nothing was changed: {detail}",
|
||||
"这个项目自带的钩子等程序要你批准后才会运行":
|
||||
"This project ships hooks or other programs that run only after you approve them",
|
||||
"这个项目的程序在批准后被改动过,本次没有运行,需要重新批准":
|
||||
"A program in this project changed after you approved it; it did not run this time and needs approval again",
|
||||
"这个项目声明的一个 MCP 服务要你批准后才会运行;详情里是它要启动的命令":
|
||||
"This project declares an MCP server that runs only after you approve it; the detail shows the command it would start",
|
||||
"一个已启用的项目 MCP 服务要启动的内容变了,本次没有运行,需要查看命令后重新启用":
|
||||
"What an enabled project MCP server would start has changed; it did not run this time - review the command and enable it again",
|
||||
"有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子":
|
||||
"A hook could not be evaluated (invalid matcher, could not start, or payload could not be serialized), so this action was blocked to be safe; fix or remove the hook in the hook settings",
|
||||
"一条外部内容看起来在向智能体下指令,已提醒它只当资料看待":
|
||||
"Some external content looks like it is giving the agent instructions; the agent was told to treat it as data only",
|
||||
"配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动":
|
||||
"The default model saved in your configuration is no longer among the configured providers, so the first available model is used this time; pick a default model again in Settings to replace it - the configuration file was not changed",
|
||||
"搜索模型的格式应为 服务/模型,搜索已关闭;到模型偏好里重新选择,或恢复自动":
|
||||
"The search model must be written as service/model, so search is off; choose one again in Model preferences, or set it back to automatic",
|
||||
"所选搜索模型所在的连接已被移出,搜索已关闭;到模型偏好里重新选择,或恢复自动":
|
||||
"The connection of the chosen search model has been removed, so search is off; choose one again in Model preferences, or set it back to automatic",
|
||||
"所选搜索模型已不在配置里,搜索已关闭;到模型偏好里重新选择,或恢复自动":
|
||||
"The chosen search model is no longer in the configuration, so search is off; choose one again in Model preferences, or set it back to automatic",
|
||||
"所选搜索模型的协议不支持原生搜索,或已关闭搜索,搜索已关闭;到模型偏好里重新选择,或恢复自动":
|
||||
"The chosen search model's protocol does not support native search, or search was turned off, so search is off; choose one again in Model preferences, or set it back to automatic",
|
||||
"网页抓取后端没有返回内容,已换用链里的下一个后端;详情见通知":
|
||||
"A fetch backend did not answer; the next one in the chain was tried. See the notice for details",
|
||||
"配置的网页抓取后端无法使用,已跳过;检查用户配置里的 web_fetch_backends":
|
||||
"A configured fetch backend cannot be used and was skipped; check web_fetch_backends in your user config",
|
||||
"搜索后端没有返回结果,已换用链里的下一个后端;详情见通知":
|
||||
"A search backend did not answer; the next one in the chain was tried. See the notice for details",
|
||||
"配置的搜索后端无法使用,已跳过;检查用户配置里的 web_search_backends 和 searxng_url":
|
||||
"A configured search backend cannot be used and was skipped; check web_search_backends and searxng_url in your user config",
|
||||
"所选搜索模型所在的连接没有凭据,搜索已关闭;到模型偏好里重新选择,或恢复自动":
|
||||
"The connection of the chosen search model has no credentials, so search is off; choose one again in Model preferences, or set it back to automatic",
|
||||
"配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换":
|
||||
"The default approval level in your configuration is not recognized by this version and is treated as ask every time; pick a level in the interface to replace it",
|
||||
"旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因":
|
||||
"The old memory file could not be backed up first, so it was not migrated and was left as it is; the detail gives the reason",
|
||||
"会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发":
|
||||
"The session, workspace or runtime is switching, so this message could not be sent right away; it is queued and will be sent automatically when the switch ends - no need to send it again",
|
||||
};
|
||||
@@ -124,6 +124,9 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"查看并管理正在操作这台电脑的设备": "See and manage devices controlling this computer",
|
||||
"手机扫码即可进入这台电脑的 Studio": "Scan with your phone to open this computer's Studio",
|
||||
"远程连接已断开": "Remote connection ended",
|
||||
"刷新页面": "Refresh page",
|
||||
"这个页面不是最新版": "This page is not the latest version",
|
||||
"它是之前打开的旧版本,继续用可能出现莫名的报错。刷新一下就能换成最新版。": "It is an older build that was opened earlier, and may show confusing errors. Refreshing switches to the latest.",
|
||||
"这台电脑已停止网页控制。重新连接需要再次验证设备状态。": "This computer has stopped web control. Reconnecting will verify the device again.",
|
||||
"重新连接": "Reconnect",
|
||||
"这个浏览器版本太旧,不支持远程控制需要的加密。请改用较新版本的 Chrome、Edge、Safari 或 Firefox 打开。": "This browser is too old for the encryption remote control needs. Open it in a recent version of Chrome, Edge, Safari or Firefox.",
|
||||
|
||||
@@ -2,13 +2,30 @@
|
||||
// alone — one catalogue is still the model, and this file is read as its
|
||||
// continuation: same keying by Chinese source text, same grouping by screen.
|
||||
export const EN_SETTINGS: Record<string, string> = {
|
||||
"「{from}」发来一条消息,等你批准": "\u201c{from}\u201d sent a message and is waiting for your approval",
|
||||
"来自 {from} 的消息": "Message from {from}",
|
||||
"来自另一个会话「{from}」的文字,不是你写的。批准后才会交给这个会话的模型。": "Text from another session, \u201c{from}\u201d; you did not write it. It reaches this session\u2019s model only after you approve it.",
|
||||
"批准并交给模型": "Approve and give it to the model",
|
||||
"等你决定的会话间消息": "Session messages waiting for your decision",
|
||||
"还有 {n} 条等你决定": "{n} more waiting for your decision",
|
||||
"无法读取会话间消息的状态。": "Could not read the state of messages between sessions.",
|
||||
"会话间消息没有开启。在用户配置里写入 [peers] enabled = true,重新打开会话后生效;默认关闭。": "Messages between sessions are off. Write [peers] enabled = true in your user config and reopen the session; it is off by default.",
|
||||
"已送到「{to}」,对方的用户批准后才会读到。": "Delivered to \u201c{to}\u201d; it is read only after that session\u2019s user approves it.",
|
||||
"本机没有别的会话在监听。": "No other session on this machine is listening.",
|
||||
"发给": "Send to",
|
||||
"要发的内容": "Message",
|
||||
"写下要告诉另一个会话的话。对方的用户批准后,它才会交给那边的模型。": "Write what to tell the other session. It reaches that session\u2019s model only after its user approves.",
|
||||
"发送中…": "Sending\u2026",
|
||||
"会话间消息(实验)": "Messages between sessions (experimental)",
|
||||
"同一台机器上的会话互相发文字。收到的文字先挂起,只有你批准才会交给模型;发出去的文字也要对方的用户批准。没有给模型用的工具,默认关闭。": "Sessions on one machine send each other text. What you receive is held until you approve it; what you send needs the other user\u2019s approval too. There is no tool for the model, and it is off by default.",
|
||||
"浏览器标签": "Browser tabs",
|
||||
"辅助会话": "Side question",
|
||||
"只读 · 不写入主会话 · 关闭即丢": "Read-only · not in the main session · gone when closed",
|
||||
"收起辅助会话": "Collapse the side question",
|
||||
"展开辅助会话": "Expand the side question",
|
||||
"关闭辅助会话": "Close the side question",
|
||||
"移除引用 {n}": "Remove quote {n}",
|
||||
"向辅助会话提问": "Ask the side question",
|
||||
"向辅助会话提问(只读,不写入主会话)": "Ask a side question (read-only, kept out of the main session)",
|
||||
"提问…": "Ask…",
|
||||
"先在对话里选中一段文字": "Select some text in the conversation first",
|
||||
"正在读取并回答…": "Reading and answering…",
|
||||
@@ -16,6 +33,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"用 {name} 打开": "Open with {name}",
|
||||
"回到会话顶部": "Back to top",
|
||||
"跳到最新": "Jump to latest",
|
||||
"回到会话顶部(Shift+点击:上一个展开的块)": "Back to top (Shift+click: previous expanded block)",
|
||||
"跳到最新(Shift+点击:下一个展开的块)": "Jump to latest (Shift+click: next expanded block)",
|
||||
"首字时间": "Time to first token",
|
||||
"关闭其他浏览器标签": "Close other browser tabs",
|
||||
"关闭右侧浏览器标签": "Close browser tabs to the right",
|
||||
@@ -56,7 +75,10 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"步骤 1 · 连接服务": "Step 1 · Connect service",
|
||||
"先连接服务,再选择这个来源要启用的模型。": "Connect the service, then choose which models to enable from it.",
|
||||
"来源名称": "Source name",
|
||||
"例如:公司中转站": "For example: Company gateway",
|
||||
"上次测试失败:{why}": "Last test failed: {why}",
|
||||
"上次测试失败": "Last test failed",
|
||||
"上次测试通过": "Last test passed",
|
||||
"连接后自动生成,可改": "Made after connecting; editable",
|
||||
"API Key 仅保存在运行内核的这台机器上。": "The API key is stored only on the machine running the kernel.",
|
||||
"检测并继续": "Test and continue",
|
||||
"自动识别协议与模型": "Protocol and models are detected automatically",
|
||||
@@ -132,6 +154,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"重建运行时": "Rebuilds the runtime",
|
||||
"重启后生效": "Takes effect after a restart",
|
||||
"已保存 · 重启后生效": "Saved · takes effect after a restart",
|
||||
"已保存 · 新开的会话生效": "Saved · applies to sessions opened from now on",
|
||||
"新开的会话生效": "Applies to new sessions",
|
||||
|
||||
// ── 设置:分区与导航 ─────────────────────────────────────────────
|
||||
"设置分类": "Settings sections",
|
||||
@@ -189,6 +213,10 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"推理强度": "Reasoning effort",
|
||||
"子代理声明": "Subagent declarations",
|
||||
"{name} 的子代理声明": "Subagent declarations for {name}",
|
||||
"技能信息": "Skill information",
|
||||
"{name} 的技能信息": "Skill information for {name}",
|
||||
"许可": "License",
|
||||
"运行要求": "Requirements",
|
||||
"声明的模型": "Declared model",
|
||||
"声明的推理强度": "Declared reasoning effort",
|
||||
"声明的工具": "Declared tools",
|
||||
@@ -360,6 +388,30 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"agent 用来打开和操作网页的工具。该开关单独保存,不受命令行版本浏览器设置的影响。修改会重建运行时,任务运行期间无法变更。": "The tools the agent uses to open and operate web pages. This switch is stored on its own and is not affected by the command-line version's browser setting. Changing it rebuilds the runtime and cannot be done while a task is running.",
|
||||
"无法读取内置浏览器设置。": "Could not read the built-in browser setting.",
|
||||
"启用内置浏览器": "Enable the built-in browser",
|
||||
"乐观模式(效率优先)": "Optimistic mode (efficiency first)",
|
||||
"同时保留的会话标签数": "Session tabs kept open",
|
||||
"保持实时连接的会话标签数": "Session tabs kept live",
|
||||
"默认留空:自动。浏览器对同一个地址只开约 6 个连接,会话列表占一个,所以只让少数标签保持实时连接,其余的标签在显示时从断点接着读,运行中的后台标签每 5 秒读一次状态。调高会让更多后台标签实时更新,但开太多会让停止、发送排不上队。": "Empty means automatic. A browser opens only about six connections to one address and the session list takes one, so only a few tabs stay live; the others resume from where they stopped when shown, and a running background tab has its status read every 5 seconds. A higher number updates more background tabs live, but too many makes Stop and Send queue behind them.",
|
||||
"实时连接的标签数": "Tabs with a live connection",
|
||||
"留空表示自动;可填 1 到 {max}": "Empty is automatic; 1 to {max}",
|
||||
"默认 0:不自动收起,打开的会话都保留。设成一个数字后,打开的会话超过它时,会收起最久没动的空闲会话;对话仍在列表里,随时可以重新打开。保留得越多,占用的内存越多。": "Default 0: nothing is folded away and every open session stays. Set a number and, once more sessions than that are open, the idle one untouched longest is folded away; its conversation stays in the list and can be opened again. The more you keep, the more memory it uses.",
|
||||
"会话标签数上限": "Limit on session tabs",
|
||||
"0 表示不限制": "0 means no limit",
|
||||
"个": "tabs",
|
||||
"无法读取多会话写入设置。": "Could not read the multi-session write setting.",
|
||||
"无法读取计划批准设置。": "Could not read the plan approval setting.",
|
||||
"计划批准": "Plan approval",
|
||||
"计划模式提交计划后,默认等你点批准才开始执行。长时间无人值守的任务可以改成自动批准。": "After Plan mode submits a plan, it waits for your approval by default. For long unattended runs it can be set to approve automatically.",
|
||||
"自动批准计划": "Approve plans automatically",
|
||||
"计划提交后不再等你点批准,直接开始执行,对话里会留下一条说明。只放行这一次点击:工具批准、沙箱和拒绝规则照常生效。默认关闭,只写在你自己的用户配置里,项目配置不能替你打开。": "A submitted plan starts without waiting for your click, and the transcript notes it. Only that click is released: tool approvals, the sandbox and deny rules still apply. Off by default, written only to your own user config; a project config cannot turn it on for you.",
|
||||
"多个会话同时写一个项目": "Several sessions writing one project",
|
||||
"多个会话在同一个项目里工作时如何互相等待。默认严格:没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)会占住整个工作区,其他会话要等它结束。只影响之后新开的会话,已在运行的会话保持它启动时的设置。": "How sessions working in one project wait for each other. Strict by default: an operation that declares no write scope (bash, MCP, a sub-agent with no write_paths) holds the whole workspace and other sessions wait for it. It affects sessions opened afterwards; a session already running keeps the setting it started with.",
|
||||
"乐观模式(多会话并行)": "Optimistic mode (sessions in parallel)",
|
||||
"没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)不再占住整个工作区,所以同一个项目里的多个会话可以同时工作,不必互相等待。代价:声明了路径的写入(如编辑文件)仍按路径互斥,但 bash 和 MCP 的写入没有事前的路径围栏,只在事后观察,两个会话用它们改同一个文件时可能互相覆盖。默认关闭(严格模式);只对之后新开的会话生效。": "An operation that declares no write scope (bash, MCP, a sub-agent with no write_paths) no longer holds the whole workspace, so several sessions in one project can work at the same time without waiting. The cost: writes that declare a path (such as editing a file) still exclude each other by path, but bash and MCP writes have no path fence beforehand and are only observed afterwards, so two sessions using them on one file can overwrite each other. Off by default (strict mode); applies only to sessions opened afterwards.",
|
||||
"无法读取子代理写入范围设置。": "Could not read the sub-agent write scope setting.",
|
||||
"子代理的写入范围": "Sub-agent write scope",
|
||||
"后台子代理写文件时如何占用工作区。默认严格:没有声明 write_paths 的子代理占住整个工作区,其他写入要等它结束。修改会重建运行时,任务运行期间无法变更。": "How a background sub-agent holds the workspace while it writes. Strict by default: a sub-agent that names no write_paths holds the whole workspace and other writers wait for it. Changing it rebuilds the runtime and cannot be done while a task runs.",
|
||||
"没有声明 write_paths 的子代理不再占住整个工作区,只在写某个文件的那一刻占用该路径,所以可以和主会话、其他子代理并发。代价:它的 bash 和 MCP 写入不受路径围栏约束,只在事后观察;两边同时改同一个文件时,后写的一方会被拒绝。默认关闭(严格模式)。": "A sub-agent that names no write_paths no longer holds the whole workspace; it holds a path only while it writes that file, so it can run beside the main conversation and other sub-agents. The cost: its bash and MCP writes are not confined by a path fence and are only observed afterwards; if two sides edit the same file at once, the later write is refused. Off by default (strict mode).",
|
||||
"agent 可以打开网页、读取内容并操作页面;浏览器在第一次使用时才启动。关闭后这些工具不会出现在工具列表中": "The agent can open web pages, read them and operate them; the browser only starts the first time it is used. When off, these tools are not in the tool list",
|
||||
"当前项目的配置文件开启了它,此工作区仍会提供内置浏览器。": "This project's config file turns it on, so this workspace still has the built-in browser.",
|
||||
"当前项目的配置文件关闭了它,此工作区不会提供内置浏览器。": "This project's config file turns it off, so this workspace has no built-in browser.",
|
||||
@@ -387,6 +439,16 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"推理强度选「自动」时使用的档位。": "The level used when reasoning effort is set to Auto.",
|
||||
"{n} 个档位": "{n} levels",
|
||||
"{n} 个模型单独设置": "{n} models with their own levels",
|
||||
"按模型设置价格": "Price by model",
|
||||
"每百万 token 的单价。留空表示未知,用量页会如实显示「未知」,而不是 0;填 0 表示免费。缓存命中价留空时按输入价计算。": "Price per million tokens. Left empty it is unknown, and the usage page says unknown rather than 0; enter 0 for free. An empty cache-hit price is charged at the input price.",
|
||||
"{model} 的输入单价": "{model} input price",
|
||||
"{model} 的输出单价": "{model} output price",
|
||||
"{model} 的缓存命中单价": "{model} cache-hit price",
|
||||
"{model} 的货币": "{model} currency",
|
||||
"未知": "Unknown",
|
||||
"同输入价": "Same as input",
|
||||
"缓存命中": "Cache hit",
|
||||
"输入价和输出价需要都填写,才会保存这一行。": "Fill in both input and output to save this row.",
|
||||
"按模型设置限制": "Limits per model",
|
||||
"留空的模型沿用上面的值,灰字是它当前继承到的值;填写后只对该模型生效。":
|
||||
"A blank field follows the values above; the grey text is what that model inherits now. A value applies to that model only.",
|
||||
@@ -535,6 +597,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"允许这一次": "Allow once",
|
||||
"本会话允许此类操作,不再询问": "Allow this kind for this session, stop asking",
|
||||
"始终允许此类操作,不再询问": "Always allow this kind, stop asking",
|
||||
"“本会话允许”只在这次会话内有效,不写入磁盘;“始终允许”写入用户配置的权限规则,可在设置 → 工具与权限里撤销。": "“Allow for this session” lasts only for this session and is not written to disk; “Always allow” is saved as a permission rule in your user config and can be undone in Settings → Tools and permissions.",
|
||||
"“始终允许”写入用户配置的权限规则,可在设置 → 工具与权限里撤销。": "“Always allow” is saved as a permission rule in your user config and can be undone in Settings → Tools and permissions.",
|
||||
"“本会话允许”只在这次会话内有效,不写入磁盘。": "“Allow for this session” lasts only for this session and is not written to disk.",
|
||||
"内核已记入会话授权,不写入磁盘。": "The kernel records it for this session only; nothing is written to disk.",
|
||||
"agent 已收到拒绝,将改用其他方式或终止。": "The agent takes the refusal and either finds another way or stops.",
|
||||
"下次同样的操作仍会请求确认。": "The same operation will ask again next time.",
|
||||
@@ -546,6 +611,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"确认(还有 {n} 个没答)": "Confirm ({n} left)",
|
||||
"确认": "Confirm",
|
||||
"先不选择,直接回复": "Skip the choice and reply instead",
|
||||
"{time} 后自动跳过这个问题,点任意处保留": "This question is skipped in {time}; click anywhere to keep it",
|
||||
"需要你决定": "Need your decision",
|
||||
"需要你补充信息": "Need more information from you",
|
||||
"MCP 服务器 {name} 请求信息": "MCP server {name} is asking for information",
|
||||
@@ -604,6 +670,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"两者都没声明 write_paths,各自按整个工作区处理": "neither declared write_paths, so each is treated as writing the whole workspace",
|
||||
"它们要写的路径重叠": "they want to write the same path",
|
||||
"上下文约 {total},已超过压缩阈值 {at},没有更多可折叠的内容。占用最多:{top}。{active}": "The context holds about {total}, over the {at} compaction threshold, and nothing more can be folded. Largest: {top}. {active}",
|
||||
"折叠后仍有 {n}:{top}": "{n} still held after the fold: {top}",
|
||||
"最大的一条是 {tool} 的结果 {n}": "the largest is a {tool} result, {n}",
|
||||
"最大的一条是 {role} 消息 {n}": "the largest is a {role} message, {n}",
|
||||
"正在进行的这一轮占 {n},折叠动不了它。": "The turn in progress holds {n} of it, and a fold cannot take it.",
|
||||
"用户消息": "User messages",
|
||||
"自动压缩暂缓,上次尝试没有完成:{why}": "Automatic compaction is paused, the last attempt did not finish: {why}",
|
||||
@@ -708,7 +777,70 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"安装失败": "Install failed",
|
||||
"无法读取 {v} 的发布信息": "Could not read the release details for {v}",
|
||||
"版本目录暂时连不上,可能是网络或代理问题。当前版本未被改动,稍后重试即可。": "The version index cannot be reached right now, likely a network or proxy problem. The running version was not touched; try again later.",
|
||||
"源文件夹有更新": "Source has changed",
|
||||
"来源文件夹里的内容比已安装的副本新:{path}": "The source folder has newer content than the installed copy: {path}",
|
||||
"副本被改过": "Copy was edited",
|
||||
"已安装的副本与安装时不同,来源文件夹没有变化:{path}": "The installed copy differs from what was installed; the source folder has not changed: {path}",
|
||||
"两边都改过": "Both changed",
|
||||
"已安装的副本和来源文件夹各自都改过:{path}": "The installed copy and the source folder have each changed: {path}",
|
||||
"源文件夹不见了": "Source folder is gone",
|
||||
"找不到来源文件夹:{path}": "The source folder cannot be found: {path}",
|
||||
"从源文件夹更新…": "Update from source folder…",
|
||||
"文件太大,这里不显示内容。请到来源文件夹里查看:{path}": "This file is too large to show here. Read it in the source folder: {path}",
|
||||
"不是文本文件,这里不显示内容。": "Not a text file, so its content is not shown here.",
|
||||
"(没有可显示的差异)": "(no difference to show)",
|
||||
"更新 {name}:来源文件夹有 {n} 处变化": "Update {name}: {n} change(s) in the source folder",
|
||||
"{name} 与来源文件夹的内容一致,没有可更新的内容": "{name} matches its source folder; there is nothing to update",
|
||||
"来源:{path}。更新后,已安装的副本会与来源文件夹完全一致。只有你点击“更新”才会执行,AI 不能触发。": "Source: {path}. After the update the installed copy is identical to the source folder. It runs only when you click Update; the AI cannot trigger it.",
|
||||
"已安装的副本在安装后被改过": "The installed copy was edited after it was installed",
|
||||
"更新会让副本与来源文件夹完全一致,覆盖这些本地改动。": "The update makes the copy identical to the source folder, overwriting those local edits.",
|
||||
"我知道会覆盖本地改动": "I understand this overwrites local edits",
|
||||
"以下文件不是说明文字,需要逐项展开看过": "These files are not plain instructions; open each one to read it",
|
||||
"还有 {n} 个文件没有展开": "{n} file(s) not opened yet",
|
||||
"以下文件是说明文字": "These files are plain instructions",
|
||||
"有 {n} 个文件在这里无法显示,我已在来源文件夹里看过": "{n} file(s) cannot be shown here; I have read them in the source folder",
|
||||
"替换「我的」里已安装的副本,所有项目均可使用": "Replaces the installed copy under Mine, available to every project",
|
||||
"重新查看": "Review again",
|
||||
"修改": "Changed",
|
||||
"{name} 的内容已与来源文件夹一致,只是安装记录还停在旧版本": "{name} already matches its source folder; only its install record is behind",
|
||||
"查看会话概览": "Open the session overview",
|
||||
"{h} 小时 {m} 分": "{h} h {m} min",
|
||||
"{s} 秒": "{s} s",
|
||||
"主代理": "Main agent",
|
||||
"会话标题": "Session title",
|
||||
"分类器": "Classifier",
|
||||
"目标评估": "Goal evaluation",
|
||||
"部分请求没有价格信息": "Some requests carried no price",
|
||||
"价格未知:{reason}": "Price unknown: {reason}",
|
||||
"价格未知": "Price unknown",
|
||||
"还没有计费的请求": "Nothing billed yet",
|
||||
"会话概览": "Session overview",
|
||||
"下一次请求会带上的内容,是当前值,不是累计": "What the next request would carry: a current reading, not a running total",
|
||||
"自动压缩于 {n}": "Folds automatically at {n}",
|
||||
"自本次打开此会话起;恢复或分叉会重新计数": "Since this session was opened in this run; resuming or forking counts again",
|
||||
"这个会话还没有计费的请求": "This session has no billed requests yet",
|
||||
"返回了用量与价格的模型响应": "Model responses that reported usage and a price",
|
||||
"Token": "Tokens",
|
||||
"输入 {a} · 输出 {b}": "In {a} · out {b}",
|
||||
"命中 ÷ (命中 + 未命中);不含服务商未区分的 {n} tokens": "hit ÷ (hit + miss); leaves out {n} tokens the provider did not split",
|
||||
"命中 ÷ (命中 + 未命中)": "hit ÷ (hit + miss)",
|
||||
"工作时长": "Work time",
|
||||
"给出最终回答的回合耗时之和,含审批与工具等待": "Sum of the turns that gave a final answer, approval and tool waits included",
|
||||
"费用": "Cost",
|
||||
"请求 · Token · 缓存 · 费用": "Requests · Tokens · Cache · Cost",
|
||||
"{v} 下载中断": "Downloading {v} was interrupted",
|
||||
"更新方式": "Update mode",
|
||||
"后台检查新版本": "Check for new versions in the background",
|
||||
"有新版本 {v},在设置的「版本」里查看": "Version {v} is available; see Settings, Version",
|
||||
"决定窗口自己做不做事:两种方式都不会替你安装新版本,重启始终由你决定。": "Chooses whether the window does anything on its own. Neither mode installs a new version for you; restarting is always your call.",
|
||||
"提醒": "Notify",
|
||||
"不会在后台联网查新版本。要更新时在上面的版本列表里自己下载并重启。这是默认。": "Nothing is checked in the background. To update, download and restart yourself from the version list above. This is the default.",
|
||||
"每天在后台读取一次版本目录,有更新的版本时在设置图标上显示一个小点。不会下载,也不会安装或重启。": "Reads the version catalog once a day in the background and shows a dot on the Settings icon when a newer release exists. It never downloads, installs or restarts.",
|
||||
"自动下载": "Download",
|
||||
"在提醒的基础上,后台下载并校验新版本。下载完成后在没有会话运行时提示你,由你点「重启并更新」;不会安装,也不会自动重启。": "On top of Notify, downloads and verifies the new release in the background. When it is ready and no session is running you get a notice; you press Restart and update. It never installs or restarts by itself.",
|
||||
"下载暂停了:连接长时间没有数据,已重连 {n} 次…": "Download paused: the connection delivered nothing for a while, reconnected {n} time(s)…",
|
||||
"{v} 下载卡住了": "Downloading {v} got stuck",
|
||||
"下载连接长时间没有数据,重连几次仍未恢复。当前版本未被改动,可以稍后重试,或下载完整安装包手动安装。": "The download connection delivered nothing for a long time and several reconnects did not help. The running version was not touched; try again later, or download the full installer.",
|
||||
"网络中断或超时。当前版本未被改动,可以重试。": "The connection dropped or timed out. The running version was not touched; you can try again.",
|
||||
"{v} 未通过签名校验": "{v} failed its signature check",
|
||||
"下载到的文件与发布签名不符,已丢弃,没有安装。多为下载损坏或网络被篡改,请重试;反复出现请反馈。": "The downloaded file does not match the release signature, so it was discarded and nothing was installed. This is usually a corrupted download or a tampered network; try again, and report it if it keeps happening.",
|
||||
@@ -894,6 +1026,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"超出一屏的命令输出与文件内容": "Command output and file contents longer than a screen",
|
||||
"压缩简报": "Compaction brief",
|
||||
"上下文压缩后,模型接下来依据的那份简报": "The brief the model works from after context is compacted",
|
||||
"收起并预览首句": "Folded, with first sentence",
|
||||
"进行中展开": "Open while running",
|
||||
"失败时展开": "Open on failure",
|
||||
"改动时展开": "Open on file change",
|
||||
@@ -1086,7 +1219,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"提问等待超时": "Question wait time",
|
||||
"长任务中途遇到提问时,不必一直停在那里等人。": "A long task need not stop at a question and wait for someone.",
|
||||
"提问超时后自动跳过": "Skip a question nobody answers",
|
||||
"默认关闭:提问一直等你回答。开启后,超过下面的时间没有回答,这个问题会被跳过,任务继续;模型会被告知这是超时而不是你的回答,并按自己的假设往下做(全自动或目标模式下尤其如此)。终端界面读同一项设置,用 /ask-timeout 修改。": "Off by default: a question waits for you. When on, a question left unanswered for the time below is skipped and the task goes on; the model is told it was a timeout, not your answer, and carries on under its own assumption (especially in full-auto or goal runs). The terminal reads the same setting; change it there with /ask-timeout.",
|
||||
"默认关闭:提问一直等你回答。开启后,超过下面的时间没有回答,这个问题会被跳过,任务继续;模型会被告知这是超时而不是你的回答,并按自己的假设往下做(全自动或目标模式下尤其如此)。模型把任务清单交还给你、等你输入时,同样等这么久,之后宿主让它按自己的判断继续并写明假设。终端界面读同一项设置,用 /ask-timeout 修改。": "Off by default: a question waits for you. When on, a question left unanswered for the time below is skipped and the task goes on; the model is told it was a timeout, not your answer, and carries on under its own assumption (especially in full-auto or goal runs). When the model hands its task list back and waits for you, it waits this long too, then the host has it continue on its own judgement and state its assumptions. The terminal reads the same setting; change it there with /ask-timeout.",
|
||||
"等待时长": "Wait time",
|
||||
"配置文件里的提问等待时长无效(须为 0 或 {min} 到 {max} 之间),现在按“一直等待”处理:{path}": "The question wait in the config file is not valid (it must be 0 or between {min} and {max}), so questions wait as long as it takes: {path}",
|
||||
"从内核发出问题时开始计时。默认 {n} 秒。": "Counted from when the kernel asks the question. Default {n} seconds.",
|
||||
|
||||
@@ -7,6 +7,10 @@ export const EN_STORAGE: Record<string, string> = {
|
||||
"选择旧版的数据文件夹。扫描只会复制会话,不会移动或删除原文件;找不到原工作区的会话会归入当前工作区。":
|
||||
"Choose the older data folder. The scan only copies sessions and never moves or deletes the originals; sessions whose workspace no longer exists are placed in the current workspace.",
|
||||
"选择旧版数据目录…": "Choose older data folder…",
|
||||
"无法打开文件夹选择器。请输入旧版数据文件夹的路径(里面有 sessions、projects 或 desktop-sessions-v5)。":
|
||||
"The folder panel could not be opened. Type the path of the older data folder (the one holding sessions, projects or desktop-sessions-v5).",
|
||||
"旧版数据目录路径": "Older data folder path",
|
||||
"导入此文件夹": "Import this folder",
|
||||
"正在扫描…": "Scanning…",
|
||||
"已找回 {n} 个会话。": "Recovered {n} sessions.",
|
||||
"没有发现尚未导入的旧会话。": "Nothing new to import.",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { t } from ".";
|
||||
import { elapsed } from "./format";
|
||||
|
||||
export const JOB_FINISHED = "job_finished";
|
||||
export const JOB_KILLED = "job_killed";
|
||||
export const JOB_FAILED = "job_failed";
|
||||
export const JOB_STALLED = "job_stalled";
|
||||
|
||||
// The typed payload a background-job notice carries as its detail, read as the
|
||||
// variables its sentence is worded from. Anything that does not parse reads as no
|
||||
@@ -18,6 +20,21 @@ export function jobNoticeVars(detail: string | undefined): { name: string } | un
|
||||
}
|
||||
}
|
||||
|
||||
// A stalled job's sentence also says how long its own output has been silent:
|
||||
// without that span the sentence would be half-filled, so it reads as no payload.
|
||||
export function jobStalledVars(detail: string | undefined): { name: string; quiet: string } | undefined {
|
||||
const named = jobNoticeVars(detail);
|
||||
if (!named || !detail) return undefined;
|
||||
try {
|
||||
const p = JSON.parse(detail) as { quietSeconds?: unknown };
|
||||
return typeof p.quietSeconds === "number" && Number.isInteger(p.quietSeconds) && p.quietSeconds > 0
|
||||
? { ...named, quiet: elapsed(p.quietSeconds * 1000) }
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// How a failed process ended, worded from the typed fields the producer wrote:
|
||||
// a signal first, since an exit code is then only the shell's echo of it. The
|
||||
// job function's own error text stands in when the producer did not type it.
|
||||
|
||||
@@ -98,10 +98,10 @@ describe("what a reader is told a refusal was", () => {
|
||||
// authority never actually left the kernel.
|
||||
it("explains the project limit in Chinese and English", () => {
|
||||
const error = coded("storage limit", "workspace.limit_reached");
|
||||
expect(reason(error)).toBe("项目列表已满(32 个),请先移除一个项目再添加");
|
||||
expect(reason(error)).toBe("项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加");
|
||||
localStorage.setItem(STORAGE, "en");
|
||||
boot();
|
||||
expect(reason(error)).toBe("The project list is full (32 projects); remove one before adding another");
|
||||
expect(reason(error)).toBe("The project list is at the limit you set (desktop.max_workspaces); remove one before adding another");
|
||||
});
|
||||
|
||||
it("does not move when the kernel rewords the same code", () => {
|
||||
|
||||
@@ -49,6 +49,9 @@ const SAID: Record<string, string> = {
|
||||
"session.title_no_message": "该会话还没有可用于命名的用户消息",
|
||||
"session.title_unavailable": "未配置可用于自动命名的模型,请先配置模型",
|
||||
"session.title_failed": "自动命名失败,原标题未更改,请重试",
|
||||
"session.title_timeout": "自动命名超时:标题模型没有及时回答,原标题未更改,请稍后重试",
|
||||
"session.title_no_text": "自动命名失败:标题模型没有返回任何文字,原标题未更改",
|
||||
"session.title_refused": "自动命名失败:标题模型的接口拒绝了请求(HTTP {status}){detail}",
|
||||
"session.title_save_failed": "会话标题保存失败,请重试",
|
||||
|
||||
// ── 忙:不是出错,是「现在不行」 ─────────────────────────────────
|
||||
@@ -67,9 +70,13 @@ const SAID: Record<string, string> = {
|
||||
"workspace.file_missing": "找不到该文件,它可能已被移动或删除",
|
||||
"workspace.file_failed": "文件操作失败,请重试",
|
||||
"workspace.path_outside_tree": "该路径不在当前工作区内",
|
||||
"workspace.root_not_granted": "这个文件夹没有授权给当前工作区",
|
||||
"workspace.target_access_unconfigured": "未配置文件访问策略,无法执行此文件操作。",
|
||||
"workspace.read_forbidden": "无法读取目标文件:没有权限访问。",
|
||||
"workspace.read_outside_scope": "无法读取目标文件:没有权限访问。",
|
||||
"video.ffmpeg_missing": "读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。",
|
||||
"video.unsupported_container": "不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。",
|
||||
"video.too_large": "视频文件太大,超过了能读取的上限。",
|
||||
"workspace.overwrite_read_forbidden": "无法覆盖目标文件:没有权限读取原文件内容。",
|
||||
"workspace.overwrite_read_outside_scope": "无法覆盖目标文件:没有权限读取原文件内容。",
|
||||
"workspace.write_outside_scope": "写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹",
|
||||
@@ -157,8 +164,12 @@ const SAID: Record<string, string> = {
|
||||
"backup.not_found": "这份备份已不存在",
|
||||
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
|
||||
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
|
||||
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
|
||||
"workspace.limit_reached": "项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加",
|
||||
"provider.bad_model_price": "{model} 的价格必须是不小于 0 的数字,并选择货币",
|
||||
"provider.model_prices_unlisted": "{model} 不在已选模型中,未保存价格",
|
||||
"workspace.changing_disabled": "这台服务器不支持切换工作区",
|
||||
"workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支",
|
||||
"workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree",
|
||||
"settings.unknown_preset": "不存在该预设",
|
||||
"settings.unknown_model": "没有找到这个模型,默认模型没有改动",
|
||||
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
|
||||
@@ -251,7 +262,7 @@ const SAID: Record<string, string> = {
|
||||
"side.unavailable": "这个会话不支持辅助提问",
|
||||
"side.unknown": "这个辅助提问已经关闭,请重新打开",
|
||||
"side.busy": "上一个问题还在回答,请稍候再问",
|
||||
"side.limit": "同时打开的辅助提问太多,请先关闭几个",
|
||||
"side.limit": "同时打开的辅助提问已到你设置的上限(agent.max_side_sessions),请先关闭几个",
|
||||
"side.empty": "请先写下要问的内容",
|
||||
"side.failed": "辅助提问没能完成,请重试",
|
||||
|
||||
@@ -375,7 +386,14 @@ const SAID: Record<string, string> = {
|
||||
|
||||
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
|
||||
"inbox.not_found": "该条已不在待送达队列中",
|
||||
"tool_call.not_running": "这条调用已经结束了",
|
||||
"tool.cancelled_by_user": "你停止了这条调用,它在外部已经做了什么无法确定",
|
||||
"history.query_required": "请输入要搜索的内容",
|
||||
"history.search_failed": "对话内容索引暂时无法回答,稍后再试",
|
||||
"job.not_running": "这个后台任务已经不在运行了",
|
||||
"mux.unknown_connection": "事件流连接已经关闭,需要重新打开并重新订阅各窗格",
|
||||
"mux.pane_not_local": "这个窗格由另一台机器驱动,使用它自己的事件流",
|
||||
"job.stop_in_progress": "已经请求停止这个后台任务,它的进程还没有退出,仍在等待",
|
||||
"inbox.invalid_state": "该条当前状态不允许此操作",
|
||||
"inbox.paused": "待送达已暂停,请先恢复派发再进行操作",
|
||||
"inbox.capacity_items": "待送达条数已达上限,请先发送部分内容",
|
||||
@@ -387,7 +405,7 @@ const SAID: Record<string, string> = {
|
||||
"inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同",
|
||||
|
||||
// ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ─────────
|
||||
"config.unparsed": "配置文件无法读取,因此本次未保存",
|
||||
"config.unparsed": "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复",
|
||||
"changes.path_outside_tree": "{path} 不在当前工作树中,无法查看其改动",
|
||||
"changes.diff_failed": "无法读取该文件的改动 —— git 未返回结果",
|
||||
"trajectory.unreadable": "无法读取本次运行的轨迹记录,因此无法确定其覆盖范围",
|
||||
@@ -401,6 +419,28 @@ const SAID: Record<string, string> = {
|
||||
"compaction.no_soft_limit": "本次请求未包含阈值,未做任何修改",
|
||||
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
|
||||
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
|
||||
"subagent_claims.editing_disabled": "这台服务器未开放子代理写入范围设置",
|
||||
"subagent_claims.save_failed": "子代理写入范围设置未能保存:{detail}",
|
||||
"subagent_claims.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
"write_lease.editing_disabled": "这台服务器未开放多会话写入设置",
|
||||
"write_lease.save_failed": "多会话写入设置未能保存:{detail}",
|
||||
"plan_approval.editing_disabled": "这台服务器未开放计划批准设置",
|
||||
"plan_approval.save_failed": "计划批准设置未能保存:{detail}",
|
||||
"plan_approval.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
"peer.disabled": "会话间消息未开启",
|
||||
"peer.editing_disabled": "这个客户端不能收发会话间消息",
|
||||
"peer.not_found": "本机没有叫这个名字的会话",
|
||||
"peer.ambiguous": "有多个会话叫这个名字,请改用「项目/会话名」",
|
||||
"peer.self": "不能给自己发消息",
|
||||
"peer.refused": "对方不接收消息",
|
||||
"peer.rate_limited": "发得太快了,请稍后再发",
|
||||
"peer.empty": "请先写下要发的内容",
|
||||
"peer.too_large": "这条消息太长",
|
||||
"peer.not_held": "这条消息已经处理过了",
|
||||
"peer.send_failed": "会话间消息没能完成:{detail}",
|
||||
"provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥",
|
||||
"write_lease.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
"write.path_busy": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写",
|
||||
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
|
||||
"display_currency.runtime_changed": "运行时已更换,费用显示币种未保存,请重试",
|
||||
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
|
||||
@@ -423,6 +463,30 @@ const SAID: Record<string, string> = {
|
||||
"hooks.dry_run_failed": "该钩子未能运行:{detail}",
|
||||
"memory.forget_failed": "未能删除该条记忆:{detail}",
|
||||
"network.rejected": "网络设置未能保存:{detail}",
|
||||
"net.proxy.off": "不走代理(已关闭)",
|
||||
"net.proxy.direct": "这个地址直连,不走代理",
|
||||
"net.proxy.via": "经由 {proxy}",
|
||||
"net.proxy.invalid": "代理配置本身不合法,先改这里",
|
||||
"net.dns.unreadable": "读不懂这个地址:{endpoint}",
|
||||
"net.dns.failed": "解析不了 {host}:{error}",
|
||||
"net.dns.advice": "域名解析就失败了,通常是 DNS 或者根本没有网",
|
||||
"net.connect.failed": "连不上 {host}:{port}:{error}",
|
||||
"net.connect.ok": "{host}:{port} 通了",
|
||||
"net.connect.proxy_down": "解析得到地址但连不上代理,检查代理是不是没开、端口对不对",
|
||||
"net.connect.blocked": "解析得到地址但连不上,可能被防火墙挡了,或者需要走代理",
|
||||
"net.tls.ok": "握手成功 · HTTP {status}",
|
||||
"net.tls.untrusted": "证书不被信任 —— 公司网络里的解密代理常有这种情况,需要把它的根证书装进系统信任",
|
||||
"net.tls.not_https": "对面不像是一个 HTTPS 端点,检查地址或者代理协议是不是填错了",
|
||||
"net.config.unreadable": "读不到配置:{error}",
|
||||
"net.no_provider": "没有配置 provider,没有可以测的目标",
|
||||
"net.auth.no_model": "没有可用的模型配置",
|
||||
"net.auth.no_key": "没有找到 API key",
|
||||
"net.auth.no_key_advice": "网络这一侧是通的,缺的是 key —— 去「模型」那页填",
|
||||
"net.auth.rejected": "key 被拒了 — HTTP {status}",
|
||||
"net.auth.rejected_advice": "网络通了,是 key 的问题,去「模型」那页换一个",
|
||||
"net.auth.server_error": "对面出错了 — HTTP {status}",
|
||||
"net.auth.server_error_advice": "不是你的配置问题,是服务端;过一会儿再试",
|
||||
"net.auth.ok": "key 可用 — HTTP {status}",
|
||||
"shell.rejected": "shell 设置未能保存:{detail}",
|
||||
"extension.action_failed": "扩展未执行该动作:{detail}",
|
||||
"extension.form_rejected": "扩展未接受本次提交:{detail}",
|
||||
@@ -486,6 +550,7 @@ const SAID: Record<string, string> = {
|
||||
"workspace_roots.covered_by_primary": "这个文件夹已经在当前工作区里",
|
||||
"workspace_roots.too_broad": "这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)",
|
||||
"workspace_roots.protected": "这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限",
|
||||
"workspace_roots.skill_store": "这里是 Reasonix 保存已安装技能的位置,不能授予写入权限。想让 AI 持续改进自己的技能:把技能的源文件夹用“登记”(register)方式安装,之后在源文件夹里修改。",
|
||||
"workspace_roots.not_owned": "这个文件夹不属于当前用户,不能授予写入权限",
|
||||
"workspace_roots.overlaps_write_root": "这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠",
|
||||
"provider.preset_unknown": "没有这个预设",
|
||||
@@ -517,6 +582,10 @@ const SAID: Record<string, string> = {
|
||||
"feedback.reply_limit": "这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试",
|
||||
"feedback.not_replyable": "这份反馈现在不接收回复",
|
||||
"feedback.challenge_required": "反馈服务要求额外验证,请稍后再试",
|
||||
"feedback.account_required": "需要先登录账号",
|
||||
"feedback.link_conflict": "这台电脑已关联到另一个账号",
|
||||
"feedback.link_limit": "这个账号关联的电脑已达上限",
|
||||
"feedback.no_install": "这台电脑还没有发过反馈",
|
||||
|
||||
// ── 远程连接停下来问的那一句 ───────────────────────────────────
|
||||
"ask.not_found": "不存在该待回答的问题",
|
||||
@@ -532,7 +601,18 @@ const SAID: Record<string, string> = {
|
||||
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
|
||||
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
|
||||
"studio.no_logs": "这个内核没有窗口外壳,没有可以指出的日志目录",
|
||||
"skills.update_bad_name": "这不是一个技能名",
|
||||
"skills.update_not_recorded": "这个技能没有安装来源的记录,无法从源文件夹更新",
|
||||
"skills.update_not_local": "这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)",
|
||||
"skills.update_source_missing": "找不到来源文件夹,可能被移动或删除了",
|
||||
"skills.update_plan_stale": "自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新",
|
||||
"skills.update_needs_confirm": "已安装的副本被改过,更新会覆盖这些改动,需要你确认",
|
||||
"skills.update_too_large": "这个技能太大,无法更新(文件数或总大小超过上限)",
|
||||
"skills.update_failed": "更新没有完成,已安装的副本保持原样:{detail}",
|
||||
"skills.update_plan_required": "请先查看更新预览,再确认更新",
|
||||
"studio.pin_rejected": "版本固定未能保存:{detail}",
|
||||
"studio.update_mode_invalid": "不支持这种更新方式,没有改动。",
|
||||
"studio.update_mode_save_failed": "更新方式未能保存:{detail}",
|
||||
"studio.notes_bad_version": "这不是一个已发布的版本号",
|
||||
"studio.notes_absent": "这个版本没有发布更新内容",
|
||||
"studio.notes_unreachable": "暂时取不到更新内容,请检查网络后重试",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EN } from "./en";
|
||||
import { NOTICE_TEXT } from "./notices";
|
||||
import { NOTICE_VAR_CODES, leftoverDetail, noticeWording } from "./notice_wording";
|
||||
|
||||
@@ -11,6 +12,7 @@ const PAYLOAD: Record<string, string | undefined> = {
|
||||
job_finished: JSON.stringify({ id: "j", label: "build" }),
|
||||
job_killed: JSON.stringify({ id: "j", label: "build" }),
|
||||
job_failed: JSON.stringify({ id: "j", label: "build" }),
|
||||
job_stalled: JSON.stringify({ id: "j", label: "build", quietSeconds: 900 }),
|
||||
context_composition: JSON.stringify({ total: 561061, at: 419430, active: 400000, parts: [{ name: "tool_output", tokens: 500000 }] }),
|
||||
subagent_waiting: JSON.stringify({ task: "fix the typo", by: "rewrite the docs", whole: true }),
|
||||
display_currency: "USD",
|
||||
@@ -20,6 +22,12 @@ const PAYLOAD: Record<string, string | undefined> = {
|
||||
};
|
||||
|
||||
describe("notice wording", () => {
|
||||
it.each(Object.entries(NOTICE_TEXT))("%s reads in English too", (code, sentence) => {
|
||||
const english = EN[sentence];
|
||||
expect(english, `${code}: no English for "${sentence}" - an English window would show the Chinese`).toBeTruthy();
|
||||
expect(/[\u4e00-\u9fff]/.test(english), `${code}: the English still holds Chinese`).toBe(false);
|
||||
});
|
||||
|
||||
const withSlots = Object.entries(NOTICE_TEXT).filter(([, s]) => placeholders(s).length > 0);
|
||||
|
||||
it.each(withSlots)("%s registers a reader for every placeholder it has", (code, sentence) => {
|
||||
@@ -56,6 +64,9 @@ describe("notice wording", () => {
|
||||
["job_finished", '{"label":"build"}'],
|
||||
["job_killed", '{"id":""}'],
|
||||
["job_failed", '{"id":5}'],
|
||||
["job_stalled", '{"id":"j"}'],
|
||||
["job_stalled", '{"id":"j","quietSeconds":"900"}'],
|
||||
["job_stalled", '{"id":"j","quietSeconds":0}'],
|
||||
["compact_failed", "no_such_reason"],
|
||||
])("%s reads valid JSON of the wrong shape (%s) as no payload", (code, detail) => {
|
||||
expect(noticeWording(code, detail)).toBeUndefined();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CONTEXT_COMPOSITION, contextCompositionVars } from "./context_compositi
|
||||
import { SUBAGENT_WAITING, subagentWaitingVars } from "./subagent_waiting";
|
||||
import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
|
||||
import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
|
||||
import { JOB_FAILED, JOB_FINISHED, JOB_KILLED, jobErrorText, jobNoticeVars } from "./job_notice";
|
||||
import { JOB_FAILED, JOB_FINISHED, JOB_KILLED, JOB_STALLED, jobErrorText, jobNoticeVars, jobStalledVars } from "./job_notice";
|
||||
import { NOTICE_TEXT } from "./notices";
|
||||
import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
|
||||
|
||||
@@ -24,6 +24,7 @@ const VARS: Record<string, (detail: string | undefined) => NoticeVars | undefine
|
||||
[JOB_FINISHED]: jobNoticeVars,
|
||||
[JOB_KILLED]: jobNoticeVars,
|
||||
[JOB_FAILED]: jobNoticeVars,
|
||||
[JOB_STALLED]: jobStalledVars,
|
||||
[CONTEXT_COMPOSITION]: contextCompositionVars,
|
||||
[SUBAGENT_WAITING]: subagentWaitingVars,
|
||||
display_currency: (detail) => ({ mode: detail || "auto" }),
|
||||
|
||||
@@ -22,11 +22,16 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
|
||||
await_user: "等待你的输入",
|
||||
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
|
||||
branch_model_unavailable: "分支保存的模型已不可用,本次改用其他已配置的模型;可以在模型设置中重新选择",
|
||||
web_search_model_bad_ref: "搜索模型的格式应为 服务/模型,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
web_search_model_not_added: "所选搜索模型所在的连接已被移出,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
web_search_model_removed: "所选搜索模型已不在配置里,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
web_search_model_unsupported: "所选搜索模型的协议不支持原生搜索,或已关闭搜索,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
web_search_model_no_credentials: "所选搜索模型所在的连接没有凭据,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
web_search_backend_fallback: "搜索后端没有返回结果,已换用链里的下一个后端;详情见通知",
|
||||
web_fetch_backend_fallback: "网页抓取后端没有返回内容,已换用链里的下一个后端;详情见通知",
|
||||
web_fetch_backend_unusable: "配置的网页抓取后端无法使用,已跳过;检查用户配置里的 web_fetch_backends",
|
||||
web_search_backend_unusable: "配置的搜索后端无法使用,已跳过;检查用户配置里的 web_search_backends 和 searxng_url",
|
||||
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
|
||||
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
|
||||
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
|
||||
@@ -37,6 +42,7 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
job_finished: "后台任务已结束:{name}",
|
||||
job_killed: "后台任务已终止:{name}",
|
||||
job_failed: "后台任务 {name} 失败,需要处理",
|
||||
job_stalled: "后台任务 {name} 已有 {quiet} 没往自己的输出里写任何内容:可能是在写文件,也可能卡住了;可以用 wait 或 bash_output 查看,或用 kill_shell 结束它",
|
||||
compacted: "已压缩",
|
||||
compact_declined: "无需压缩:{why}",
|
||||
compact_failed: "压缩失败:{why}",
|
||||
@@ -46,5 +52,7 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)",
|
||||
extension_skipped: "扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展",
|
||||
ask_timeout_invalid: "配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个",
|
||||
fold_not_reused_history: "这个会话保存的压缩结果没有被复用:它所总结的历史之后发生了变化,现在发送的是完整历史,可能超出模型容量",
|
||||
fold_not_reused_lineage: "这个会话保存的压缩结果是在另一个模型或工作区下生成的,与当前历史对不上,没有被复用,现在发送的是完整历史",
|
||||
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
|
||||
};
|
||||
@@ -22,4 +22,18 @@ describe("write claim attribution", () => {
|
||||
expect(say({ code: "workspace.write_conflict" }, "")).not.toBe("");
|
||||
});
|
||||
}
|
||||
|
||||
for (const language of ["zh", "en"]) {
|
||||
it(`says a repository folded the folders into one write domain, with the way out, in ${language}`, () => {
|
||||
localStorage.setItem(STORAGE, language);
|
||||
boot();
|
||||
const folded = { contended: 0, heldMs: 0, idleMs: 0, holder: "A", holderSessionId: "s", paths: ["d:/home/ws"], requestedPaths: ["d:/home/ws"], repository: "d:/home/ws" };
|
||||
const detail = workspaceLeaseDetail(folded);
|
||||
expect(detail).toContain("d:/home/ws");
|
||||
expect(detail).toContain(language === "en" ? "same Git repository" : "同一个 Git 仓库");
|
||||
expect(detail).toContain("write_lease");
|
||||
expect(workspaceLeaseDetail({ ...folded, repository: undefined })).not.toContain("write_lease");
|
||||
expect(workspaceLeaseDetail(folded, false)).not.toContain("write_lease");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4,5 +4,6 @@ import { t } from "./index";
|
||||
export function workspaceLeaseDetail(claim: WorkspaceLease, showHolder = true): string {
|
||||
return [showHolder && (claim.holder || claim.holderSessionId) ? t("会话 {holder}({session})持有写入范围:{paths}", {
|
||||
holder: claim.holder ?? "", session: claim.holderSessionId ?? "", paths: JSON.stringify(claim.paths ?? []),
|
||||
}) : "", claim.requestedPaths?.length ? t("所需写入范围:{paths}", { paths: JSON.stringify(claim.requestedPaths) }) : ""].filter(Boolean).join(" · ");
|
||||
}) : "", claim.requestedPaths?.length ? t("所需写入范围:{paths}", { paths: JSON.stringify(claim.requestedPaths) }) : "",
|
||||
showHolder && claim.repository ? t("这些工作区在同一个 Git 仓库 {repo} 内,同一仓库共用一个写入域;把项目移出该仓库,或在用户配置里设 write_lease = \"optimistic\",才能并行写入", { repo: claim.repository }) : ""].filter(Boolean).join(" · ");
|
||||
}
|
||||
@@ -7,14 +7,17 @@ import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import "./styles/dock.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/planpeek.css";
|
||||
import "./styles/vthumb.css";
|
||||
import "./styles/dialogcenter.css";
|
||||
import "./styles/stophint.css";
|
||||
import "./styles/phonecomposer.css";
|
||||
import "./styles/tips.css";
|
||||
import "./styles/skillpin.css";
|
||||
import "./styles/waitnote.css";
|
||||
import "./styles/bring.css";
|
||||
import "./styles/line.css";
|
||||
import "./styles/notice.css";
|
||||
import "./styles/peers.css";
|
||||
import "./styles/contentmatches.css";
|
||||
import "./styles/jsontext.css";
|
||||
import "./styles/narrow.css";
|
||||
import "./styles/workfold.css";
|
||||
@@ -26,6 +29,7 @@ import "./styles/sponsor.css";
|
||||
import "./styles/chart.css";
|
||||
import "./styles/versions.css";
|
||||
import "./styles/changedpill.css";
|
||||
import "./styles/askskip.css";
|
||||
import "./styles/pastekeep.css";
|
||||
import "./styles/ctxitems.css";
|
||||
import "./styles/sidepanel.css";
|
||||
@@ -39,8 +43,10 @@ import "./styles/treerows.css";
|
||||
import "./styles/workbench-changes.css";
|
||||
import "./styles/gitgraph.css";
|
||||
import "./styles/modelmenu.css";
|
||||
import "./styles/composer-menu.css";
|
||||
import "./styles/runsignals.css";
|
||||
import "./styles/toasts.css";
|
||||
import "./styles/overview.css";
|
||||
import { App } from "./ui/App";
|
||||
import { SseHub } from "./port/hub";
|
||||
import { HttpError } from "./port/http_error";
|
||||
@@ -52,6 +58,7 @@ import { settled } from "./boot/gate";
|
||||
import { failureView, type FailureView } from "./port/cloud_failure";
|
||||
import { RemoteLinkError } from "./port/cloud_link";
|
||||
import { RemoteNotice } from "./ui/RemoteNotice";
|
||||
import { useStalePage } from "./ui/stale_page";
|
||||
import { onRemoteConnectionEnded, remoteConnectionEnded, openRemoteHome, remoteHub, signInAgain, type RemoteEnd } from "./port/cloud_remote";
|
||||
|
||||
const failureAction = (view: FailureView) => (view.action === "home" ? openRemoteHome : () => location.reload());
|
||||
@@ -63,11 +70,21 @@ function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: string | null }
|
||||
if (!remote) return;
|
||||
return onRemoteConnectionEnded(setEnded);
|
||||
}, [remote]);
|
||||
const stale = useStalePage(!!remote);
|
||||
const reauth = ended?.kind === "reauth";
|
||||
const specific = !reauth && ended?.failure ? failureView(ended.failure) : null;
|
||||
return (
|
||||
<>
|
||||
<App hub={hub} />
|
||||
{stale && !ended && (
|
||||
<RemoteNotice
|
||||
title={t("这个页面不是最新版")}
|
||||
body={t("它是之前打开的旧版本,继续用可能出现莫名的报错。刷新一下就能换成最新版。")}
|
||||
action={t("刷新页面")}
|
||||
info
|
||||
onAction={() => location.reload()}
|
||||
/>
|
||||
)}
|
||||
{ended && remote && (
|
||||
<RemoteNotice
|
||||
title={reauth ? t("需要重新登录") : specific?.title ?? t("远程连接已断开")}
|
||||
|
||||
@@ -99,6 +99,55 @@ export interface BrowserToolsSettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// How a sub-agent that declared no write_paths holds its claim, as the user
|
||||
// file records it. A project file cannot set it, so there is one value.
|
||||
export interface SubagentClaimsSettings {
|
||||
optimistic: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// How this machine's sessions hold the workspace write lease against one
|
||||
// another, as the user file records it. A project file cannot set it. A running
|
||||
// session keeps the lease it was built with, so a change reaches sessions
|
||||
// opened after it.
|
||||
export interface WriteLeaseSettings {
|
||||
optimistic: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Whether a submitted plan starts executing without the approval click, as the
|
||||
// user file records it. A project file cannot set it.
|
||||
export interface PlanApprovalSettings {
|
||||
auto: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PeerView {
|
||||
key: string;
|
||||
title: string;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
export interface PeerHeld {
|
||||
id: string;
|
||||
fromSession: string;
|
||||
fromTitle?: string;
|
||||
fromProject?: string;
|
||||
sentAt: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface PeersState {
|
||||
enabled: boolean;
|
||||
peers: PeerView[];
|
||||
held: PeerHeld[];
|
||||
}
|
||||
|
||||
export interface PeerReceipt {
|
||||
id: string;
|
||||
to: PeerView;
|
||||
}
|
||||
|
||||
export interface AutoArchiveSettings {
|
||||
enabled: boolean;
|
||||
days: number;
|
||||
|
||||
@@ -66,7 +66,9 @@ describe("remote Studio binary framing", () => {
|
||||
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(`/rt/one/events/replay?lastEventId=${Number.MAX_SAFE_INTEGER}`);
|
||||
// No cursor is an attach, and an attach is answered with whatever a run is
|
||||
// stopped on rather than with the stream's history.
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe("/rt/one/events/replay");
|
||||
expect(seen).toEqual([{ kind: "stream_watermark", seq: 7 }]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
@@ -78,6 +80,33 @@ describe("remote Studio binary framing", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("hands an attaching page the question a run is waiting on", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
frames: [{ kind: "ask_request", ask: { id: "ask-1" } }],
|
||||
complete: true,
|
||||
watermark: 3,
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValue(new Response(JSON.stringify({ frames: [], complete: true, watermark: 3 }), { status: 200 }));
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const source = new RemoteEventSource("/rt/one/events");
|
||||
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
// What a run is blocked on is not in the ordinary history a client resumes
|
||||
// past, so a page that read only the log would draw the run as stopped with
|
||||
// nothing to answer.
|
||||
expect(seen).toEqual([{ kind: "ask_request", ask: { id: "ask-1" } }]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(String(fetchMock.mock.calls[1][0])).toBe("/rt/one/events/replay?lastEventId=3");
|
||||
|
||||
source.close();
|
||||
fetchMock.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resumes from the cursor its URL carries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
|
||||
@@ -129,7 +158,9 @@ describe("remote Studio binary framing", () => {
|
||||
await vi.advanceTimersByTimeAsync(STREAM_SILENCE_MS * 3);
|
||||
stop();
|
||||
expect(opened).toBe(1);
|
||||
for (const [input] of fetchMock.mock.calls) expect(String(input)).toMatch(/^\/rt\/one\/events\/replay\?/);
|
||||
// Every read goes to the replay route: the attach carries no cursor, and
|
||||
// every later read resumes from the watermark.
|
||||
for (const [input] of fetchMock.mock.calls) expect(String(input)).toMatch(/^\/rt\/one\/events\/replay(\?|$)/);
|
||||
fetchMock.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -173,21 +173,27 @@ interface ReplayAnswer {
|
||||
}
|
||||
|
||||
// Stands in for EventSource over the relay by polling /events/replay. A cursor
|
||||
// the URL carries is where a replacement stream resumes; without one it attaches
|
||||
// at the watermark, and asking past it returns no frames, not the whole log.
|
||||
// Frames the SSE transport writes on its own (the watermark, a gap) are said
|
||||
// here too, or the stream reads as silent to whoever watches it.
|
||||
// the URL carries is where a replacement stream resumes. Frames the SSE
|
||||
// transport writes on its own (the watermark, a gap) are said here too, or the
|
||||
// stream reads as silent to whoever watches it.
|
||||
export class RemoteEventSource {
|
||||
onmessage: ((event: MessageEvent<string>) => void) | null = null;
|
||||
private closed = false;
|
||||
private after: number | null;
|
||||
private readonly replay: string;
|
||||
// What an attach asks for. Only the main event stream carries connection-local
|
||||
// state to hand back — the pending prompt — so only its attach asks for
|
||||
// nothing and is answered with that prompt. The tree stream reads the tree,
|
||||
// not history, so its attach asks past the watermark as it always has:
|
||||
// replaying the tree's history would only re-read the tree.
|
||||
private readonly attachQuery: string;
|
||||
|
||||
constructor(url: string) {
|
||||
const target = new URL(url, location.origin);
|
||||
const cursor = Number(target.searchParams.get("lastEventId") ?? "");
|
||||
this.after = target.searchParams.has("lastEventId") && Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : null;
|
||||
this.replay = `${target.pathname}/replay`;
|
||||
this.attachQuery = target.pathname.endsWith("/tree/events") ? `?lastEventId=${Number.MAX_SAFE_INTEGER}` : "";
|
||||
queueMicrotask(() => void this.poll());
|
||||
}
|
||||
|
||||
@@ -203,12 +209,15 @@ export class RemoteEventSource {
|
||||
while (!this.closed) {
|
||||
let active = false;
|
||||
try {
|
||||
const response = await fetch(`${this.replay}?lastEventId=${this.after ?? Number.MAX_SAFE_INTEGER}`, { credentials: "same-origin" });
|
||||
// No cursor yet is an attach; the main stream's attach asks for nothing
|
||||
// so the kernel answers with the waiting prompt, the tree stream's asks
|
||||
// past the watermark. Every later read resumes from where it stands.
|
||||
const query = this.after !== null ? `?lastEventId=${this.after}` : this.attachQuery;
|
||||
const response = await fetch(`${this.replay}${query}`, { credentials: "same-origin" });
|
||||
if (response.ok && !this.closed) {
|
||||
const body = await response.json() as ReplayAnswer;
|
||||
const watermark = typeof body.watermark === "number" ? body.watermark : null;
|
||||
const attaching = this.after === null;
|
||||
const frames = attaching ? [] : body.frames ?? [];
|
||||
const frames = body.frames ?? [];
|
||||
active = frames.length > 0;
|
||||
if (this.after === null) this.after = watermark ?? 0;
|
||||
else if (body.complete === false) {
|
||||
@@ -303,6 +312,12 @@ async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap
|
||||
}
|
||||
}
|
||||
|
||||
// ownFetch is the page's own fetch, bound before remoteHub replaces
|
||||
// globalThis.fetch with one that routes same-origin requests over the tunnel.
|
||||
// A hosted page has to read its own origin — the build it is running — not the
|
||||
// kernel's, so anything that reads the page itself uses this and not the global.
|
||||
export const ownFetch: typeof fetch = globalThis.fetch.bind(globalThis);
|
||||
|
||||
export async function remoteHub(deviceId: string): Promise<HubPort> {
|
||||
const nativeFetch = globalThis.fetch.bind(globalThis);
|
||||
let first: RemoteConnection;
|
||||
@@ -321,7 +336,10 @@ export async function remoteHub(deviceId: string): Promise<HubPort> {
|
||||
return new URL(request.url).origin === location.origin ? link.fetch(request) : nativeFetch(request);
|
||||
}) as typeof fetch;
|
||||
globalThis.EventSource = RemoteEventSource as unknown as typeof EventSource;
|
||||
return new SseHub();
|
||||
// RemoteEventSource is a polled stand-in for one stream. The multiplexed
|
||||
// stream also needs an open frame and per-pane subscriptions, which polling
|
||||
// cannot carry, so every pane here reads its own /events instead.
|
||||
return new SseHub({ mux: false });
|
||||
}
|
||||
|
||||
export const remoteCodec = {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// What holds the tokens of a prompt: the six classes by size, largest first,
|
||||
// and the biggest single messages. active is the running turn, which a fold
|
||||
// cannot take.
|
||||
export interface ContextComposition {
|
||||
total: number;
|
||||
at: number;
|
||||
active: number;
|
||||
parts: { name: string; tokens: number }[];
|
||||
largest?: { role: string; tool?: string; tokens: number; active?: boolean }[];
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SseHub, type RuntimeView } from "./hub";
|
||||
import type { WireEvent } from "./wire";
|
||||
|
||||
// Every way a hub can be built has to put a frame the kernel sends into the
|
||||
// pane's reducer. A change to the event transport is held to this table, which
|
||||
// is where "the page stays connected and goes silent" gets caught.
|
||||
const rt = { id: "r1", base: "/rt/r1", root: "/w", name: "w" } as RuntimeView;
|
||||
|
||||
interface Source { url: string; closed: boolean; say: (data: object) => void }
|
||||
|
||||
function kernel(opts: { muxAnswers: boolean }) {
|
||||
const sources: Source[] = [];
|
||||
const puts: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
onmessage: ((m: { data: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
readonly s: Source;
|
||||
constructor(url: string) {
|
||||
this.s = { url, closed: false, say: (data) => this.onmessage?.({ data: JSON.stringify(data) }) };
|
||||
sources.push(this.s);
|
||||
const answers = url.endsWith("/events/mux") ? opts.muxAnswers : true;
|
||||
// A kernel that answers the mux path with the SPA shell opens a stream
|
||||
// that never says anything, which is what an older kernel does.
|
||||
if (answers && url.endsWith("/events/mux")) queueMicrotask(() => this.s.say({ kind: "mux_open", conn: "c1" }));
|
||||
}
|
||||
close() {
|
||||
this.s.closed = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", async (url: string, init: RequestInit = {}) => {
|
||||
if (init.method === "PUT") puts.push(url);
|
||||
return { ok: true, status: 204, json: async () => ({}) } as Response;
|
||||
});
|
||||
return {
|
||||
sources,
|
||||
puts,
|
||||
live: (suffix: string) => sources.filter((s) => !s.closed && s.url.includes(suffix)),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const tick = (ms = 0) => vi.advanceTimersByTimeAsync(ms);
|
||||
|
||||
describe("a pane receives what its kernel sends", () => {
|
||||
it("on a local hub, through the shared stream", async () => {
|
||||
const k = kernel({ muxAnswers: true });
|
||||
const got: WireEvent[] = [];
|
||||
const stop = new SseHub().portFor(rt).subscribe((ev) => got.push(ev));
|
||||
await tick();
|
||||
expect(k.puts).toEqual(["/events/mux/c1/panes/r1"]);
|
||||
k.live("/events/mux")[0].say({ pane: "r1", frame: { kind: "text", seq: 1, text: "hi" } });
|
||||
expect(got.map((e) => e.kind)).toEqual(["text"]);
|
||||
expect(k.live("/rt/r1/events")).toHaveLength(0);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("on a hub reached over a relay, through the pane's own stream and never the shared one", async () => {
|
||||
const k = kernel({ muxAnswers: true });
|
||||
const got: WireEvent[] = [];
|
||||
const stop = new SseHub({ mux: false }).portFor(rt).subscribe((ev) => got.push(ev));
|
||||
await tick(20_000);
|
||||
expect(k.sources.some((s) => s.url.endsWith("/events/mux"))).toBe(false);
|
||||
k.live("/rt/r1/events")[0].say({ kind: "text", seq: 1, text: "hi" });
|
||||
expect(got.map((e) => e.kind)).toEqual(["text"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("on a kernel that answers the shared path with something else, through the pane's own stream", async () => {
|
||||
const k = kernel({ muxAnswers: false });
|
||||
const got: WireEvent[] = [];
|
||||
const stop = new SseHub().portFor(rt).subscribe((ev) => got.push(ev));
|
||||
await tick(11_000);
|
||||
expect(k.live("/events/mux")).toHaveLength(0);
|
||||
k.live("/rt/r1/events")[0].say({ kind: "text", seq: 1, text: "hi" });
|
||||
expect(got.map((e) => e.kind)).toEqual(["text"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("on every one of them, a second frame follows the first in order", async () => {
|
||||
for (const [hub, muxAnswers] of [[new SseHub(), true], [new SseHub({ mux: false }), true], [new SseHub(), false]] as const) {
|
||||
const k = kernel({ muxAnswers });
|
||||
const got: number[] = [];
|
||||
const stop = hub.portFor({ ...rt, id: `r${got.length}` } as RuntimeView).subscribe((ev) => got.push(ev.seq ?? 0));
|
||||
await tick(11_000);
|
||||
const source = k.live("/events/mux")[0] ?? k.live("/rt/")[0];
|
||||
const say = (seq: number) => source.say(source.url.endsWith("/events/mux") ? { pane: "r0", frame: { kind: "tick", seq } } : { kind: "tick", seq });
|
||||
say(1);
|
||||
say(2);
|
||||
expect(got).toEqual([1, 2]);
|
||||
stop();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,15 @@ export interface FeedbackMine {
|
||||
profile?: FeedbackProfile | null;
|
||||
}
|
||||
|
||||
// One install an account holds, as the service lists it: the short id, when it
|
||||
// was linked, and whether it is this machine. The install's token never comes
|
||||
// with it.
|
||||
export interface FeedbackLinkedInstall {
|
||||
id: string;
|
||||
linkedAt: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
// The window a refusal came from, as the service names it. A refusal carries
|
||||
// these in its params; an identifier not listed here is still a window.
|
||||
export const FEEDBACK_LIMIT = {
|
||||
@@ -187,6 +196,10 @@ export const FEEDBACK_CODE = {
|
||||
replyLimit: "feedback.reply_limit",
|
||||
notReplyable: "feedback.not_replyable",
|
||||
challengeRequired: "feedback.challenge_required",
|
||||
accountRequired: "feedback.account_required",
|
||||
linkConflict: "feedback.link_conflict",
|
||||
linkLimit: "feedback.link_limit",
|
||||
noInstall: "feedback.no_install",
|
||||
offline: "feedback.offline",
|
||||
unavailable: "feedback.unavailable",
|
||||
internal: "feedback.internal",
|
||||
|
||||
@@ -442,6 +442,7 @@ export const SCRIPT: Beat[] = [
|
||||
projectionTokens: 31_200,
|
||||
coverageRequired: 12,
|
||||
coverageMissing: 2,
|
||||
remains: { total: 31_200, at: 160_000, active: 6_400, parts: [{ name: "tool_output", tokens: 14_800 }, { name: "reply", tokens: 9_100 }, { name: "user", tokens: 4_300 }], largest: [{ role: "tool", tool: "browser_read", tokens: 7_200 }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -74,3 +74,15 @@ describe("host capabilities", () => {
|
||||
expect(calls).toEqual([["GET", "/host/capabilities"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closing a pane the kernel no longer has", () => {
|
||||
it("counts as closed, so a stale pane id never reaches the person as an error", async () => {
|
||||
answer(404, JSON.stringify({ code: "request.not_found", error: "no runtime named r1", params: { kind: "runtime", name: "r1" } }));
|
||||
await expect(new SseHub().close("r1")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("still surfaces every other refusal", async () => {
|
||||
answer(500, JSON.stringify({ code: "internal", error: "teardown failed" }));
|
||||
await expect(new SseHub().close("r1")).rejects.toBeInstanceOf(HttpError);
|
||||
});
|
||||
});
|
||||
@@ -142,11 +142,25 @@ export function treeQuery(window: TreeWindow | undefined): string {
|
||||
|
||||
// HubPort is the window's view of the kernel: which panes it drives and which
|
||||
// folders it can open. One AgentPort hangs off each pane.
|
||||
/** One conversation whose words matched, with the passage that matched. */
|
||||
export interface HistoryHit {
|
||||
path: string;
|
||||
id: string;
|
||||
title?: string;
|
||||
workspace?: string;
|
||||
snippet: string;
|
||||
role: string;
|
||||
messageIndex: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface HubPort extends SharePort {
|
||||
runtimes(): Promise<RuntimeView[]>;
|
||||
open(req: { root?: string; sessionPath?: string }): Promise<RuntimeView>;
|
||||
close(id: string): Promise<void>;
|
||||
tree(window?: TreeWindow): Promise<TreeWorkspace[]>;
|
||||
/** Conversations, in every workspace, that say these words. */
|
||||
searchHistory(query: string): Promise<HistoryHit[]>;
|
||||
onTreeChanged(listener: () => void): () => void;
|
||||
addWorkspace(path: string): Promise<TreeWorkspace>;
|
||||
removeWorkspace(path: string): Promise<void>;
|
||||
@@ -229,7 +243,8 @@ export class SseHub implements HubPort {
|
||||
// last list. The default matches the kernel's own until one arrives.
|
||||
// Bindings the shell exposes are pane-independent; this reaches them without
|
||||
// pretending to belong to a runtime.
|
||||
private readonly shell = new SsePort();
|
||||
private readonly shell: SsePort;
|
||||
private readonly mux: boolean;
|
||||
private readonly settings = new Map<string, AgentPort>();
|
||||
// Questions seen this session, kept so an answer can carry the identities the
|
||||
// kernel will check it against.
|
||||
@@ -238,6 +253,11 @@ export class SseHub implements HubPort {
|
||||
private dialing = 0;
|
||||
private askTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(options: { mux?: boolean } = {}) {
|
||||
this.mux = options.mux !== false;
|
||||
this.shell = new SsePort("", "", this.mux);
|
||||
}
|
||||
|
||||
// A refusal carries a code, and it is the code that has words in the reader's
|
||||
// language. Flattening the body into the message threw that away: what
|
||||
// reached the screen was the raw JSON, or a filesystem error naming a path.
|
||||
@@ -290,7 +310,12 @@ export class SseHub implements HubPort {
|
||||
}
|
||||
|
||||
async close(id: string) {
|
||||
await this.post<void>(`/runtimes/${encodeURIComponent(id)}/close`);
|
||||
try {
|
||||
await this.post<void>(`/runtimes/${encodeURIComponent(id)}/close`);
|
||||
} catch (e) {
|
||||
// A pane the kernel no longer holds is already closed.
|
||||
if (!(e instanceof HttpError && e.reason?.code === "request.not_found")) throw e;
|
||||
}
|
||||
this.ports.delete(id);
|
||||
this.settings.delete(id);
|
||||
}
|
||||
@@ -303,6 +328,10 @@ export class SseHub implements HubPort {
|
||||
return this.get<TreeWorkspace[]>("/tree" + treeQuery(window));
|
||||
}
|
||||
|
||||
searchHistory(query: string) {
|
||||
return this.get<HistoryHit[]>("/history/search?q=" + encodeURIComponent(query));
|
||||
}
|
||||
|
||||
addWorkspace(path: string) {
|
||||
return this.post<TreeWorkspace>("/tree/workspaces", { path });
|
||||
}
|
||||
@@ -590,7 +619,7 @@ export class SseHub implements HubPort {
|
||||
const held = this.settings.get(runtimeId);
|
||||
if (held) return held;
|
||||
const base = runtimeId ? `/host/settings/runtime/${encodeURIComponent(runtimeId)}` : "/host/settings";
|
||||
const port = new SsePort(base);
|
||||
const port = new SsePort(base, "", this.mux);
|
||||
this.settings.set(runtimeId, port);
|
||||
return port;
|
||||
}
|
||||
@@ -598,7 +627,7 @@ export class SseHub implements HubPort {
|
||||
portFor(rt: RuntimeView): AgentPort {
|
||||
const held = this.ports.get(rt.id);
|
||||
if (held) return held;
|
||||
const port = new SsePort(rt.base, rt.id);
|
||||
const port = new SsePort(rt.base, rt.id, this.mux);
|
||||
this.ports.set(rt.id, port);
|
||||
return port;
|
||||
}
|
||||
|
||||
@@ -82,3 +82,61 @@ describe("a stream that goes silent", () => {
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a stream that gives its connection up", () => {
|
||||
it("closes the connection, keeps its place, and resumes from the last frame it folded", () => {
|
||||
const opened = stubStreams();
|
||||
vi.stubGlobal("fetch", async () => ({ ok: true, json: async () => ({ frames: [], complete: true }) }));
|
||||
const seen: number[] = [];
|
||||
const sub = new SsePort("", "r1").subscribe((ev) => ev.seq && seen.push(ev.seq));
|
||||
opened[0].say('{"kind":"stream_watermark","seq":0}');
|
||||
opened[0].say('{"kind":"turn_started","seq":1}');
|
||||
sub.pause!();
|
||||
expect(opened).toHaveLength(1);
|
||||
expect(opened[0].closed).toBe(true);
|
||||
// Silence while paused is not a dead stream: nothing replaces it.
|
||||
vi.advanceTimersByTime(STREAM_SILENCE_MS * 3);
|
||||
expect(opened).toHaveLength(1);
|
||||
sub.resume!();
|
||||
expect(opened).toHaveLength(2);
|
||||
expect(opened[1].url).toBe("/events?lastEventId=1");
|
||||
opened[1].say('{"kind":"turn_started","seq":1}');
|
||||
opened[1].say('{"kind":"turn_done","seq":2}');
|
||||
expect(seen).toEqual([1, 2]);
|
||||
sub();
|
||||
});
|
||||
|
||||
it("recovers the frames that went by while it was paused, in order", async () => {
|
||||
const opened = stubStreams();
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
expect(url).toContain("/events/replay?lastEventId=1");
|
||||
return { ok: true, json: async () => ({ frames: [{ kind: "text", seq: 2 }, { kind: "turn_done", seq: 3 }], complete: true }) };
|
||||
});
|
||||
vi.useRealTimers();
|
||||
const seen: number[] = [];
|
||||
const sub = new SsePort("", "r1").subscribe((ev) => ev.seq && seen.push(ev.seq));
|
||||
opened[0].say('{"kind":"stream_watermark","seq":0}');
|
||||
opened[0].say('{"kind":"turn_started","seq":1}');
|
||||
sub.pause!();
|
||||
sub.resume!();
|
||||
// The server's first word on the new connection states where it is: past the
|
||||
// cursor, so the missing frames are fetched back before anything newer folds.
|
||||
opened[1].say('{"kind":"stream_watermark","seq":3}');
|
||||
opened[1].say('{"kind":"turn_started","seq":4}');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(seen).toEqual([1, 2, 3, 4]);
|
||||
sub();
|
||||
});
|
||||
|
||||
it("says it has a hole when it paused before the stream ever stated a position", () => {
|
||||
const opened = stubStreams();
|
||||
const gaps: number[] = [];
|
||||
const sub = new SsePort("", "r1").subscribe(() => {}, () => gaps.push(1));
|
||||
sub.pause!();
|
||||
expect(gaps).toEqual([]);
|
||||
sub.resume!();
|
||||
expect(gaps).toEqual([1]);
|
||||
expect(opened[1].url).toBe("/events");
|
||||
sub();
|
||||
});
|
||||
});
|
||||
@@ -5,27 +5,34 @@
|
||||
export const STREAM_SILENCE_MS = 45_000;
|
||||
const CHECK_MS = 5_000;
|
||||
|
||||
/** The disposer of a live stream, which can also give its connection back and
|
||||
* take one again. A paused stream keeps whatever cursor `url` reads, so the
|
||||
* connection that replaces it asks for exactly what went by in between. */
|
||||
export type LiveStream = (() => void) & { pause: () => void; resume: () => void; holdsConnection?: () => boolean };
|
||||
|
||||
/** Holds one EventSource open and replaces it once it has gone silent. `url`
|
||||
* names where to resume, so a replacement asks for what the dead one missed.
|
||||
* Timers stop while a phone sleeps, so waking and coming back online check at
|
||||
* once rather than on the next tick. */
|
||||
export function openLiveStream(url: () => string, onData: (raw: string) => void, silenceMs = STREAM_SILENCE_MS): () => void {
|
||||
let es: EventSource;
|
||||
export function openLiveStream(url: () => string, onData: (raw: string) => void, silenceMs = STREAM_SILENCE_MS): LiveStream {
|
||||
let es: EventSource | null = null;
|
||||
let paused = false;
|
||||
let heard = 0;
|
||||
const open = () => {
|
||||
heard = Date.now();
|
||||
es = new EventSource(url(), { withCredentials: true });
|
||||
es.onopen = () => {
|
||||
const next = new EventSource(url(), { withCredentials: true });
|
||||
es = next;
|
||||
next.onopen = () => {
|
||||
heard = Date.now();
|
||||
};
|
||||
es.onmessage = (m) => {
|
||||
next.onmessage = (m) => {
|
||||
heard = Date.now();
|
||||
onData(m.data);
|
||||
};
|
||||
};
|
||||
const check = () => {
|
||||
if (Date.now() - heard < silenceMs) return;
|
||||
es.close();
|
||||
if (paused || Date.now() - heard < silenceMs) return;
|
||||
es?.close();
|
||||
open();
|
||||
};
|
||||
const wake = () => {
|
||||
@@ -38,12 +45,28 @@ export function openLiveStream(url: () => string, onData: (raw: string) => void,
|
||||
document.addEventListener("visibilitychange", wake);
|
||||
addEventListener("online", check);
|
||||
}
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
if (dom) {
|
||||
document.removeEventListener("visibilitychange", wake);
|
||||
removeEventListener("online", check);
|
||||
}
|
||||
es.close();
|
||||
};
|
||||
const stop: LiveStream = Object.assign(
|
||||
() => {
|
||||
clearInterval(timer);
|
||||
if (dom) {
|
||||
document.removeEventListener("visibilitychange", wake);
|
||||
removeEventListener("online", check);
|
||||
}
|
||||
es?.close();
|
||||
},
|
||||
{
|
||||
pause: () => {
|
||||
if (paused) return;
|
||||
paused = true;
|
||||
es?.close();
|
||||
es = null;
|
||||
},
|
||||
resume: () => {
|
||||
if (!paused) return;
|
||||
paused = false;
|
||||
open();
|
||||
},
|
||||
},
|
||||
);
|
||||
return stop;
|
||||
}
|
||||
@@ -75,6 +75,9 @@ export interface Appearance {
|
||||
// rather than carrying a copy.
|
||||
zoomRange?: ZoomRange;
|
||||
readSize?: number;
|
||||
// Interface text alone, as a scale over the stylesheet's sizes; zoom moves
|
||||
// spacing and controls with it, this does not.
|
||||
textSize?: number;
|
||||
fontUi?: string;
|
||||
fontMono?: string;
|
||||
// How far prose may run: "standard" holds it to a reading line length,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
|
||||
import type { PlanAction } from "./session";
|
||||
import type { JobOutput, PlanAction } from "./session";
|
||||
import { approvalKind } from "./mock_approval";
|
||||
import { HttpError } from "./port";
|
||||
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockWorkspace } from "./mock_workspace";
|
||||
import { MockWorkspace, mockWorkspaces, mockWorktreeSources } from "./mock_workspace";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
|
||||
import { mockStorage, mockStoragePlan } from "./mock_storage";
|
||||
@@ -11,8 +12,6 @@ import { MEMORIES } from "./mock_memory";
|
||||
import { mockModels } from "./mock_models";
|
||||
import { mockUsage } from "./mock_usage";
|
||||
|
||||
|
||||
|
||||
export class MockPort extends MockWorkspace implements AgentPort {
|
||||
private listeners = new Set<(ev: WireEvent) => void>();
|
||||
private log: WireEvent[] = [];
|
||||
@@ -226,15 +225,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
async accountLogout(): Promise<void> {}
|
||||
|
||||
async workspaces(): Promise<WorkspaceInfo> {
|
||||
return {
|
||||
current: this.state.workspaceRoot ?? "",
|
||||
canSwitch: true,
|
||||
canIsolate: true,
|
||||
recents: [
|
||||
{ path: "~/projects/reasonix-site", name: "reasonix-site" },
|
||||
{ path: "~/work/notes", name: "notes" },
|
||||
],
|
||||
};
|
||||
return mockWorkspaces(this.state.workspaceRoot ?? "");
|
||||
}
|
||||
|
||||
async setWorkspace(path: string) {
|
||||
@@ -243,8 +234,13 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
await this.newSession();
|
||||
}
|
||||
|
||||
async isolateWorkspace() {
|
||||
async worktreeSources(): Promise<import("./workspace").WorktreeSources> {
|
||||
return mockWorktreeSources(this.state.workspaceRoot ?? "", this.branchState.branch);
|
||||
}
|
||||
|
||||
async isolateWorkspace(_selection?: import("./workspace").WorktreeSelection) {
|
||||
await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)");
|
||||
this.branchState.branch = "reasonix/delivery-demo";
|
||||
}
|
||||
|
||||
async openInEditor() {
|
||||
@@ -299,7 +295,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
return `目标:${draft.trim()}\n\n约束:不改变现有行为。\n\n完成标准:相关测试通过。`;
|
||||
}
|
||||
|
||||
async complete(line: string, cursor: number): Promise<Completion> {
|
||||
async complete(line: string, cursor: number, _signal?: AbortSignal): Promise<Completion> {
|
||||
const before = line.slice(0, cursor);
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at >= 0 && !/\s/.test(before.slice(at + 1)) && (at === 0 || /\s/.test(line[at - 1]))) {
|
||||
@@ -322,7 +318,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
insert: "@" + dir + n,
|
||||
descend: n.endsWith("/"),
|
||||
kind: n.endsWith("/") ? "dir" : "file",
|
||||
}));
|
||||
})).filter((it) => it.insert !== line.slice(at, to));
|
||||
return { kind: "ref", from: at, to, query: frag.slice(dir.length), items };
|
||||
}
|
||||
if (line.startsWith("/") && !/\s/.test(line)) {
|
||||
@@ -385,7 +381,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
if (path === ".reasonix") return { files: [".reasonix/REASONIX.local.md"], directories: [] };
|
||||
return hidden ? { files: [".gitignore", "README.md"], directories: [".reasonix", "internal"] } : { files: ["README.md"], directories: ["internal"] };
|
||||
}
|
||||
async workspaceFile(path: string) { return { path, content: `// ${path}\n`, revision: "fixture" }; }
|
||||
async workspaceFile(path: string, _root?: string): Promise<import("./port").WorkspaceFile> { return { path, content: `// ${path}\n`, revision: "fixture" }; }
|
||||
workspaceImageURL(path: string) { return `/fixture/${path}`; }
|
||||
async saveWorkspaceFile(file: import("./port").WorkspaceFile) { return { ...file, revision: file.revision + "-saved" }; }
|
||||
|
||||
@@ -655,6 +651,12 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
this.state = { ...this.state, jobs: this.state.jobs?.map((j) => (j === job ? { ...j, status: "killed" } : j)) };
|
||||
}
|
||||
|
||||
async jobOutput(jobId: string): Promise<JobOutput> {
|
||||
const job = this.state.jobs?.find((j) => j.id === jobId);
|
||||
if (!job || job.status !== "running") throw new HttpError(409, "no running background job with this id", { code: "job.not_running" });
|
||||
return { text: "> dev\n> vite\n\n VITE ready in 312 ms\n Local: http://localhost:5173/\n", status: job.status, bytes: 66, truncated: false };
|
||||
}
|
||||
|
||||
async retryQueued(itemId: string) {
|
||||
void itemId;
|
||||
}
|
||||
@@ -787,14 +789,3 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
this.state.goal = text;
|
||||
}
|
||||
}
|
||||
|
||||
// Which owner answers a pending approval. The kernel decides it from the
|
||||
// approval itself — a recovery gate, the plan tool, otherwise the tool gate —
|
||||
// and the fixture reads the same fields the card does rather than inventing a
|
||||
// third rule.
|
||||
function approvalKind(ev: WireEvent): "plan_approval" | "recovery_approval" | "tool_approval" {
|
||||
const a = ev.approval;
|
||||
if (a?.kind === "plan" || a?.tool === "exit_plan_mode") return "plan_approval";
|
||||
if (a?.kind === "recovery") return "recovery_approval";
|
||||
return "tool_approval";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { WireEvent } from "./wire";
|
||||
|
||||
// Which owner answers a pending approval. The kernel decides it from the
|
||||
// approval itself — a recovery gate, the plan tool, otherwise the tool gate —
|
||||
// and the fixture reads the same fields the card does rather than inventing a
|
||||
// third rule.
|
||||
export function approvalKind(ev: WireEvent): "plan_approval" | "recovery_approval" | "tool_approval" {
|
||||
const a = ev.approval;
|
||||
if (a?.kind === "plan" || a?.tool === "exit_plan_mode") return "plan_approval";
|
||||
if (a?.kind === "recovery") return "recovery_approval";
|
||||
return "tool_approval";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockShell } from "./mock_shell";
|
||||
@@ -137,6 +137,55 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.browser };
|
||||
}
|
||||
|
||||
private claims: SubagentClaimsSettings = { optimistic: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async subagentClaims(): Promise<SubagentClaimsSettings> {
|
||||
return { ...this.claims };
|
||||
}
|
||||
|
||||
async saveSubagentClaims(optimistic: boolean): Promise<SubagentClaimsSettings> {
|
||||
this.claims = { ...this.claims, optimistic };
|
||||
return { ...this.claims };
|
||||
}
|
||||
|
||||
private plans: PlanApprovalSettings = { auto: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async planApproval(): Promise<PlanApprovalSettings> {
|
||||
return { ...this.plans };
|
||||
}
|
||||
|
||||
async savePlanApproval(auto: boolean): Promise<PlanApprovalSettings> {
|
||||
this.plans = { ...this.plans, auto };
|
||||
return { ...this.plans };
|
||||
}
|
||||
|
||||
private lease: WriteLeaseSettings = { optimistic: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async writeLease(): Promise<WriteLeaseSettings> {
|
||||
return { ...this.lease };
|
||||
}
|
||||
|
||||
async saveWriteLease(optimistic: boolean): Promise<WriteLeaseSettings> {
|
||||
this.lease = { ...this.lease, optimistic };
|
||||
return { ...this.lease };
|
||||
}
|
||||
|
||||
protected peerState: PeersState = { enabled: false, peers: [], held: [] };
|
||||
|
||||
async peers(): Promise<PeersState> {
|
||||
return { ...this.peerState, peers: [...this.peerState.peers], held: [...this.peerState.held] };
|
||||
}
|
||||
|
||||
async sendPeerMessage(to: string, text: string): Promise<PeerReceipt> {
|
||||
const target = this.peerState.peers.find((p) => p.key === to || p.title === to);
|
||||
if (!target) throw new Error("no session answers to that name");
|
||||
return { id: `m-${text.length}`, to: target };
|
||||
}
|
||||
|
||||
async resolvePeerMessage(id: string, _approve: boolean): Promise<void> {
|
||||
this.peerState = { ...this.peerState, held: this.peerState.held.filter((h) => h.id !== id) };
|
||||
}
|
||||
|
||||
private archive: AutoArchiveSettings = { enabled: false, days: 30, defaultDays: 30, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async autoArchive(): Promise<AutoArchiveSettings> {
|
||||
@@ -251,4 +300,8 @@ export class MockBoundary extends MockShell {
|
||||
this.broken = null;
|
||||
return { backup, problem: null };
|
||||
}
|
||||
|
||||
private mode: UpdateMode = "manual";
|
||||
async updateMode(): Promise<UpdateMode> { return this.mode; }
|
||||
async setUpdateMode(mode: UpdateMode): Promise<UpdateMode> { this.mode = mode; return mode; }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { download } from "./download";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry } from "./port";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry, SkillUpdatePlan } from "./port";
|
||||
import { MockMarket } from "./mock_market";
|
||||
|
||||
// The extension half of the fixture: skills, and the packages that bring them.
|
||||
@@ -22,6 +22,7 @@ export class MockExtensions extends MockMarket {
|
||||
{
|
||||
name: "release-notes", description: "从提交历史起草发布说明",
|
||||
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", enabled: false,
|
||||
license: "Apache-2.0", compatibility: "需要 git,并能访问网络", metadata: { author: "example-org", version: "1.0" },
|
||||
},
|
||||
{
|
||||
name: "deploy-runbook", slashName: "deploy-runbook", description: "只在你点名时跑的部署清单",
|
||||
@@ -71,6 +72,11 @@ export class MockExtensions extends MockMarket {
|
||||
// real store keys them by repository, which a browser fixture cannot do.
|
||||
protected localSkills = new Set<string>();
|
||||
|
||||
async planSkillUpdate(name: string): Promise<SkillUpdatePlan> {
|
||||
return { name, source: "/home/demo/skills/" + name, state: "source_changed", changes: [], overwritesLocal: false, planId: "demo" };
|
||||
}
|
||||
async applySkillUpdate(): Promise<void> {}
|
||||
|
||||
async setSkillEnabled(name: string, enabled: boolean, scope: ScopeLayer = "project") {
|
||||
const sk = this.skillList.find((x) => x.name === name);
|
||||
if (!sk) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockCommit } from "./mock_commit";
|
||||
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
|
||||
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
|
||||
|
||||
@@ -80,6 +80,7 @@ const STATUS: Record<string, number> = {
|
||||
[FEEDBACK_CODE.tooLarge]: 413, [FEEDBACK_CODE.rateLimited]: 429, [FEEDBACK_CODE.disabled]: 503,
|
||||
[FEEDBACK_CODE.duplicate]: 409, [FEEDBACK_CODE.badToken]: 409, [FEEDBACK_CODE.offline]: 502, [FEEDBACK_CODE.unavailable]: 502, [FEEDBACK_CODE.internal]: 500, [FEEDBACK_CODE.busy]: 503, [FEEDBACK_CODE.imageMetadata]: 400,
|
||||
[FEEDBACK_CODE.replyLimit]: 429, [FEEDBACK_CODE.notReplyable]: 409, [FEEDBACK_CODE.challengeRequired]: 403,
|
||||
[FEEDBACK_CODE.accountRequired]: 401, [FEEDBACK_CODE.linkConflict]: 409, [FEEDBACK_CODE.linkLimit]: 409, [FEEDBACK_CODE.noInstall]: 409,
|
||||
};
|
||||
|
||||
// A fault is a code, optionally followed by "@" and the window it names:
|
||||
@@ -135,7 +136,10 @@ export class MockFeedback extends MockCommit {
|
||||
this.announced += 1;
|
||||
}
|
||||
|
||||
async replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt> {
|
||||
readonly replyImages: number[] = [];
|
||||
|
||||
async replyFeedback(receipt: string, body: string, images: FeedbackImage[] = []): Promise<FeedbackReplyReceipt> {
|
||||
this.replyImages.push(images.length);
|
||||
const code = fault(REPLY_FAULT);
|
||||
if (code === "feedback.invalid") {
|
||||
throw new HttpError(400, "invalid", { code, error: "invalid", params: { field: "body", reason: "empty" } });
|
||||
@@ -153,6 +157,33 @@ export class MockFeedback extends MockCommit {
|
||||
return { replyId, createdAt };
|
||||
}
|
||||
|
||||
// The demo keeps its links in the tab: signed-in is the same switch account() reads.
|
||||
private links: FeedbackLinkedInstall[] = [{ id: "ba9876543210", linkedAt: ago(9), current: false }];
|
||||
|
||||
private requireAccount(): void {
|
||||
if (!localStorage.getItem("reasonix.mock.account")) throw refusal(FEEDBACK_CODE.accountRequired);
|
||||
}
|
||||
|
||||
async feedbackLinks(): Promise<FeedbackLinkedInstall[]> {
|
||||
this.requireAccount();
|
||||
return this.links.map((l) => ({ ...l }));
|
||||
}
|
||||
|
||||
async linkFeedbackAccount(): Promise<void> {
|
||||
this.requireAccount();
|
||||
if (!this.links.some((l) => l.current)) this.links = [...this.links, { id: "0123456789ab", linkedAt: new Date().toISOString(), current: true }];
|
||||
}
|
||||
|
||||
async unlinkFeedbackAccount(): Promise<void> {
|
||||
this.requireAccount();
|
||||
this.links = this.links.filter((l) => !l.current);
|
||||
}
|
||||
|
||||
async unlinkFeedbackInstall(id: string): Promise<void> {
|
||||
this.requireAccount();
|
||||
this.links = this.links.filter((l) => l.id !== id);
|
||||
}
|
||||
|
||||
async feedbackSeen(receipt: string, upTo: number): Promise<void> {
|
||||
if (this.rows.some((i) => i.receipt === receipt)) this.seen.set(receipt, Math.max(this.seen.get(receipt) ?? 0, upTo));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MockMcp } from "./mock_mcp";
|
||||
import type { HookCatalog, HookDryRun, HookEntry } from "./port";
|
||||
import { HttpError, type HookCatalog, type HookDryRun, type HookEntry } from "./port";
|
||||
|
||||
// Hooks, and what a dry run of one reports back.
|
||||
export class MockHook extends MockMcp {
|
||||
@@ -8,6 +8,10 @@ export class MockHook extends MockMcp {
|
||||
{ event: "PreToolUse", match: "bash", command: "./scripts/guard.sh", scope: "global", blocking: true, usesMatch: true },
|
||||
];
|
||||
|
||||
async stopToolCall(_callId: string): Promise<void> {
|
||||
throw new HttpError(409, "no running tool call with this id", { code: "tool_call.not_running" });
|
||||
}
|
||||
|
||||
async hooks(): Promise<HookCatalog> {
|
||||
return {
|
||||
hooks: this.hookList.map((h) => ({ ...h })),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentPort } from "./port";
|
||||
import type { HubPort, RuntimeView, TreeWindow, TreeWorkspace } from "./hub";
|
||||
import type { HistoryHit, HubPort, RuntimeView, TreeWindow, TreeWorkspace } from "./hub";
|
||||
import type { RemoteHost, RemoteListing, RemoteProbe } from "./remote";
|
||||
import type { ShareStatus } from "./share";
|
||||
import { MockPort } from "./mock";
|
||||
@@ -210,6 +210,10 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
searchHistory(_query: string): Promise<HistoryHit[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
placeWorkspace(path: string, before: string) {
|
||||
const at = this.roots.indexOf(path);
|
||||
if (at < 0) return Promise.resolve();
|
||||
|
||||
Loaded 100 of 1163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user