mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 03:36:10 +00:00
Compare commits
795
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca6605b5f | ||
|
|
2de89d0543 | ||
|
|
8f504e8543 | ||
|
|
6c97516822 | ||
|
|
81efaf0af7 | ||
|
|
adba484df4 | ||
|
|
37c263e153 | ||
|
|
204f48de8b | ||
|
|
e80b2b3d9a | ||
|
|
a817fe5e6c | ||
|
|
381f6c47b4 | ||
|
|
b6e14b5a74 | ||
|
|
36979c9641 | ||
|
|
4e067a2014 | ||
|
+30 |
5913c1db0b | ||
|
|
88f572cfce | ||
|
|
2271f9b222 | ||
|
|
8a36abd328 | ||
|
|
532292b5f3 | ||
|
|
89e3141079 | ||
|
|
7a1f9764a2 | ||
|
|
b91dd78ab3 | ||
|
|
dba5da7c10 | ||
|
|
aea36d7630 | ||
|
|
03474816ea | ||
|
|
5a9ed4d350 | ||
|
|
4204b9d087 | ||
|
|
8fad13365b | ||
|
|
5841b04fe7 | ||
|
|
e12ec8681b | ||
|
|
a3e2cc0dcd | ||
|
|
648183cecb | ||
|
|
58d18be590 | ||
|
|
dc9fd126a0 | ||
|
|
794137b33b | ||
|
|
0e08b7330f | ||
|
|
ced3d5e02a | ||
|
|
6e826f3e22 | ||
|
|
8bb1cfaa3b | ||
|
|
691a7d93c8 | ||
|
|
ca6da05d07 | ||
|
|
23483ea013 | ||
|
|
69c05ae3fc | ||
|
|
59ad593d9c | ||
|
|
6b9136e797 | ||
|
|
80dc21d8f7 | ||
|
|
c8a40450e5 | ||
|
|
e84938b309 | ||
|
|
c821d49386 | ||
|
|
f683eef5f9 | ||
|
|
a0a6963beb | ||
|
|
da35eac93f | ||
|
|
c944048bcf | ||
|
|
c2351e308f | ||
|
|
8de40be6ea | ||
|
|
2ed8fe5960 | ||
|
|
1ea9137c01 | ||
|
+29 |
77d3289d27 | ||
|
|
e6607fb58d | ||
|
|
128e2550ef | ||
|
|
e8d273ae5b | ||
|
|
7ec1b6e580 | ||
|
|
c4fa5e6619 | ||
|
|
6ec17e5d55 | ||
|
|
0405670cab | ||
|
|
caf727ecb7 | ||
|
|
9c38358197 | ||
|
|
dd6c95fdc7 | ||
|
|
6f4b9504e5 | ||
|
|
dbfbb13ccc | ||
|
|
99b2e78d75 | ||
|
|
023f76f6fc | ||
|
|
69d7c2add7 | ||
|
|
cf651bc41b | ||
|
|
8d80365ef4 | ||
|
|
3ea8895299 | ||
|
|
3304e79827 | ||
|
|
cf2c1bf425 | ||
|
|
e0810753f2 | ||
|
|
7243bd9e12 | ||
|
|
dd50d457b0 | ||
|
|
592ef7433a | ||
|
|
fd97d789ef | ||
|
|
7111f93836 | ||
|
|
e770415fd5 | ||
|
|
96dc560833 | ||
|
|
eb4ff91c2d | ||
|
|
a81c04fd31 | ||
|
|
63e2054f50 | ||
|
|
09a38f1984 | ||
|
|
065b108bba | ||
|
|
39fdd67123 | ||
|
|
44b6938b2a | ||
|
|
fc7e4cf93e | ||
|
|
43c08387f1 | ||
|
|
407ac2d8aa | ||
|
|
9445b4d940 | ||
|
|
0409e6884d | ||
|
|
ace77a5cf7 | ||
|
|
f5a487ffcd | ||
|
|
455b5d3165 | ||
|
|
a2c4c7a64b | ||
|
|
e93fa06d05 | ||
|
|
fa4ec98e1d | ||
|
+28 |
4c9a4309ce | ||
|
|
c0ba62b972 | ||
|
|
8cb9361149 | ||
|
|
642950a763 | ||
|
|
df1f42d80f | ||
|
|
0c18d06c9f | ||
|
|
17c1e9b083 | ||
|
|
0b2e2cbab0 | ||
|
|
79d0077f63 | ||
|
|
0aa4031dd0 | ||
|
|
f971aa0719 | ||
|
|
61b7caa0b5 | ||
|
|
98c29075b4 | ||
|
|
6d50023457 | ||
|
|
e8b19afa8f | ||
|
|
3da0dea8e7 | ||
|
|
c7aa47c144 | ||
|
|
7be95bcd6a | ||
|
|
3f4fb3f9db | ||
|
|
6702ce0d3f | ||
|
|
985ee1e2ec | ||
|
|
a9895a1e8a | ||
|
|
75c7ac6a2c | ||
|
|
0eb71d0fc7 | ||
|
|
deee40c572 | ||
|
|
8b5655ed53 | ||
|
|
b9525b5878 | ||
|
|
2b8d1998d6 | ||
|
|
391cfbcfe7 | ||
|
|
fe0c74f4df | ||
|
|
6d32bc9cb0 | ||
|
|
925c2423de | ||
|
|
71cb419570 | ||
|
|
edc93ceff1 | ||
|
|
86a468c4d8 | ||
|
|
f05d2ab551 | ||
|
|
bef6cfbffe | ||
|
|
fe9a936867 | ||
|
|
d5669ca934 | ||
|
|
04f0a771a3 | ||
|
|
c50554d907 | ||
|
|
3f5ad8441f | ||
|
|
cf6e5b3604 | ||
|
|
cbcf191fdb | ||
|
|
ba0bbdafaa | ||
|
|
a288cb5a0c | ||
|
|
57ff57595a | ||
|
|
584fdefe6f | ||
|
|
c310ef82f4 | ||
|
|
4e85a37590 | ||
|
|
fe9b051d1a | ||
|
|
33f1b269e9 | ||
|
|
0d68b0bb20 | ||
|
|
4e56998d3c | ||
|
|
d625bc86fc | ||
|
|
5f437a09b0 | ||
|
|
7d4496eafc | ||
|
|
1f2de535aa | ||
|
|
529d55b1c3 | ||
|
|
deb5b144c3 | ||
|
|
cd3cca0006 | ||
|
|
08a7080e11 | ||
|
|
f2579c41b6 | ||
|
|
c4830c147d | ||
|
|
22c23cb44c | ||
|
|
60ce33cde8 | ||
|
|
6ea8247e0f | ||
|
|
987242b3e8 | ||
|
|
fafb04ed59 | ||
|
|
87d5b27668 | ||
|
|
c7a7900ff2 | ||
|
|
f2a4011371 | ||
|
|
4bc8faa01c | ||
|
|
5d5b33f195 | ||
|
|
3fc06accbd | ||
|
|
677526d72e | ||
|
|
bce9c639d1 | ||
|
|
48adb9521c | ||
|
|
9b3a4655f1 | ||
|
|
bd79e24842 | ||
|
|
d67e035c18 | ||
|
|
f8470404e3 | ||
|
|
651751405d | ||
|
|
44f7bb71c1 | ||
|
|
b26d948b76 | ||
|
|
2180fb8e7b | ||
|
|
3db4458e6c | ||
|
|
42e2dde739 | ||
|
|
a1b274e6f8 | ||
|
|
27e1692848 | ||
|
|
73567f3570 | ||
|
|
91238441a6 | ||
|
|
faf964691b | ||
|
|
4a7f760d25 | ||
|
|
f76e18201a | ||
|
|
331533deff | ||
|
|
309860558d | ||
|
|
b4a4ef0b3c | ||
|
+21 |
e916b99742 | ||
|
|
88b8883122 | ||
|
|
ac1b802820 | ||
|
|
5ee4c1082a | ||
|
|
8a70d70006 | ||
|
|
91b4634363 | ||
|
|
4f60bde502 | ||
|
|
0d3b6d430e | ||
|
|
a7cf21e157 | ||
|
|
e4a16830f1 | ||
|
|
103f764624 | ||
|
|
7a7075d86f | ||
|
|
c764732aea | ||
|
|
72af084dc1 | ||
|
|
65a42fd549 | ||
|
|
ad8e6b1fb6 | ||
|
|
041cda905d | ||
|
|
2ad6c42143 | ||
|
|
60c7f847c1 | ||
|
|
7ab8a08efa | ||
|
|
7388e69b5c | ||
|
|
56c0658e6f | ||
|
|
06a2592836 | ||
|
|
4829308f2d | ||
|
|
eec6cd5234 | ||
|
|
fc37ae4452 | ||
|
|
5fcef6773c | ||
|
|
a5b28c2af2 | ||
|
|
d01dfa57b7 | ||
|
|
198ca749fd | ||
|
|
75f9fd5208 | ||
|
|
282f3f7eb2 | ||
|
|
4678bd1049 | ||
|
|
5fb0470b44 | ||
|
|
6bf92aa7de | ||
|
|
0a5530d0be | ||
|
|
dc7e0f6d2e | ||
|
|
b4be28d4c3 | ||
|
+22 |
7624c4f7b3 | ||
|
|
720f062cd0 | ||
|
|
b5177adb5b | ||
|
|
a772767e9b | ||
|
|
fd19fa89c4 | ||
|
|
0cfa7b39db | ||
|
|
5e2e0d6965 | ||
|
|
f92d84746b | ||
|
|
f2f5eb6f16 | ||
|
|
b95a5dc259 | ||
|
|
3fdd12fa81 | ||
|
|
bb15b16499 | ||
|
|
b6ccb66610 | ||
|
|
ec8ee60e0b | ||
|
|
75bc611ef1 | ||
|
|
4f1298063b | ||
|
|
22334b94c8 | ||
|
|
f5dd181443 | ||
|
|
e8bd386973 | ||
|
|
36f8a1e6e5 | ||
|
|
35f14900ae | ||
|
|
83cfafc884 | ||
|
|
09cd7445f1 | ||
|
|
39f1336621 | ||
|
|
ce636daf04 | ||
|
|
c1d2d7aba3 | ||
|
|
e8964ce672 | ||
|
|
00093c70c1 | ||
|
|
7eb7fe0763 | ||
|
|
2cb2f9d538 | ||
|
|
94a23b8537 | ||
|
|
ff3442ce34 | ||
|
|
72cf7f12f3 | ||
|
|
ba1f3d3d32 | ||
|
|
05fdbcce04 | ||
|
|
387bff8fd9 | ||
|
|
9a25673c37 | ||
|
|
563f6a65de | ||
|
|
fca3bca19d | ||
|
|
d88faeb6da | ||
|
|
2508a74956 | ||
|
|
947566f611 | ||
|
|
a149a61a89 | ||
|
|
ece2b16cdf | ||
|
|
ea89a2f619 | ||
|
+22 |
0d4f9797f4 | ||
|
|
a32c480d45 | ||
|
|
f4f761246a | ||
|
|
915be9b54d | ||
|
|
6039f54126 | ||
|
|
016545513f | ||
|
|
b67bed061a | ||
|
|
b2741d2f97 | ||
|
|
4a93972a78 | ||
|
|
5c5579e90c | ||
|
|
cd9be63484 | ||
|
|
40fedf086e | ||
|
|
a6b5cf94b0 | ||
|
+22 |
634386fe0f | ||
|
|
26e27c7a7f | ||
|
|
ea386fba1f | ||
|
|
aa8fc4234d | ||
|
|
9b8282ad3d | ||
|
|
2a08cd3b96 | ||
|
|
5cf24bf185 | ||
|
|
c15e3487b2 | ||
|
|
6963f2f6da | ||
|
|
547e0148c7 | ||
|
|
358d4746a9 | ||
|
|
ce2e301e24 | ||
|
|
fbe7f26e71 | ||
|
|
ecb5754f4c | ||
|
|
36f8cb7054 | ||
|
|
4c45a5cf23 | ||
|
|
748b0d8836 | ||
|
|
ed1636e3bd | ||
|
|
90a87b2a0c | ||
|
|
af8480b2e2 | ||
|
|
2096adae12 | ||
|
|
41349ff20a | ||
|
|
a2f5a0df5d | ||
|
|
3bc924252b | ||
|
|
8c1808ab74 | ||
|
|
685f887771 | ||
|
|
9db5073eab | ||
|
|
775fce0177 | ||
|
|
4e74f77e44 | ||
|
|
b980f74ed9 | ||
|
|
2987c30238 | ||
|
|
50a1add183 | ||
|
|
3533047421 | ||
|
|
823bc20427 | ||
|
|
339fd50cb9 | ||
|
|
828a4b3f15 | ||
|
|
a43de3f86d | ||
|
|
fe3b2e8f90 | ||
|
|
20af1f5e79 | ||
|
|
8daf912fbf | ||
|
|
3d545f960b | ||
|
|
58201a32c1 | ||
|
|
64ca9b8d77 | ||
|
|
a4b91d33d9 | ||
|
|
397993e898 | ||
|
|
c0ed0106b1 | ||
|
|
f112a73c06 | ||
|
|
7e9b9cb0fd | ||
|
|
09a6cecf23 | ||
|
|
6e55ddd078 | ||
|
|
f8f8cc9546 | ||
|
|
3c60470269 | ||
|
|
b2bdab24e6 | ||
|
|
3568dd1b99 | ||
|
+21 |
1e17202413 | ||
|
|
49cb73b348 | ||
|
|
430750e2f8 | ||
|
|
7913c4a490 | ||
|
|
5414697bd1 | ||
|
|
e22e3b8f2c | ||
|
|
8e657c7db5 | ||
|
|
02e2277057 | ||
|
|
ac1ddc3f83 | ||
|
|
8f904c8e9a | ||
|
|
ec07ee56f4 | ||
|
|
de86d73d19 | ||
|
|
0fa9e5039e | ||
|
|
c073387723 | ||
|
|
1956497f42 | ||
|
|
2a7e32c416 | ||
|
|
56a7c06a80 | ||
|
|
75e8fd4da2 | ||
|
|
b9f39dd751 | ||
|
|
6eeeb4bfcf | ||
|
|
1ccaca826e | ||
|
|
04f9b15178 | ||
|
|
66b9cc7931 | ||
|
|
e3f7637eb2 | ||
|
|
5945a8d429 | ||
|
|
c7ceccf869 | ||
|
|
00ab94c44f | ||
|
|
fe09a2e9b7 | ||
|
|
59202356fe | ||
|
|
637282f3b9 | ||
|
|
d1550cb599 | ||
|
|
ed6c117184 | ||
|
|
6ce62bd84a | ||
|
|
a4a948316b | ||
|
|
aca3b7813d | ||
|
|
0eaa75ec0f | ||
|
|
6b3c4f5839 | ||
|
|
8e76adb08f | ||
|
|
0bb24a46c1 | ||
|
|
d3f4695af3 | ||
|
|
5b2715d24f | ||
|
|
20e37e7122 | ||
|
|
b87bb4486f | ||
|
|
feaec7a6be | ||
|
|
1c67004999 | ||
|
+18 |
43ecf3ff1b | ||
|
|
9028c2d8f8 | ||
|
|
be18f22842 | ||
|
|
6216dbae15 | ||
|
|
96a9731947 | ||
|
|
768a69bbbd | ||
|
|
b7b941c354 | ||
|
|
cf5e0adf02 | ||
|
|
41d0a9f010 | ||
|
|
accaa792d8 | ||
|
|
76d7a11585 | ||
|
|
c867e66ab5 | ||
|
|
278c510549 | ||
|
|
0d6ccd2a50 | ||
|
|
39cceeb143 | ||
|
|
a6449cb45c | ||
|
|
6a85f0d3db | ||
|
|
fbb0fdf88a | ||
|
|
2a4b298108 | ||
|
|
efeff63749 | ||
|
|
d54038b9d2 | ||
|
|
8f04a09d1a | ||
|
|
761f37370f | ||
|
|
b452368b3b | ||
|
|
3785eddfa0 | ||
|
|
4a006b1210 | ||
|
|
2db7ccb453 | ||
|
|
629b304c48 | ||
|
|
1004fbb2f5 | ||
|
+17 |
a7746379d5 | ||
|
|
a72992e00f | ||
|
|
99156c10e6 | ||
|
|
f6f0a5c10f | ||
|
|
e2eed76101 | ||
|
|
9ff7ef3fb0 | ||
|
|
8fe78c8f8d | ||
|
|
bd0dffd781 | ||
|
|
0ac8f91ed6 | ||
|
|
4c80c23e11 | ||
|
|
33c0cc2bb6 | ||
|
|
cc686ab8f6 | ||
|
|
6524dfc818 | ||
|
|
c91604d519 | ||
|
|
a2718177be | ||
|
|
6fb526741b | ||
|
|
85fdb3bde6 | ||
|
|
b642f9c7bc | ||
|
|
855a9569b5 | ||
|
|
aa0e626b12 | ||
|
|
389c866cac | ||
|
|
7feefb697f | ||
|
|
d4155f2906 | ||
|
|
67f2393f83 | ||
|
|
cccd013801 | ||
|
|
be7a684791 | ||
|
|
984cab7938 | ||
|
|
7eea97184a | ||
|
|
0590819090 | ||
|
+22 |
687dbba6a3 | ||
|
|
2347f4d7b4 | ||
|
|
7698a5e6ac | ||
|
|
ec8eea7cbe | ||
|
|
b44a981eef | ||
|
|
79415f625a | ||
|
|
19f42f7102 | ||
|
|
212c9f99ee | ||
|
|
99e52303e6 | ||
|
|
88925ec6e6 | ||
|
|
cedf365674 | ||
|
|
5b39972947 | ||
|
|
09553d46e0 | ||
|
|
e1d72fa338 | ||
|
|
b3cf749a7f | ||
|
|
dd842f9862 | ||
|
|
3dd29094f4 | ||
|
|
05e8d5be73 | ||
|
|
8b634e4a58 | ||
|
|
c6156f171c | ||
|
|
4d2b06f8cf | ||
|
|
bf15c97e4b | ||
|
|
3cbcd8d727 | ||
|
|
cc29f86cdc | ||
|
|
86e3828da6 | ||
|
|
8d00a51766 | ||
|
|
ed4f833813 | ||
|
|
e96c24ce2e | ||
|
|
ed6ad272ec | ||
|
|
d27746e5b3 | ||
|
|
eb661e6e6d | ||
|
|
3379e44abb | ||
|
|
e1abf59c59 | ||
|
|
4f976bcf1a | ||
|
|
e25a724bc2 | ||
|
|
521ea87192 | ||
|
|
3100701488 | ||
|
|
9c54291ae8 | ||
|
|
52ad916ba4 | ||
|
|
5db320b02d | ||
|
|
910e37f6d8 | ||
|
|
81f6e06681 | ||
|
|
da68e2865e | ||
|
|
f86e1cc1ff | ||
|
|
947bbf9490 | ||
|
|
3e57b43a4a | ||
|
|
cb18a42a47 | ||
|
|
1fb2837fd3 | ||
|
|
66a8d830aa | ||
|
|
d0733f83bf | ||
|
|
b2ce195bda | ||
|
|
6c065ca6a5 | ||
|
|
dd1c007877 | ||
|
|
8deb0e5780 | ||
|
|
f488089179 | ||
|
|
e3a39b214f | ||
|
|
b6a2912bb1 | ||
|
|
93b7ca9b4d | ||
|
|
fcb1d4b418 | ||
|
|
04b673432c | ||
|
|
bd947658bb | ||
|
|
bb6ee0a5cc | ||
|
|
fd4497e6b9 | ||
|
|
59790380de | ||
|
|
7668eb728d | ||
|
|
79301fbed5 | ||
|
|
c0ea1a43f9 | ||
|
|
96717c1a8c | ||
|
|
e312f0d775 | ||
|
|
bfdbf43ef8 | ||
|
|
984430c97d | ||
|
|
bf01264661 | ||
|
|
b046bbe4e3 | ||
|
|
8dc9ac07a2 | ||
|
|
ba24037e64 | ||
|
|
df471872de | ||
|
|
8942f97998 | ||
|
|
754dd57177 | ||
|
|
f044def957 | ||
|
|
9c2842b9b0 | ||
|
+17 |
9e0d3976e1 | ||
|
|
f87998f37f | ||
|
|
e57d9ca390 | ||
|
|
d603eed5a1 | ||
|
|
0e1c0414c2 | ||
|
|
dc99c600b6 | ||
|
|
6ffe61b9fe | ||
|
|
36d17c30a7 | ||
|
|
4d100de194 | ||
|
|
c13f06c30c | ||
|
|
91f1815732 | ||
|
|
1a52e1118e | ||
|
|
f2f62438b8 | ||
|
|
2830176972 | ||
|
|
06dcf3f221 | ||
|
|
f9442e7107 | ||
|
|
ff499c3603 | ||
|
|
4cee5bd824 | ||
|
|
f406fed924 | ||
|
|
5b2d90a441 | ||
|
|
04e8801f5b | ||
|
|
7f008df79d | ||
|
|
175d200e53 | ||
|
|
41d11a8a89 | ||
|
|
0bba8c780a | ||
|
|
32cf36de9d | ||
|
|
fb75ea2cf6 | ||
|
|
147da5d278 | ||
|
|
eed23d8ee9 | ||
|
|
e6e951b252 | ||
|
|
4e019ba5d9 | ||
|
|
50a762e7b9 | ||
|
|
780c99bc2e | ||
|
|
5bcf8d5a0b | ||
|
|
652e8ff3fb | ||
|
|
7b88de47c3 | ||
|
|
33cb536879 | ||
|
|
6ffecf9345 | ||
|
|
08741f6b93 | ||
|
|
baacb2e776 | ||
|
|
a75978815a | ||
|
|
c3d26c4912 | ||
|
|
a9b7bd9e2f | ||
|
|
f9d1d3b259 | ||
|
|
f950497173 | ||
|
|
391aa38281 | ||
|
|
29e8502bb0 | ||
|
|
44b182fe23 | ||
|
|
905123b9c0 | ||
|
|
bb3b6a2f65 | ||
|
|
b8efb33cde | ||
|
|
d65ecd4a90 | ||
|
|
57fb3e5cc5 | ||
|
|
ba07481b59 | ||
|
|
b99759c7de | ||
|
|
c590e27639 | ||
|
|
8f4b62eb49 | ||
|
|
945d1c8cb2 | ||
|
|
610e618bc5 | ||
|
|
9daa4d85a4 | ||
|
|
62af66a74f | ||
|
|
5b44e5bf41 | ||
|
|
a15afbe8f2 | ||
|
|
8e0856c43b | ||
|
|
f66c829231 | ||
|
|
3baaabede8 | ||
|
|
afe3ebbc35 | ||
|
|
e2faeb84e5 | ||
|
|
35ed09ff37 | ||
|
|
9751615651 | ||
|
|
c9b24ef027 | ||
|
|
b04d8d53e6 | ||
|
|
7bd3f8ac83 | ||
|
|
6ae2fa5196 | ||
|
|
650d774372 | ||
|
|
64e4f6f91b | ||
|
|
d097cc8065 | ||
|
|
438654768c | ||
|
|
37b26e495b | ||
|
|
5d14c7a185 | ||
|
|
e66cbf36e9 | ||
|
|
50977dd4fe | ||
|
|
a644e0e7a0 | ||
|
|
ca861fdf43 | ||
|
|
34a08cbdb8 | ||
|
|
bf3ae45439 | ||
|
|
394e0b9045 | ||
|
|
4790a2772c | ||
|
|
2ff19171dc | ||
|
|
1401901529 | ||
|
|
1b88ff8d53 | ||
|
|
08293e74a0 | ||
|
|
24d26365e6 | ||
|
|
c22793d5f6 | ||
|
|
c6a52a39b5 | ||
|
|
aad8d90dd1 | ||
|
|
6f47459c68 | ||
|
|
dd768e30e2 | ||
|
|
de476aa51b | ||
|
|
bd8d858bf7 | ||
|
|
5fc4e18b82 | ||
|
|
12d9f4b29b | ||
|
|
a2769b5ade | ||
|
|
1fa605ad5e | ||
|
|
698ef25f33 | ||
|
|
88dc960af8 | ||
|
|
5657cec4f2 | ||
|
|
8d790a9ed5 | ||
|
|
b62d24e40b | ||
|
|
a1b6c84135 | ||
|
|
85fde8c11d | ||
|
|
c167bde6a0 | ||
|
|
d9bf30fc22 | ||
|
|
3ce5e9800d | ||
|
|
af5eabcb26 | ||
|
|
0087dd56d9 | ||
|
|
ef2140d121 | ||
|
|
1de3c6e4a6 | ||
|
|
b6c3a8ea7b | ||
|
|
efcf2c3f5d | ||
|
|
e65477ab1d | ||
|
|
cd0b274856 | ||
|
|
57e9e9771d | ||
|
|
1aae92c42a | ||
|
|
6555df912a | ||
|
|
7ebd344fa2 | ||
|
|
460cdc5aec | ||
|
|
b0ca5520a1 | ||
|
|
bc2e270f82 | ||
|
|
33705e632a | ||
|
|
cff2345c12 | ||
|
|
0405518180 | ||
|
|
c9604c86ec | ||
|
|
967c44552e | ||
|
|
ec95694e2f | ||
|
|
c176baee82 | ||
|
|
016d296f7c | ||
|
|
dbaa53329c | ||
|
|
7843f8fb38 | ||
|
|
4045041554 | ||
|
|
7ec7413fdb | ||
|
|
f016392368 | ||
|
|
140224b0fc | ||
|
|
cfd35c9354 | ||
|
|
674d08f9be | ||
|
|
02f012f5d1 | ||
|
|
bea6e1499d | ||
|
|
50a1fe49bc | ||
|
|
6380919fda | ||
|
|
d972aa9d83 | ||
|
|
d544ab4d91 | ||
|
|
ce228bfd7c | ||
|
|
eabf85aea2 | ||
|
|
2f4a688790 | ||
|
|
4a90ffedfb | ||
|
|
95cf5039be | ||
|
|
041215996c | ||
|
|
d0b920e187 | ||
|
+22 |
8c94e9005f | ||
|
|
932a40cfd9 | ||
|
|
716f6658db | ||
|
|
9f1a8f2149 | ||
|
|
b3650e2316 | ||
|
|
fb884bb91e | ||
|
|
98c09884bf | ||
|
|
5e47501b8b | ||
|
|
d51ba6ed94 | ||
|
|
a6983b65fc | ||
|
|
1a06198954 | ||
|
|
72ec09cf74 | ||
|
|
e2bca216a2 | ||
|
|
a10733dbf4 | ||
|
|
b5823d1077 | ||
|
|
e1fafa2e54 | ||
|
|
ae49b21376 | ||
|
|
5710064ae1 | ||
|
|
4617b03ca9 | ||
|
|
6a91a682e4 | ||
|
|
f8626865b9 | ||
|
|
2324a63fc6 | ||
|
|
1ce607c230 | ||
|
|
c26f6f95f7 | ||
|
|
d77b87ea65 | ||
|
|
6ca6566bd3 | ||
|
|
8dd993d25a | ||
|
|
24ab17e718 | ||
|
|
0a60662d71 | ||
|
|
194b0615e0 | ||
|
|
a5c51e11d0 | ||
|
|
8f1db7d06d | ||
|
|
b1ca070b3b | ||
|
|
12887e572e | ||
|
|
75715e2115 | ||
|
|
a1250cd690 | ||
|
|
23adaaaeab | ||
|
|
524ee8fc03 | ||
|
|
0f9719a7b5 | ||
|
|
1b83c08b8a | ||
|
|
02cb350880 | ||
|
|
4ce830a919 | ||
|
|
461a1c3ab4 | ||
|
|
6846542115 | ||
|
|
f928b5be07 | ||
|
|
ecfa918760 | ||
|
|
f80624cf17 | ||
|
|
fe59174c23 | ||
|
|
2fe057324f | ||
|
|
19a5b5a05d | ||
|
|
ff4cab03c1 | ||
|
|
b2d46ecd7e | ||
|
|
360d85a521 | ||
|
|
c65a7d50c1 | ||
|
|
fa73546a86 | ||
|
|
935ac2db91 | ||
|
|
01edae4a7f | ||
|
|
381d67572e | ||
|
|
e8ac44430b | ||
|
|
5ae93092aa | ||
|
|
595c6bd4a7 | ||
|
|
7073e8797f | ||
|
|
f7034a35a8 | ||
|
|
53b93b6991 | ||
|
|
6067019434 | ||
|
|
42a3cf9645 | ||
|
|
04c6bed240 | ||
|
|
94e3a29d2f | ||
|
+8 |
41283933ff | ||
|
|
11bf8d8a42 | ||
|
|
c22973ab9f | ||
|
|
612009e0f1 | ||
|
|
a0a6c9545e | ||
|
|
bceb6d0a9e | ||
|
|
49e7dc191f | ||
|
|
f4720be08e | ||
|
|
d1d7ebc2c6 | ||
|
|
cd942d0669 | ||
|
|
4741e3ee6b | ||
|
|
4673bfbaa0 | ||
|
|
b458dd8c63 | ||
|
|
7edd6c3a1d | ||
|
|
f43f066741 | ||
|
|
b6553d14e1 | ||
|
|
ab0042a666 | ||
|
|
5df049d081 | ||
|
|
573ab9c24b | ||
|
|
6a16c41e8f | ||
|
|
a491cbee64 | ||
|
|
658cbe9caf | ||
|
|
62b2bc39df | ||
|
|
beb2c52c3f | ||
|
|
655adbf46e | ||
|
|
ac2a78391f | ||
|
|
9a9bdaba95 | ||
|
|
8b682c42b6 | ||
|
|
ad4f1c1018 | ||
|
|
a0afb63ed0 | ||
|
|
df9ecb8f6a | ||
|
|
e6f660fecf | ||
|
|
83bee1e776 | ||
|
|
124714ca3a |
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
||||
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
||||
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
||||
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
||||
"x86_64-linux": "sha256-21XkFTx9fCAI//Nqb7AGRjKiJ/50IeMWjnJSjfs85vQ=",
|
||||
"aarch64-linux": "sha256-ZOFJ642fOaKti0TQJK8YutrDfBcRUNKLZ6r1QZAfdkw=",
|
||||
"aarch64-darwin": "sha256-RDvLaaGmSQyhSCIUqb7S8reGvNSmw/j1MjunJtVsMPo=",
|
||||
"x86_64-darwin": "sha256-SXgTKdQPbXY9rCAFpSoL7fk/V+clgAMwfm7PonuM4c0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,5 +105,9 @@ function json(route: Route, body: unknown, status = 200) {
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
||||
await panel.locator("#session-side-panel-review-tab").click()
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||
await page.keyboard.press("Control+w")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).click()
|
||||
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
|
||||
@@ -61,7 +61,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: events,
|
||||
: [
|
||||
...(path === "/global/event"
|
||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||
: []),
|
||||
...(events ?? []),
|
||||
],
|
||||
config.eventRetry,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,6 +197,14 @@ export async function installSseTransport<T>(
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
if (url.pathname === "/global/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
frame({
|
||||
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||
}),
|
||||
),
|
||||
)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
|
||||
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
delay="intent"
|
||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||
value={
|
||||
<ModelTooltip
|
||||
|
||||
@@ -93,7 +93,7 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
delay="intent"
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
@@ -452,7 +452,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
delay="intent"
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
|
||||
@@ -310,7 +310,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
)
|
||||
const resources = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||
id: `resource:${resource.client}:${resource.uri}`,
|
||||
id: `resource:${resource.server}:${resource.uri}`,
|
||||
kind: "resource" as const,
|
||||
label: `@${resource.name}`,
|
||||
path: resource.uri,
|
||||
@@ -327,7 +327,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
source: {
|
||||
type: "resource" as const,
|
||||
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||
clientName: resource.client,
|
||||
clientName: resource.server,
|
||||
uri: resource.uri,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type: "resource",
|
||||
name: resource.name,
|
||||
uri: resource.uri,
|
||||
client: resource.client,
|
||||
client: resource.server,
|
||||
display: resource.name,
|
||||
description: resource.description,
|
||||
mime: resource.mimeType,
|
||||
|
||||
@@ -47,7 +47,6 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -52,12 +52,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
<For each={props.comments ?? []}>
|
||||
{(item) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2
|
||||
value={item.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<CommentCardV2
|
||||
comment={item.comment ?? ""}
|
||||
path={item.path}
|
||||
|
||||
@@ -19,6 +19,7 @@ const optimistic: Array<{
|
||||
}> = []
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const sessionDirectories: Record<string, string> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
@@ -89,6 +90,27 @@ const clientFor = (directory: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
session: {
|
||||
async create(input: { location: { directory: string } }) {
|
||||
await createSessionGate
|
||||
createdSessions.push(input.location.directory)
|
||||
const session = {
|
||||
id: `session-${createdSessions.length}`,
|
||||
title: `New session ${createdSessions.length}`,
|
||||
}
|
||||
sessionDirectories[session.id] = input.location.directory
|
||||
return session
|
||||
},
|
||||
async shell(input: { sessionID: string }) {
|
||||
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
|
||||
},
|
||||
async prompt() {},
|
||||
async command() {},
|
||||
async interrupt() {},
|
||||
},
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
|
||||
@@ -171,6 +193,7 @@ beforeAll(async () => {
|
||||
const sdk = {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
api,
|
||||
client: rootClient,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
@@ -265,6 +288,7 @@ beforeEach(() => {
|
||||
permissionServer = "server-a"
|
||||
createSessionGate = undefined
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
|
||||
@@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -39,7 +41,7 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: DirectorySDK["client"]
|
||||
api: DirectorySDK["api"]["session"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
@@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.command({
|
||||
const messageID = Identifier.ascending("message")
|
||||
await input.api.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||
variant: input.draft.variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
return true
|
||||
@@ -152,13 +156,36 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.promptAsync({
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant: input.draft.variant,
|
||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: requestParts.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const text = part.source?.text
|
||||
return [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
||||
},
|
||||
]
|
||||
}),
|
||||
agents: requestParts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
mention: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -210,6 +237,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
@@ -235,9 +263,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk()
|
||||
.client.session.abort({
|
||||
sessionID,
|
||||
})
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -364,9 +390,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await client.session
|
||||
.create()
|
||||
.then((x) => x.data ?? undefined)
|
||||
const created = await sdk()
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.then(normalizeSessionInfo)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
@@ -450,12 +480,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
client.session
|
||||
.shell({
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.api.session.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -473,23 +505,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
client.session
|
||||
.command({
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
@@ -573,7 +605,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||
})
|
||||
|
||||
test("detects LSP failures that do not block chatting", () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
mcp: Array<McpStatus["status"]>
|
||||
mcp: Array<McpServer["status"]["status"]>
|
||||
lsp: Array<LspStatus["status"]>
|
||||
}) {
|
||||
return (
|
||||
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
|
||||
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
|
||||
input.lsp.some((status) => status === "error")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -419,16 +419,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
if (index === -1) return
|
||||
|
||||
index -= 1
|
||||
if (index === -1) index = tabsStore.length - 1
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
onSelect: tabs.previous,
|
||||
},
|
||||
{
|
||||
id: `tab.next`,
|
||||
@@ -436,16 +427,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
if (index === -1) return
|
||||
|
||||
index += 1
|
||||
if (index === tabsStore.length) index = 0
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
onSelect: tabs.next,
|
||||
},
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
@@ -607,7 +589,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={800}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -637,7 +618,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
>
|
||||
<Show when={hasProjects() && nav()}>
|
||||
<div class="flex items-center gap-0 transition-transform">
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
@@ -647,7 +628,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { State } from "./global-sync/types"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const sessionFields = new Set([
|
||||
@@ -15,6 +16,7 @@ const sessionFields = new Set([
|
||||
"permission",
|
||||
"question",
|
||||
"message",
|
||||
"session_message",
|
||||
"part",
|
||||
"part_text_accum_delta",
|
||||
])
|
||||
@@ -114,7 +116,6 @@ export const createDirSyncContext = (
|
||||
await serverSync.session.sync(sessionID, options)
|
||||
index(sessionID)
|
||||
},
|
||||
diff: serverSync.session.diff,
|
||||
todo: serverSync.session.todo,
|
||||
history: serverSync.session.history,
|
||||
evict(sessionID: string) {
|
||||
@@ -123,9 +124,9 @@ export const createDirSyncContext = (
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = current()
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await client.session.list()
|
||||
const sessions = (response.data ?? [])
|
||||
.filter((session) => !!session?.id)
|
||||
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||
const sessions = response.data
|
||||
.map(normalizeSessionInfo)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
sessions.forEach(serverSync.session.remember)
|
||||
@@ -133,7 +134,7 @@ export const createDirSyncContext = (
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
await serverSDK.api.session.archive({ sessionID, directory })
|
||||
current()[1](
|
||||
"session",
|
||||
produce((draft) => {
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { createServerSession } from "../server-session"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
const api = {
|
||||
agent: { list: async () => ({ location: {}, data: [] }) },
|
||||
provider: { list: async () => ({ location: {}, data: [] }) },
|
||||
model: {
|
||||
list: async () => ({ location: {}, data: [] }),
|
||||
default: async () => ({ location: {}, data: null }),
|
||||
},
|
||||
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||
project: {
|
||||
list: async () => [],
|
||||
current: async () => ({ id: "project", directory: "/project" }),
|
||||
},
|
||||
question: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||
reference: { list: async () => ({ location: {}, data: [] }) },
|
||||
vcs: { get: async () => ({ location: {}, data: {} }) },
|
||||
} as unknown as ServerApi
|
||||
|
||||
function directoryState() {
|
||||
return createStore<State>({
|
||||
@@ -41,6 +66,7 @@ function directoryState() {
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
@@ -64,7 +90,6 @@ describe("bootstrapDirectory", () => {
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
list: async () => {
|
||||
@@ -83,6 +108,7 @@ describe("bootstrapDirectory", () => {
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
api,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
@@ -98,79 +124,108 @@ describe("bootstrapDirectory", () => {
|
||||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads).toEqual([])
|
||||
})
|
||||
|
||||
test("seeds session status even while warming session info stalls", async () => {
|
||||
const [store, setStore] = directoryState()
|
||||
const stalled = Promise.withResolvers<never>()
|
||||
const client = {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: {
|
||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
||||
get: () => stalled.promise,
|
||||
},
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient
|
||||
const session = createServerSession(client)
|
||||
const stale: Session = {
|
||||
id: "ses_stale",
|
||||
slug: "ses_stale",
|
||||
projectID: "project",
|
||||
directory: "/project",
|
||||
title: "stale",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
session.remember(stale)
|
||||
session.set("session_status", stale.id, { type: "busy" })
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: client,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
session,
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 500
|
||||
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
|
||||
expect(session.data.session_status[stale.id]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const client = {} as OpencodeClient
|
||||
const client = {} as Parameters<typeof loadPathQuery>[2]
|
||||
const api = {} as CatalogApi
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
|
||||
"https://debian.example",
|
||||
null,
|
||||
"providers",
|
||||
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||
})
|
||||
|
||||
test("loads the current provider and model catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
provider: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["provider", input])
|
||||
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["model", input])
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
default: async (input: unknown) => {
|
||||
calls.push(["default", input])
|
||||
return { location: {}, data: null }
|
||||
},
|
||||
},
|
||||
} as unknown as CatalogApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([
|
||||
["provider", { location: { directory: "/repo" } }],
|
||||
["model", { location: { directory: "/repo" } }],
|
||||
["default", { location: { directory: "/repo" } }],
|
||||
])
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
})
|
||||
|
||||
test("loads agents from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
} as unknown as AgentApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("loads commands from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: {},
|
||||
data: [{ name: "review", template: "Review files", source: "command" as const }],
|
||||
}
|
||||
},
|
||||
} as unknown as CommandApi
|
||||
|
||||
const result = await loadCommands("/repo", api)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }])
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const api = {
|
||||
list: async () => [
|
||||
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
} as unknown as ProjectApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("loads references from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
||||
},
|
||||
} as unknown as ReferenceApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,26 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
CatalogApi,
|
||||
CommandInfo,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
McpApi,
|
||||
PathGetInput,
|
||||
PathGetOutput,
|
||||
PermissionApi,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectListOutput,
|
||||
QuestionApi,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
SessionApi,
|
||||
VcsApi,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
@@ -16,12 +36,20 @@ import { batch } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import type { ServerSession } from "../server-session"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import {
|
||||
cmp,
|
||||
normalizeAgentList,
|
||||
normalizePermissionRequest,
|
||||
normalizeProjectInfo,
|
||||
normalizeProviderList,
|
||||
} from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { ServerProtocol } from "@/utils/server-protocol"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -88,15 +116,25 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
|
||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||
})
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
|
||||
type PathApi = {
|
||||
readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
|
||||
}
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
sdk.project.list().then((x) => {
|
||||
return (x.data ?? [])
|
||||
api.list().then((projects) => {
|
||||
return projects
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.map(normalizeProjectInfo)
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
@@ -105,6 +143,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverSDK: OpencodeClient
|
||||
serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
|
||||
protocol?: Promise<ServerProtocol>
|
||||
scope: ServerScope
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
@@ -114,11 +154,14 @@ export async function bootstrapGlobal(input: {
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||
),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
]
|
||||
await runAll(slow)
|
||||
@@ -162,44 +205,117 @@ function warmSessions(input: {
|
||||
ids: string[]
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
sdk: OpencodeClient
|
||||
api: SessionApi
|
||||
}) {
|
||||
const known = new Set(input.store.session.map((item) => item.id))
|
||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||
if (ids.length === 0) return Promise.resolve()
|
||||
return Promise.all(
|
||||
ids.map((sessionID) =>
|
||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
||||
const session = x.data
|
||||
if (!session?.id) return
|
||||
mergeSession(input.setStore, session)
|
||||
}),
|
||||
retry(() => input.api.get({ sessionID })).then((session) =>
|
||||
mergeSession(input.setStore, normalizeSessionInfo(session)),
|
||||
),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
export const loadProvidersQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
sdk: CatalogApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "providers"],
|
||||
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
const result = await legacy.provider.list()
|
||||
return normalizeProviderList(result.data!)
|
||||
}
|
||||
const location = directory ? { location: { directory } } : undefined
|
||||
const [providers, models, defaultModel] = await Promise.all([
|
||||
sdk.provider.list(location),
|
||||
sdk.model.list(location),
|
||||
sdk.model.default(location),
|
||||
])
|
||||
return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
||||
}),
|
||||
})
|
||||
|
||||
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
type AgentListApi = {
|
||||
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
||||
}
|
||||
|
||||
type CommandListApi = {
|
||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||
}
|
||||
|
||||
type ReferenceListApi = {
|
||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||
}
|
||||
|
||||
export const loadAgentsQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
sdk: AgentListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
|
||||
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
|
||||
}),
|
||||
})
|
||||
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
export const loadCommands = (
|
||||
directory: string,
|
||||
api: CommandListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
): Promise<CommandInfo[]> =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
return ((await legacy.command.list()).data ?? []).map((command) => {
|
||||
const [providerID, id] = command.model?.split("/") ?? []
|
||||
return {
|
||||
name: command.name,
|
||||
template: command.template,
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: providerID && id ? { providerID, id } : undefined,
|
||||
subtask: command.subtask,
|
||||
source: command.source === "skill" ? undefined : command.source,
|
||||
}
|
||||
})
|
||||
}
|
||||
return api.list({ location: { directory } }).then((result) => result.data)
|
||||
})
|
||||
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: PathApi) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
||||
queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
export const loadReferencesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: ReferenceListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
|
||||
return api.list({ location: { directory } }).then((result) => result.data)
|
||||
}).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
@@ -208,6 +324,18 @@ export async function bootstrapDirectory(input: {
|
||||
scope: ServerScope
|
||||
mcp: boolean
|
||||
sdk: OpencodeClient
|
||||
api: CatalogApi & {
|
||||
readonly agent: AgentListApi
|
||||
readonly command: CommandListApi
|
||||
readonly mcp: McpApi
|
||||
readonly path: PathApi
|
||||
readonly permission: PermissionApi
|
||||
readonly project: ProjectApi
|
||||
readonly question: QuestionApi
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
}
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
vcsCache: VcsCache
|
||||
@@ -221,6 +349,7 @@ export async function bootstrapDirectory(input: {
|
||||
}
|
||||
queryClient: QueryClient
|
||||
session?: ServerSession
|
||||
protocol?: Promise<ServerProtocol>
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
@@ -240,66 +369,55 @@ export async function bootstrapDirectory(input: {
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.session.status().then(async (x) => {
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
return
|
||||
}
|
||||
const statuses = x.data ?? {}
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
// Warm session info only after seeding statuses so a stalled session
|
||||
// fetch cannot park busy indicators behind it, mirroring how live
|
||||
// session.status events apply first and resolve info in the background.
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
!seededProject &&
|
||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
||||
(() =>
|
||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||
input.setStore("project", project.id),
|
||||
)),
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
input.queryClient
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
|
||||
const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
|
||||
input.setStore("command", commands),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
||||
return input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
})().then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
@@ -323,12 +441,19 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
||||
return input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
})().then((questions) => {
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
@@ -351,17 +476,20 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
|
||||
input.mcp &&
|
||||
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
|
||||
@@ -255,6 +255,7 @@ export function createChildStoreManager(input: {
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
|
||||
@@ -80,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||
vcs: undefined,
|
||||
limit: 10,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
...input,
|
||||
@@ -261,8 +262,8 @@ describe("applyDirectoryEvent", () => {
|
||||
|
||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||
const cases = [
|
||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
|
||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
|
||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
@@ -286,7 +287,10 @@ describe("applyDirectoryEvent", () => {
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.deleted", properties: { info: item.info } },
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: item.current ? { sessionID: item.info.id } : { info: item.info },
|
||||
},
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
@@ -171,8 +171,11 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||
const sessionID = properties.info?.id ?? properties.sessionID
|
||||
if (!sessionID) break
|
||||
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||
const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
@@ -181,14 +184,77 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
|
||||
if (info?.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.renamed": {
|
||||
const properties = event.properties as { sessionID: string; title: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
title: properties.title,
|
||||
time: { ...session.time, updated: Date.now() },
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "session.usage.updated": {
|
||||
const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
cost: properties.cost,
|
||||
tokens: properties.tokens,
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "session.archived": {
|
||||
const properties = event.properties as { sessionID: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
const info = input.store.session[result.index]
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => void draft.splice(result.index, 1)),
|
||||
)
|
||||
cleanupSessionCaches(input.setStore, properties.sessionID)
|
||||
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.moved": {
|
||||
const properties = event.properties as {
|
||||
sessionID: string
|
||||
location: { directory: string; workspaceID?: string }
|
||||
projectID?: string
|
||||
subpath?: string
|
||||
}
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
if (properties.location.directory === input.directory) {
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
projectID: properties.projectID ?? session.projectID,
|
||||
workspaceID: properties.location.workspaceID,
|
||||
directory: properties.location.directory,
|
||||
path: properties.subpath,
|
||||
time: { ...session.time, updated: Date.now() },
|
||||
}))
|
||||
break
|
||||
}
|
||||
const info = input.store.session[result.index]
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => void draft.splice(result.index, 1)),
|
||||
)
|
||||
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
|
||||
break
|
||||
}
|
||||
case "todo.updated": {
|
||||
|
||||
@@ -31,4 +31,24 @@ describe("toggleMcp", () => {
|
||||
await toggleMcp(input("disabled"))
|
||||
expect(calls).toEqual(["connect", "refresh"])
|
||||
})
|
||||
|
||||
test("does not toggle a server while its connection is pending", async () => {
|
||||
const calls: string[] = []
|
||||
await toggleMcp({
|
||||
status: "pending",
|
||||
connect: async () => {
|
||||
calls.push("connect")
|
||||
},
|
||||
disconnect: async () => {
|
||||
calls.push("disconnect")
|
||||
},
|
||||
authenticate: async () => {
|
||||
calls.push("authenticate")
|
||||
},
|
||||
refresh: async () => {
|
||||
calls.push("refresh")
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
|
||||
export async function toggleMcp(input: {
|
||||
status: McpStatus["status"]
|
||||
status: McpServer["status"]["status"]
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
authenticate: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}) {
|
||||
if (input.status === "pending") return
|
||||
await {
|
||||
connected: input.disconnect,
|
||||
needs_auth: input.authenticate,
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
@@ -33,9 +26,10 @@ describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
@@ -45,6 +39,7 @@ describe("app session cache", () => {
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] as Todo[] },
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
@@ -67,9 +62,10 @@ describe("app session cache", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
@@ -79,6 +75,7 @@ describe("app session cache", () => {
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: { ses_1: [m] },
|
||||
session_message: {},
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
@@ -37,6 +32,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
for (const sessionID of stale) {
|
||||
delete store.message[sessionID]
|
||||
delete store.todo[sessionID]
|
||||
delete store.session_message[sessionID]
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import type { RootLoadArgs } from "./types"
|
||||
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
||||
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||
const result = await input.api.list({
|
||||
directory: input.directory,
|
||||
parentID: null,
|
||||
limit: input.limit,
|
||||
order: "desc",
|
||||
})
|
||||
return {
|
||||
data: result.data.map(normalizeSessionInfo),
|
||||
limit: input.limit,
|
||||
limited: true,
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
|
||||
try {
|
||||
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: true,
|
||||
} as const
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return { data: result.data, limit: input.limit, limited: true } as const
|
||||
} catch {
|
||||
const result = await input.list({ directory: input.directory, roots: true })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: false,
|
||||
} as const
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
||||
return { data: result.data, limit: input.limit, limited: false } as const
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type {
|
||||
Agent,
|
||||
Command,
|
||||
Config,
|
||||
LspStatus,
|
||||
McpResource,
|
||||
McpStatus,
|
||||
Message,
|
||||
Part,
|
||||
Path,
|
||||
@@ -13,11 +10,12 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
@@ -35,7 +33,7 @@ export type ProjectMeta = {
|
||||
export type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
command: CommandInfo[]
|
||||
reference: ReferenceInfo[]
|
||||
project: string
|
||||
projectMeta: ProjectMeta | undefined
|
||||
@@ -51,7 +49,7 @@ export type State = {
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
@@ -64,7 +62,7 @@ export type State = {
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
[name: string]: McpServer["status"]
|
||||
}
|
||||
mcp_resource: {
|
||||
[key: string]: McpResource
|
||||
@@ -76,6 +74,9 @@ export type State = {
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
session_message: {
|
||||
[sessionID: string]: SessionMessageInfo[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
@@ -128,18 +129,6 @@ export type DisposeCheck = {
|
||||
loadingSessions: boolean
|
||||
}
|
||||
|
||||
export type RootLoadArgs = {
|
||||
directory: string
|
||||
limit: number
|
||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
||||
}
|
||||
|
||||
export type RootLoadResult = {
|
||||
data?: Session[]
|
||||
limit: number
|
||||
limited: boolean
|
||||
}
|
||||
|
||||
export const MAX_DIR_STORES = 30
|
||||
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
||||
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
||||
|
||||
@@ -1,36 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
||||
import { directoryKey, normalizeAgentList } from "./utils"
|
||||
|
||||
const agent = (name = "build") =>
|
||||
({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: {},
|
||||
options: {},
|
||||
}) as Agent
|
||||
import type {
|
||||
AgentListOutput,
|
||||
ModelDefaultOutput,
|
||||
ModelListOutput,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("keeps array payloads", () => {
|
||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
test("adapts current agents to the app agent shape", () => {
|
||||
const result = normalizeAgentList([
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
color: "primary",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
|
||||
system: "Build software",
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
},
|
||||
] as AgentListOutput["data"])
|
||||
|
||||
test("wraps a single agent payload", () => {
|
||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "build",
|
||||
description: undefined,
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
temperature: 0.2,
|
||||
topP: 0.9,
|
||||
color: "primary",
|
||||
permission: [{ permission: "read", pattern: "*", action: "allow" }],
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "high",
|
||||
prompt: "Build software",
|
||||
options: { temperature: 0.2, topP: 0.9 },
|
||||
steps: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
test("extracts agents from keyed objects", () => {
|
||||
describe("normalizePermissionRequest", () => {
|
||||
test("adapts the current permission request to app state", () => {
|
||||
expect(
|
||||
normalizeAgentList({
|
||||
build: agent("build"),
|
||||
docs: agent("docs"),
|
||||
normalizePermissionRequest({
|
||||
id: "permission-1",
|
||||
sessionID: "session-1",
|
||||
action: "read",
|
||||
resources: ["README.md"],
|
||||
save: ["*.md"],
|
||||
metadata: { path: "README.md" },
|
||||
source: { type: "tool", messageID: "message-1", callID: "call-1" },
|
||||
}),
|
||||
).toEqual([agent("build"), agent("docs")])
|
||||
).toEqual({
|
||||
id: "permission-1",
|
||||
sessionID: "session-1",
|
||||
permission: "read",
|
||||
patterns: ["README.md"],
|
||||
always: ["*.md"],
|
||||
metadata: { path: "README.md" },
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("drops invalid payloads", () => {
|
||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
||||
describe("normalizeProviderList", () => {
|
||||
test("groups current models into the app provider catalog", () => {
|
||||
const result = normalizeProviderList(
|
||||
[{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
|
||||
[
|
||||
{
|
||||
id: "gpt-5",
|
||||
modelID: "gpt-5",
|
||||
providerID: "openai",
|
||||
name: "GPT-5",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
variants: [{ id: "high" }],
|
||||
time: { released: 1 },
|
||||
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
},
|
||||
{
|
||||
id: "gpt-old",
|
||||
modelID: "gpt-old",
|
||||
providerID: "openai",
|
||||
name: "GPT Old",
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "deprecated",
|
||||
enabled: true,
|
||||
limit: { context: 1, output: 1 },
|
||||
},
|
||||
] as ModelListOutput["data"],
|
||||
{ id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"],
|
||||
)
|
||||
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
expect(result.default).toEqual({ openai: "gpt-5" })
|
||||
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
|
||||
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
|
||||
id: "gpt-5",
|
||||
providerID: "openai",
|
||||
capabilities: { toolcall: true, attachment: true },
|
||||
cost: { input: 1, output: 2 },
|
||||
variants: { high: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,39 +1,152 @@
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
AgentListOutput,
|
||||
ModelDefaultOutput,
|
||||
ModelListOutput,
|
||||
PermissionV2Request,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function isAgent(input: unknown): input is Agent {
|
||||
if (!input || typeof input !== "object") return false
|
||||
const item = input as { name?: unknown; mode?: unknown }
|
||||
if (typeof item.name !== "string") return false
|
||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
|
||||
if (input.every((agent) => !("request" in agent))) return input as Agent[]
|
||||
return (input as AgentListOutput["data"]).map((agent) => ({
|
||||
name: agent.id,
|
||||
description: agent.description,
|
||||
mode: agent.mode,
|
||||
hidden: agent.hidden,
|
||||
temperature:
|
||||
typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined,
|
||||
topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined,
|
||||
color: agent.color,
|
||||
permission: agent.permissions.map((rule) => ({
|
||||
permission: rule.action,
|
||||
pattern: rule.resource,
|
||||
action: rule.effect,
|
||||
})),
|
||||
model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id },
|
||||
variant: agent.model?.variant,
|
||||
prompt: agent.system,
|
||||
options: agent.request.settings,
|
||||
steps: agent.steps,
|
||||
}))
|
||||
}
|
||||
|
||||
export function normalizeAgentList(input: unknown): Agent[] {
|
||||
if (Array.isArray(input)) return input.filter(isAgent)
|
||||
if (isAgent(input)) return [input]
|
||||
if (!input || typeof input !== "object") return []
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
||||
export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest {
|
||||
if ("permission" in input) return input
|
||||
return {
|
||||
...input,
|
||||
all: new Map(
|
||||
input.all.map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.id,
|
||||
{
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
},
|
||||
] as const,
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
permission: input.action,
|
||||
patterns: input.resources,
|
||||
always: input.save ?? [],
|
||||
metadata: input.metadata ?? {},
|
||||
tool:
|
||||
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProviderList(
|
||||
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||
models?: ModelListOutput["data"],
|
||||
defaultModel?: ModelDefaultOutput["data"],
|
||||
): NormalizedProviderListResponse {
|
||||
if (!Array.isArray(providers)) {
|
||||
return {
|
||||
...providers,
|
||||
all: new Map(
|
||||
providers.all.map((provider) => [
|
||||
provider.id,
|
||||
{
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}
|
||||
}
|
||||
const all = new Map<string, Provider>()
|
||||
|
||||
for (const provider of providers) {
|
||||
all.set(provider.id, {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: provider.settings ?? {},
|
||||
models: {},
|
||||
})
|
||||
}
|
||||
|
||||
for (const model of models ?? []) {
|
||||
const provider = all.get(model.providerID)
|
||||
if (!provider || model.status === "deprecated") continue
|
||||
const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0]
|
||||
provider.models[model.id] = {
|
||||
id: model.id,
|
||||
providerID: model.providerID,
|
||||
api: {
|
||||
id: model.modelID,
|
||||
url: "",
|
||||
npm: model.package ?? provider.id,
|
||||
},
|
||||
name: model.name,
|
||||
family: model.family,
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: false,
|
||||
attachment: model.capabilities.input.some((item) => item !== "text"),
|
||||
toolcall: model.capabilities.tools,
|
||||
input: {
|
||||
text: model.capabilities.input.includes("text"),
|
||||
audio: model.capabilities.input.includes("audio"),
|
||||
image: model.capabilities.input.includes("image"),
|
||||
video: model.capabilities.input.includes("video"),
|
||||
pdf: model.capabilities.input.includes("pdf"),
|
||||
},
|
||||
output: {
|
||||
text: model.capabilities.output.includes("text"),
|
||||
audio: model.capabilities.output.includes("audio"),
|
||||
image: model.capabilities.output.includes("image"),
|
||||
video: model.capabilities.output.includes("video"),
|
||||
pdf: model.capabilities.output.includes("pdf"),
|
||||
},
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: cost?.input ?? 0,
|
||||
output: cost?.output ?? 0,
|
||||
cache: {
|
||||
read: cost?.cache.read ?? 0,
|
||||
write: cost?.cache.write ?? 0,
|
||||
},
|
||||
},
|
||||
limit: model.limit,
|
||||
status: model.status,
|
||||
options: model.settings ?? {},
|
||||
headers: model.headers ?? {},
|
||||
release_date: new Date(model.time.released).toISOString().slice(0, 10),
|
||||
variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
all,
|
||||
connected: providers.map((provider) => provider.id),
|
||||
default: Object.fromEntries(
|
||||
providers.flatMap((provider) => {
|
||||
const model =
|
||||
defaultModel?.providerID === provider.id
|
||||
? defaultModel
|
||||
: models?.find((item) => item.providerID === provider.id && item.status !== "deprecated")
|
||||
return model ? [[provider.id, model.id]] : []
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -49,3 +162,10 @@ export function sanitizeProject(project: Project) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||
return {
|
||||
...project,
|
||||
vcs: project.vcs === "git" ? "git" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { createV2SessionReducer } from "./server-session-v2-reducer"
|
||||
|
||||
const event = (input: object) => input as OpenCodeEvent
|
||||
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
|
||||
|
||||
describe("v2 session reducer", () => {
|
||||
test("projects promoted input and streaming assistant content", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
const apply = (input: object) => {
|
||||
const result = reducer.reduce(messages, event(input))
|
||||
if (result) messages = result.messages
|
||||
return result
|
||||
}
|
||||
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "steer", data: { text: "hello" } },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_step",
|
||||
type: "session.step.started",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_start",
|
||||
type: "session.text.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_delta",
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_end",
|
||||
type: "session.text.ended",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" },
|
||||
})
|
||||
|
||||
expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" })
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("folds tool, retry, and completion events", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
const apply = (input: object) => {
|
||||
const result = reducer.reduce(messages, event(input))
|
||||
if (result) messages = result.messages
|
||||
}
|
||||
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_step",
|
||||
type: "session.step.started",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_start",
|
||||
type: "session.tool.input.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_delta",
|
||||
type: "session.tool.input.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_called",
|
||||
type: "session.tool.called",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_success",
|
||||
type: "session.tool.success",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
callID: "call_1",
|
||||
structured: {},
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_retry",
|
||||
type: "session.retry.scheduled",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
attempt: 2,
|
||||
at: 10,
|
||||
error: { type: "ProviderError", message: "retry" },
|
||||
},
|
||||
})
|
||||
apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } })
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: "assistant",
|
||||
retry: undefined,
|
||||
content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }],
|
||||
})
|
||||
})
|
||||
|
||||
test("requests hydration when promotion admission was missed", () => {
|
||||
const result = createV2SessionReducer().reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,502 @@
|
||||
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
|
||||
|
||||
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||
type Shell = Extract<SessionMessageInfo, { type: "shell" }>
|
||||
|
||||
export type V2SessionReduction = {
|
||||
sessionID: string
|
||||
messages: SessionMessageInfo[]
|
||||
touched: string[]
|
||||
missing?: string
|
||||
}
|
||||
|
||||
export function createV2SessionReducer() {
|
||||
const pending = new Map<string, SessionPendingMessage>()
|
||||
|
||||
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
|
||||
sessionID,
|
||||
messages,
|
||||
touched,
|
||||
})
|
||||
const append = (message: SessionMessageInfo) =>
|
||||
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
||||
|
||||
switch (event.type) {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
if (!input) return { ...result([...source]), missing: event.data.inputID }
|
||||
if (input.type === "user")
|
||||
return append({
|
||||
id: event.data.inputID,
|
||||
type: "user",
|
||||
metadata: input.data.metadata,
|
||||
text: input.data.text,
|
||||
files: input.data.files,
|
||||
agents: input.data.agents,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return append({
|
||||
id: event.data.inputID,
|
||||
type: "synthetic",
|
||||
metadata: input.data.metadata,
|
||||
text: input.data.text,
|
||||
description: input.data.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
}
|
||||
case "session.agent.selected":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.model.selected":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous: source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "synthetic",
|
||||
metadata: event.data.metadata,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.skill.activated":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "skill",
|
||||
metadata: event.metadata,
|
||||
skill: event.data.id,
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.started":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.ended":
|
||||
return updateMessage<Shell>(
|
||||
source,
|
||||
(item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id,
|
||||
(item) => ({
|
||||
...item,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
output: event.data.output,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
case "session.step.started": {
|
||||
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
|
||||
const completed =
|
||||
current && current.id !== event.data.assistantMessageID
|
||||
? update(source, current.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? { ...item, retry: undefined, time: { ...item.time, completed: event.created } }
|
||||
: item,
|
||||
)
|
||||
: [...source]
|
||||
const existing = completed.find((item) => item.id === event.data.assistantMessageID)
|
||||
if (existing?.type === "assistant")
|
||||
return result(
|
||||
update(completed, existing.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? {
|
||||
...item,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
retry: undefined,
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
|
||||
time: { ...item.time, completed: undefined },
|
||||
}
|
||||
: item,
|
||||
),
|
||||
current && current.id !== existing.id ? [current.id, existing.id] : [existing.id],
|
||||
)
|
||||
return result(
|
||||
[
|
||||
...completed,
|
||||
{
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID],
|
||||
)
|
||||
}
|
||||
case "session.step.ended":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
finish: event.data.finish,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.step.failed":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
finish: "error",
|
||||
error: event.data.error,
|
||||
retry: undefined,
|
||||
cost: event.data.cost ?? item.cost,
|
||||
tokens: event.data.tokens ?? item.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.text.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }),
|
||||
}))
|
||||
case "session.text.delta":
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}))
|
||||
case "session.text.ended":
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
}))
|
||||
case "session.reasoning.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: insertOrdinal(item.content, "reasoning", event.data.ordinal, {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
}))
|
||||
case "session.reasoning.delta":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}),
|
||||
)
|
||||
case "session.reasoning.ended":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
state: event.data.state ?? item.state,
|
||||
time: { created: item.time?.created ?? event.created, completed: event.created },
|
||||
}),
|
||||
)
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
|
||||
? item.content
|
||||
: [
|
||||
...item.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
}))
|
||||
case "session.tool.input.delta":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming"
|
||||
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.input.ended":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
|
||||
)
|
||||
case "session.tool.called":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
|
||||
...tool,
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||
time: { ...tool.time, ran: event.created },
|
||||
}))
|
||||
case "session.tool.progress":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "running"
|
||||
? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } }
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.success":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
executed: event.data.executed || tool.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: tool.state.input,
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { ...tool.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.tool.failed":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
executed: event.data.executed || tool.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "error",
|
||||
input: typeof tool.state.input === "string" ? {} : tool.state.input,
|
||||
structured: tool.state.status === "running" ? tool.state.structured : {},
|
||||
content: tool.state.status === "running" ? tool.state.content : [],
|
||||
error: event.data.error,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { ...tool.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.retry.scheduled":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
|
||||
}))
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted": {
|
||||
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
|
||||
if (!current?.retry) return result([...source])
|
||||
return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined }))
|
||||
}
|
||||
case "session.compaction.started":
|
||||
return append({
|
||||
id: event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.compaction.delta":
|
||||
return updateMessage<Extract<Compaction, { status: "running" }>>(
|
||||
source,
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
(item) => ({
|
||||
...item,
|
||||
summary: item.summary + event.data.text,
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
case "session.compaction.ended": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
if (!current)
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return result(
|
||||
update(source, current.id, () => ({
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})),
|
||||
[current.id],
|
||||
)
|
||||
}
|
||||
case "session.compaction.failed": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
const failed: Extract<Compaction, { status: "failed" }> = {
|
||||
id: current?.id ?? event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
metadata: current?.metadata ?? event.metadata,
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
time: current?.time ?? { created: event.created },
|
||||
}
|
||||
if (!current) return append(failed)
|
||||
return result(
|
||||
update(source, current.id, () => failed),
|
||||
[failed.id],
|
||||
)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reduce,
|
||||
clear(sessionID: string) {
|
||||
for (const id of pending.keys()) {
|
||||
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function key(sessionID: string, inputID: string) {
|
||||
return `${sessionID}:${inputID}`
|
||||
}
|
||||
|
||||
function messageID(eventID: string) {
|
||||
return eventID.replace(/^evt_/, "msg_")
|
||||
}
|
||||
|
||||
function update(
|
||||
source: readonly SessionMessageInfo[],
|
||||
id: string,
|
||||
apply: (item: SessionMessageInfo) => SessionMessageInfo,
|
||||
) {
|
||||
return source.map((item) => (item.id === id ? apply(item) : item))
|
||||
}
|
||||
|
||||
function updateMessage<T extends SessionMessageInfo>(
|
||||
source: readonly SessionMessageInfo[],
|
||||
matches: (item: SessionMessageInfo) => item is T,
|
||||
apply: (item: T) => T,
|
||||
sessionID: string,
|
||||
): V2SessionReduction {
|
||||
const current = source.findLast(matches)
|
||||
if (!current) return { sessionID, messages: [...source], touched: [] }
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)),
|
||||
touched: [current.id],
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssistant(
|
||||
source: readonly SessionMessageInfo[],
|
||||
id: string,
|
||||
sessionID: string,
|
||||
apply: (item: Assistant) => Assistant,
|
||||
): V2SessionReduction {
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)),
|
||||
touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function updateContent<T extends "text" | "reasoning">(
|
||||
source: readonly SessionMessageInfo[],
|
||||
messageID: string,
|
||||
sessionID: string,
|
||||
type: T,
|
||||
ordinal: number,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: T }>,
|
||||
) => Extract<Assistant["content"][number], { type: T }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => {
|
||||
let index = -1
|
||||
return {
|
||||
...assistant,
|
||||
content: assistant.content.map((item) => {
|
||||
if (item.type !== type || ++index !== ordinal) return item
|
||||
return apply(item as Extract<Assistant["content"][number], { type: T }>)
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateTool(
|
||||
source: readonly SessionMessageInfo[],
|
||||
messageID: string,
|
||||
callID: string,
|
||||
sessionID: string,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) => Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => ({
|
||||
...assistant,
|
||||
content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)),
|
||||
}))
|
||||
}
|
||||
|
||||
function insertOrdinal<T extends Assistant["content"][number]["type"]>(
|
||||
source: Assistant["content"],
|
||||
type: T,
|
||||
ordinal: number,
|
||||
item: Extract<Assistant["content"][number], { type: T }>,
|
||||
) {
|
||||
const matches = source.filter((content) => content.type === type)
|
||||
if (matches[ordinal]) return source
|
||||
return [...source, item]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
|
||||
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
@@ -158,6 +159,57 @@ function setup(sessions: Record<string, Session>) {
|
||||
}
|
||||
|
||||
describe("server session", () => {
|
||||
test("projects V2 session events into current and legacy message state", () => {
|
||||
const ctx = setup({ child: session("child") })
|
||||
ctx.store.remember(session("child"))
|
||||
ctx.store.set("session_message", "child", [
|
||||
{
|
||||
id: "msg_1_user",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
time: { created: 1 },
|
||||
},
|
||||
])
|
||||
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
|
||||
|
||||
apply({
|
||||
id: "evt_step",
|
||||
created: 2,
|
||||
type: "session.step.started",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
assistantMessageID: "msg_2_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
id: "evt_text_start",
|
||||
created: 3,
|
||||
type: "session.text.started",
|
||||
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 },
|
||||
})
|
||||
apply({
|
||||
id: "evt_text_delta",
|
||||
created: 4,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
|
||||
})
|
||||
|
||||
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
|
||||
id: "msg_2_assistant",
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "world" }],
|
||||
})
|
||||
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
|
||||
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
|
||||
})
|
||||
|
||||
test("resolves lineage by session ID without directory", async () => {
|
||||
const ctx = setup({ child: session("child", "root"), root: session("root") })
|
||||
|
||||
@@ -178,6 +230,111 @@ describe("server session", () => {
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
test("loads current session content through the current message API", async () => {
|
||||
const requests: unknown[] = []
|
||||
const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
|
||||
const assistant = {
|
||||
id: "msg_a_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
}
|
||||
const client = {
|
||||
session: {
|
||||
messages: () => {
|
||||
throw new Error("legacy message endpoint called")
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const messageApi = {
|
||||
list: async (input: unknown) => {
|
||||
requests.push(input)
|
||||
return { data: [assistant, user], cursor: { previous: null, next: null } }
|
||||
},
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession(client, {} as SessionApi, messageApi)
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
test("reprojects current assistants when an older page supplies their user", async () => {
|
||||
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
|
||||
const assistant = (id: string, created: number) => ({
|
||||
id,
|
||||
type: "assistant" as const,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text" as const, text: id }],
|
||||
time: { created, completed: created },
|
||||
})
|
||||
const assistants = [
|
||||
assistant("msg_2_assistant", 2),
|
||||
assistant("msg_3_assistant", 3),
|
||||
assistant("msg_4_assistant", 4),
|
||||
]
|
||||
const pages = [
|
||||
{ data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
|
||||
{ data: [assistants[0], user], cursor: { previous: null, next: null } },
|
||||
]
|
||||
const messageApi = {
|
||||
list: async () => pages.shift()!,
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
expect(store.data.message.root).toEqual([])
|
||||
|
||||
await store.history.loadMore("root")
|
||||
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
||||
user.id,
|
||||
...assistants.map((item) => item.id),
|
||||
])
|
||||
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
||||
})
|
||||
|
||||
test("indexes V1 messages for the current timeline projection", async () => {
|
||||
const user = userMessage("message-1", { sessionID: "root" })
|
||||
const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
|
||||
const client = messageClient(
|
||||
response([
|
||||
{ info: user, parts: [textPart(user.id, { sessionID: "root" })] },
|
||||
{ info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
|
||||
]),
|
||||
)
|
||||
const messageApi = {
|
||||
list: () => {
|
||||
throw new Error("current message endpoint called")
|
||||
},
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession(client, {} as SessionApi, messageApi, {
|
||||
protocol: Promise.resolve("v1"),
|
||||
})
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
expect(store.data.session_message.root).toMatchObject([
|
||||
{ id: user.id, type: "user", text: "text" },
|
||||
{ id: assistant.id, type: "assistant" },
|
||||
])
|
||||
|
||||
const next = userMessage("message-3", { sessionID: "root" })
|
||||
store.apply({ type: "message.updated", properties: { info: next } })
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
Message,
|
||||
OpencodeClient,
|
||||
@@ -8,15 +9,18 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||
import { message as cleanMessage } from "@/utils/diffs"
|
||||
import { sessionNotFoundError } from "@/utils/server-errors"
|
||||
import { rootSession } from "@/utils/session-route"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
@@ -36,10 +40,37 @@ type OptimisticItem = {
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
source?: SessionMessageInfo[]
|
||||
sourceMode?: "latest" | "older"
|
||||
projectSource?: boolean
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
|
||||
return items
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.info.id, b.info.id))
|
||||
.map((item) => {
|
||||
if (item.info.role === "user") {
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "user" as const,
|
||||
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
time: item.info.time,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "assistant" as const,
|
||||
agent: item.info.agent ?? item.info.mode,
|
||||
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||
content: [],
|
||||
time: item.info.time,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||
type MessageLoadState = {
|
||||
touchedMessages: Set<string>
|
||||
@@ -52,6 +83,7 @@ type MessageLoadState = {
|
||||
optimisticParts: Map<string, Set<string>>
|
||||
orphanParents: Set<string>
|
||||
clearedMessageParts: Set<string>
|
||||
touchedSource: Set<string>
|
||||
}
|
||||
|
||||
type MessageLoadBaseline = Pick<
|
||||
@@ -137,15 +169,25 @@ function reconcileFetched<T extends { id: string }>(
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
||||
|
||||
export function createServerSession(
|
||||
client: OpencodeClient,
|
||||
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
|
||||
messageApi?: MessageApi,
|
||||
currentOptions?: ServerSessionOptions,
|
||||
) {
|
||||
const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
|
||||
const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
session_diff: {} as Record<string, SnapshotFileDiff[]>,
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
session_message: {} as Record<string, SessionMessageInfo[]>,
|
||||
part: {} as Record<string, Part[]>,
|
||||
part_text_accum_delta: {} as Record<string, string>,
|
||||
session_working(id: string) {
|
||||
@@ -154,9 +196,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
})
|
||||
const requests = new Map<string, Promise<Session>>()
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const v2 = createV2SessionReducer()
|
||||
const messageLoads = new Map<string, MessageLoadState>()
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
const orphanParts = new Map<string, Set<string>>()
|
||||
@@ -191,6 +233,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
at: {} as Record<string, number | undefined>,
|
||||
})
|
||||
|
||||
const indexLegacyMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
|
||||
)
|
||||
}
|
||||
|
||||
const remember = (session: Session) => {
|
||||
setData("info", session.id, reconcile(session))
|
||||
infoSeen.delete(session.id)
|
||||
@@ -200,7 +252,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
...pinned.keys(),
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
@@ -242,27 +293,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const pending = requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
if (generations.get(sessionID) !== active) return result.data
|
||||
return remember(result.data)
|
||||
const request = sessionApi
|
||||
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
||||
: client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
return result.data
|
||||
})
|
||||
const resolved = request.then((result) => {
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
})
|
||||
requests.set(sessionID, request)
|
||||
requests.set(sessionID, resolved)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||
if (
|
||||
generations.get(sessionID) === active &&
|
||||
!data.info[sessionID] &&
|
||||
!requests.has(sessionID) &&
|
||||
!messageLoads.has(sessionID) &&
|
||||
!inflight.has(sessionID) &&
|
||||
!inflightDiff.has(sessionID) &&
|
||||
!inflightTodo.has(sessionID)
|
||||
)
|
||||
generations.delete(sessionID)
|
||||
}
|
||||
void request.then(cleanup, cleanup)
|
||||
return request
|
||||
void resolved.then(cleanup, cleanup)
|
||||
return resolved
|
||||
}
|
||||
|
||||
const peekLineage = (sessionID: string) => {
|
||||
@@ -419,9 +474,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
clearOptimistic(sessionID)
|
||||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightDiff.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
messageLoads.delete(sessionID)
|
||||
v2.clear(sessionID)
|
||||
pendingParts.delete(sessionID)
|
||||
orphanParts.delete(sessionID)
|
||||
removedMessages.delete(sessionID)
|
||||
@@ -449,7 +504,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
...pinned.keys(),
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
@@ -470,6 +524,25 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
if (messageApi && (await options?.protocol) !== "v1") {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" })
|
||||
})
|
||||
const source = [...response.data].reverse()
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
source,
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
projectSource: true,
|
||||
cursor: response.cursor.next ?? undefined,
|
||||
complete: response.data.length === 0,
|
||||
}
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
@@ -481,12 +554,24 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
id: item.info.id,
|
||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
})),
|
||||
source: legacyMessageSource(items),
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
|
||||
complete: !response.response.headers.get("x-next-cursor"),
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
||||
if (sessionApi && (await options?.protocol) !== "v1") {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return sessionApi.message({ sessionID, messageID })
|
||||
})
|
||||
const normalized = normalizeSessionMessages(sessionID, [response])
|
||||
const message = normalized.messages[0]
|
||||
if (!message) throw new Error(`Message not found: ${messageID}`)
|
||||
return { message, parts: normalized.parts.get(messageID) ?? [] }
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.message({ sessionID, messageID })
|
||||
@@ -571,7 +656,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||
cleanupOrphans: boolean,
|
||||
) => {
|
||||
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
const source = page.source
|
||||
? (() => {
|
||||
const incoming = new Map(page.source.map((message) => [message.id, message]))
|
||||
const existing = data.session_message[sessionID] ?? []
|
||||
const current = existing.filter((message) => !incoming.has(message.id))
|
||||
const live = new Map(existing.map((message) => [message.id, message]))
|
||||
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
|
||||
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
|
||||
)
|
||||
})()
|
||||
: undefined
|
||||
const projected =
|
||||
page.projectSource && source
|
||||
? (() => {
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
...page,
|
||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
}
|
||||
})()
|
||||
: page
|
||||
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
merged.observed.forEach((item) => {
|
||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||
})
|
||||
@@ -583,6 +692,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
preserveUnfetched,
|
||||
})
|
||||
batch(() => {
|
||||
if (source) setData("session_message", sessionID, reconcile(source))
|
||||
const messageIDs = replaceMessages(sessionID, messages)
|
||||
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||
const orphans = orphanParts.get(sessionID)
|
||||
@@ -613,6 +723,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
optimisticParts: new Map(),
|
||||
orphanParents: new Set(),
|
||||
clearedMessageParts: new Set(),
|
||||
touchedSource: new Set(),
|
||||
}
|
||||
messageLoads.set(sessionID, load)
|
||||
setMeta("loading", sessionID, true)
|
||||
@@ -747,6 +858,109 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return properties.part.sessionID
|
||||
}
|
||||
|
||||
const projectV2 = (reduction: V2SessionReduction) => {
|
||||
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
|
||||
setData("session_message", reduction.sessionID, reconcile(reduction.messages))
|
||||
if (reduction.touched.length === 0) return
|
||||
|
||||
const touched = new Set(reduction.touched)
|
||||
let parentID: string | undefined
|
||||
for (const message of reduction.messages) {
|
||||
if (message.type === "user" || (message.type === "synthetic" && message.description?.trim()))
|
||||
parentID = message.id
|
||||
if (message.type === "shell") {
|
||||
if (touched.has(message.id)) touched.add(`${message.id}:assistant`)
|
||||
parentID = undefined
|
||||
}
|
||||
if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID)
|
||||
if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID)
|
||||
}
|
||||
|
||||
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
|
||||
batch(() => {
|
||||
for (const message of normalized.messages) {
|
||||
if (!touched.has(message.id)) continue
|
||||
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
||||
}
|
||||
for (const messageID of touched) {
|
||||
const next = normalized.parts.get(messageID) ?? []
|
||||
const nextIDs = new Set(next.map((part) => part.id))
|
||||
for (const part of next) {
|
||||
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
|
||||
}
|
||||
for (const part of data.part[messageID] ?? []) {
|
||||
if (nextIDs.has(part.id)) continue
|
||||
apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: reduction.sessionID, messageID, partID: part.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hydrateV2Message = (sessionID: string, messageID: string) => {
|
||||
if (!sessionApi) return
|
||||
void sessionApi
|
||||
.message({ sessionID, messageID })
|
||||
.then((message) => {
|
||||
const current = data.session_message[sessionID] ?? []
|
||||
const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
|
||||
projectV2({ sessionID, messages, touched: [message.id] })
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const applyV2 = (event: OpenCodeEvent) => {
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
|
||||
if (reduction) {
|
||||
projectV2(reduction)
|
||||
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
|
||||
}
|
||||
|
||||
const info = data.info[sessionID]
|
||||
if (event.type === "session.renamed" && info)
|
||||
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
|
||||
if (event.type === "session.moved" && info)
|
||||
remember({
|
||||
...info,
|
||||
projectID: event.data.projectID ?? info.projectID,
|
||||
workspaceID: event.data.location.workspaceID,
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subpath,
|
||||
time: { ...info.time, updated: event.created },
|
||||
})
|
||||
if (event.type === "session.usage.updated" && info)
|
||||
remember({ ...info, cost: event.data.cost, tokens: event.data.tokens })
|
||||
if (event.type === "session.archived") {
|
||||
if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
|
||||
evict([sessionID])
|
||||
}
|
||||
if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" })
|
||||
if (
|
||||
event.type === "session.execution.succeeded" ||
|
||||
event.type === "session.execution.failed" ||
|
||||
event.type === "session.execution.interrupted"
|
||||
)
|
||||
setData("session_status", sessionID, { type: "idle" })
|
||||
if (event.type === "session.retry.scheduled")
|
||||
setData("session_status", sessionID, {
|
||||
type: "retry",
|
||||
attempt: event.data.attempt,
|
||||
message: event.data.error.message,
|
||||
next: event.data.at,
|
||||
})
|
||||
if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
|
||||
if (
|
||||
event.type === "session.revert.staged" ||
|
||||
event.type === "session.revert.cleared" ||
|
||||
event.type === "session.revert.committed"
|
||||
)
|
||||
void resolve(sessionID, { force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
const apply = (event: { type: string; properties?: unknown }) => {
|
||||
const eventID = eventSessionID(event)
|
||||
if (eventID) {
|
||||
@@ -770,7 +984,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "session.deleted": {
|
||||
const sessionID = (event.properties as { info: Session }).info.id
|
||||
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||
const sessionID = properties.info?.id ?? properties.sessionID
|
||||
if (!sessionID) return
|
||||
infoSeen.delete(sessionID)
|
||||
setData(
|
||||
"info",
|
||||
@@ -779,11 +995,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
evict([sessionID])
|
||||
return
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||
return
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
@@ -796,6 +1007,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||
indexLegacyMessage(info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
@@ -828,6 +1040,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
}
|
||||
case "message.removed": {
|
||||
const props = event.properties as { sessionID: string; messageID: string }
|
||||
setData("session_message", props.sessionID, (messages) =>
|
||||
messages?.filter((message) => message.id !== props.messageID),
|
||||
)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
load?.touchedMessages.add(props.messageID)
|
||||
load?.removedMessages.add(props.messageID)
|
||||
@@ -1140,23 +1355,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
},
|
||||
diff(sessionID: string, options?: { force?: boolean }) {
|
||||
async todo(sessionID: string, request?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightDiff, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||
})
|
||||
})
|
||||
},
|
||||
todo(sessionID: string, options?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
if (data.todo[sessionID] !== undefined && !request?.force) return
|
||||
if ((await options?.protocol) === "v2") {
|
||||
setData("todo", sessionID, [])
|
||||
return
|
||||
}
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
})
|
||||
@@ -1190,6 +1398,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
if (count && count > 1) pinned.set(sessionID, count - 1)
|
||||
},
|
||||
apply,
|
||||
applyV2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,103 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
McpApi,
|
||||
McpListInput,
|
||||
McpResourceCatalogInput,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
describe("MCP queries", () => {
|
||||
test("loads current servers for the requested location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpQuery(ServerScope.local, "/project", {
|
||||
list: async (input: McpListInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: [
|
||||
{ name: "docs", status: { status: "connected" } },
|
||||
{ name: "search", status: { status: "pending" } },
|
||||
],
|
||||
}
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
|
||||
})
|
||||
|
||||
test("loads and keys the current resource catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(ServerScope.local, "/project", {
|
||||
resource: {
|
||||
catalog: async (input: McpResourceCatalogInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: {
|
||||
resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
|
||||
templates: [],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("active session query", () => {
|
||||
test("loads active sessions once per server cache", async () => {
|
||||
let calls = 0
|
||||
const queryClient = new QueryClient()
|
||||
const options = loadActiveSessionsQuery(ServerScope.local, {
|
||||
active: async () => {
|
||||
calls++
|
||||
return { ses_running: { type: "running" } }
|
||||
},
|
||||
})
|
||||
|
||||
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
|
||||
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
|
||||
expect(calls).toBe(1)
|
||||
expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"])
|
||||
})
|
||||
|
||||
test("does not overwrite statuses already written by events", () => {
|
||||
const session = createServerSession({} as OpencodeClient)
|
||||
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
|
||||
|
||||
seedActiveSessionStatuses(session, {
|
||||
ses_running: { type: "running" },
|
||||
ses_retry: { type: "running" },
|
||||
})
|
||||
|
||||
expect(session.data.session_status.ses_running).toEqual({ type: "busy" })
|
||||
expect(session.data.session_status.ses_retry).toEqual({
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "retrying",
|
||||
next: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
@@ -23,46 +120,57 @@ describe("pickDirectoriesToEvict", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadRootSessionsWithFallback", () => {
|
||||
test("uses limited roots query when supported", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
describe("loadRootSessions", () => {
|
||||
test("loads and normalizes a limited page of root sessions", async () => {
|
||||
const calls: SessionListInput[] = []
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
const result = await loadRootSessions({
|
||||
api: {
|
||||
list: async (query = {}) => {
|
||||
calls.push(query)
|
||||
return { data: [sessionInfo("session-1")], cursor: {} }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">,
|
||||
directory: "dir",
|
||||
limit: 10,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
return { data: [] }
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.data).toEqual([
|
||||
expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
|
||||
])
|
||||
expect(result.limited).toBe(true)
|
||||
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
|
||||
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
|
||||
})
|
||||
|
||||
test("falls back to full roots query on limited-query failure", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
limit: 25,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
if (query.limit) throw new Error("unsupported")
|
||||
return { data: [] }
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.limited).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
{ directory: "dir", roots: true, limit: 25 },
|
||||
{ directory: "dir", roots: true },
|
||||
])
|
||||
test("propagates list failures", () => {
|
||||
expect(
|
||||
loadRootSessions({
|
||||
api: {
|
||||
list: async () => {
|
||||
throw new Error("failed")
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">,
|
||||
directory: "dir",
|
||||
limit: 25,
|
||||
}),
|
||||
).rejects.toThrow("failed")
|
||||
})
|
||||
})
|
||||
|
||||
function sessionInfo(id: string) {
|
||||
return {
|
||||
id,
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: id,
|
||||
location: { directory: "dir" },
|
||||
} as SessionInfo
|
||||
}
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
Config,
|
||||
McpResource,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
bootstrapGlobal,
|
||||
clearProviderRev,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadGlobalConfigQuery,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
@@ -26,12 +27,13 @@ import {
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import type { SolidQueryOptions } from "@tanstack/solid-query"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { PathKey } from "@/utils/path-key"
|
||||
@@ -45,8 +47,18 @@ import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpListOutput,
|
||||
McpResource,
|
||||
McpResourceCatalogInput,
|
||||
McpResourceCatalogOutput,
|
||||
McpServer,
|
||||
SessionActiveOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession } from "./server-session"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -59,16 +71,76 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions({
|
||||
type McpListApi = {
|
||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||
}
|
||||
|
||||
type McpResourceApi = {
|
||||
readonly resource: {
|
||||
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
|
||||
}
|
||||
}
|
||||
|
||||
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
||||
initialData?: undefined
|
||||
queryKey: K
|
||||
}
|
||||
|
||||
type SessionActiveApi = {
|
||||
readonly active: () => Promise<SessionActiveOutput>
|
||||
}
|
||||
|
||||
export const loadMcpQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<"v1" | "v2">,
|
||||
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpServer["status"]>,
|
||||
Error,
|
||||
Record<string, McpServer["status"]>,
|
||||
readonly [ServerScope, string, "mcp"]
|
||||
>({
|
||||
queryKey: [scope, directory, "mcp"] as const,
|
||||
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
||||
queryFn: async () => {
|
||||
if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {}
|
||||
return api
|
||||
.list({ location: { directory } })
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
},
|
||||
})
|
||||
|
||||
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions<Record<string, McpResource>>({
|
||||
export const loadMcpResourcesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpResourceApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<"v1" | "v2">,
|
||||
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpResource>,
|
||||
Error,
|
||||
Record<string, McpResource>,
|
||||
readonly [ServerScope, string, "mcpResources"]
|
||||
>({
|
||||
queryKey: [scope, directory, "mcpResources"] as const,
|
||||
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
|
||||
queryFn: async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
return Object.fromEntries(
|
||||
Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [
|
||||
key,
|
||||
{ ...resource, server: resource.client },
|
||||
]),
|
||||
)
|
||||
}
|
||||
return api.resource
|
||||
.catalog({ location: { directory } })
|
||||
.then((result) =>
|
||||
Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
||||
)
|
||||
},
|
||||
placeholderData: {},
|
||||
})
|
||||
|
||||
@@ -78,22 +150,51 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod
|
||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||
})
|
||||
|
||||
export const loadActiveSessionsQuery = (
|
||||
scope: ServerScope,
|
||||
api: SessionActiveApi,
|
||||
): ApiQueryOptions<SessionActiveOutput, readonly [ServerScope, "activeSessions"]> =>
|
||||
queryOptions<SessionActiveOutput, Error, SessionActiveOutput, readonly [ServerScope, "activeSessions"]>({
|
||||
queryKey: [scope, "activeSessions"] as const,
|
||||
queryFn: () => api.active(),
|
||||
enabled: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
export function seedActiveSessionStatuses(
|
||||
session: Pick<ServerSession, "data" | "set">,
|
||||
active: SessionActiveOutput | Record<string, SessionStatus>,
|
||||
) {
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
if (session.data.session_status[sessionID] !== undefined) continue
|
||||
const status = active[sessionID]
|
||||
session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status)
|
||||
}
|
||||
}
|
||||
|
||||
function makeQueryOptionsApi(
|
||||
scope: ServerScope,
|
||||
serverSDK: () => OpencodeClient,
|
||||
serverAPI: ServerApi,
|
||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
||||
protocol: Promise<"v1" | "v2">,
|
||||
) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||
projects: () => loadProjectsQuery(scope, serverSDK()),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
path: (directory: PathKey | null) =>
|
||||
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
|
||||
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
|
||||
references: (directory: PathKey) =>
|
||||
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
|
||||
mcpResources: (directory: PathKey) =>
|
||||
loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
@@ -122,11 +223,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return sdk
|
||||
}
|
||||
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
|
||||
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
||||
protocol: serverSDK.protocol,
|
||||
})
|
||||
const queryOptionsApi = makeQueryOptionsApi(
|
||||
serverSDK.scope,
|
||||
() => serverSDK.client,
|
||||
serverSDK.api,
|
||||
sdkFor,
|
||||
serverSDK.protocol,
|
||||
)
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||
}))
|
||||
const activeSessionsQuery = useQuery(() =>
|
||||
loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
session.set("session_status", sessionID, reconcile(status))
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([sessionID, status]) =>
|
||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||
),
|
||||
)
|
||||
}
|
||||
const active = await serverSDK.api.session.active()
|
||||
seedActiveSessionStatuses(session, active)
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return active
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||
get ready() {
|
||||
@@ -183,6 +317,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverSDK: serverSDK.client,
|
||||
serverAPI: serverSDK.api,
|
||||
protocol: serverSDK.protocol,
|
||||
scope: serverSDK.scope,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
@@ -212,8 +348,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
bootstrapInstance,
|
||||
})
|
||||
|
||||
const session = createServerSession(serverSDK.client)
|
||||
|
||||
const children = createChildStoreManager({
|
||||
owner,
|
||||
scope: serverSDK.scope,
|
||||
@@ -224,17 +358,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void retry(() =>
|
||||
sdkFor(directory)
|
||||
.command.list()
|
||||
.then((x) => setStore("command", x.data ?? [])),
|
||||
).catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
onDispose: (directory) => {
|
||||
const key = directoryKey(directory)
|
||||
@@ -279,11 +411,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
.fetchQuery({
|
||||
...queryOptionsApi.sessions(key),
|
||||
queryFn: () =>
|
||||
loadRootSessionsWithFallback({
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => serverSDK.client.session.list(query),
|
||||
})
|
||||
serverSDK.protocol
|
||||
.then((protocol) =>
|
||||
protocol === "v1"
|
||||
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
|
||||
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
|
||||
)
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
@@ -353,6 +486,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
api: serverSDK.api,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
vcsCache: cache,
|
||||
@@ -360,6 +494,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
translate: language.t,
|
||||
queryClient,
|
||||
session,
|
||||
protocol: serverSDK.protocol,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -371,12 +506,31 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return promise
|
||||
}
|
||||
|
||||
const indexSession = (info: Parameters<typeof session.remember>[0]) => {
|
||||
const key = directoryKey(info.directory)
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info } },
|
||||
directory: key,
|
||||
store: existing[0],
|
||||
setStore: existing[1],
|
||||
push: queue.push,
|
||||
retainedLimit: sessionMeta.get(key)?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
loadLsp() {},
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||
homeSessions.apply(event)
|
||||
@@ -384,6 +538,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
homeSessions.refresh(event.type)
|
||||
|
||||
if (directory === "global") {
|
||||
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
||||
void activeSessionsQuery.refetch()
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
@@ -393,7 +549,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (
|
||||
eventType === "config.updated" ||
|
||||
eventType === "catalog.updated" ||
|
||||
eventType === "agent.updated" ||
|
||||
eventType === "project.directories.updated"
|
||||
)
|
||||
bootstrap.refetch()
|
||||
if (eventType === "server.connected" || eventType === "global.disposed") {
|
||||
if (recent) return
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
if (!children.active(directory)) continue
|
||||
@@ -403,9 +566,30 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.current?.type === "session.moved") {
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) indexSession(info)
|
||||
}
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
.then(indexSession)
|
||||
.catch(() => {})
|
||||
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
children.mark(key)
|
||||
if (
|
||||
event.current?.type === "session.moved" ||
|
||||
event.current?.type === "session.archived" ||
|
||||
event.current?.type === "session.forked" ||
|
||||
eventType === "command.updated" ||
|
||||
eventType === "config.updated" ||
|
||||
eventType === "agent.updated"
|
||||
)
|
||||
queue.push(key)
|
||||
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
||||
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
||||
const [store, setStore] = existing
|
||||
applyDirectoryEvent({
|
||||
event,
|
||||
@@ -502,14 +686,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const sdk = sdkFor(key)
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
|
||||
if (!status) return
|
||||
await toggleMcp({
|
||||
status,
|
||||
connect: async () => {
|
||||
await sdk.mcp.connect({ name })
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.connect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
|
||||
},
|
||||
disconnect: async () => {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
|
||||
},
|
||||
authenticate: async () => {
|
||||
await sdk.mcp.auth.authenticate({ name })
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
function history(): TabHistory {
|
||||
return { stack: [], index: -1 }
|
||||
}
|
||||
|
||||
describe("tab history", () => {
|
||||
test("moves backward and forward through selected tabs", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const available = new Set(selected.stack)
|
||||
|
||||
const previous = previousTab(selected, available)
|
||||
expect(previous?.key).toBe("b")
|
||||
|
||||
const first = previousTab(previous!.state, available)
|
||||
expect(first?.key).toBe("a")
|
||||
|
||||
const next = nextTab(first!.state, available)
|
||||
expect(next?.key).toBe("b")
|
||||
})
|
||||
|
||||
test("replaces forward history after a new selection", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const previous = previousTab(selected, new Set(selected.stack))
|
||||
const next = rememberTab(previous!.state, "d")
|
||||
|
||||
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
|
||||
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("skips tabs that are no longer open", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
|
||||
})
|
||||
|
||||
test("skips a repeated current tab after closing the previous selection", () => {
|
||||
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
const MAX_TAB_HISTORY = 100
|
||||
|
||||
export type TabHistory = {
|
||||
stack: string[]
|
||||
index: number
|
||||
}
|
||||
|
||||
export function rememberTab(state: TabHistory, key: string): TabHistory {
|
||||
if (state.stack[state.index] === key) return state
|
||||
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
|
||||
return { stack, index: stack.length - 1 }
|
||||
}
|
||||
|
||||
export function previousTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, -1, available)
|
||||
}
|
||||
|
||||
export function nextTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, 1, available)
|
||||
}
|
||||
|
||||
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
|
||||
const current = state.stack[state.index]
|
||||
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
|
||||
const key = state.stack[index]
|
||||
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { sessionHref } from "@/utils/session-route"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -79,6 +80,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const memory = createTabMemory(getOwner())
|
||||
|
||||
const closing = new Set<string>()
|
||||
let history: TabHistory = { stack: [], index: -1 }
|
||||
let recentWrite = 0
|
||||
let recentValue: string | undefined
|
||||
|
||||
@@ -150,10 +152,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
history = rememberTab(history, tabKey(tab))
|
||||
setRecentKey(tabKey(tab))
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const moveHistory = (direction: "previous" | "next") => {
|
||||
const available = new Set(store.map(tabKey))
|
||||
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
|
||||
if (!result) return
|
||||
const tab = store.find((item) => tabKey(item) === result.key)
|
||||
if (!tab) return
|
||||
history = result.state
|
||||
navigateTab(tab)
|
||||
}
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
@@ -359,8 +372,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
select: navigateTab,
|
||||
remember(tab: Tab) {
|
||||
const key = tabKey(tab)
|
||||
history = rememberTab(history, key)
|
||||
if (recentKey() !== key) setRecentKey(key)
|
||||
},
|
||||
previous: () => moveHistory("previous"),
|
||||
next: () => moveHistory("next"),
|
||||
toggleHome(input: { home: boolean; current?: Tab }) {
|
||||
if (input.home) {
|
||||
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
||||
|
||||
@@ -2,6 +2,17 @@ import { describe, expect, test } from "bun:test"
|
||||
import { DESKTOP_MENU } from "./desktop-menu"
|
||||
|
||||
describe("desktop menu", () => {
|
||||
test("navigates between tabs", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && (item.label === "Previous Tab" || item.label === "Next Tab"),
|
||||
)
|
||||
|
||||
expect(items).toEqual([
|
||||
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("exports logs through the desktop command registry", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && item.label === "Export Logs...",
|
||||
|
||||
@@ -168,8 +168,8 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
||||
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
|
||||
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
|
||||
@@ -263,7 +263,6 @@ function ProviderTip(props: { ready: () => boolean; connected: () => boolean; op
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -532,7 +532,6 @@ export default function Page() {
|
||||
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||
const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
|
||||
const canReview = createMemo(() => !!sync().project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
@@ -690,8 +689,8 @@ export default function Page() {
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk()
|
||||
.client.vcs.diff({ mode })
|
||||
.then((result) => list(result.data))
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
return []
|
||||
@@ -738,8 +737,12 @@ export default function Page() {
|
||||
retry: 2,
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.client.vcs.diff({ mode, directory: scope, context })
|
||||
.then((result) => result.data ?? []),
|
||||
.api.vcs.diff({
|
||||
location: { directory: scope },
|
||||
mode: mode === "git" ? "working" : mode,
|
||||
context,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
})
|
||||
.then((diffs) => diffs.find((diff) => diff.file === file))
|
||||
|
||||
@@ -946,10 +949,11 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const details = evt.details as { type: string; properties?: unknown }
|
||||
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
typeof details.properties === "object" && details.properties
|
||||
? (details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
@@ -1464,44 +1468,6 @@ export default function Page() {
|
||||
requestAnimationFrame(() => attempt(0))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
|
||||
if (!wantsReview()) return
|
||||
if (sync().data.session_diff[id] !== undefined) return
|
||||
if (sync().status === "loading") return
|
||||
|
||||
void sync().session.diff(id)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sessionKey(), wantsReview()] as const,
|
||||
([key, wants]) => {
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
diffFrame = undefined
|
||||
diffTimer = undefined
|
||||
if (!wants) return
|
||||
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
|
||||
|
||||
diffFrame = requestAnimationFrame(() => {
|
||||
diffFrame = undefined
|
||||
diffTimer = window.setTimeout(() => {
|
||||
diffTimer = undefined
|
||||
if (sessionKey() !== key) return
|
||||
void sync().session.diff(id, { force: true })
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
let treeDir: string | undefined
|
||||
createEffect(() => {
|
||||
const dir = sdk().directory
|
||||
@@ -1757,7 +1723,7 @@ export default function Page() {
|
||||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
client: sdk().client,
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft: item,
|
||||
@@ -1853,13 +1819,13 @@ export default function Page() {
|
||||
const halt = (sessionID: string) =>
|
||||
busy(sessionID)
|
||||
? sdk()
|
||||
.client.session.abort({ sessionID })
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
: Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const client = sdk().client
|
||||
const session = sdk().api.session
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
const value = draft(input.messageID)
|
||||
@@ -1869,10 +1835,8 @@ export default function Page() {
|
||||
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||
prompt.set(value)
|
||||
},
|
||||
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
|
||||
complete: () => undefined,
|
||||
rollback: () => roll(input.sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
@@ -1884,7 +1848,7 @@ export default function Page() {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const client = sdk().client
|
||||
const session = sdk().api.session
|
||||
const target = sync()
|
||||
const next = userMessages().find((item) => item.id > id)
|
||||
const last = target.session.get(sessionID)?.revert
|
||||
@@ -1901,11 +1865,9 @@ export default function Page() {
|
||||
},
|
||||
request: () =>
|
||||
!next
|
||||
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
? halt(sessionID).then(() => session.revert.clear({ sessionID }))
|
||||
: halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
|
||||
complete: () => undefined,
|
||||
rollback: () => roll(sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createApiForServer, createSdkForServer } from "./server"
|
||||
import { createCompatibleApi } from "./server-compat"
|
||||
|
||||
function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
|
||||
function setup(
|
||||
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
||||
responses?: { vcs?: { branch: string; default_branch: string } },
|
||||
) {
|
||||
const requests: Request[] = []
|
||||
const fetcher = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
@@ -32,6 +35,8 @@ function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
|
||||
delivery: "steer",
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
|
||||
return Response.json(responses?.vcs ?? {})
|
||||
if (request.method === "GET") return Response.json([])
|
||||
return new Response(undefined, { status: 204 })
|
||||
},
|
||||
@@ -110,4 +115,12 @@ describe("createCompatibleApi", () => {
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
||||
})
|
||||
|
||||
test("projects the V1 default branch", async () => {
|
||||
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
|
||||
|
||||
expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({
|
||||
data: { branch: "feature", defaultBranch: "dev" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -318,7 +318,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
...input.current.vcs,
|
||||
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
||||
const result = await legacy(value?.location).vcs.get()
|
||||
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
|
||||
return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
|
||||
},
|
||||
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
||||
const result = await legacy(value?.location).vcs.status()
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionMessages } from "./session-message"
|
||||
|
||||
describe("normalizeSessionMessages", () => {
|
||||
test("projects current turns into stable legacy rendering records", () => {
|
||||
const source = [
|
||||
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "model-switched",
|
||||
model: { id: "claude", providerID: "anthropic", variant: "high" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
{
|
||||
id: "msg_3",
|
||||
type: "user",
|
||||
text: "inspect this",
|
||||
files: [
|
||||
{
|
||||
data: "aGVsbG8=",
|
||||
mime: "text/plain",
|
||||
name: "note.txt",
|
||||
source: { type: "inline" },
|
||||
},
|
||||
],
|
||||
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_4",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude", providerID: "anthropic", variant: "high" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } },
|
||||
{ type: "text", text: "Result" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "note.txt" },
|
||||
structured: { title: "note.txt" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
time: { created: 5, ran: 6, completed: 7 },
|
||||
},
|
||||
],
|
||||
cost: 0.1,
|
||||
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
|
||||
time: { created: 4, completed: 7 },
|
||||
},
|
||||
{
|
||||
id: "msg_5",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "summary",
|
||||
recent: "recent",
|
||||
time: { created: 8 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.messages).toHaveLength(2)
|
||||
expect(result.messages[0]).toMatchObject({
|
||||
id: "msg_3",
|
||||
role: "user",
|
||||
agent: "build",
|
||||
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
|
||||
})
|
||||
expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 })
|
||||
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
|
||||
"msg_3:text:0",
|
||||
"msg_3:file:0",
|
||||
"msg_3:agent:0",
|
||||
"msg_5:compaction",
|
||||
])
|
||||
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
|
||||
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: { status: "completed", output: "hello" },
|
||||
})
|
||||
})
|
||||
|
||||
test("does not invent a parent for an assistant-only page", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "orphan" }],
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
expect(normalizeSessionMessages("ses_1", source).messages).toEqual([])
|
||||
})
|
||||
|
||||
test("projects a current shell message into a renderable standalone turn", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_shell",
|
||||
type: "shell",
|
||||
shellID: "shell_1",
|
||||
command: "printf hello",
|
||||
status: "exited",
|
||||
exit: 0,
|
||||
output: { output: "hello", cursor: 5, size: 5, truncated: false },
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
expect.objectContaining({ id: "msg_shell", role: "user" }),
|
||||
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
|
||||
])
|
||||
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
|
||||
expect(result.parts.get("msg_shell:assistant")).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: expect.objectContaining({
|
||||
status: "completed",
|
||||
input: { command: "printf hello" },
|
||||
output: "hello",
|
||||
title: "Shell",
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("adapts current edit fields for the legacy edit renderer", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "edit it", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_edit",
|
||||
name: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
|
||||
content: [{ type: "text", text: "Edited file successfully" }],
|
||||
structured: {
|
||||
files: [
|
||||
{
|
||||
file: "README.md",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
},
|
||||
],
|
||||
replacements: 1,
|
||||
},
|
||||
},
|
||||
time: { created: 2, ran: 3, completed: 4 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 4 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.parts.get("msg_assistant")).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool",
|
||||
tool: "edit",
|
||||
state: expect.objectContaining({
|
||||
status: "completed",
|
||||
input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
|
||||
metadata: expect.objectContaining({
|
||||
filediff: {
|
||||
file: "README.md",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
import type {
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionMessageShell,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeToolInput(name: string, input: Record<string, unknown>) {
|
||||
if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string")
|
||||
return input
|
||||
return { ...input, filePath: input.path }
|
||||
}
|
||||
|
||||
function normalizeToolMetadata(name: string, metadata: Record<string, unknown>) {
|
||||
if (name !== "edit" || !Array.isArray(metadata.files)) return metadata
|
||||
const file = metadata.files.find(record)
|
||||
if (!file || typeof file.file !== "string") return metadata
|
||||
return {
|
||||
...metadata,
|
||||
filediff: {
|
||||
file: file.file,
|
||||
patch: typeof file.patch === "string" ? file.patch : undefined,
|
||||
additions: typeof file.additions === "number" ? file.additions : 0,
|
||||
deletions: typeof file.deletions === "number" ? file.deletions : 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) {
|
||||
const messages: Message[] = []
|
||||
const parts = new Map<string, Part[]>()
|
||||
let agent = ""
|
||||
let model = emptyModel
|
||||
let parentID: string | undefined
|
||||
|
||||
source.forEach((message) => {
|
||||
if (message.type === "agent-switched") {
|
||||
agent = message.agent
|
||||
return
|
||||
}
|
||||
if (message.type === "model-switched") {
|
||||
model = message.model
|
||||
return
|
||||
}
|
||||
if (message.type === "user") {
|
||||
parentID = message.id
|
||||
messages.push(userMessage(sessionID, message, agent, model))
|
||||
parts.set(message.id, userParts(sessionID, message))
|
||||
return
|
||||
}
|
||||
if (message.type === "synthetic" && message.description?.trim()) {
|
||||
parentID = message.id
|
||||
messages.push({
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: message.time,
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
})
|
||||
parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)])
|
||||
return
|
||||
}
|
||||
if (message.type === "shell") {
|
||||
messages.push(...shellMessages(sessionID, message, agent, model))
|
||||
parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)])
|
||||
parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)])
|
||||
parentID = undefined
|
||||
return
|
||||
}
|
||||
if (message.type === "assistant") {
|
||||
agent = message.agent
|
||||
model = message.model
|
||||
if (!parentID) return
|
||||
const parent = messages.findLast((item) => item.id === parentID)
|
||||
if (parent?.role === "user") {
|
||||
parent.agent = message.agent
|
||||
parent.model = {
|
||||
providerID: message.model.providerID,
|
||||
modelID: message.model.id,
|
||||
variant: message.model.variant,
|
||||
}
|
||||
}
|
||||
messages.push(assistantMessage(sessionID, parentID, message))
|
||||
parts.set(message.id, assistantParts(sessionID, message))
|
||||
return
|
||||
}
|
||||
if (message.type !== "compaction" || !parentID) return
|
||||
parts.set(parentID, [
|
||||
...(parts.get(parentID) ?? []),
|
||||
{
|
||||
id: `${message.id}:compaction`,
|
||||
sessionID,
|
||||
messageID: parentID,
|
||||
type: "compaction",
|
||||
auto: message.reason === "auto",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
return { messages, parts }
|
||||
}
|
||||
|
||||
function shellMessages(
|
||||
sessionID: string,
|
||||
message: SessionMessageShell,
|
||||
agent: string,
|
||||
model: { id: string; providerID: string; variant?: string },
|
||||
): [UserMessage, AssistantMessage] {
|
||||
return [
|
||||
{
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: message.time.created },
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
},
|
||||
{
|
||||
id: `${message.id}:assistant`,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: message.time,
|
||||
parentID: message.id,
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
variant: model.variant,
|
||||
mode: agent,
|
||||
agent,
|
||||
path: { cwd: "", root: "" },
|
||||
cost: 0,
|
||||
tokens: emptyTokens,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function shellPart(sessionID: string, message: SessionMessageShell): ToolPart {
|
||||
const input = { command: message.command }
|
||||
const start = message.time.created
|
||||
const state: ToolPart["state"] =
|
||||
message.status === "running"
|
||||
? { status: "running", input, time: { start } }
|
||||
: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: message.output?.output ?? "",
|
||||
title: "Shell",
|
||||
metadata: {
|
||||
status: message.status,
|
||||
exit: message.exit,
|
||||
truncated: message.output?.truncated,
|
||||
},
|
||||
time: { start, end: message.time.completed ?? start },
|
||||
}
|
||||
return {
|
||||
id: `${message.id}:tool`,
|
||||
sessionID,
|
||||
messageID: `${message.id}:assistant`,
|
||||
type: "tool",
|
||||
callID: message.shellID,
|
||||
tool: "bash",
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
|
||||
return `${messageID}:${type}:${ordinal}`
|
||||
}
|
||||
|
||||
function userMessage(
|
||||
sessionID: string,
|
||||
message: SessionMessageUser,
|
||||
agent: string,
|
||||
model: { id: string; providerID: string; variant?: string },
|
||||
): UserMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: message.time,
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
}
|
||||
}
|
||||
|
||||
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
return [
|
||||
textPart(sessionID, message.id, 0, message.text),
|
||||
...(message.files ?? []).map(
|
||||
(file, index): FilePart => ({
|
||||
id: `${message.id}:file:${index}`,
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "file",
|
||||
mime: file.mime,
|
||||
filename: file.name,
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
}),
|
||||
),
|
||||
...(message.agents ?? []).map(
|
||||
(item, index): Part => ({
|
||||
id: `${message.id}:agent:${index}`,
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "agent",
|
||||
name: item.name,
|
||||
source: item.mention
|
||||
? { value: item.mention.text, start: item.mention.start, end: item.mention.end }
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage {
|
||||
const error = message.error
|
||||
? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
|
||||
? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
|
||||
: { name: "UnknownError" as const, data: { message: message.error.message } }
|
||||
: undefined
|
||||
return {
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: message.time,
|
||||
error,
|
||||
parentID,
|
||||
modelID: message.model.id,
|
||||
providerID: message.model.providerID,
|
||||
variant: message.model.variant,
|
||||
mode: message.agent,
|
||||
agent: message.agent,
|
||||
path: { cwd: "", root: "" },
|
||||
cost: message.cost ?? 0,
|
||||
tokens: message.tokens ?? emptyTokens,
|
||||
finish: message.finish,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.content.flatMap((content): Part[] => {
|
||||
if (content.type === "text") {
|
||||
const part = textPart(sessionID, message.id, ordinals.text++, content.text)
|
||||
return content.text.trim() ? [part] : []
|
||||
}
|
||||
if (content.type === "reasoning") {
|
||||
const part: Part = {
|
||||
id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++),
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "reasoning",
|
||||
text: content.text,
|
||||
metadata: content.state,
|
||||
time: {
|
||||
start: content.time?.created ?? message.time.created,
|
||||
end: content.time?.completed,
|
||||
},
|
||||
}
|
||||
return content.text.trim() ? [part] : []
|
||||
}
|
||||
return [toolPart(sessionID, message.id, content)]
|
||||
})
|
||||
}
|
||||
|
||||
function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
|
||||
return {
|
||||
id: sessionMessagePartID(messageID, "text", ordinal),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
synthetic,
|
||||
}
|
||||
}
|
||||
|
||||
function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart {
|
||||
const start = tool.time.ran ?? tool.time.created
|
||||
const state = (() => {
|
||||
if (tool.state.status === "streaming") {
|
||||
const value = Option.getOrUndefined(decodeToolInput(tool.state.input))
|
||||
const input = normalizeToolInput(tool.name, record(value) ? value : {})
|
||||
return { status: "pending" as const, input, raw: tool.state.input }
|
||||
}
|
||||
if (tool.state.status === "running") {
|
||||
return {
|
||||
status: "running" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
time: { start },
|
||||
}
|
||||
}
|
||||
if (tool.state.status === "error") {
|
||||
return {
|
||||
status: "error" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
error: tool.state.error.message,
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
time: { start, end: tool.time.completed ?? start },
|
||||
}
|
||||
}
|
||||
const attachments = tool.state.content.flatMap((item, index): FilePart[] =>
|
||||
item.type === "file"
|
||||
? [
|
||||
{
|
||||
id: `${tool.id}:file:${index}`,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "file",
|
||||
mime: item.mime,
|
||||
filename: item.name,
|
||||
url: item.uri,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
return {
|
||||
status: "completed" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
|
||||
title: tool.name,
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
time: { start, end: tool.time.completed ?? start },
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
}
|
||||
})()
|
||||
return {
|
||||
id: tool.id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "tool",
|
||||
callID: tool.id,
|
||||
tool: tool.name,
|
||||
state,
|
||||
metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import { listAllSessions, normalizeSessionInfo } from "./session"
|
||||
|
||||
describe("normalizeSessionInfo", () => {
|
||||
test("adapts a current session to the app session shape", () => {
|
||||
const result = normalizeSessionInfo({
|
||||
id: "session-1",
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: "New session",
|
||||
location: { directory: "/repo/worktree", workspaceID: "workspace-1" },
|
||||
subpath: "worktree",
|
||||
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] },
|
||||
} as SessionInfo)
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "session-1",
|
||||
slug: "session-1",
|
||||
projectID: "project-1",
|
||||
workspaceID: "workspace-1",
|
||||
directory: "/repo/worktree",
|
||||
path: "worktree",
|
||||
parentID: undefined,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
title: "New session",
|
||||
agent: "build",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
version: "",
|
||||
time: { created: 1, updated: 1 },
|
||||
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("listAllSessions", () => {
|
||||
test("loads every page in server order and retains the query", async () => {
|
||||
const calls: SessionListInput[] = []
|
||||
const pages = new Map<string | undefined, { data: SessionInfo[]; cursor: { next?: string } }>([
|
||||
[undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }],
|
||||
["next", { data: [sessionInfo("session-1", true)], cursor: {} }],
|
||||
])
|
||||
const api = {
|
||||
list: async (query = {}) => {
|
||||
calls.push(query)
|
||||
return pages.get(query.cursor) ?? { data: [], cursor: {} }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">
|
||||
|
||||
const result = await listAllSessions(api, { directory: "/repo", order: "desc" })
|
||||
|
||||
expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"])
|
||||
expect(result[2]?.time.archived).toBe(2)
|
||||
expect(calls).toEqual([
|
||||
{ directory: "/repo", order: "desc", limit: 100, cursor: undefined },
|
||||
{ directory: "/repo", order: "desc", limit: 100, cursor: "next" },
|
||||
])
|
||||
})
|
||||
|
||||
test("requests the terminal empty page when the server returns a next cursor", async () => {
|
||||
const cursors: Array<string | undefined> = []
|
||||
const api = {
|
||||
list: async (query = {}) => {
|
||||
cursors.push(query.cursor)
|
||||
if (query.cursor) return { data: [], cursor: { next: "unused" } }
|
||||
return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">
|
||||
|
||||
const result = await listAllSessions(api, { directory: "/repo", limit: 25 })
|
||||
|
||||
expect(result.map((session) => session.id)).toEqual(["session-1"])
|
||||
expect(cursors).toEqual([undefined, "terminal"])
|
||||
})
|
||||
})
|
||||
|
||||
function sessionInfo(id: string, archived = false) {
|
||||
return {
|
||||
id,
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1, archived: archived ? 2 : undefined },
|
||||
title: id,
|
||||
location: { directory: "/repo" },
|
||||
} as SessionInfo
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
if (!("location" in input)) return input
|
||||
return {
|
||||
id: input.id,
|
||||
slug: input.id,
|
||||
projectID: input.projectID,
|
||||
workspaceID: input.location.workspaceID,
|
||||
directory: input.location.directory,
|
||||
path: input.subpath,
|
||||
parentID: input.parentID,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
version: "",
|
||||
time: input.time,
|
||||
revert: input.revert && {
|
||||
messageID: input.revert.messageID,
|
||||
partID: input.revert.partID,
|
||||
snapshot: input.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAllSessions(api: Pick<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) {
|
||||
const load = async (cursor?: string): Promise<Session[]> => {
|
||||
const result = await api.list({ ...input, limit: input.limit ?? 100, cursor })
|
||||
const sessions = result.data.map(normalizeSessionInfo)
|
||||
if (result.data.length === 0 || !result.cursor.next) return sessions
|
||||
return [...sessions, ...(await load(result.cursor.next))]
|
||||
}
|
||||
return load()
|
||||
}
|
||||
@@ -32,7 +32,6 @@ export function CommentCardV2(props: {
|
||||
return (
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={props.title ?? props.comment}
|
||||
disabled={!props.tooltip || !truncated()}
|
||||
class={props.wide ? "w-full" : undefined}
|
||||
|
||||
@@ -385,12 +385,7 @@ export function PromptInputV2Attachments(props: {
|
||||
<For each={props.comments ?? []}>
|
||||
{(comment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2
|
||||
value={comment.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<TooltipV2 value={comment.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<CommentCardV2
|
||||
comment={comment.comment ?? ""}
|
||||
path={comment.path}
|
||||
|
||||
@@ -214,7 +214,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
</Show>
|
||||
<div class="flex items-center">
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!prev()}
|
||||
value={
|
||||
<>
|
||||
@@ -234,7 +233,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
/>
|
||||
</TooltipV2>
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!next()}
|
||||
value={
|
||||
<>
|
||||
@@ -268,12 +266,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
|
||||
>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<Icon name="expand" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<Icon name="collapse" />
|
||||
</SegmentedControlItemV2>
|
||||
@@ -289,12 +287,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
|
||||
>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<Icon name="unified" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<Icon name="split" />
|
||||
</SegmentedControlItemV2>
|
||||
|
||||
@@ -104,7 +104,6 @@ const appGlobalBindingCommands = [
|
||||
] as const
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"model.list",
|
||||
"model.cycle_recent",
|
||||
"model.cycle_recent_reverse",
|
||||
@@ -963,6 +962,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
commands: appCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => dialog.stack.length === 0,
|
||||
bindings: tuiConfig.keybinds.get(COMMAND_PALETTE_COMMAND),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
|
||||
@@ -5,7 +5,13 @@ import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { TuiKeybind } from "../src/config/keybind"
|
||||
import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
|
||||
import {
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
getOpencodeModeStack,
|
||||
OPENCODE_BASE_MODE,
|
||||
OpencodeKeymapProvider,
|
||||
registerOpencodeKeymap,
|
||||
} from "../src/keymap"
|
||||
|
||||
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
|
||||
const keybinds = TuiKeybind.parse(input)
|
||||
@@ -73,12 +79,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
const offGlobal = keymap.registerLayer({
|
||||
commands: [
|
||||
{ name: COMMAND_PALETTE_COMMAND, run() {} },
|
||||
{ name: "session.list", run() {} },
|
||||
{ name: "session.new", run() {} },
|
||||
{ name: "session.page.up", run() {} },
|
||||
{ name: "session.first", run() {} },
|
||||
],
|
||||
bindings: config.keybinds.gather("test.global", [
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
@@ -95,7 +103,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
Array.from(
|
||||
keymap.getCommandBindings({
|
||||
visibility: "active",
|
||||
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
|
||||
commands: [
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
"session.first",
|
||||
"model.list",
|
||||
],
|
||||
}),
|
||||
([command, bindings]) => [command, bindings.length],
|
||||
),
|
||||
@@ -125,9 +140,24 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(counts).toEqual({
|
||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
|
||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
|
||||
base: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 1,
|
||||
},
|
||||
question: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 0,
|
||||
},
|
||||
autocomplete: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
|
||||
@@ -3,12 +3,13 @@ import { createEffect, Match, onCleanup, splitProps, Switch, type JSX } from "so
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> {
|
||||
export interface TooltipProps extends Omit<ComponentProps<typeof KobalteTooltip>, "openDelay"> {
|
||||
value: JSX.Element
|
||||
class?: string
|
||||
contentClass?: string
|
||||
contentStyle?: JSX.CSSProperties
|
||||
inactive?: boolean
|
||||
delay?: "standard" | "intent"
|
||||
forceOpen?: boolean
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ export function Tooltip(props: TooltipProps) {
|
||||
"contentClass",
|
||||
"contentStyle",
|
||||
"inactive",
|
||||
"delay",
|
||||
"forceOpen",
|
||||
"ignoreSafeArea",
|
||||
"value",
|
||||
@@ -107,8 +109,8 @@ export function Tooltip(props: TooltipProps) {
|
||||
<Match when={true}>
|
||||
<KobalteTooltip
|
||||
gutter={4}
|
||||
openDelay={400}
|
||||
skipDelayDuration={300}
|
||||
openDelay={local.delay === "intent" ? 1000 : 400}
|
||||
skipDelayDuration={local.delay === "intent" ? 0 : 300}
|
||||
{...others}
|
||||
closeDelay={0}
|
||||
ignoreSafeArea={local.ignoreSafeArea ?? true}
|
||||
|
||||
@@ -4,12 +4,13 @@ import type { ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import "./tooltip-v2.css"
|
||||
|
||||
export interface TooltipV2Props extends ComponentProps<typeof KobalteTooltip> {
|
||||
export interface TooltipV2Props extends Omit<ComponentProps<typeof KobalteTooltip>, "openDelay"> {
|
||||
value: JSX.Element
|
||||
class?: string
|
||||
contentClass?: string
|
||||
contentStyle?: JSX.CSSProperties
|
||||
inactive?: boolean
|
||||
delay?: "standard" | "intent"
|
||||
forceOpen?: boolean
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
"contentClass",
|
||||
"contentStyle",
|
||||
"inactive",
|
||||
"delay",
|
||||
"forceOpen",
|
||||
"ignoreSafeArea",
|
||||
"value",
|
||||
@@ -88,8 +90,8 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
<Match when={true}>
|
||||
<KobalteTooltip
|
||||
gutter={4}
|
||||
openDelay={400}
|
||||
skipDelayDuration={300}
|
||||
openDelay={local.delay === "intent" ? 1000 : 400}
|
||||
skipDelayDuration={local.delay === "intent" ? 0 : 300}
|
||||
{...others}
|
||||
closeDelay={0}
|
||||
ignoreSafeArea={local.ignoreSafeArea ?? true}
|
||||
|
||||
@@ -109,6 +109,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -137,6 +138,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -210,6 +212,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- MiMo-V2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Laguna S 2.1 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Ling-3.0-flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- North Mini Code Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Nemotron 3 Ultra Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Big Pickle نموذج خفي ومتاح مجانا على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
@@ -267,6 +270,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- MiMo-V2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- Laguna S 2.1 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- Ling-3.0-flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- North Mini Code Free: خلال فترته المجانية، قد يُحتفَظ بالبيانات المُجمَّعة وتُستخدم لتحسين النموذج. لا تُرسل بيانات شخصية أو سرية. راجع [شروط الاستخدام](https://cohere.com/terms-of-use) و[سياسة الخصوصية](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (نقاط نهاية NVIDIA المجانية): للاستخدام التجريبي فقط — لا ترسل بيانات شخصية أو سرية. يُسجَّل استخدامك لأغراض أمنية ولتحسين منتجات وخدمات NVIDIA. بيانات الجلسة المُسجَّلة لأغراض التحسين غير مرتبطة بهويتك أو بأي مُعرِّف دائم. لمزيد من المعلومات حول ممارسات معالجة البيانات لدينا، راجع [سياسة الخصوصية](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). بتفاعلك مع نقطة النهاية هذه، فإنك توافق على جمعنا لهذه المعلومات وتسجيلها واستخدامها وعلى [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ Besplatni modeli:
|
||||
- DeepSeek V4 Flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- MiMo-V2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Laguna S 2.1 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Ling-3.0-flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- North Mini Code Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Nemotron 3 Ultra Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Big Pickle je stealth model koji je besplatan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
@@ -279,6 +282,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke:
|
||||
- DeepSeek V4 Flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- MiMo-V2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- Laguna S 2.1 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- Ling-3.0-flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- North Mini Code Free: Tokom besplatnog perioda, prikupljeni podaci mogu biti zadržani i korišteni za poboljšanje modela. Nemojte slati lične ili povjerljive podatke. Pogledajte naše [Uslove korištenja](https://cohere.com/terms-of-use) i [Politiku privatnosti](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (besplatni NVIDIA endpointi): Samo za probnu upotrebu — nemojte slati lične ili povjerljive podatke. Vaše korištenje se bilježi radi sigurnosti i poboljšanja NVIDIA proizvoda i usluga. Zabilježeni podaci sesije koji se koriste u svrhu poboljšanja nisu povezani s vašim identitetom niti bilo kojim trajnim identifikatorom. Za više informacija o našim praksama obrade podataka pogledajte našu [Politiku privatnosti](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interakcijom s ovim endpointom pristajete na naše prikupljanje, bilježenje i korištenje takvih informacija te na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ De gratis modeller:
|
||||
- DeepSeek V4 Flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- MiMo-V2.5 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Laguna S 2.1 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Ling-3.0-flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- North Mini Code Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Nemotron 3 Ultra Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Big Pickle er en stealth-model, som er gratis på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
@@ -277,6 +280,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti
|
||||
- DeepSeek V4 Flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- MiMo-V2.5 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- Laguna S 2.1 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- Ling-3.0-flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- North Mini Code Free: I gratisperioden kan indsamlede data blive opbevaret og brugt til at forbedre modellen. Indsend ikke personlige eller fortrolige oplysninger. Se vores [Brugsvilkår](https://cohere.com/terms-of-use) og [Privatlivspolitik](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (gratis NVIDIA-endpoints): Kun til prøvebrug — indsend ikke personlige eller fortrolige data. Din brug logges af sikkerhedshensyn og for at forbedre NVIDIAs produkter og tjenester. De loggede sessionsdata, der bruges til forbedringsformål, er ikke knyttet til din identitet eller nogen vedvarende identifikator. For mere information om vores databehandlingspraksis, se vores [privatlivspolitik](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved at interagere med dette endpoint giver du samtykke til vores indsamling, registrering og brug af sådanne oplysninger samt [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ Die kostenlosen Modelle:
|
||||
- DeepSeek V4 Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- MiMo-V2.5 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Laguna S 2.1 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Ling-3.0-flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- North Mini Code Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Nemotron 3 Ultra Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Big Pickle ist ein Stealth-Modell, das für begrenzte Zeit kostenlos auf OpenCode verfügbar ist. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
@@ -263,6 +266,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer
|
||||
- DeepSeek V4 Flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- MiMo-V2.5 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- Laguna S 2.1 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- Ling-3.0-flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- North Mini Code Free: Während des kostenlosen Zeitraums können erhobene Daten gespeichert und zur Verbesserung des Modells verwendet werden. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. Weitere Informationen finden Sie in unseren [Nutzungsbedingungen](https://cohere.com/terms-of-use) und unserer [Datenschutzerklärung](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (kostenlose NVIDIA-Endpunkte): Nur für Testzwecke — übermitteln Sie keine personenbezogenen oder vertraulichen Daten. Ihre Nutzung wird zu Sicherheitszwecken und zur Verbesserung der Produkte und Dienste von NVIDIA protokolliert. Die zu Verbesserungszwecken protokollierten Sitzungsdaten sind nicht mit Ihrer Identität oder einem dauerhaften Identifikator verknüpft. Weitere Informationen zu unseren Datenverarbeitungspraktiken finden Sie in unserer [Datenschutzrichtlinie](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Durch die Interaktion mit diesem Endpunkt stimmen Sie unserer Erhebung, Aufzeichnung und Nutzung solcher Informationen sowie den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) zu.
|
||||
- OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert.
|
||||
|
||||
@@ -114,6 +114,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ Los modelos gratuitos:
|
||||
- DeepSeek V4 Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- MiMo-V2.5 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Laguna S 2.1 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Ling-3.0-flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- North Mini Code Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Nemotron 3 Ultra Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Big Pickle es un modelo stealth que es gratuito en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
@@ -277,6 +280,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po
|
||||
- DeepSeek V4 Flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- MiMo-V2.5 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- Laguna S 2.1 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- Ling-3.0-flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- North Mini Code Free: Durante el período gratuito, los datos recopilados podrán conservarse y utilizarse para mejorar el modelo. No envíes datos personales ni confidenciales. Consulta nuestros [Términos de uso](https://cohere.com/terms-of-use) y nuestra [Política de privacidad](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoints gratuitos de NVIDIA): Solo para uso de prueba — no envíes datos personales ni confidenciales. Tu uso se registra con fines de seguridad y para mejorar los productos y servicios de NVIDIA. Los datos de sesión registrados con fines de mejora no están vinculados a tu identidad ni a ningún identificador persistente. Para obtener más información sobre nuestras prácticas de procesamiento de datos, consulta nuestra [Política de privacidad](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Al interactuar con este endpoint, aceptas que recopilemos, registremos y usemos dicha información, así como los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ Les modèles gratuits :
|
||||
- DeepSeek V4 Flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- MiMo-V2.5 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Laguna S 2.1 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Ling-3.0-flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- North Mini Code Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Nemotron 3 Ultra Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Big Pickle est un modèle stealth gratuit sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
@@ -263,6 +266,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique
|
||||
- DeepSeek V4 Flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- MiMo-V2.5 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- Laguna S 2.1 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- Ling-3.0-flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- North Mini Code Free : Pendant la période de gratuité, les données collectées peuvent être conservées et utilisées pour améliorer le modèle. Ne transmettez aucune donnée personnelle ou confidentielle. Consultez nos [Conditions d’utilisation](https://cohere.com/terms-of-use) et notre [Politique de confidentialité](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoints NVIDIA gratuits) : Réservé à un usage d'essai — n'envoyez pas de données personnelles ou confidentielles. Votre utilisation est journalisée à des fins de sécurité et pour améliorer les produits et services de NVIDIA. Les données de session journalisées à des fins d'amélioration ne sont pas liées à votre identité ni à un quelconque identifiant persistant. Pour plus d'informations sur nos pratiques de traitement des données, consultez notre [Politique de confidentialité](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). En interagissant avec cet endpoint, vous consentez à notre collecte, à notre enregistrement et à notre utilisation de ces informations ainsi qu'aux [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ I modelli gratuiti:
|
||||
- DeepSeek V4 Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- MiMo-V2.5 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Laguna S 2.1 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Ling-3.0-flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- North Mini Code Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Nemotron 3 Ultra Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Big Pickle è un modello stealth che è gratuito su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
@@ -277,6 +280,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol
|
||||
- DeepSeek V4 Flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- MiMo-V2.5 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- Laguna S 2.1 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- Ling-3.0-flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- North Mini Code Free: Durante il periodo gratuito, i dati raccolti possono essere conservati e utilizzati per migliorare il modello. Non inviare dati personali o riservati. Consulta i nostri [Termini di utilizzo](https://cohere.com/terms-of-use) e la nostra [Informativa sulla privacy](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoint NVIDIA gratuiti): solo per uso di prova — non inviare dati personali o riservati. Il tuo utilizzo viene registrato per finalità di sicurezza e per migliorare i prodotti e i servizi di NVIDIA. I dati di sessione registrati a fini di miglioramento non sono collegati alla tua identità né ad alcun identificatore persistente. Per maggiori informazioni sulle nostre pratiche di trattamento dei dati, consulta la nostra [Informativa sulla privacy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interagendo con questo endpoint, acconsenti alla nostra raccolta, registrazione e utilizzo di tali informazioni e ai [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- MiMo-V2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Laguna S 2.1 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Ling-3.0-flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- North Mini Code Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Nemotron 3 Ultra Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Big Pickle はステルスモデルで、期間限定で OpenCode で無料提供されています。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
@@ -263,6 +266,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- MiMo-V2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- Laguna S 2.1 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- Ling-3.0-flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- North Mini Code Free: 無料提供期間中、収集されたデータは保持され、モデルの改善に使用される場合があります。個人情報や機密情報を送信しないでください。詳しくは、[利用規約](https://cohere.com/terms-of-use)および[プライバシーポリシー](https://cohere.com/privacy)をご覧ください。
|
||||
- Nemotron 3 Ultra Free(NVIDIA の無料エンドポイント): 試用専用です — 個人情報や機密データは送信しないでください。お客様の利用は、セキュリティ目的および NVIDIA の製品とサービスの改善のために記録されます。改善目的で記録されたセッションデータは、お客様の身元や永続的な識別子とは関連付けられません。当社のデータ処理慣行の詳細については、[プライバシーポリシー](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)をご覧ください。このエンドポイントを利用することで、お客様はそのような情報の当社による収集、記録、利用、および [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に同意したものとみなされます。
|
||||
- OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。
|
||||
|
||||
@@ -105,6 +105,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- MiMo-V2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Laguna S 2.1 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Ling-3.0-flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- North Mini Code Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Nemotron 3 Ultra Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Big Pickle은 한정된 기간 동안 OpenCode에서 무료로 제공되는 stealth model입니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
@@ -263,6 +266,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- MiMo-V2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- Laguna S 2.1 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- Ling-3.0-flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- North Mini Code Free: 무료 제공 기간 동안 수집된 데이터는 보관되며 모델 개선에 사용될 수 있습니다. 개인 정보나 기밀 정보를 제출하지 마세요. 자세한 내용은 [이용 약관](https://cohere.com/terms-of-use) 및 [개인정보 처리방침](https://cohere.com/privacy)을 참조하세요.
|
||||
- Nemotron 3 Ultra Free(NVIDIA 무료 엔드포인트): 평가판 전용이며 — 개인 정보나 기밀 데이터는 제출하지 마세요. 사용 내역은 보안 목적과 NVIDIA 제품 및 서비스 개선을 위해 기록됩니다. 개선 목적으로 기록된 세션 데이터는 사용자의 신원이나 영구 식별자와 연결되지 않습니다. 당사의 데이터 처리 관행에 대한 자세한 내용은 [개인정보처리방침](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)을 참조하세요. 이 엔드포인트와 상호 작용함으로써 사용자는 당사가 이러한 정보를 수집, 기록, 사용하는 것과 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 동의하게 됩니다.
|
||||
- OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다.
|
||||
|
||||
@@ -114,6 +114,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ Gratis-modellene:
|
||||
- DeepSeek V4 Flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- MiMo-V2.5 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Laguna S 2.1 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Ling-3.0-flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- North Mini Code Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Nemotron 3 Ultra Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Big Pickle er en stealth-modell som er gratis på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
@@ -277,6 +280,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer
|
||||
- DeepSeek V4 Flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- MiMo-V2.5 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- Laguna S 2.1 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- Ling-3.0-flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- North Mini Code Free: I gratisperioden kan innsamlede data bli oppbevart og brukt til å forbedre modellen. Ikke send inn personopplysninger eller konfidensielle opplysninger. Se våre [Vilkår for bruk](https://cohere.com/terms-of-use) og vår [Personvernerklæring](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (gratis NVIDIA-endepunkter): Kun for prøvebruk — ikke send inn personopplysninger eller konfidensielle data. Bruken din logges av sikkerhetshensyn og for å forbedre NVIDIAs produkter og tjenester. Sesjonsdataene som logges for forbedringsformål, er ikke knyttet til identiteten din eller noen vedvarende identifikator. For mer informasjon om vår databehandlingspraksis, se vår [personvernerklæring](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved å samhandle med dette endepunktet samtykker du til at vi samler inn, registrerer og bruker slik informasjon, samt til [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ Darmowe modele:
|
||||
- DeepSeek V4 Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- MiMo-V2.5 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Laguna S 2.1 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Ling-3.0-flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- North Mini Code Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Nemotron 3 Ultra Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Big Pickle to stealth model, który jest darmowy w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
@@ -277,6 +280,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero
|
||||
- DeepSeek V4 Flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- MiMo-V2.5 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- Laguna S 2.1 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- Ling-3.0-flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- North Mini Code Free: W okresie bezpłatnego dostępu zebrane dane mogą być przechowywane i wykorzystywane do ulepszania modelu. Nie przesyłaj danych osobowych ani poufnych. Zapoznaj się z naszym [Regulaminem korzystania](https://cohere.com/terms-of-use) i [Polityką prywatności](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (darmowe endpointy NVIDIA): Tylko do użytku próbnego — nie przesyłaj danych osobowych ani poufnych. Twoje korzystanie jest rejestrowane w celach bezpieczeństwa oraz w celu ulepszania produktów i usług NVIDIA. Rejestrowane dane sesji wykorzystywane do celów ulepszania nie są powiązane z Twoją tożsamością ani żadnym trwałym identyfikatorem. Aby uzyskać więcej informacji o naszych praktykach przetwarzania danych, zapoznaj się z naszą [Polityką prywatności](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Korzystając z tego endpointu, wyrażasz zgodę na gromadzenie, rejestrowanie i wykorzystywanie przez nas takich informacji oraz na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ Os modelos gratuitos:
|
||||
- DeepSeek V4 Flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- MiMo-V2.5 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Laguna S 2.1 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Ling-3.0-flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- North Mini Code Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Nemotron 3 Ultra Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Big Pickle é um modelo stealth que está gratuito no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
@@ -263,6 +266,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol
|
||||
- DeepSeek V4 Flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- MiMo-V2.5 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- Laguna S 2.1 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- Ling-3.0-flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- North Mini Code Free: Durante o período gratuito, os dados coletados poderão ser retidos e usados para aprimorar o modelo. Não envie dados pessoais ou confidenciais. Consulte nossos [Termos de Uso](https://cohere.com/terms-of-use) e nossa [Política de Privacidade](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoints gratuitos da NVIDIA): Apenas para uso de avaliação — não envie dados pessoais ou confidenciais. Seu uso é registrado para fins de segurança e para melhorar os produtos e serviços da NVIDIA. Os dados de sessão registrados para fins de melhoria não estão vinculados à sua identidade nem a qualquer identificador persistente. Para mais informações sobre nossas práticas de processamento de dados, consulte nossa [Política de Privacidade](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ao interagir com este endpoint, você consente com a nossa coleta, registro e uso dessas informações e com os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ OpenCode Zen работает как любой другой провайдер
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- MiMo-V2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Laguna S 2.1 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Ling-3.0-flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- North Mini Code Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Nemotron 3 Ultra Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
@@ -277,6 +280,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- MiMo-V2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- Laguna S 2.1 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- Ling-3.0-flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- North Mini Code Free: В течение бесплатного периода собранные данные могут храниться и использоваться для улучшения модели. Не отправляйте персональные или конфиденциальные данные. Ознакомьтесь с нашими [Условиями использования](https://cohere.com/terms-of-use) и [Политикой конфиденциальности](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (бесплатные эндпоинты NVIDIA): только для пробного использования — не отправляйте персональные или конфиденциальные данные. Использование логируется в целях безопасности и для улучшения продуктов и сервисов NVIDIA. Логируемые данные сессии, используемые в целях улучшения, не связаны с вашей личностью или каким-либо постоянным идентификатором. Подробнее о наших практиках обработки данных см. в нашей [Политике конфиденциальности](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Взаимодействуя с этим эндпоинтом, вы соглашаетесь на сбор, запись и использование нами такой информации, а также с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -107,6 +107,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -135,6 +136,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -208,6 +210,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- MiMo-V2.5 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- Laguna S 2.1 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- Ling-3.0-flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- North Mini Code Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- Nemotron 3 Ultra Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- Big Pickle เป็น stealth model ที่ใช้งานฟรีบน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
@@ -265,6 +268,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- MiMo-V2.5 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- Laguna S 2.1 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- Ling-3.0-flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- North Mini Code Free: ในช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกเก็บรักษาและนำไปใช้เพื่อปรับปรุงโมเดล โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลที่เป็นความลับ ดู[ข้อกำหนดการใช้งาน](https://cohere.com/terms-of-use)และ[นโยบายความเป็นส่วนตัว](https://cohere.com/privacy)ของเรา
|
||||
- Nemotron 3 Ultra Free (endpoint ฟรีของ NVIDIA): ใช้สำหรับการทดลองเท่านั้น — โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ การใช้งานของคุณจะถูกบันทึกเพื่อวัตถุประสงค์ด้านความปลอดภัยและเพื่อปรับปรุงผลิตภัณฑ์และบริการของ NVIDIA ข้อมูลเซสชันที่บันทึกไว้เพื่อวัตถุประสงค์ในการปรับปรุงจะไม่เชื่อมโยงกับตัวตนของคุณหรือตัวระบุถาวรใด ๆ สำหรับข้อมูลเพิ่มเติมเกี่ยวกับแนวปฏิบัติในการประมวลผลข้อมูลของเรา โปรดดู [นโยบายความเป็นส่วนตัว](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ของเรา การโต้ตอบกับ endpoint นี้ถือว่าคุณยินยอมให้เราเก็บรวบรวม บันทึก และใช้ข้อมูลดังกล่าว รวมถึง [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)
|
||||
- OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına
|
||||
- DeepSeek V4 Flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- MiMo-V2.5 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- Laguna S 2.1 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- Ling-3.0-flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- North Mini Code Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- Nemotron 3 Ultra Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- Big Pickle, sınırlı bir süre için OpenCode'da ücretsiz olan gizli bir modeldir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
@@ -263,6 +266,7 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention
|
||||
- DeepSeek V4 Flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- MiMo-V2.5 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- Laguna S 2.1 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- Ling-3.0-flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- North Mini Code Free: Ücretsiz kullanım süresi boyunca toplanan veriler saklanabilir ve modeli geliştirmek için kullanılabilir. Kişisel veya gizli veriler göndermeyin. [Kullanım Koşullarımıza](https://cohere.com/terms-of-use) ve [Gizlilik Politikamıza](https://cohere.com/privacy) bakın.
|
||||
- Nemotron 3 Ultra Free (ücretsiz NVIDIA uç noktaları): Yalnızca deneme amaçlıdır — kişisel veya gizli veri göndermeyin. Kullanımınız güvenlik amacıyla ve NVIDIA ürünlerini ve hizmetlerini geliştirmek için kaydedilir. Geliştirme amacıyla kaydedilen oturum verileri kimliğinizle veya herhangi bir kalıcı tanımlayıcıyla ilişkilendirilmez. Veri işleme uygulamalarımız hakkında daha fazla bilgi için [Gizlilik Politikamıza](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) bakın. Bu uç noktayla etkileşime geçerek, bu tür bilgileri toplamamıza, kaydetmemize ve kullanmamıza ve [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) koşullarına onay vermiş olursunuz.
|
||||
- OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır.
|
||||
|
||||
@@ -114,6 +114,7 @@ You can also access our models through the following API endpoints.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ The free models:
|
||||
- DeepSeek V4 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- MiMo-V2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Laguna S 2.1 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Ling-3.0-flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- North Mini Code Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Nemotron 3 Ultra Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
@@ -277,6 +280,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
|
||||
- DeepSeek V4 Flash Free: During its free period, collected data may be used to improve the model.
|
||||
- MiMo-V2.5 Free: During its free period, collected data may be used to improve the model.
|
||||
- Laguna S 2.1 Free: During its free period, collected data may be used to improve the model.
|
||||
- Ling-3.0-flash Free: During its free period, collected data may be used to improve the model.
|
||||
- North Mini Code Free: During its free period, collected data may be retained and used to improve the model. Do not submit personal or confidential data. See our [Terms of Use](https://cohere.com/terms-of-use) and [Privacy Policy](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (NVIDIA free endpoints): Trial use only — do not submit personal or confidential data. Your use is logged for security purposes and to improve NVIDIA products and services. The logged session data for improvement purposes is not linked to your identity or any persistent identifier. For more information about our data processing practices, see our [Privacy Policy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). By interacting with this endpoint, you consent to our collection, recording, and use of such information and the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- MiMo-V2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Laguna S 2.1 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Ling-3.0-flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- North Mini Code Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Nemotron 3 Ultra Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Big Pickle 是一个隐身模型,目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
@@ -263,6 +266,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- MiMo-V2.5 Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- Laguna S 2.1 Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- Ling-3.0-flash Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- North Mini Code Free:免费期间,所收集的数据可能会被保留并用于改进模型。请勿提交个人或机密数据。请参阅我们的[使用条款](https://cohere.com/terms-of-use)和[隐私政策](https://cohere.com/privacy)。
|
||||
- Nemotron 3 Ultra Free(NVIDIA 免费端点):仅供试用 — 请勿提交个人或机密数据。出于安全目的以及为改进 NVIDIA 产品和服务,系统会记录你的使用情况。出于改进目的而记录的会话数据不会与你的身份或任何持久标识符相关联。有关我们数据处理实践的更多信息,请参阅我们的[隐私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。与此端点进行交互,即表示你同意我们收集、记录和使用此类信息,并同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。
|
||||
- OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
|
||||
|
||||
@@ -109,6 +109,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -138,6 +139,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -211,6 +213,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- MiMo-V2.5 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- Laguna S 2.1 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- Ling-3.0-flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- North Mini Code Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- Nemotron 3 Ultra Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- Big Pickle 是一個隱身模型,在 OpenCode 上限時免費提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
@@ -269,6 +272,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- MiMo-V2.5 Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- Laguna S 2.1 Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- Ling-3.0-flash Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- North Mini Code Free:免費期間,所收集的資料可能會被保留並用於改進模型。請勿提交個人或機密資料。請參閱我們的[使用條款](https://cohere.com/terms-of-use)和[隱私權政策](https://cohere.com/privacy)。
|
||||
- Nemotron 3 Ultra Free(NVIDIA 免費端點):僅供試用 — 請勿提交個人或機密資料。基於安全目的以及為了改進 NVIDIA 產品與服務,系統會記錄你的使用情況。基於改進目的而記錄的工作階段資料不會與你的身分或任何持久識別碼相關聯。有關我們資料處理實務的更多資訊,請參閱我們的[隱私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。與此端點進行互動,即表示你同意我們收集、記錄與使用此類資訊,並同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。
|
||||
- OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
|
||||
|
||||
Reference in New Issue
Block a user