Merge branch 'main' into agents/issue-reporter-shared-anonymous

This commit is contained in:
Dmitriy Vasyura authored and GitHub committed 2026-10-05 04:10:40 -07:00
commit dbb05d20e3
429 files changed
+16272 -2192

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
disturl="https://electronjs.org/headers"
target="43.7.5"
ms_build_id="15523606"
target="43.7.7"
ms_build_id="15553055"
runtime="electron"
ignore-scripts=false
build_from_source="true"
+75 -75
View File
@@ -1,75 +1,75 @@
7ca9d9e9654f66e63d3117b868f5407605af53ca6115bcf9f0490a68568afc14 *chromedriver-v43.7.5-darwin-arm64.zip
0133b3e40b192e9ff3fa1e80773a25e591b8638a95aa50e33b773a6222c12493 *chromedriver-v43.7.5-darwin-x64.zip
f85f88fe6cf1be1e55312c1302d1f3d7d0f319dfe4e7e610f9fbf3f0b682018f *chromedriver-v43.7.5-linux-arm64.zip
97f4939ba6d58954673cbda3c14ff5c0b00e5a4b7e70a1b166d0313da91a8a5f *chromedriver-v43.7.5-linux-armv7l.zip
854d5396d3abfb91284a9e66e6cb962a7e274aad28cd86ff6d5c22492bfc06d7 *chromedriver-v43.7.5-linux-x64.zip
fd1cc7a02fc96fabb01482a87807d9e6dd7fd70df5b97be5e5704422d751d063 *chromedriver-v43.7.5-mas-arm64.zip
713387926080fab188c99674548653a144f4e3a0890924f60b81e99b2fff33b7 *chromedriver-v43.7.5-mas-x64.zip
423f1210316104b5daaf5ffee8b7e33c9f537e52b38b8f75d3bc1b9021ea47bd *chromedriver-v43.7.5-win32-arm64.zip
b868501754d37212d561bc5298caf99d38e9c6d9eef8902263a6f6d3eab5e569 *chromedriver-v43.7.5-win32-ia32.zip
9f5d50e29f3cfb13b8afd5cc711ae3219cdd76d52d2dcc86991221d2b7342a39 *chromedriver-v43.7.5-win32-x64.zip
c94a7f38ff9c34b6ef2d4861ac74b6015ce93b92814721ae1e0e7fc7ef75bc6c *electron-api.json
9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-darwin-arm64-dsym-snapshot.zip
6bf01e64c0aa457c7725ddaa8abaec50a7e06a3d2f325795934e3a6370f83db7 *electron-v43.7.5-darwin-arm64-dsym.tar.xz
9486b06f8aa6b20b082e0fa8741141a463de96b25a2b362a81f192d1a823c996 *electron-v43.7.5-darwin-arm64-symbols.zip
4ddb8717f3c9ade6c5b8a86f0f2b9648067108de93f9a1e7fd6a9ffcf06692ac *electron-v43.7.5-darwin-arm64.zip
9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-darwin-x64-dsym-snapshot.zip
25c9e7e63ce1b904a2bc7a49f4d1845b4ab4fb461f84a4520d1ec78c72c3d37a *electron-v43.7.5-darwin-x64-dsym.tar.xz
9cd6fc5cf09edfed1ba29cf6b72ce929dda71aecc584f9823a93196bcda25ed9 *electron-v43.7.5-darwin-x64-symbols.zip
fec7945208ade18b15c9f386c29bdf973f3cb7f779df9bc5e1a04f9de8a146ce *electron-v43.7.5-darwin-x64.zip
ec18f1ed85958b6bb0e7285a15fba7da3f546c5919e477a108150f86936e3988 *electron-v43.7.5-linux-arm64-debug.zip
5893561c6452225dd6dc8eb8bde572e6d675b5cdcdbba6e0d3c9ad11a95df564 *electron-v43.7.5-linux-arm64-symbols.zip
0e8017c56a243deed2510467e5de4be17a2c0634eb017429851ad026551e28ec *electron-v43.7.5-linux-arm64.zip
5224ccd7679443680c5a51f9ccaf2ede12b940a39191261dd9011eec6d0231d3 *electron-v43.7.5-linux-armv7l-debug.zip
0f8d9b40fde9a6dbc211f99a3caae6f0367afb636e1770f985eaba5993598cf3 *electron-v43.7.5-linux-armv7l-symbols.zip
1079cbaf38af752402c29e1a888a9f044403baf784693de3147c7c29097a4157 *electron-v43.7.5-linux-armv7l.zip
6ed81092b68add4ec6fb375615f14bdb4a6f5c598242f7800431c7b4d11f7645 *electron-v43.7.5-linux-x64-debug.zip
829e3e9c97c08cc61126c43885798325ce1feffcfdb0c882c5489db46a9fb079 *electron-v43.7.5-linux-x64-symbols.zip
dce41d6d9a36a2854c5fa1a97f90bc7a8ac8285cf2a76fe3f997c7a02432b364 *electron-v43.7.5-linux-x64.zip
9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-mas-arm64-dsym-snapshot.zip
abf2db07595e11531aa2cb4c572b6d5fbe81705878b382d962d00b2262f810c7 *electron-v43.7.5-mas-arm64-dsym.tar.xz
8e2cde372c0a5f5245c334343741a94ad840e86a698f1ec943f259e39ba3f552 *electron-v43.7.5-mas-arm64-symbols.zip
bebd05a60252b9a4f6c83202e3a5b435ef8a489ff6de110fa76aa52a7903dd3c *electron-v43.7.5-mas-arm64.zip
9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-mas-x64-dsym-snapshot.zip
1bbd11786d0ef8b3ba36c49e6831f4948f6fddc6a1bba1696aa2dce6724c6d8d *electron-v43.7.5-mas-x64-dsym.tar.xz
7c6c3971bcc89d4caa4a6f16a7244aac7f3ae300e86fe65820347f5e46987f9f *electron-v43.7.5-mas-x64-symbols.zip
5502585a5c698e92a5472b8fbeeaf6aa757f70d5f1a42f5592dff889c6b8ee37 *electron-v43.7.5-mas-x64.zip
012133e592664f7b1fd74a42b2f4b9e0e14ec5510ec65a079cd80d8add2eda5e *electron-v43.7.5-win32-arm64-pdb.zip
245111a9bbf1f1acf99a4c0212a7cf1226eea5cb4bcf20326802e9b5b50e2e4c *electron-v43.7.5-win32-arm64-symbols.zip
90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.5-win32-arm64-toolchain-profile.zip
996f250e86611b2e5245b8ac09a0375f24d6e343292799a6c9bf7334391fd9c9 *electron-v43.7.5-win32-arm64.zip
cfd279ac0110c4b128627eaffed83e0d3dc3b70d6d8d6a54ca5254bef5a79d15 *electron-v43.7.5-win32-ia32-pdb.zip
1b52b17efc00fab5317d191e0b871fd8e324983dcddfcc6829c867e1baf5dc9f *electron-v43.7.5-win32-ia32-symbols.zip
90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.5-win32-ia32-toolchain-profile.zip
86897584b0b36a7a55280a33bcd2040115d3651b7ea753f23ab5097286947482 *electron-v43.7.5-win32-ia32.zip
c7531d071950a50b3d8b55ec403bdcbfdd9a8ba62f91a18a88de7a638084525a *electron-v43.7.5-win32-x64-pdb.zip
1891dfd0d60dc76bbf83601a354c866642ce1fdf5b9a2063666990fa99424799 *electron-v43.7.5-win32-x64-symbols.zip
90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.5-win32-x64-toolchain-profile.zip
7acfa0646793f912ff983c8db8c3a145dc18ee40fe3d11a01840fd59cb76e5a2 *electron-v43.7.5-win32-x64.zip
81e05df0600cb229dbdb080e5200ddf3532d0c4b5a668e2cba5bc5683f805098 *electron.d.ts
c3f967e6e0fcada1d2dd24b08f190b02aaf243a014531901e82b70417193c48b *ffmpeg-v43.7.5-darwin-arm64.zip
a6860ea4f0ec3954ae2686ae2ff2fd72cb4793f7229fe0d0c9c8efd0487e7297 *ffmpeg-v43.7.5-darwin-x64.zip
ed1d9f6af263524c00ff955f50bf289e19c881625384ff421c773ef2aeb8a82d *ffmpeg-v43.7.5-linux-arm64.zip
001847bd19549781850fd6f69941a54f92356ed26bca02892e9642637215668d *ffmpeg-v43.7.5-linux-armv7l.zip
6638429b92e6d213b78114923585526aff88dbe25f91ace7f2bd5228fac08de5 *ffmpeg-v43.7.5-linux-x64.zip
5290184f135527f4b58636e14869688a9fad6249cfe9f3608c9046e06efc6660 *ffmpeg-v43.7.5-mas-arm64.zip
785ededd318692376a61ed854a95fe9103ad2f7616f64b5e3b96ce34eaecdad1 *ffmpeg-v43.7.5-mas-x64.zip
1bbd64356da38ad2c8b9dd254046b0491e07d72e27952ee7f49331191a26bdb1 *ffmpeg-v43.7.5-win32-arm64.zip
7e9044d58cf7695e5549f73a4d0a54a7b2e24af930ff69195765c890346361f2 *ffmpeg-v43.7.5-win32-ia32.zip
b1b2597a18e68c70d0f65c46cdd0f0e10cb847e227e3e34cdbe6a6eee0e663c0 *ffmpeg-v43.7.5-win32-x64.zip
47b695db7f9dedebbdeec47070622da4312574f38e8ca8d77ef94c827a522a2f *hunspell_dictionaries.zip
198993602d37429e707e494f781eea876de3e9fd85d016c01b1f7432a747b418 *libcxx-objects-v43.7.5-linux-arm64.zip
80b6639adb398b42622ec0644a3a9c9e3f696309825537a817d42cf92c0a4f2e *libcxx-objects-v43.7.5-linux-armv7l.zip
dcb37cf5b0b1941e0304379dfea3587a3ba39167d8edca9e2ab749f54349586d *libcxx-objects-v43.7.5-linux-x64.zip
88eeecbe3a86626bc8b9677d764e831b13589449126b8b5aa84cb291e38f220e *libcxx_headers.zip
88a69e434f5a001028891091b32535b7b4e95c7764e4a7597112467042615d9b *libcxxabi_headers.zip
cdf1bb3bfa3c2a95ad463f9abd0cb3c94f39b9e5eb317eced9715d6496829c34 *mksnapshot-v43.7.5-darwin-arm64.zip
08e2460962d8729e8fa36bf30758d943ef4059f99e2d41dfd329193b51f94960 *mksnapshot-v43.7.5-darwin-x64.zip
d4544b339553ba5722aeaa53c22345183ae6d041e6330de0b0f3ef21d9af2046 *mksnapshot-v43.7.5-linux-arm64-x64.zip
ab060e6272e2e536732921eccef686560268b02edf832d63c3cada562cb423e7 *mksnapshot-v43.7.5-linux-armv7l-x64.zip
1a271d78fbb4922c02043538d53a4f87fe4e1c8bd58e7a78a29aeffb2cb742f2 *mksnapshot-v43.7.5-linux-x64.zip
42e489bd90f30581941143a2285861c69316bd3a603320bbe32ef217b8650adc *mksnapshot-v43.7.5-mas-arm64.zip
c2082d5d7f90e866760d2a6424bab260b3c757cc00884e1f00d87cad6d53a453 *mksnapshot-v43.7.5-mas-x64.zip
5665055067d7f53d459f0c63884648eb7dcfbf64b417b8c0b0738b7133b97812 *mksnapshot-v43.7.5-win32-arm64-x64.zip
7c882bd39f45cc9e1112f7b949b443305e959fad57292a9f9b97c3e45d502ce8 *mksnapshot-v43.7.5-win32-ia32.zip
bff2442315ad1482db15f6230f7f98308a0b758ab46b0b6f53ff0ace715e96ce *mksnapshot-v43.7.5-win32-x64.zip
b9b7d9ff132e9655a574f03edfb01add8d43a8b0d3a79320c226f6aa99c922ea *chromedriver-v43.7.7-darwin-arm64.zip
f3b26877642739e6c668e6368daada33bb1dfcf4d5fec34143195e184e984569 *chromedriver-v43.7.7-darwin-x64.zip
091707f83e4191f36848152d664c80c255572dfe36a042250ca94037c06959d2 *chromedriver-v43.7.7-linux-arm64.zip
9c3dc509c12709522a22e3c4eb3270907050391fe05d89f68ab5ea689f9aa5b2 *chromedriver-v43.7.7-linux-armv7l.zip
7ea6157ec70b065ef6a7f77fc1822dc6441f5702b0f99660e26cfca1897ecfa4 *chromedriver-v43.7.7-linux-x64.zip
cad78ce6d3399821fde0cc5701109334409aace9b2c82ab8f4c0d3fffa152906 *chromedriver-v43.7.7-mas-arm64.zip
42221fd4d828376802e644178717f1b0626bd1188d45199e958e17ab520ff9ee *chromedriver-v43.7.7-mas-x64.zip
d2c67dad42cfdcf17fce6eac8bba81502c4b2d9cd90cc16f189b1da71dde8ca3 *chromedriver-v43.7.7-win32-arm64.zip
79aedd7837ac32ec19f98677901def62d5cdbbe63d90fe5f25ca131f8609a843 *chromedriver-v43.7.7-win32-ia32.zip
8b81ca5a4add14593548f5b4042a028e4c50758d61c5a244741ca6d07be3c95d *chromedriver-v43.7.7-win32-x64.zip
aabd65bdb7a4fbd80c114af429392aa5a126382883c7c2e8f3ee11acf2e4ce08 *electron-api.json
649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-darwin-arm64-dsym-snapshot.zip
941f6adb5eb6dbce25db81158a8f19fd5b4b24bcc97ea5986bb901f822b93a54 *electron-v43.7.7-darwin-arm64-dsym.tar.xz
34f7acfef1ff41f81b63895f010b866c1f39ac22e0d700445eb6e9ee595276c4 *electron-v43.7.7-darwin-arm64-symbols.zip
9327d8ba5bc9e279d1a2f7da90235301c65a2e80eb4ad3bc5610d28d483340f9 *electron-v43.7.7-darwin-arm64.zip
649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-darwin-x64-dsym-snapshot.zip
2a0fc941390038cad45ef580cf65ee333f61f6e4504b9433447ed8fa3585e234 *electron-v43.7.7-darwin-x64-dsym.tar.xz
6f003f93738843419063adba7dbac56ac1dcfc79dcbeee39d700c335d34d831a *electron-v43.7.7-darwin-x64-symbols.zip
cbed66567d55db4a2bffad0bb6ee9795ca0037ad241039f7473fe75680d10905 *electron-v43.7.7-darwin-x64.zip
ba871fb65de9fbfd71f9aaafc1ac278434f497ee921e9f27fee9e0a5000e9eef *electron-v43.7.7-linux-arm64-debug.zip
d13b1f995845a5a138c35100944dc1264b98be3dd5540253640a66fc388d233d *electron-v43.7.7-linux-arm64-symbols.zip
16071038a9677d0f00b11d3d0f0b7b4ea3250987727aa73aa563b87789f2d5ab *electron-v43.7.7-linux-arm64.zip
bb2295977fc6d33adce9f62def5dfaa19de0e1bd29e5bbd366e0cc35d1a57a09 *electron-v43.7.7-linux-armv7l-debug.zip
07c0739157f9674827c7d55976016972cff70e0876d68287bdf24fca54d778bd *electron-v43.7.7-linux-armv7l-symbols.zip
088c2817c1b5928c5bea706cc60aa05ea328e6d1d8bd549fda75388a3552482a *electron-v43.7.7-linux-armv7l.zip
0f20d65a6f671b1966de12d3f71c2d96a1a65e7ba8c1d534b60bc08c7f82b950 *electron-v43.7.7-linux-x64-debug.zip
a5fb797b973514622f37154d5088132705ce86641f05e6b1e858269a86961868 *electron-v43.7.7-linux-x64-symbols.zip
4d0a48398c444258dbcf2f5f83b49ca5bc53583130f354e0c299dad5b22b5271 *electron-v43.7.7-linux-x64.zip
649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-mas-arm64-dsym-snapshot.zip
dc850f562fbf669db1d9940d458c7ba9790392cff18d95ebaec1fe94a8d583ff *electron-v43.7.7-mas-arm64-dsym.tar.xz
61cf1a0dc10498aff1d1eefa9a5b96350cba32734bdc9f7f40724f9aa17d03d9 *electron-v43.7.7-mas-arm64-symbols.zip
987eebfdc29638b9066e5484b0dcb59e7096627edebad722c7649f8472f2c8a0 *electron-v43.7.7-mas-arm64.zip
649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-mas-x64-dsym-snapshot.zip
8caa427ef3e1dd3f1832a3b782ace268999afcf011540fe05a62c957917487ea *electron-v43.7.7-mas-x64-dsym.tar.xz
91f67bf1b2deedf84355d98095a6060de69fb64c01caf223a35e0edef38aee55 *electron-v43.7.7-mas-x64-symbols.zip
f5eaad26bda0bc133f8d0af0f3a77d2e16adf2572306bec611424c3bab876acf *electron-v43.7.7-mas-x64.zip
b7aa250996ba4aadcbb115e951c6a46f19f246ee6178cffc6b115088189fc1d5 *electron-v43.7.7-win32-arm64-pdb.zip
e63955e73f754a249db46a2c312fd904b6405018650d22b8a16037a9c10270e0 *electron-v43.7.7-win32-arm64-symbols.zip
90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.7-win32-arm64-toolchain-profile.zip
d3751b5046350eee0cfd88e7709697c0ef05db93bb3435195c2d0cec50f0ad1f *electron-v43.7.7-win32-arm64.zip
2e8d41b27d3c2394bfeb4ec00904d7049c8a7ccaa105e678179a983cdfb319db *electron-v43.7.7-win32-ia32-pdb.zip
67ea48dcfdcca2f7b854fbe3689d56fe047ca9a2aee0808422ef313e0bc4b52a *electron-v43.7.7-win32-ia32-symbols.zip
90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.7-win32-ia32-toolchain-profile.zip
a170eeedf4a216b3b0672b151294e7aef0b4fdc15ae5c207ea3f9f37c4a14c42 *electron-v43.7.7-win32-ia32.zip
3b65b14be1594bba502283ae02e9af54b55b1894f3f6be961c608c9e80a74179 *electron-v43.7.7-win32-x64-pdb.zip
37d27ae6956d20fde7fa1218453ad9514c106f17cf30634c452302a7453f189f *electron-v43.7.7-win32-x64-symbols.zip
90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.7-win32-x64-toolchain-profile.zip
97dcb75065444ef031b9b6ea814ccd2109b97934fffb0c503a555d4737ca79cc *electron-v43.7.7-win32-x64.zip
d78a11ef458b68672c35957f0252396a1fa19e73a8ca3b9178e2e75cd090453d *electron.d.ts
9cd0907e7c7c652cc6f519c8e7d75cee26bed06394bb1d51fabdb9d3ada33c67 *ffmpeg-v43.7.7-darwin-arm64.zip
fc2ecfa9bb7ab0131e073f138923ee58f01b88941e366a0be1bad821ed5386bd *ffmpeg-v43.7.7-darwin-x64.zip
ed1d9f6af263524c00ff955f50bf289e19c881625384ff421c773ef2aeb8a82d *ffmpeg-v43.7.7-linux-arm64.zip
001847bd19549781850fd6f69941a54f92356ed26bca02892e9642637215668d *ffmpeg-v43.7.7-linux-armv7l.zip
6638429b92e6d213b78114923585526aff88dbe25f91ace7f2bd5228fac08de5 *ffmpeg-v43.7.7-linux-x64.zip
cbbd298f4548e53200d6b0321bea708963e94acc699059721642183301752109 *ffmpeg-v43.7.7-mas-arm64.zip
7943078d8e3ee924f1cad1354a29aa3aceaf0b7b7414df3a71924ea9c8906223 *ffmpeg-v43.7.7-mas-x64.zip
c8ec52d6bbda2789c678211a76ffc6eaf1dc5faebe1269fc4d6aafa7fbc5510f *ffmpeg-v43.7.7-win32-arm64.zip
37f7bf4b42615e880f971d8714f2b1f4629c06bd8bbd73c8993a8713f8d3628f *ffmpeg-v43.7.7-win32-ia32.zip
df14da964d36f36c50030004bb5e93a3b5fac9e4ddd6dd4f0e4ecdd49456ee4f *ffmpeg-v43.7.7-win32-x64.zip
1987f0c54ac21514090440f4dc981245cae34b71e95ddbeffd5bcb5da9a4982e *hunspell_dictionaries.zip
cd2a8fc7973a60a60f1612d87528e07eb047fad61106f240f4e39bffe9b66969 *libcxx-objects-v43.7.7-linux-arm64.zip
76dc80ad01bf2f0da658d5e58e256bcbcfc5b5e03d5ce25c3a43b9073d0e2a4f *libcxx-objects-v43.7.7-linux-armv7l.zip
5df32a0f3b4e11575d619dfc30bd614238a616f87303142b37bcf62b6206047f *libcxx-objects-v43.7.7-linux-x64.zip
f318b7480e1e9b6149cfa83dedee6027a381f4dc7a4b99af89dddbab4f6d0261 *libcxx_headers.zip
600988a1000006cb10d8b88727c6877dfe3e0cbcb49baee317551d8131593c0c *libcxxabi_headers.zip
080d43c0521b53dc9fffe91b17f9592518230aa0c819bac3e7a6aff484126895 *mksnapshot-v43.7.7-darwin-arm64.zip
3a4cf3ca11eb4f98e66e9ef111f7be82d5d517851ef0882d71bf25ef3f5e4adb *mksnapshot-v43.7.7-darwin-x64.zip
052aa030567fe51301f4ba7c52098583868296f0ea91fa1a78158f763bec1332 *mksnapshot-v43.7.7-linux-arm64-x64.zip
de81d082e286d93e6c2baa6a2ce4b5ccf36dbf5fab512c1a8274e4ad1d6ba660 *mksnapshot-v43.7.7-linux-armv7l-x64.zip
1a222e37532fb300237eedfc6eae2a836f9b8ccf988af64507c88178a9ad3a41 *mksnapshot-v43.7.7-linux-x64.zip
4aaa40fcf9c283f9c3106523fcaff9301dac65b7d36434aa1970def061c82a20 *mksnapshot-v43.7.7-mas-arm64.zip
2a46e79869eaaee734f7f70ef5650bdba3b72cce26a201c8f1a87e15364efd16 *mksnapshot-v43.7.7-mas-x64.zip
8de088472502ed3a26b5a56b016849add2cc694eb2acc76e599d582c675ca8e1 *mksnapshot-v43.7.7-win32-arm64-x64.zip
e73d7a9ada185a6182bdf6872f6c5a7ae22f18a1f7efdb9e4fcb7b21b21b0c53 *mksnapshot-v43.7.7-win32-ia32.zip
4878c2cf511a54a3d38cab480b0f3a2699f84e0d0bdf80b7f300cfbe867ca8b5 *mksnapshot-v43.7.7-win32-x64.zip
+37 -37
View File
@@ -56,42 +56,6 @@
"status": "enforced"
}
},
{
"key": "chat.agent.allowedNetworkDomains",
"name": "ChatAgentAllowedNetworkDomains",
"category": "InteractiveSession",
"minimumVersion": "1.116",
"localization": {
"description": {
"key": "chat.agent.allowedNetworkDomains",
"value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.network.allowNetwork#` is enabled, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence.\n\nChanges may not take full effect until VS Code is restarted."
}
},
"type": "array",
"default": [],
"included": true,
"agentHost": {
"status": "partial"
}
},
{
"key": "chat.agent.deniedNetworkDomains",
"name": "ChatAgentDeniedNetworkDomains",
"category": "InteractiveSession",
"minimumVersion": "1.116",
"localization": {
"description": {
"key": "chat.agent.deniedNetworkDomains",
"value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.network.allowNetwork#` is enabled. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`.\n\nChanges may not take full effect until VS Code is restarted."
}
},
"type": "array",
"default": [],
"included": true,
"agentHost": {
"status": "partial"
}
},
{
"key": "chat.agent.enabled",
"name": "ChatAgentMode",
@@ -118,7 +82,7 @@
"localization": {
"description": {
"key": "chat.agent.networkFilter",
"value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled.\n\nChanges may not take full effect until VS Code is restarted."
"value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.sandbox.network.allowedDomains#` and `#chat.agent.sandbox.network.deniedDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled.\n\nChanges may not take full effect until VS Code is restarted."
}
},
"type": "boolean",
@@ -178,6 +142,24 @@
"status": "notApplicable"
}
},
{
"key": "chat.agent.sandbox.network.allowedDomains",
"name": "ChatAgentAllowedNetworkDomains",
"category": "InteractiveSession",
"minimumVersion": "1.116",
"localization": {
"description": {
"key": "chat.agent.allowedNetworkDomains",
"value": "Allowed domains for network access by agent tools (fetch tool, integrated browser) when `#chat.agent.networkFilter#` is enabled. In Copilot Agent Host sessions with sandboxing (`#chat.agent.sandbox.enabled#`) enabled, also restricts the integrated browser when outbound network access (`#chat.agent.sandbox.network.allowNetwork#`) is allowed; an empty allow list adds no sandbox restriction. This list does not enable outbound access. Supports wildcards like `*.example.com`. Outside sandboxing, when both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.sandbox.network.deniedDomains#`) take precedence.\n\nChanges may not take full effect until VS Code is restarted."
}
},
"type": "array",
"default": [],
"included": true,
"agentHost": {
"status": "partial"
}
},
{
"key": "chat.agent.sandbox.network.allowNetwork",
"name": "ChatAgentSandboxAllowNetwork",
@@ -196,6 +178,24 @@
"status": "notApplicable"
}
},
{
"key": "chat.agent.sandbox.network.deniedDomains",
"name": "ChatAgentDeniedNetworkDomains",
"category": "InteractiveSession",
"minimumVersion": "1.116",
"localization": {
"description": {
"key": "chat.agent.deniedNetworkDomains",
"value": "Denied domains for network access by agent tools (fetch tool, integrated browser) when `#chat.agent.networkFilter#` is enabled. Also applies to the integrated browser in sandboxed Copilot Agent Host sessions (`#chat.agent.sandbox.enabled#`); allowing outbound network access (`#chat.agent.sandbox.network.allowNetwork#`) does not bypass this list. Takes precedence over `#chat.agent.sandbox.network.allowedDomains#`. Supports wildcards like `*.example.com`.\n\nChanges may not take full effect until VS Code is restarted."
}
},
"type": "array",
"default": [],
"included": true,
"agentHost": {
"status": "partial"
}
},
{
"key": "chat.agentHost.claudeAgent.enabled",
"name": "Claude3PIntegration",
+3 -3
View File
@@ -529,13 +529,13 @@
"git": {
"name": "electron",
"repositoryUrl": "https://github.com/electron/electron",
"commitHash": "5338ed09bfdcf1bbd2e6bab722c2620efe8c4790",
"tag": "43.7.5"
"commitHash": "ddb762f50fbd70384d0385302697e60ada549024",
"tag": "43.7.7"
}
},
"isOnlyProductionDependency": true,
"license": "MIT",
"version": "43.7.5"
"version": "43.7.7"
},
{
"component": {
+6 -1
View File
@@ -80,7 +80,12 @@ pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result<i32,
"Agent host returned an invalid active turn start timestamp",
)
})?;
let duration = Timestamp::now().as_millisecond() - started_at.as_millisecond();
// Defensive programming: Prevent negative duration values caused by clock skew
let now_ms = Timestamp::now().as_millisecond();
let started_ms = started_at.as_millisecond();
let duration = std::cmp::max(0, now_ms - started_ms);
let turn_id = active_turn.id;
debug!(ctx.log, "Cancelling turn {} on {}", turn_id, chat_uri);
@@ -0,0 +1,147 @@
---
name: migrate-customizations
description: Guide a safe, reversible migration of VS Code local agent customizations when launched from the Agent Customizations Migrations page with VS Code-generated migration context.
argument-hint: Launch from Agent Customizations > Migrations > Migrate with Agent
user-invocable: true
disable-model-invocation: true
---
# Migrate Agent Customizations
Guide the user through migrating the inventory included in the invoking prompt. The source locations and valid target folders in that prompt were evaluated for the selected harness. Treat them as authoritative: do not substitute hardcoded destination paths or migrate to a folder that is not listed.
## Required Migration Context
Before doing anything else, verify that the invoking prompt includes all of the following VS Code-generated values:
- Selected harness
- Migration telemetry flow
- Recovery bundle folder and filesystem path
- Customizations that need migration
- Valid target folders reported by the selected harness
If any value is missing, do not inspect, copy, move, convert, or delete customizations. Explain that this workflow must be started from **Agent Customizations > Migrations** by selecting **Migrate with Agent**, then stop. Do not ask the user to manually reconstruct or paste the missing context.
Read [migration techniques](./references/migration-techniques.md) before proposing changes.
## Safety Contract
1. Never modify a customization before creating a recovery bundle.
2. Use the recovery bundle folder supplied in the invoking prompt. VS Code created and is watching this folder; do not move it or substitute another location.
3. In the recovery bundle, create:
- `migration-log.md`, containing the selected harness, scope, timestamps, source and destination paths, commands or tools used, validation results, and every user decision.
- `backups/`, containing a byte-for-byte copy of every file before its first modification or deletion.
- `restore.md`, containing exact steps for restoring the backups and reverting newly created files.
- `migration-results.json`, containing only the privacy-safe aggregate schema described in Finish. VS Code watches this file for the final migration outcome.
4. Append to the log after every attempted operation, including failures and rollbacks.
5. Approval to migrate a group authorizes destination writes only; it does not authorize deleting source files, removing source MCP entries, or clearing location settings. Preserve sources by default. After the destination is validated, ask separately for explicit approval for each kind of cleanup. If runtime validation is not possible, explain what was and was not verified, keep the source, and ask whether to defer cleanup.
6. Stop on an unexpected source change, destination conflict, invalid configuration, failed backup, or failed validation. Explain the problem instead of guessing.
7. Never include customization names, paths, contents, MCP configuration, or other user data in telemetry.
8. Classify each inventory item exactly once using its supplied category and scope. Do not offer the same item again in another group; if groups overlap, explain the overlap and use the item's inventory category to decide where it is handled.
## Workflow
### 1. Choose Scope
Ask whether to migrate:
- user customizations,
- workspace customizations, or
- both.
Do not act on an unselected scope.
If workspace customizations are selected, ask whether the user wants a pull request. If they do, keep workspace changes focused, include the validation evidence and migration rationale in the pull request, and do not include a recovery bundle stored outside the repository.
### 2. Review the Plan
Classify the supplied inventory by its reported category and scope, then handle applicable items in this order:
1. prompt files that should become skills;
2. VS Code profile user-data agents, instructions, and skills;
3. MCP servers;
4. workspace or user customizations at locations defined by settings `chat.agentSkillsLocations`, `chat.instructionsFilesLocations`, and `chat.agentFilesLocations`.
The groups are mutually exclusive for an inventory item. In particular, a file reported under `configuredLocations` belongs in group 4, not group 2, even if its type is agent, instruction, or skill. If a previously skipped item would otherwise reappear in a later group, do not ask about it again unless the user requests reconsideration.
Before each group:
- explain why the migration is useful;
- list the source and harness-reported destination locations;
- describe changes that cannot be preserved;
- state explicitly that approval covers destination writes only and that source cleanup will require a separate approval;
- ask for confirmation to proceed with that group.
Work one group at a time. Do not request approval for all writes at once.
### 3. Migrate Prompt Files to Skills
Explain the compatibility reason for converting each prompt to a skill. Convert each selected prompt into a skill directory with a `SKILL.md`, preserving supported name, description, argument guidance, invocation semantics, and body content. Add `disable-model-invocation: true` when needed to preserve an explicit-invocation-only prompt. Record unsupported frontmatter in the log for review; do not silently drop behavior.
Validate that each skill has valid frontmatter, a meaningful description, and a folder name that matches the skill name. Distinguish structural validation from runtime validation: test discovery and representative invocation in the selected destination harness when possible. A file inspection or a test against a different harness is not proof that the destination harness loads the skill. If runtime validation requires a new session or is otherwise unavailable, say so, leave the source in place, and defer cleanup unless the user explicitly chooses otherwise after hearing the limitation.
### 4. Migrate User Data
Explain that Agent Host doesn't read the VS Code profile user data folder. It only reads the harness's user folders. Copy selected agents, instructions, and skills to a compatible listed destination without silently changing their contents.
Keep user-scope items in user scope. Explain that approval to copy does not authorize deleting the VS Code source or changing sync/location settings; ask for those separately after validation.
### 5. Migrate MCP Servers
Explain that moving MCP configuration lets the selected Agent Host load the server directly. Review every server separately, including destination conflicts and properties the destination format cannot preserve. Call out that a disabled user server might become enabled after migration.
Write and statically validate the destination before considering source cleanup. Leave unselected, unsupported, and conflicting servers unchanged. Test that migrated servers are discovered and can start in the selected destination harness; spawning a command directly can verify process behavior, but does not by itself prove that the harness discovers or loads the configuration. If destination-harness testing is unavailable, report that limitation and keep source entries.
Approval for this group authorizes destination writes only. After successful destination validation, ask separately whether to remove each migrated or already-equivalent source entry. Do not remove a source entry merely because its destination entry was written or because the user approved the group.
### 6. Customizations at Custom Locations
Files in custom locations defined by `chat.agentSkillsLocations`, `chat.instructionsFilesLocations`, and `chat.agentFilesLocations` can continue to work when the selected harness is connected to VS Code. Moving them to a harness-reported standard location may improve portability or consistency, but is not automatically required for compatibility. Explain this trade-off before asking whether to migrate.
Handle only items reported as `configuredLocations` and not already handled in another group. Do not re-offer files the user explicitly skipped. Treat moving a file, deleting its old copy, and clearing or changing the location setting as separate actions; obtain explicit approval for each cleanup action after validating the destination.
### 7. Finish
Before the final summary, write `migration-results.json` in the recovery bundle:
```json
{
"version": 1,
"migrationFlowId": "<flow from the invoking prompt>",
"cancelled": false,
"results": [
{
"category": "promptFiles",
"scope": "workspace",
"customizationType": "skill",
"outcome": "migrated",
"count": 1
}
]
}
```
Aggregate results by:
- category: `promptFiles`, `userData`, `configuredLocations`, or `mcpServers`;
- scope: `user` or `workspace`;
- customization type: `agent`, `instructions`, `skill`, or `mcpServer`;
- outcome: `migrated`, `skipped`, or `failed`.
The total reported for each category, scope, and customization type must not exceed the inventory supplied by VS Code. The result file must never include names, paths, contents, configuration values, error messages, or other user data.
Write the validated JSON to the exact recovery bundle supplied by VS Code. Do not write a result file when no migration telemetry flow was supplied.
If the user stops the workflow, set `cancelled` to `true`, record completed work with its actual outcome, mark the remaining inventory as `skipped`, and still validate and write the result file before the final cancellation summary.
Summarize:
- migrated, skipped, and failed items;
- created, modified, deleted, and retained files;
- validation performed;
- cleanup still pending;
- recovery bundle and restoration instructions;
- pull request URL when one was requested.
Keep the recovery bundle until the user confirms the migrated customizations work.
For a requested pull request, verify the final diff contains only approved workspace changes, exclude the recovery bundle, and include the migration rationale and actual validation performed. Do not claim runtime validation when only static checks or direct process tests were possible.
@@ -0,0 +1,82 @@
# Agent Customization Migration Techniques
Use these techniques together with the source inventory and target folders supplied by VS Code. Harness-reported destinations take precedence over examples in general documentation.
## General Technique
- Back up every source before writing.
- Group approval authorizes destination writes only. Preserve each source by default; ask for explicit cleanup approval separately, after destination validation.
- Keep user and workspace scope unchanged unless the user explicitly chooses otherwise.
- Classify each inventory item once by its supplied category and scope. Do not offer a skipped item again in another group unless the user asks to reconsider it.
- In a multi-root workspace, keep each migrated customization in the corresponding workspace root.
- Verify discovery and representative behavior in the destination environment. Remote and Dev Container user folders belong to that environment.
- Migrated and original copies do not synchronize. Remove the original only after validation.
- Separate structural checks (file shape, frontmatter, or valid JSON) from runtime checks in the selected harness. A direct server-process test is useful evidence about that process, but does not prove the harness discovers or loads its configuration. If destination-harness testing is unavailable, report the limitation and keep the source unless the user explicitly chooses otherwise after being informed.
## Prompt Files to Skills
Agent Host harnesses do not load `*.prompt.md` files. Convert a prompt to `<skill-folder>/SKILL.md`.
- Preserve supported `name`, `description`, and argument guidance.
- Preserve invocation semantics. Add `disable-model-invocation: true` when needed to retain an explicit-invocation-only prompt.
- Choose a valid, unique skill folder name.
- Record unsupported prompt frontmatter rather than silently discarding it.
- Keep the prompt body focused as a reusable workflow.
- Validate the skill's frontmatter and invoke it with a representative request.
- Do not treat structural validation as proof of runtime discovery. Test the skill in the selected destination harness when possible; if testing requires a new session, defer it and keep the prompt until the user has been told runtime validation is pending.
## User Data Locations
VS Code profile user data are Local-only. Move agents, instructions, and skills to a destination reported by the selected harness.
- Preserve file type, name, and content unless a compatibility issue requires an explained change.
- Keep workspace files in their originating repository root.
- Explain that harness user folders might not participate in VS Code Settings Sync.
- Treat copying a file, deleting the source, and clearing or changing a location setting as separate decisions. Group migration approval authorizes the copy only.
- `chat.promptFilesLocations` entries are prompt-to-skill migrations, not ordinary location moves.
## MCP Servers
Migrate each MCP server independently.
- Review source and destination JSON before writing.
- Detect duplicate server names and non-equivalent destination entries.
- Explain how every source property and variable will migrate by using the table below.
- A disabled user server might become enabled at the destination; review enablement before starting a session.
- Write and statically validate the destination entry before runtime testing or considering source cleanup.
- Leave unsupported, invalid, conflicting, and unselected servers unchanged.
- Do not remove a source entry solely because an equivalent destination entry already exists. After successful validation, ask separately before removing each migrated or redundant source entry.
| Source configuration | Migration result | What to do |
|----------------------|------------------|------------|
| Standard `command`, `args`, `env`, `url`, and `headers` values | Migrate automatically and add `tools: ["*"]` | Inspect the server's tools. Replace `*` with the specific tools the agent should invoke when access to every server tool is not appropriate. |
| `${workspaceFolder}`, `${workspaceRoot}`, `${workspaceFolderBasename}`, `${workspaceRootFolderName}`, `${cwd}`, or `${pathSeparator}` | Resolve the variable and write its current value | Review the resulting value before sharing the destination file or using it on another machine. |
| `gallery`, `version`, `dev`, or `sandboxEnabled` | Migrate while removing these properties | Explain the removal and decide how to handle updates, development behavior, or sandboxing after migration. |
| `${input:...}`, `${config:...}`, `${command:...}`, or other interactive VS Code variables | Do not migrate automatically | Reconfigure the value for the destination harness. Never copy a resolved secret into the MCP file. |
| `${env:NAME}` | Do not migrate automatically | Use `$NAME`, `${NAME}`, or `${NAME:-default}`, and define the variable in the Agent Host environment. |
| `cwd` | Do not migrate automatically | Add `cwd` manually only when the server requires it, and verify the path on the Agent Host machine. |
| `envFile` | Do not migrate automatically | Export the required variables in the Agent Host environment and reference them from `env`. Never copy secret values into the MCP file. |
| SSE transport | Do not migrate automatically | Use `type: "sse"` only when the server does not support Streamable HTTP. SSE is deprecated. |
| A VS Code `oauth` object | Do not migrate automatically | Remove the nested object to use OAuth discovery, or translate supported client settings to the flat destination OAuth fields. Authenticate when prompted. |
| Environment variables with `null` values | Do not migrate automatically | Remove the entry or provide a supported value. |
| Additional VS Code-specific properties | Do not migrate automatically | Remove or replace the unsupported property before retrying. |
| A different server with the same name in the destination or another workspace root | Stop without changing the source | Rename or remove the conflicting server, and then retry. |
Input variables require manual configuration. VS Code can prompt for values such as API keys through `${input:api-key}`, but the destination MCP format does not use that input flow. Ask the user how the value should be supplied securely in the Agent Host environment.
## Custom Locations
Agents, skill, and instruction files located in custom locations defined by settings `chat.agentSkillsLocations`, `chat.instructionsFilesLocations`, and `chat.agentFilesLocations` can continue to work when the selected harness is connected to VS Code. Moving them to a harness-reported standard location may improve portability or consistency, but is not automatically required for compatibility. Explain the trade-off rather than presenting the move as mandatory.
Handle only items reported under `configuredLocations` and not already handled under another inventory category. Do not re-ask about files the user explicitly skipped. Treat copying/moving a file, deleting its old copy, and clearing or changing the location setting as separate actions requiring separate approval.
## Verification
After each migration group:
1. Confirm the item appears in the Agent Customizations editor for the selected harness.
2. Start a session with that harness and run a representative task.
3. Confirm MCP servers start and expose the expected tools.
4. Review version-control changes for workspace migrations.
5. Report structural validation separately from runtime validation. If the selected harness cannot be exercised in this session, mark runtime validation as pending and keep source files and entries.
6. Only after successful destination validation, ask for explicit approval before removing old files, MCP entries, or settings. Approval to migrate is not approval to clean up.
+15 -15
View File
@@ -1,12 +1,12 @@
{
"name": "copilot-chat",
"version": "0.69.0",
"version": "0.70.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "copilot-chat",
"version": "0.69.0",
"version": "0.70.0",
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
@@ -143,7 +143,7 @@
"engines": {
"node": ">=22.14.0",
"npm": ">=9.0.0",
"vscode": "^1.141.0"
"vscode": "^1.142.0"
}
},
"node_modules/@anthropic-ai/sdk": {
@@ -266,9 +266,9 @@
}
},
"node_modules/@azure/core-client/node_modules/@typespec/ts-http-runtime": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz",
"integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==",
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
"integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -277,7 +277,7 @@
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
}
},
"node_modules/@azure/core-http-compat": {
@@ -387,9 +387,9 @@
}
},
"node_modules/@azure/core-util/node_modules/@typespec/ts-http-runtime": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz",
"integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==",
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
"integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==",
"license": "MIT",
"dependencies": {
"http-proxy-agent": "^7.0.0",
@@ -397,7 +397,7 @@
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
}
},
"node_modules/@azure/identity": {
@@ -520,9 +520,9 @@
}
},
"node_modules/@azure/logger/node_modules/@typespec/ts-http-runtime": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz",
"integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==",
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
"integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==",
"license": "MIT",
"dependencies": {
"http-proxy-agent": "^7.0.0",
@@ -530,7 +530,7 @@
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
}
},
"node_modules/@azure/msal-browser": {
+10 -2
View File
@@ -2,7 +2,7 @@
"name": "copilot-chat",
"displayName": "GitHub Copilot",
"description": "AI chat features powered by Copilot",
"version": "0.69.0",
"version": "0.70.0",
"build": "1",
"completionsCoreVersion": "1.378.1799",
"internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917",
@@ -22,7 +22,7 @@
"icon": "assets/copilot.png",
"pricing": "Trial",
"engines": {
"vscode": "^1.141.0",
"vscode": "^1.142.0",
"npm": ">=9.0.0",
"node": ">=22.14.0"
},
@@ -6866,6 +6866,14 @@
"copilotcli"
]
},
{
"path": "./assets/prompts/skills/migrate-customizations/SKILL.md",
"sessionTypes": [
"copilotcli",
"claude",
"codex"
]
},
{
"path": "./assets/prompts/skills/init/SKILL.md",
"sessionTypes": [
@@ -826,8 +826,8 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
const fileSystemSetting = rawFileSystemSetting && typeof rawFileSystemSetting === 'object'
? rawFileSystemSetting as IAgentSandboxFileSystemSettings
: undefined;
const allowedHosts = readStringArraySetting(this.configurationService, 'chat.agent.allowedNetworkDomains');
const blockedHosts = readStringArraySetting(this.configurationService, 'chat.agent.deniedNetworkDomains');
const allowedHosts = readStringArraySetting(this.configurationService, 'chat.agent.sandbox.network.allowedDomains');
const blockedHosts = readStringArraySetting(this.configurationService, 'chat.agent.sandbox.network.deniedDomains');
return buildSandboxConfigForCLI(process.platform, sandboxSetting, fileSystemSetting, { allowedHosts, blockedHosts });
}
@@ -32,8 +32,8 @@ export class ApiEmbeddingsIndex implements IApiEmbeddingsIndex {
) {
const cacheVersion = sanitizeVSCodeVersion(envService.getEditorInfo().version);
this.embeddingsCache = useRemoteCache ?
instantiationService.createInstance(RemoteEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'api', cacheVersion, EmbeddingType.text3small_512, RemoteCacheType.Api) :
instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'api', cacheVersion, EmbeddingType.text3small_512);
instantiationService.createInstance(RemoteEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'api', cacheVersion, EmbeddingType.metis_1024_I16_Binary, RemoteCacheType.Api) :
instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'api', cacheVersion, EmbeddingType.metis_1024_I16_Binary);
}
async updateIndex(): Promise<void> {
@@ -95,7 +95,7 @@ export class VSCodeAPIContextElement extends PromptElement<VSCodeAPIContextProps
return [];
}
const embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.text3small_512, [this.props.query], {}, new TelemetryCorrelationId('VSCodeAPIContextElement::getSnippets'), token);
const embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.metis_1024_I16_Binary, [this.props.query], { inputType: 'query' }, new TelemetryCorrelationId('VSCodeAPIContextElement::getSnippets'), token);
if (embeddingResult.values.length === 0) {
return [];
}
@@ -11,7 +11,7 @@ import { AutoChatEndpoint } from '../../../platform/endpoint/node/autoChatEndpoi
import { IAutomodeService } from '../../../platform/endpoint/node/automodeService';
import { CopilotChatEndpoint, CopilotUtilityChatEndpoint, CopilotUtilitySmallChatEndpoint } from '../../../platform/endpoint/node/copilotChatEndpoint';
import { EmbeddingEndpoint } from '../../../platform/endpoint/node/embeddingsEndpoint';
import { IModelMetadataFetcher, ModelMetadataFetcher } from '../../../platform/endpoint/node/modelMetadataFetcher';
import { EmbeddingsModelFamily, IModelMetadataFetcher, ModelMetadataFetcher } from '../../../platform/endpoint/node/modelMetadataFetcher';
import { ExtensionContributedChatEndpoint } from '../../../platform/endpoint/vscode-node/extChatEndpoint';
import { ILogService } from '../../../platform/log/common/logService';
import { IChatEndpoint, IEmbeddingsEndpoint } from '../../../platform/networking/common/networking';
@@ -20,6 +20,10 @@ import { Emitter, Event } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
const embeddingsModelFamilies: Record<EmbeddingsEndpointFamily, EmbeddingsModelFamily> = {
text3small: 'text-embedding-3-small',
metis: 'metis',
};
// Keep in sync with `BYOKUtilityModelDefault` in `src/vs/workbench/contrib/chat/common/constants.ts` and the `chat.byokUtilityModelDefault` enum in `chat.shared.contribution.ts`.
const enum BYOKUtilityModelDefault {
@@ -353,14 +357,14 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP
async getEmbeddingsEndpoint(family?: EmbeddingsEndpointFamily): Promise<IEmbeddingsEndpoint> {
this._logService.trace(`Resolving embedding model`);
const modelMetadata = await this._modelFetcher.getEmbeddingsModel('text-embedding-3-small');
const modelMetadata = await this._modelFetcher.getEmbeddingsModel(embeddingsModelFamilies[family ?? 'metis']);
const model = await this.getOrCreateEmbeddingEndpointInstance(modelMetadata);
this._logService.trace(`Resolved embedding model`);
return model;
}
private async getOrCreateEmbeddingEndpointInstance(modelMetadata: IEmbeddingModelInformation): Promise<IEmbeddingsEndpoint> {
const modelId = 'text-embedding-3-small';
const modelId = modelMetadata.id;
let embeddingEndpoint = this._embeddingEndpoints.get(modelId);
if (!embeddingEndpoint) {
embeddingEndpoint = this._instantiationService.createInstance(EmbeddingEndpoint, modelMetadata);
@@ -32,7 +32,7 @@ export class SettingsEditorSearchServiceImpl implements ISettingsEditorSearchSer
// Start searching for embedding results.
let embeddingResult: Embeddings;
try {
embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.text3small_512, [query], {}, new TelemetryCorrelationId('SettingsEditorSearchServiceImpl::provideSettingsSearchResults'), token);
embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.metis_1024_I16_Binary, [query], { inputType: 'query' }, new TelemetryCorrelationId('SettingsEditorSearchServiceImpl::provideSettingsSearchResults'), token);
} catch {
this.reportEmptyEmbeddingsResult(query, progress);
if (!options.embeddingsOnly) {
@@ -105,7 +105,7 @@ export class NewWorkspacePrompt extends PromptElement<NewWorkspacePromptProps, N
}
else if (instruction.intent === 'Project') {
if (this.props.useTemplates) {
const result = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.text3small_512, [instruction.question], {}, undefined);
const result = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.metis_1024_I16_Binary, [instruction.question], { inputType: 'query' }, undefined);
if (result.values.length === 0) {
return { intent: instruction };
}
@@ -136,7 +136,7 @@ export class VscodePrompt extends PromptElement<VscodePromptProps, VscodePromptS
return { settings: [], commands: [], query: userQuery };
}
const embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.text3small_512, [userQuery], {}, undefined);
const embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.metis_1024_I16_Binary, [userQuery], { inputType: 'query' }, undefined);
if (token.isCancellationRequested) {
return { settings: [], commands: [], query: userQuery };
}
@@ -56,9 +56,9 @@ class FakeModelMetadataFetcher implements IModelMetadataFetcher {
};
}
async getEmbeddingsModel(): Promise<IEmbeddingModelInformation> {
async getEmbeddingsModel(family: 'text-embedding-3-small' | 'metis'): Promise<IEmbeddingModelInformation> {
return {
id: 'text-embedding-3-small',
id: family,
name: 'fake-name',
vendor: 'fake-vendor',
version: 'fake-version',
@@ -68,7 +68,7 @@ class FakeModelMetadataFetcher implements IModelMetadataFetcher {
capabilities: {
type: 'embeddings',
tokenizer: TokenizerType.O200K,
family: 'text-embedding-3-small',
family,
limits: { max_inputs: 256 }
}
};
@@ -102,6 +102,26 @@ suite('Endpoint Class Test', function () {
assert.strictEqual(endpoint.model, 'gpt-5.4-nano');
});
test('resolves embedding endpoints by family with Metis as the default', async function () {
const defaultEndpoint = await endpointProvider.getEmbeddingsEndpoint();
const metisEndpoint = await endpointProvider.getEmbeddingsEndpoint('metis');
const text3smallEndpoint = await endpointProvider.getEmbeddingsEndpoint('text3small');
assert.deepStrictEqual({
defaultFamily: defaultEndpoint.family,
metisFamily: metisEndpoint.family,
text3smallFamily: text3smallEndpoint.family,
reusesMetisEndpoint: defaultEndpoint === metisEndpoint,
keepsFamiliesSeparate: metisEndpoint !== text3smallEndpoint,
}, {
defaultFamily: 'metis',
metisFamily: 'metis',
text3smallFamily: 'text-embedding-3-small',
reusesMetisEndpoint: true,
keepsFamiliesSeparate: true,
});
});
});
class CopilotMatchableModelMetadataFetcher implements IModelMetadataFetcher {
@@ -122,9 +142,9 @@ class CopilotMatchableModelMetadataFetcher implements IModelMetadataFetcher {
async getChatModelFromCapiFamily(family: string): Promise<IChatModelInformation> {
return makeChatModel(family);
}
async getEmbeddingsModel(): Promise<IEmbeddingModelInformation> {
async getEmbeddingsModel(family: 'text-embedding-3-small' | 'metis'): Promise<IEmbeddingModelInformation> {
return {
id: 'text-embedding-3-small',
id: family,
name: 'fake-name',
vendor: 'fake-vendor',
version: 'fake-version',
@@ -134,7 +154,7 @@ class CopilotMatchableModelMetadataFetcher implements IModelMetadataFetcher {
capabilities: {
type: 'embeddings',
tokenizer: TokenizerType.O200K,
family: 'text-embedding-3-small',
family,
limits: { max_inputs: 256 }
}
};
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Embedding, EmbeddingType } from '../../../../platform/embeddings/common/embeddingsComputer';
import { Embedding, EmbeddingType, EmbeddingVector } from '../../../../platform/embeddings/common/embeddingsComputer';
import { EmbeddingCacheType, IEmbeddingsCache, RemoteCacheType, RemoteEmbeddingsCache } from '../../../../platform/embeddings/common/embeddingsIndex';
import { IEnvService } from '../../../../platform/env/common/envService';
import { ILogService } from '../../../../platform/log/common/logService';
@@ -11,7 +11,11 @@ import { sanitizeVSCodeVersion } from '../../../../util/common/vscodeVersion';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { IToolEmbeddingsCache } from './toolEmbeddingsComputer';
export const EMBEDDING_TYPE_FOR_TOOL_GROUPING = EmbeddingType.text3small_512;
export const EMBEDDING_TYPE_FOR_TOOL_GROUPING = EmbeddingType.metis_1024_I16_Binary;
type ToolEmbeddingCacheData =
| Record<string, { readonly embedding: EmbeddingVector }>
| readonly { readonly key: string; readonly embedding: EmbeddingVector }[];
export class PreComputedToolEmbeddingsCache implements IToolEmbeddingsCache {
private readonly cache: IEmbeddingsCache;
@@ -44,18 +48,21 @@ export class PreComputedToolEmbeddingsCache implements IToolEmbeddingsCache {
private async _loadEmbeddings() {
try {
const embeddingsData = await this.cache.getCache();
const embeddingsData = await this.cache.getCache<ToolEmbeddingCacheData>();
const embeddingsMap = new Map<string, Embedding>();
if (embeddingsData) {
for (const [key, embeddingVector] of Object.entries(embeddingsData)) {
if (embeddingVector === undefined) {
this._logService.warn(`Tool embedding missing for key: ${key}`);
continue;
}
if (Array.isArray(embeddingsData)) {
for (const entry of embeddingsData) {
embeddingsMap.set(entry.key, {
type: this.embeddingType,
value: entry.embedding
});
}
} else if (embeddingsData) {
for (const [key, entry] of Object.entries(embeddingsData)) {
embeddingsMap.set(key, {
type: this.embeddingType,
value: embeddingVector.embedding
value: entry.embedding
});
}
}
@@ -67,5 +74,3 @@ export class PreComputedToolEmbeddingsCache implements IToolEmbeddingsCache {
}
}
}
@@ -116,7 +116,7 @@ export class ToolEmbeddingsComputer implements IToolEmbeddingsComputer {
return [];
}
const queryEmbedding = await this._embeddingsComputer.computeEmbeddings(this._embeddingType, [query], {}, new TelemetryCorrelationId('ToolEmbeddingsComputer::searchToolsByQuery'), token);
const queryEmbedding = await this._embeddingsComputer.computeEmbeddings(this._embeddingType, [query], { inputType: 'query' }, new TelemetryCorrelationId('ToolEmbeddingsComputer::searchToolsByQuery'), token);
if (!queryEmbedding || queryEmbedding.values.length === 0) {
return [];
}
@@ -184,7 +184,7 @@ export class ToolEmbeddingsComputer implements IToolEmbeddingsComputer {
const toolNames = tools.map(t => t.name + '\n\n' + t.description);
const start = new StopWatch();
const embeddings = await this._embeddingsComputer.computeEmbeddings(this._embeddingType, toolNames, {}, new TelemetryCorrelationId('ToolEmbeddingsComputer::computeEmbeddingsForTools'), token);
const embeddings = await this._embeddingsComputer.computeEmbeddings(this._embeddingType, toolNames, { inputType: 'document' }, new TelemetryCorrelationId('ToolEmbeddingsComputer::computeEmbeddingsForTools'), token);
this._logService.trace(`[virtual-tools] Computed embeddings for ${toolNames.length} tools in ${start.elapsed()}ms`);
if (embeddings?.values.length === 0 || embeddings?.values.length !== toolNames.length) {
@@ -351,7 +351,7 @@ export class VirtualToolGrouper implements IToolCategorization {
}
// compute the embeddings for the query
const queryEmbedding = await this.embeddingsComputer.computeEmbeddings(EMBEDDING_TYPE_FOR_TOOL_GROUPING, [query], {}, new TelemetryCorrelationId('VirtualToolGrouper::_getPredictedTools'), token);
const queryEmbedding = await this.embeddingsComputer.computeEmbeddings(EMBEDDING_TYPE_FOR_TOOL_GROUPING, [query], { inputType: 'query' }, new TelemetryCorrelationId('VirtualToolGrouper::_getPredictedTools'), token);
if (!queryEmbedding || queryEmbedding.values.length === 0) {
return [];
}
@@ -0,0 +1,50 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { describe, expect, test } from 'vitest';
import { EmbeddingType, EmbeddingVector } from '../../../../../platform/embeddings/common/embeddingsComputer';
import { IEmbeddingsCache } from '../../../../../platform/embeddings/common/embeddingsIndex';
import { NullEnvService } from '../../../../../platform/env/common/nullEnvService';
import { TestLogService } from '../../../../../platform/testing/common/testLogService';
import { mock } from '../../../../../util/common/test/simpleMock';
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
import { PreComputedToolEmbeddingsCache } from '../../../common/virtualTools/preComputedToolEmbeddingsCache';
function createCache(data: readonly { readonly key: string; readonly embedding: EmbeddingVector }[] | Record<string, { readonly embedding: EmbeddingVector }>): PreComputedToolEmbeddingsCache {
const embeddingsCache = new class extends mock<IEmbeddingsCache>() {
override readonly embeddingType = EmbeddingType.metis_1024_I16_Binary;
override async getCache<T>(): Promise<T> {
return data as T;
}
}();
const instantiationService = new class extends mock<IInstantiationService>() { }();
instantiationService.createInstance = (() => embeddingsCache) as IInstantiationService['createInstance'];
return new PreComputedToolEmbeddingsCache(new TestLogService(), instantiationService, NullEnvService.Instance);
}
describe('PreComputedToolEmbeddingsCache', () => {
test('loads published array entries by tool name', async () => {
const cache = createCache([{ key: 'click_element', embedding: [1, 0] }]);
await cache.initialize();
expect(cache.get({ name: 'click_element' })).toEqual({
type: EmbeddingType.metis_1024_I16_Binary,
value: [1, 0],
});
});
test('loads legacy object entries by tool name', async () => {
const cache = createCache({ click_element: { embedding: [1, 0] } });
await cache.initialize();
expect(cache.get({ name: 'click_element' })).toEqual({
type: EmbeddingType.metis_1024_I16_Binary,
value: [1, 0],
});
});
});
@@ -38,7 +38,7 @@ export enum RemoteCacheType {
// These values are the blob storage container names where we publish computed embeddings
enum RemoteEmbeddingsContainer {
TEXT3SMALL = 'text-3-small',
METIS_1024_I16_BINARY = 'metis-1024-I16-Binary'
METIS_1024_I16_BINARY = 'metis-1024-i16-binary'
}
function embeddingsModelToRemoteContainer(embeddingType: EmbeddingType): RemoteEmbeddingsContainer {
@@ -150,8 +150,8 @@ class EmbeddingsCache {
await this.fileSystemService.stat(this.cacheStorageUri);
await this.fileSystemService.delete(cacheFile, { useTrash: false });
} catch (e) {
if (e.code === 'ENOENT') {
throw new Error(`Cache file ${cacheFile} does not exist`);
if (e.code !== 'ENOENT' && e.code !== 'FileNotFound') {
throw e;
}
}
}
@@ -196,7 +196,7 @@ export class LocalEmbeddingsCache implements IEmbeddingsCache {
this._embeddingsCache = instantiationService.createInstance(
EmbeddingsCache,
cacheType,
cacheKey,
`${cacheKey}-${embeddingType.id}`,
cacheVersion
);
}
@@ -235,13 +235,14 @@ export class RemoteEmbeddingsCache implements IEmbeddingsCache {
@IFetcherService protected readonly fetcherService: IFetcherService,
@IInstantiationService instantiationService: IInstantiationService
) {
const modelCacheKey = `${cacheKey}-${embeddingType.id}`;
this.embeddingsCache = instantiationService.createInstance(
EmbeddingsCache,
cacheType,
cacheKey,
modelCacheKey,
cacheVersion
);
this.remoteCacheVersionKey = `${cacheKey}-version-remote`;
this.remoteCacheVersionKey = `${modelCacheKey}-version-remote`;
}
async clearCache(): Promise<void> {
@@ -19,7 +19,7 @@ import { IEmbeddingsEndpoint, postRequest } from '../../networking/common/networ
import { GenAiAttr, GenAiOperationName, GenAiProviderName } from '../../otel/common/genAiAttributes';
import { IOTelService, SpanKind, SpanStatusCode } from '../../otel/common/otelService';
import { ITelemetryService } from '../../telemetry/common/telemetry';
import { ComputeEmbeddingsOptions, Embedding, EmbeddingType, EmbeddingTypeInfo, EmbeddingVector, Embeddings, IEmbeddingsComputer, getWellKnownEmbeddingTypeInfo } from './embeddingsComputer';
import { ComputeEmbeddingsOptions, Embedding, EmbeddingInputType, EmbeddingType, EmbeddingTypeInfo, EmbeddingVector, Embeddings, IEmbeddingsComputer, LEGACY_EMBEDDING_MODEL_ID, getWellKnownEmbeddingTypeInfo } from './embeddingsComputer';
interface CAPIEmbeddingResults {
readonly type: 'success';
@@ -72,7 +72,7 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer {
// Determine endpoint type: use CAPI for no-auth users, otherwise use GitHub
const copilotToken = await this._authService.getCopilotToken();
if (copilotToken.isNoAuthUser) {
const embeddings = await this.computeCAPIEmbeddings(inputs, options, cancellationToken);
const embeddings = await this.computeCAPIEmbeddings(embeddingType, inputs, options, cancellationToken);
return embeddings ?? { type: embeddingType, values: [] };
}
@@ -168,19 +168,21 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer {
}
private async computeCAPIEmbeddings(
embeddingType: EmbeddingType,
inputs: readonly string[],
options?: ComputeEmbeddingsOptions,
cancellationToken?: CancellationToken,
) {
const typeInfo = getWellKnownEmbeddingTypeInfo(EmbeddingType.text3small_512);
const typeInfo = getWellKnownEmbeddingTypeInfo(embeddingType);
if (!typeInfo) {
throw new Error(`Embeddings type info not found: ${EmbeddingType.text3small_512}`);
throw new Error(`Embeddings type info not found: ${embeddingType}`);
}
const endpoint = await this._endpointProvider.getEmbeddingsEndpoint('text3small');
const endpointFamily = typeInfo.model === LEGACY_EMBEDDING_MODEL_ID.Metis_I16_Binary ? 'metis' : 'text3small';
const endpoint = await this._endpointProvider.getEmbeddingsEndpoint(endpointFamily);
const batchSize = endpoint.maxBatchSize;
// Open AI seems to allow 1 less than max tokens for the model requests. So if the max tokens is 8192, we can only send 8191 tokens.
const maxTokens = endpoint.modelMaxPromptTokens - 1;
return this.fetchResponseWithBatches(typeInfo, endpoint, inputs, cancellationToken, maxTokens, batchSize);
return this.fetchResponseWithBatches(embeddingType, typeInfo, endpoint, inputs, options?.inputType ?? 'document', cancellationToken, maxTokens, batchSize);
}
/**
@@ -191,9 +193,11 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer {
* @returns The embeddings
*/
private async fetchResponseWithBatches(
embeddingType: EmbeddingType,
type: EmbeddingTypeInfo,
endpoint: IEmbeddingsEndpoint,
inputs: readonly string[],
inputType: EmbeddingInputType,
cancellationToken: CancellationToken | undefined,
maxTokens: number,
batchSize: number,
@@ -218,7 +222,7 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer {
return;
}
const r = await this.rawEmbeddingsFetchWithTelemetry(type, endpoint, generateUuid(), currentBatch, cancellationToken);
const r = await this.rawEmbeddingsFetchWithTelemetry(type, endpoint, generateUuid(), currentBatch, inputType, cancellationToken);
if (r.type === 'failed') {
throw new Error('Embeddings request failed ' + r.reason);
}
@@ -241,7 +245,7 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer {
if (embeddings.length === 0) {
return undefined;
}
return { type: EmbeddingType.text3small_512, values: embeddings.map((value): Embedding => ({ type: EmbeddingType.text3small_512, value })) };
return { type: embeddingType, values: embeddings.map((value): Embedding => ({ type: embeddingType, value })) };
}
private async rawEmbeddingsFetchWithTelemetry(
@@ -249,10 +253,11 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer {
endpoint: IEmbeddingsEndpoint,
requestId: string,
inputs: readonly string[],
inputType: EmbeddingInputType,
cancellationToken: CancellationToken | undefined
) {
const startTime = Date.now();
const rawRequest = await this.rawEmbeddingsFetch(type, endpoint, requestId, inputs, cancellationToken);
const rawRequest = await this.rawEmbeddingsFetch(type, endpoint, requestId, inputs, inputType, cancellationToken);
if (rawRequest.type === 'failed') {
this._telemetryService.sendMSFTTelemetryErrorEvent('embedding.error', {
type: rawRequest.type,
@@ -281,12 +286,18 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer {
endpoint: IEmbeddingsEndpoint,
requestId: string,
inputs: readonly string[],
inputType: EmbeddingInputType,
cancellationToken: CancellationToken | undefined
): Promise<CAPIEmbeddingResults | CAPIEmbeddingError> {
try {
const token = await this._authService.getCopilotToken();
const body = { input: inputs, model: type.model, dimensions: type.dimensions };
const body = {
input: inputs,
input_type: inputType,
model: type.model,
dimensions: type.dimensions
};
endpoint.interceptBody?.(body);
const response = await this._instantiationService.invokeFunction(postRequest, {
endpointOrUrl: endpoint,
@@ -108,7 +108,7 @@ abstract class RelatedInformationProviderEmbeddingsIndex<V extends { key: string
return [];
}
const startOfEmbeddingRequest = Date.now();
const embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.text3small_512, [query], {}, new TelemetryCorrelationId('RelatedInformationProviderEmbeddingsIndex::provideRelatedInformation'), token);
const embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.metis_1024_I16_Binary, [query], { inputType: 'query' }, new TelemetryCorrelationId('RelatedInformationProviderEmbeddingsIndex::provideRelatedInformation'), token);
this._logService.debug(`Related Information: Remote similarly request took ${Date.now() - startOfEmbeddingRequest}ms`);
if (token.isCancellationRequested) {
// return an array of 0s the same length as comparisons
@@ -127,7 +127,7 @@ abstract class RelatedInformationProviderEmbeddingsIndex<V extends { key: string
break;
}
if (item.embedding) {
const score = distance(embeddingResult.values[0], { value: item.embedding, type: EmbeddingType.text3small_512 }).value;
const score = distance(embeddingResult.values[0], { value: item.embedding, type: EmbeddingType.metis_1024_I16_Binary }).value;
if (score > this.relatedInformationConfig.threshold) {
results.push(this.toRelatedInformation(item, score));
}
@@ -161,13 +161,13 @@ class CommandIdIndex extends RelatedInformationProviderEmbeddingsIndex<CommandLi
) {
super(
'CommandIdIndex',
EmbeddingType.text3small_512,
EmbeddingType.metis_1024_I16_Binary,
'commandEmbeddings',
embeddingsFetcher,
embeddingscache,
{
type: RelatedInformationType.CommandInformation,
threshold: /* min threshold of 0 for text-3-small*/ 0,
threshold: 0,
maxResults: 100,
},
logService,
@@ -222,13 +222,13 @@ class SettingsIndex extends RelatedInformationProviderEmbeddingsIndex<SettingLis
) {
super(
'SettingsIndex',
EmbeddingType.text3small_512,
EmbeddingType.metis_1024_I16_Binary,
'settingEmbeddings',
embeddingsFetcher,
embeddingsCache,
{
type: RelatedInformationType.SettingInformation,
threshold: /* min threshold of 0 for text-3-small*/ 0,
threshold: 0,
maxResults: 100,
},
logService,
@@ -291,11 +291,11 @@ export class VSCodeCombinedIndexImpl implements ICombinedEmbeddingIndex {
) {
// Local embeddings cache version is locked to 1.98
const settingsEmbeddingsCache = useRemoteCache ?
instantiationService.createInstance(RemoteEmbeddingsExtensionCache, EmbeddingCacheType.GLOBAL, 'settingEmbeddings', sanitizeVSCodeVersion(envService.getEditorInfo().version), EmbeddingType.text3small_512, RemoteCacheType.Settings) :
instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'settingEmbeddings', '1.98', EmbeddingType.text3small_512);
instantiationService.createInstance(RemoteEmbeddingsExtensionCache, EmbeddingCacheType.GLOBAL, 'settingEmbeddings', sanitizeVSCodeVersion(envService.getEditorInfo().version), EmbeddingType.metis_1024_I16_Binary, RemoteCacheType.Settings) :
instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'settingEmbeddings', '1.98', EmbeddingType.metis_1024_I16_Binary);
const commandsEmbeddingsCache = useRemoteCache ?
instantiationService.createInstance(RemoteEmbeddingsExtensionCache, EmbeddingCacheType.GLOBAL, 'commandEmbeddings', sanitizeVSCodeVersion(envService.getEditorInfo().version), EmbeddingType.text3small_512, RemoteCacheType.Commands) :
instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'commandEmbeddings', '1.98', EmbeddingType.text3small_512);
instantiationService.createInstance(RemoteEmbeddingsExtensionCache, EmbeddingCacheType.GLOBAL, 'commandEmbeddings', sanitizeVSCodeVersion(envService.getEditorInfo().version), EmbeddingType.metis_1024_I16_Binary, RemoteCacheType.Commands) :
instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'commandEmbeddings', '1.98', EmbeddingType.metis_1024_I16_Binary);
this.settingsIndex = instantiationService.createInstance(SettingsIndex, settingsEmbeddingsCache);
this.commandIdIndex = instantiationService.createInstance(CommandIdIndex, commandsEmbeddingsCache);
@@ -0,0 +1,114 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { describe, expect, test, vi } from 'vitest';
import { createTestExtendedTokenInfo, CopilotToken } from '../../../authentication/common/copilotToken';
import { IAuthenticationService } from '../../../authentication/common/authentication';
import { IEndpointProvider } from '../../../endpoint/common/endpointProvider';
import { NullEnvService } from '../../../env/common/nullEnvService';
import { IEmbeddingsEndpoint } from '../../../networking/common/networking';
import { NoopOTelService } from '../../../otel/common/noopOtelService';
import { resolveOTelConfig } from '../../../otel/common/otelConfig';
import { NullTelemetryService } from '../../../telemetry/common/nullTelemetryService';
import { TestLogService } from '../../../testing/common/testLogService';
import { mock } from '../../../../util/common/test/simpleMock';
import { ITokenizer } from '../../../../util/common/tokenizer';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { EmbeddingType, LEGACY_EMBEDDING_MODEL_ID } from '../../common/embeddingsComputer';
import { RemoteEmbeddingsComputer } from '../../common/remoteEmbeddingsComputer';
class TestAuthenticationService extends mock<IAuthenticationService>() {
override readonly hasCopilotTokenSource = true;
override async getCopilotToken(): Promise<CopilotToken> {
return new CopilotToken(createTestExtendedTokenInfo({ sku: 'no_auth_limited_copilot' }));
}
}
class TestTokenizer extends mock<ITokenizer>() {
override async tokenLength(): Promise<number> {
return 1;
}
}
class TestEmbeddingsEndpoint extends mock<IEmbeddingsEndpoint>() {
override readonly maxBatchSize = 100;
override readonly modelMaxPromptTokens = 8192;
override acquireTokenizer(): ITokenizer {
return new TestTokenizer();
}
}
class TestEndpointProvider extends mock<IEndpointProvider>() {
readonly requestedFamilies: Array<string | undefined> = [];
override async getEmbeddingsEndpoint(family?: 'text3small' | 'metis'): Promise<IEmbeddingsEndpoint> {
this.requestedFamilies.push(family);
return new TestEmbeddingsEndpoint();
}
}
describe('RemoteEmbeddingsComputer', () => {
function createComputer(endpointProvider: IEndpointProvider): RemoteEmbeddingsComputer {
return new RemoteEmbeddingsComputer(
new TestAuthenticationService(),
NullEnvService.Instance,
new TestLogService(),
new NullTelemetryService(),
endpointProvider,
new class extends mock<IInstantiationService>() { }(),
new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '1.0.0', sessionId: 'test' })),
);
}
test('uses the Metis CAPI endpoint for Metis embeddings', async () => {
const endpointProvider = new TestEndpointProvider();
const computer = createComputer(endpointProvider);
const fetch = vi.spyOn(computer, 'rawEmbeddingsFetch').mockResolvedValue({
type: 'success',
embeddings: [[1, 0]],
});
const result = await computer.computeEmbeddings(EmbeddingType.metis_1024_I16_Binary, ['input'], { inputType: 'query' });
expect({
requestedFamilies: endpointProvider.requestedFamilies,
requestedModel: fetch.mock.calls[0][0].model,
inputType: fetch.mock.calls[0][4],
resultType: result.type.id,
valueTypes: result.values.map(value => value.type.id),
}).toEqual({
requestedFamilies: ['metis'],
requestedModel: LEGACY_EMBEDDING_MODEL_ID.Metis_I16_Binary,
inputType: 'query',
resultType: EmbeddingType.metis_1024_I16_Binary.id,
valueTypes: [EmbeddingType.metis_1024_I16_Binary.id],
});
});
test('preserves the text3small endpoint for text3small embeddings', async () => {
const endpointProvider = new TestEndpointProvider();
const computer = createComputer(endpointProvider);
const fetch = vi.spyOn(computer, 'rawEmbeddingsFetch').mockResolvedValue({
type: 'success',
embeddings: [[1, 0]],
});
const result = await computer.computeEmbeddings(EmbeddingType.text3small_512, ['input']);
expect({
requestedFamilies: endpointProvider.requestedFamilies,
requestedModel: fetch.mock.calls[0][0].model,
inputType: fetch.mock.calls[0][4],
resultType: result.type.id,
}).toEqual({
requestedFamilies: ['text3small'],
requestedModel: LEGACY_EMBEDDING_MODEL_ID.TEXT3SMALL,
inputType: 'document',
resultType: EmbeddingType.text3small_512.id,
});
});
});
@@ -22,6 +22,8 @@ import { IExperimentationService } from '../../telemetry/common/nullExperimentat
import { getModelCapabilityOverride } from '../common/chatModelCapabilities';
import { IChatModelInformation, ICompletionModelInformation, IEmbeddingModelInformation, IModelAPIResponse, isChatModelInformation, isCompletionModelInformation, isEmbeddingModelInformation } from '../common/endpointProvider';
export type EmbeddingsModelFamily = 'text-embedding-3-small' | 'metis';
export interface IModelMetadataFetcher {
/**
@@ -67,7 +69,7 @@ export interface IModelMetadataFetcher {
* Retrieves an embeddings model by its family name
* @param family The family of the model to fetch
*/
getEmbeddingsModel(family: 'text-embedding-3-small'): Promise<IEmbeddingModelInformation>;
getEmbeddingsModel(family: EmbeddingsModelFamily): Promise<IEmbeddingModelInformation>;
}
/**
@@ -219,7 +221,7 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe
return resolvedModel;
}
public async getEmbeddingsModel(family: 'text-embedding-3-small'): Promise<IEmbeddingModelInformation> {
public async getEmbeddingsModel(family: EmbeddingsModelFamily): Promise<IEmbeddingModelInformation> {
await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this));
const resolvedModel = this._familyMap.get(family)?.[0];
if (!resolvedModel || !isEmbeddingModelInformation(resolvedModel)) {
@@ -37,8 +37,8 @@ export class ProjectTemplatesIndex implements IProjectTemplatesIndex {
) {
const cacheVersion = sanitizeVSCodeVersion(envService.getEditorInfo().version);
this.embeddingsCache = useRemoteCache ?
instantiationService.createInstance(RemoteEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'projectTemplateEmbeddings', cacheVersion, EmbeddingType.text3small_512, RemoteCacheType.ProjectTemplates)
: instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'projectTemplateEmbeddings', cacheVersion, EmbeddingType.text3small_512);
instantiationService.createInstance(RemoteEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'projectTemplateEmbeddings', cacheVersion, EmbeddingType.metis_1024_I16_Binary, RemoteCacheType.ProjectTemplates)
: instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'projectTemplateEmbeddings', cacheVersion, EmbeddingType.metis_1024_I16_Binary);
}
async updateIndex(): Promise<void> {
@@ -60,4 +60,47 @@ suite('built-in skills', () => {
expect(skill).not.toContain('extensions_manage({');
expect(skill).not.toContain('run `/extensions`');
});
test('contributes the customization migration skill to Agent Host sessions', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(copilotRoot, 'package.json'), 'utf-8')) as ICopilotPackage;
const contributions = manifest.contributes?.chatSkills?.filter(skill => skill.path.endsWith('/migrate-customizations/SKILL.md'));
const skill = fs.readFileSync(
path.join(copilotRoot, 'assets', 'prompts', 'skills', 'migrate-customizations', 'SKILL.md'),
'utf-8',
);
expect(skill).not.toContain('report_customization_migration');
expect({
contributions,
requiredContent: [
'name: migrate-customizations',
'user-invocable: true',
'disable-model-invocation: true',
'Agent Customizations > Migrations',
'Do not ask the user to manually reconstruct or paste the missing context',
'./references/migration-techniques.md',
'migration-log.md',
'restore.md',
'pull request',
'migration-results.json',
].filter(anchor => skill.includes(anchor)),
}).toEqual({
contributions: [{
path: './assets/prompts/skills/migrate-customizations/SKILL.md',
sessionTypes: ['copilotcli', 'claude', 'codex'],
}],
requiredContent: [
'name: migrate-customizations',
'user-invocable: true',
'disable-model-invocation: true',
'Agent Customizations > Migrations',
'Do not ask the user to manually reconstruct or paste the missing context',
'./references/migration-techniques.md',
'migration-log.md',
'restore.md',
'pull request',
'migration-results.json',
],
});
});
});
+6 -1
View File
@@ -979,13 +979,18 @@ const coAuthorRegex = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim;
export function parseCoAuthors(message: string): CoAuthor[] {
const coAuthors: CoAuthor[] = [];
const emails = new Set<string>();
let match;
coAuthorRegex.lastIndex = 0;
while ((match = coAuthorRegex.exec(message)) !== null) {
const name = match[1].trim();
const email = match[2].trim();
if (name && email) {
// Squash merge commit messages repeat the trailers of each squashed commit
const emailKey = email.toLowerCase();
if (name && email && !emails.has(emailKey)) {
emails.add(emailKey);
coAuthors.push({ name, email });
}
}
+26
View File
@@ -642,6 +642,32 @@ suite('git', () => {
[{ name: 'Jane Doe', email: 'jane@example.com' }]
);
});
test('duplicate co-authors (squash merge)', function () {
assert.deepStrictEqual(
parseCoAuthors([
'Fix bug (#123)',
'',
'* First change',
'',
'Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>',
'',
'* Second change',
'',
'Co-authored-by: Jane Doe <jane@example.com>',
'Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>',
'',
'---------',
'',
'Co-authored-by: Copilot <223556219+copilot@users.noreply.github.com>',
'Co-authored-by: Jane <JANE@example.com>'
].join('\n')),
[
{ name: 'Copilot', email: '223556219+Copilot@users.noreply.github.com' },
{ name: 'Jane Doe', email: 'jane@example.com' }
]
);
});
});
suite('splitInChunks', () => {
@@ -168,10 +168,11 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
});
const browserConfig = vscode.workspace.getConfiguration('workbench.browser');
const agentConfig = vscode.workspace.getConfiguration('chat.agent');
const agentNetworkConfig = vscode.workspace.getConfiguration('chat.agent.sandbox.network');
try {
await agentConfig.update('allowedNetworkDomains', ['*'], vscode.ConfigurationTarget.Global);
await agentConfig.update('deniedNetworkDomains', ['localhost'], vscode.ConfigurationTarget.Global);
await agentNetworkConfig.update('allowedDomains', ['*'], vscode.ConfigurationTarget.Global);
await agentNetworkConfig.update('deniedDomains', ['localhost'], vscode.ConfigurationTarget.Global);
await agentConfig.update('networkFilter', true, vscode.ConfigurationTarget.Global);
await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global);
@@ -233,8 +234,8 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
} finally {
await browserConfig.update('dataStorage', undefined, vscode.ConfigurationTarget.Global);
await agentConfig.update('networkFilter', undefined, vscode.ConfigurationTarget.Global);
await agentConfig.update('allowedNetworkDomains', undefined, vscode.ConfigurationTarget.Global);
await agentConfig.update('deniedNetworkDomains', undefined, vscode.ConfigurationTarget.Global);
await agentNetworkConfig.update('allowedDomains', undefined, vscode.ConfigurationTarget.Global);
await agentNetworkConfig.update('deniedDomains', undefined, vscode.ConfigurationTarget.Global);
await Promise.all(vscode.window.browserTabs.map(tab => tab.close()));
await new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
@@ -319,7 +319,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
this.timeout(60000);
const configuration = vscode.workspace.getConfiguration();
await configuration.update('chat.agent.allowedNetworkDomains', ['example.com'], vscode.ConfigurationTarget.Global);
await configuration.update('chat.agent.sandbox.network.allowedDomains', ['example.com'], vscode.ConfigurationTarget.Global);
try {
const output = await invokeRunInTerminal('curl -s --max-time 5 https://example.com');
const trimmed = output.trim();
@@ -329,7 +329,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
];
assert.ok(acceptable.some(value => trimmed.includes(value) || trimmed === value), `Unexpected output: ${JSON.stringify(trimmed)}`);
} finally {
await configuration.update('chat.agent.allowedNetworkDomains', undefined, vscode.ConfigurationTarget.Global);
await configuration.update('chat.agent.sandbox.network.allowedDomains', undefined, vscode.ConfigurationTarget.Global);
}
});
+38 -38
View File
@@ -1,18 +1,18 @@
{
"name": "code-oss-dev",
"version": "1.141.0",
"version": "1.142.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "code-oss-dev",
"version": "1.141.0",
"version": "1.142.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.82.0",
"@devcontainers/cli": "0.88.0",
"@github/copilot-sdk": "1.0.17-preview.0",
"@github/copilot-sdk": "1.0.17-preview.4",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@microsoft/dev-tunnels-connections": "^1.3.41",
@@ -1170,9 +1170,9 @@
}
},
"node_modules/@github/copilot-sdk": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.0.tgz",
"integrity": "sha512-XYoM7Jv+KOw28i+SbZ8hddpkSNjFOeBGZX7aGl+dRWzRFGOUlS4wpmaH33I3EvywfXRj48CO/6/0FabX98fk7Q==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.4.tgz",
"integrity": "sha512-tt5pyDLUjiaxJSMCU2HRDLRSFOA4mIFpI6+360EC0jqNDmhlaeJy88YwO6zDO77D21l8lGt8INBj+IYMbPb30Q==",
"license": "MIT",
"dependencies": {
"koffi": "3.2.1",
@@ -1183,20 +1183,20 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@github/copilot-sdk-darwin-arm64": "1.0.17-preview.0",
"@github/copilot-sdk-darwin-x64": "1.0.17-preview.0",
"@github/copilot-sdk-linux-arm64": "1.0.17-preview.0",
"@github/copilot-sdk-linux-x64": "1.0.17-preview.0",
"@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.0",
"@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.0",
"@github/copilot-sdk-win32-arm64": "1.0.17-preview.0",
"@github/copilot-sdk-win32-x64": "1.0.17-preview.0"
"@github/copilot-sdk-darwin-arm64": "1.0.17-preview.4",
"@github/copilot-sdk-darwin-x64": "1.0.17-preview.4",
"@github/copilot-sdk-linux-arm64": "1.0.17-preview.4",
"@github/copilot-sdk-linux-x64": "1.0.17-preview.4",
"@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.4",
"@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.4",
"@github/copilot-sdk-win32-arm64": "1.0.17-preview.4",
"@github/copilot-sdk-win32-x64": "1.0.17-preview.4"
}
},
"node_modules/@github/copilot-sdk-darwin-arm64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.0.tgz",
"integrity": "sha512-p7J5iCeq6ntw4GD42AK7Kmck9jyhVjKZFFH2W8G8B76fopYSBnvMX+LxdfLDkvOHb0OMY91rG0KA9EImZLkiiA==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.4.tgz",
"integrity": "sha512-cufab0CWxFH0wwIw1/dlPNx4gFP/PJoL4+tUQ/d6lZ+SWX4/2VQgRJCQ97qe9Nqql3YWDikiJTTHXQ55xkmmRA==",
"cpu": [
"arm64"
],
@@ -1207,9 +1207,9 @@
]
},
"node_modules/@github/copilot-sdk-darwin-x64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.0.tgz",
"integrity": "sha512-5whrJOGWrfDZz97VH2TavhWPppNbf9E19IcGtF2DhxBCdWklLWhBfSDCMzrWvpiUnukHSJA925sKQCoThJDKZg==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.4.tgz",
"integrity": "sha512-4kj7nU3oXCFFm24d6FijBkuiLTCBtfiI8qEHsDcSxfgPdPnwV+9osT/EAZRKPTh3DYGwqTO7i+bIHEHgogd+Wg==",
"cpu": [
"x64"
],
@@ -1220,9 +1220,9 @@
]
},
"node_modules/@github/copilot-sdk-linux-arm64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.0.tgz",
"integrity": "sha512-Dm1Q+/SIU4V1/+VBI2zUfTHcrKBAhuMY204WjuVxyry9BBdrAe98XuZuaGv8+ztbWOsIQ/Vhdcbj7PkEBHetrQ==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.4.tgz",
"integrity": "sha512-aOHcV5YiFGhtxh+H3qhOuMzktyhwuepGl/jWkWu2vTLy2oztpMnxmoRgwJaj9NdNQoiM8PO13YjbFhbb3TGaOA==",
"cpu": [
"arm64"
],
@@ -1236,9 +1236,9 @@
]
},
"node_modules/@github/copilot-sdk-linux-x64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.0.tgz",
"integrity": "sha512-UG37BcakUmUh5C7Av1ab2smsFJiitev9mWH3vohaAaCYujj/Zb4CRkTVezVA8KLkkdnq67R7AOQSvTFArb0NoA==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.4.tgz",
"integrity": "sha512-W98/SrLJXVk5xn8GgvUxNs8wHM10nPe5Ff3fU9vKb4Kb0aS2KyxoAhC4eoNn8VRB2XDW2wFTBvUJAutCiWGL+Q==",
"cpu": [
"x64"
],
@@ -1252,9 +1252,9 @@
]
},
"node_modules/@github/copilot-sdk-linuxmusl-arm64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.0.tgz",
"integrity": "sha512-DgwS8F4IAra48ML6jWprk1HeJAANf7FdjCRIViVAuhaVLvdjbPi/7pYTQGLtwTcxN63O4cG+/1mPKf2roz83YA==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.4.tgz",
"integrity": "sha512-hym02Fv/JuqjmmMqs4E+XFEgx8nzIpIdMLSr5CFpMLWVVKQYBhY8x6r+03qXhfutUWlcoL5f8gK/pdOR23k9jw==",
"cpu": [
"arm64"
],
@@ -1268,9 +1268,9 @@
]
},
"node_modules/@github/copilot-sdk-linuxmusl-x64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.0.tgz",
"integrity": "sha512-9T2ik5qK5akLqVhIyvGVicjcumKGOivvuLaAPYU+210MV+t70u9BGQ0a3Vy3tlgDPGHos/js72ZL2RUo6hmFZg==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.4.tgz",
"integrity": "sha512-IhjdLXtUSV8lSZiWsciGHD2iwv3nS2pAwLMywfgB1GozouBDIfWZJlbTUX5Jqv4JeZjE7lpvD5e+Sc11HL6kQQ==",
"cpu": [
"x64"
],
@@ -1284,9 +1284,9 @@
]
},
"node_modules/@github/copilot-sdk-win32-arm64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.0.tgz",
"integrity": "sha512-7nSjNxKXQWCEsf+iDQMEW8dr8q4SkQAimemb0U03fqD3jj7JA6Z/XvmAIFb/Cf+RWqTLexHw0O3HjFs9vF5MNQ==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.4.tgz",
"integrity": "sha512-MxJJiT2DoDXi3a6dPY/DwMbJPweK75ywlIIbK6Agnof055euC4st3bKMUXz8Nuq4pWzXU3HEWFUVWnNr/63TNw==",
"cpu": [
"arm64"
],
@@ -1297,9 +1297,9 @@
]
},
"node_modules/@github/copilot-sdk-win32-x64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.0.tgz",
"integrity": "sha512-4pa51WL3KZgouFrODCngtmGw3yavDIIEwiK+cGAA7jH45uFu5EpPnuVHlpFIrAU/fa80ZSbiX5KkiigP+EpLow==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.4.tgz",
"integrity": "sha512-NGC8k/WHw6lP6O6h/nJiw3lBaaaMjb1hu+urr+Wpa9EP9a/PFL4rWXpxixgQT4qym7EAVY+EE1vSTA8jCi7cAA==",
"cpu": [
"x64"
],
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "code-oss-dev",
"version": "1.141.0",
"distro": "a620eb1a35f4f1994abbd67502c0b2c3b8e73ee0",
"version": "1.142.0",
"distro": "a0e7521b2a1e3289e4118fad155277b200aebd35",
"author": {
"name": "Microsoft Corporation"
},
@@ -13,7 +13,7 @@
"test": "node -e \"console.error('Run a test script from the scripts folder, for example: ./scripts/test.sh --run <file>.'); process.exit(1)\"",
"test-browser": "npx playwright install && node test/unit/browser/index.js",
"test-browser-no-install": "node test/unit/browser/index.js",
"test-node": "mocha test/unit/node/index.js --delay --ui=tdd --timeout=5000 --exit",
"test-node": "mocha test/unit/node/index.js --delay --ui=tdd --exit",
"test-extension": "vscode-test",
"test-build-scripts": "cd build && npm run test",
"test-agent-host-e2e": "node scripts/test-agent-host-e2e.ts",
@@ -102,7 +102,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.82.0",
"@devcontainers/cli": "0.88.0",
"@github/copilot-sdk": "1.0.17-preview.0",
"@github/copilot-sdk": "1.0.17-preview.4",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@microsoft/dev-tunnels-connections": "^1.3.41",
@@ -325,5 +325,5 @@
"@vscode/windows-ca-certs@0.3.4": true,
"@vscode/windows-process-tree@0.8.0": true
},
"copilotRuntimeVersion": "1.0.92-0"
"copilotRuntimeVersion": "1.0.92-4"
}
+36 -36
View File
@@ -9,7 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@devcontainers/cli": "0.88.0",
"@github/copilot-sdk": "1.0.17-preview.0",
"@github/copilot-sdk": "1.0.17-preview.4",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@microsoft/mxc-sdk": "0.9.0",
@@ -73,9 +73,9 @@
}
},
"node_modules/@github/copilot-sdk": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.0.tgz",
"integrity": "sha512-XYoM7Jv+KOw28i+SbZ8hddpkSNjFOeBGZX7aGl+dRWzRFGOUlS4wpmaH33I3EvywfXRj48CO/6/0FabX98fk7Q==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.4.tgz",
"integrity": "sha512-tt5pyDLUjiaxJSMCU2HRDLRSFOA4mIFpI6+360EC0jqNDmhlaeJy88YwO6zDO77D21l8lGt8INBj+IYMbPb30Q==",
"license": "MIT",
"dependencies": {
"koffi": "3.2.1",
@@ -86,20 +86,20 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@github/copilot-sdk-darwin-arm64": "1.0.17-preview.0",
"@github/copilot-sdk-darwin-x64": "1.0.17-preview.0",
"@github/copilot-sdk-linux-arm64": "1.0.17-preview.0",
"@github/copilot-sdk-linux-x64": "1.0.17-preview.0",
"@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.0",
"@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.0",
"@github/copilot-sdk-win32-arm64": "1.0.17-preview.0",
"@github/copilot-sdk-win32-x64": "1.0.17-preview.0"
"@github/copilot-sdk-darwin-arm64": "1.0.17-preview.4",
"@github/copilot-sdk-darwin-x64": "1.0.17-preview.4",
"@github/copilot-sdk-linux-arm64": "1.0.17-preview.4",
"@github/copilot-sdk-linux-x64": "1.0.17-preview.4",
"@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.4",
"@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.4",
"@github/copilot-sdk-win32-arm64": "1.0.17-preview.4",
"@github/copilot-sdk-win32-x64": "1.0.17-preview.4"
}
},
"node_modules/@github/copilot-sdk-darwin-arm64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.0.tgz",
"integrity": "sha512-p7J5iCeq6ntw4GD42AK7Kmck9jyhVjKZFFH2W8G8B76fopYSBnvMX+LxdfLDkvOHb0OMY91rG0KA9EImZLkiiA==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.4.tgz",
"integrity": "sha512-cufab0CWxFH0wwIw1/dlPNx4gFP/PJoL4+tUQ/d6lZ+SWX4/2VQgRJCQ97qe9Nqql3YWDikiJTTHXQ55xkmmRA==",
"cpu": [
"arm64"
],
@@ -110,9 +110,9 @@
]
},
"node_modules/@github/copilot-sdk-darwin-x64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.0.tgz",
"integrity": "sha512-5whrJOGWrfDZz97VH2TavhWPppNbf9E19IcGtF2DhxBCdWklLWhBfSDCMzrWvpiUnukHSJA925sKQCoThJDKZg==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.4.tgz",
"integrity": "sha512-4kj7nU3oXCFFm24d6FijBkuiLTCBtfiI8qEHsDcSxfgPdPnwV+9osT/EAZRKPTh3DYGwqTO7i+bIHEHgogd+Wg==",
"cpu": [
"x64"
],
@@ -123,9 +123,9 @@
]
},
"node_modules/@github/copilot-sdk-linux-arm64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.0.tgz",
"integrity": "sha512-Dm1Q+/SIU4V1/+VBI2zUfTHcrKBAhuMY204WjuVxyry9BBdrAe98XuZuaGv8+ztbWOsIQ/Vhdcbj7PkEBHetrQ==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.4.tgz",
"integrity": "sha512-aOHcV5YiFGhtxh+H3qhOuMzktyhwuepGl/jWkWu2vTLy2oztpMnxmoRgwJaj9NdNQoiM8PO13YjbFhbb3TGaOA==",
"cpu": [
"arm64"
],
@@ -139,9 +139,9 @@
]
},
"node_modules/@github/copilot-sdk-linux-x64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.0.tgz",
"integrity": "sha512-UG37BcakUmUh5C7Av1ab2smsFJiitev9mWH3vohaAaCYujj/Zb4CRkTVezVA8KLkkdnq67R7AOQSvTFArb0NoA==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.4.tgz",
"integrity": "sha512-W98/SrLJXVk5xn8GgvUxNs8wHM10nPe5Ff3fU9vKb4Kb0aS2KyxoAhC4eoNn8VRB2XDW2wFTBvUJAutCiWGL+Q==",
"cpu": [
"x64"
],
@@ -155,9 +155,9 @@
]
},
"node_modules/@github/copilot-sdk-linuxmusl-arm64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.0.tgz",
"integrity": "sha512-DgwS8F4IAra48ML6jWprk1HeJAANf7FdjCRIViVAuhaVLvdjbPi/7pYTQGLtwTcxN63O4cG+/1mPKf2roz83YA==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.4.tgz",
"integrity": "sha512-hym02Fv/JuqjmmMqs4E+XFEgx8nzIpIdMLSr5CFpMLWVVKQYBhY8x6r+03qXhfutUWlcoL5f8gK/pdOR23k9jw==",
"cpu": [
"arm64"
],
@@ -171,9 +171,9 @@
]
},
"node_modules/@github/copilot-sdk-linuxmusl-x64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.0.tgz",
"integrity": "sha512-9T2ik5qK5akLqVhIyvGVicjcumKGOivvuLaAPYU+210MV+t70u9BGQ0a3Vy3tlgDPGHos/js72ZL2RUo6hmFZg==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.4.tgz",
"integrity": "sha512-IhjdLXtUSV8lSZiWsciGHD2iwv3nS2pAwLMywfgB1GozouBDIfWZJlbTUX5Jqv4JeZjE7lpvD5e+Sc11HL6kQQ==",
"cpu": [
"x64"
],
@@ -187,9 +187,9 @@
]
},
"node_modules/@github/copilot-sdk-win32-arm64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.0.tgz",
"integrity": "sha512-7nSjNxKXQWCEsf+iDQMEW8dr8q4SkQAimemb0U03fqD3jj7JA6Z/XvmAIFb/Cf+RWqTLexHw0O3HjFs9vF5MNQ==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.4.tgz",
"integrity": "sha512-MxJJiT2DoDXi3a6dPY/DwMbJPweK75ywlIIbK6Agnof055euC4st3bKMUXz8Nuq4pWzXU3HEWFUVWnNr/63TNw==",
"cpu": [
"arm64"
],
@@ -200,9 +200,9 @@
]
},
"node_modules/@github/copilot-sdk-win32-x64": {
"version": "1.0.17-preview.0",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.0.tgz",
"integrity": "sha512-4pa51WL3KZgouFrODCngtmGw3yavDIIEwiK+cGAA7jH45uFu5EpPnuVHlpFIrAU/fa80ZSbiX5KkiigP+EpLow==",
"version": "1.0.17-preview.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.4.tgz",
"integrity": "sha512-NGC8k/WHw6lP6O6h/nJiw3lBaaaMjb1hu+urr+Wpa9EP9a/PFL4rWXpxixgQT4qym7EAVY+EE1vSTA8jCi7cAA==",
"cpu": [
"x64"
],
+2 -2
View File
@@ -4,7 +4,7 @@
"private": true,
"dependencies": {
"@devcontainers/cli": "0.88.0",
"@github/copilot-sdk": "1.0.17-preview.0",
"@github/copilot-sdk": "1.0.17-preview.4",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@microsoft/mxc-sdk": "0.9.0",
@@ -78,5 +78,5 @@
"@vscode/windows-ca-certs@0.3.4": true,
"@vscode/windows-process-tree@0.8.0": true
},
"copilotRuntimeVersion": "1.0.92-0"
"copilotRuntimeVersion": "1.0.92-4"
}
+1 -1
View File
@@ -1293,7 +1293,7 @@ export class Emitter<T> {
let stack: Stacktrace | undefined;
if (this._leakWarningThreshold !== undefined && this._size >= Math.ceil(this._leakWarningThreshold * 0.2)) {
const leakageMon = this._getLeakageMonitor();
if (leakageMon) {
if (leakageMon && this._size + 1 >= leakageMon.threshold) {
// check and record this emitter for potential leakage
contained.stack = Stacktrace.create();
removeMonitor = leakageMon.check(contained.stack, this._size + 1);
@@ -17,7 +17,7 @@ export function popup(items: IContextMenuItem[], options?: IPopupOptions, onHide
const itemId = args[0] as number;
const context = args[1] as IContextMenuEvent;
const item = processedItems[itemId];
item.click?.(context);
item?.click?.(context);
};
ipcRenderer.once(onClickChannel, onClickChannelHandler);
@@ -27,8 +27,6 @@ export function popup(items: IContextMenuItem[], options?: IPopupOptions, onHide
return;
}
ipcRenderer.removeListener(onClickChannel, onClickChannelHandler);
onHide?.();
});
@@ -20,6 +20,8 @@ export function registerContextMenuListener(): void {
// It turns out that the menu gets GC'ed if not referenced anymore
// As such we drag it into this scope so that it is not being GC'ed
if (menu) {
// Complete the one-shot click listener even when no item was selected.
event.sender.send(onClickChannel, -1);
event.sender.send(CONTEXT_MENU_CLOSE_CHANNEL, contextMenuId);
}
}
@@ -38,6 +38,7 @@ import { MessageController } from '../../message/browser/messageController.js';
import { PreferredPasteConfiguration } from './copyPasteContribution.js';
import { DefaultTextPasteOrDropEditProvider } from './defaultProviders.js';
import { createCombinedWorkspaceEdit, sortEditsByYieldTo } from './edit.js';
import { PasteEditSession } from './pasteEditSession.js';
import { PostEditWidgetManager } from './postEditWidget.js';
export const changePasteTypeCommandId = 'editor.changePasteType';
@@ -60,11 +61,6 @@ type PasteEditWithProvider = DocumentPasteEdit & {
};
interface DocumentPasteWithProviderEditsSession {
edits: readonly PasteEditWithProvider[];
dispose(): void;
}
export type PastePreference =
| { readonly only: HierarchicalKind }
| { readonly preferences: readonly HierarchicalKind[] }
@@ -339,7 +335,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi
const disposables = new DisposableStore();
const cts = disposables.add(new CancellationTokenSource(pToken));
disposables.add(editorStateCts.token.onCancellationRequested(() => cts.cancel()));
const editorStateListener = disposables.add(editorStateCts.token.onCancellationRequested(() => cts.cancel()));
const token = cts.token;
try {
@@ -359,8 +355,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi
triggerKind: DocumentPasteTriggerKind.Automatic,
};
const editSession = await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, token);
disposables.add(editSession);
const editSession = disposables.add(await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, token));
if (token.isCancellationRequested) {
return;
}
@@ -372,7 +367,9 @@ export class CopyPasteController extends Disposable implements IEditorContributi
if (editSession.edits.length) {
const canShowWidget = editor.getOption(EditorOption.pasteAs).showPasteSelector === 'afterPaste';
return this._postPasteWidgetManager.applyEditAndShowIfNeeded(selections, { activeEditIndex: this.getInitialActiveEditIndex(model, editSession.edits), allEdits: editSession.edits }, canShowWidget, async (edit, resolveToken) => {
// Applying the paste changes editor state itself. The widget manager handles cancellation during resolve.
disposables.delete(editorStateListener);
return await this._postPasteWidgetManager.applyEditAndShowIfNeeded(selections, { activeEditIndex: this.getInitialActiveEditIndex(model, editSession.edits), allEdits: editSession.edits }, canShowWidget, async (edit, resolveToken) => {
if (!edit.provider.resolveDocumentPasteEdit) {
return edit;
}
@@ -388,7 +385,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi
edit.additionalEdit = resolved.additionalEdit;
}
return edit;
}, token);
}, token, editSession);
}
await this.applyDefaultPasteHandler(dataTransfer, metadata, token, clipboardEvent);
@@ -443,28 +440,26 @@ export class CopyPasteController extends Disposable implements IEditorContributi
triggerKind: DocumentPasteTriggerKind.PasteAs,
only: preference && 'only' in preference ? preference.only : undefined,
};
let editSession = disposables.add(await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, tokenSource.token));
const editSession = disposables.add(await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, tokenSource.token));
if (tokenSource.token.isCancellationRequested) {
return;
}
// Filter out any edits that don't match the requested kind
let edits = editSession.edits;
if (preference) {
editSession = {
edits: editSession.edits.filter(edit => {
if ('only' in preference) {
return preference.only.contains(edit.kind);
} else if ('preferences' in preference) {
return preference.preferences.some(preference => preference.contains(edit.kind));
} else {
return preference.providerId === edit.provider.id;
}
}),
dispose: editSession.dispose
};
edits = edits.filter(edit => {
if ('only' in preference) {
return preference.only.contains(edit.kind);
} else if ('preferences' in preference) {
return preference.preferences.some(preference => preference.contains(edit.kind));
} else {
return preference.providerId === edit.provider.id;
}
});
}
if (!editSession.edits.length) {
if (!edits.length) {
if (preference) {
this.showPasteAsNoEditMessage(selections, preference);
}
@@ -473,7 +468,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi
let pickedEdit: DocumentPasteEdit | undefined;
if (preference) {
pickedEdit = editSession.edits.at(0);
pickedEdit = edits.at(0);
} else {
type ItemWithEdit = IQuickPickItem & { edit?: DocumentPasteEdit };
const configureDefaultItem: ItemWithEdit = {
@@ -484,7 +479,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi
const selected = await this._quickInputService.pick<ItemWithEdit>(
[
...editSession.edits.map((edit): ItemWithEdit => ({
...edits.map((edit): ItemWithEdit => ({
label: edit.title,
description: edit.kind?.value,
edit,
@@ -596,7 +591,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi
}
}
private async getPasteEdits(providers: readonly DocumentPasteEditProvider[], dataTransfer: VSDataTransfer, model: ITextModel, selections: readonly Selection[], context: DocumentPasteContext, token: CancellationToken): Promise<DocumentPasteWithProviderEditsSession> {
private async getPasteEdits(providers: readonly DocumentPasteEditProvider[], dataTransfer: VSDataTransfer, model: ITextModel, selections: readonly Selection[], context: DocumentPasteContext, token: CancellationToken): Promise<PasteEditSession<PasteEditWithProvider>> {
const disposables = new DisposableStore();
const results = await raceCancellation(
@@ -618,10 +613,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi
const edits = coalesce(results ?? []).flat().filter(edit => {
return !context.only || context.only.contains(edit.kind);
});
return {
edits: sortEditsByYieldTo(edits),
dispose: () => disposables.dispose()
};
return new PasteEditSession(sortEditsByYieldTo(edits), disposables);
}
private async applyDefaultPasteHandler(dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined, token: CancellationToken, clipboardEvent: ClipboardEvent | undefined) {
@@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { DocumentPasteEdit } from '../../../common/languages.js';
/** Owns cached paste edits until they are disposed or transferred to another session. */
export class PasteEditSession<T extends DocumentPasteEdit = DocumentPasteEdit> extends Disposable {
private readonly _disposables = this._register(new MutableDisposable<IDisposable>());
constructor(readonly edits: readonly T[], disposables: IDisposable | undefined) {
super();
this._disposables.value = disposables;
}
/** Transfers the edits to a new owner. Disposing this session will no longer release them. */
take(): PasteEditSession<T> {
return new PasteEditSession(this.edits, this._disposables.clearAndLeak());
}
}
@@ -28,6 +28,7 @@ import { DocumentDropEdit, DocumentPasteEdit } from '../../../common/languages.j
import { TrackedRangeStickiness } from '../../../common/model.js';
import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from '../../editorState/browser/editorState.js';
import { createCombinedWorkspaceEdit } from './edit.js';
import { PasteEditSession } from './pasteEditSession.js';
import './postEditWidget.css';
@@ -47,6 +48,8 @@ class PostEditWidget<T extends DocumentPasteEdit | DocumentDropEdit> extends Dis
readonly allowEditorOverflow = true;
readonly suppressMouseDown = true;
private readonly _editSession: PasteEditSession | undefined;
private domNode!: HTMLElement;
private button!: Button;
@@ -59,14 +62,16 @@ class PostEditWidget<T extends DocumentPasteEdit | DocumentDropEdit> extends Dis
private readonly showCommand: ShowCommand,
private readonly range: Range,
private readonly edits: EditSet<T>,
private readonly onSelectNewEdit: (editIndex: number) => void,
private readonly onSelectNewEdit: (editIndex: number, editSession: PasteEditSession | undefined) => void,
private readonly additionalActions: readonly IAction[],
editSessionOwner: PasteEditSession | undefined,
@IContextKeyService contextKeyService: IContextKeyService,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@IActionWidgetService private readonly _actionWidgetService: IActionWidgetService,
) {
super();
this._editSession = editSessionOwner ? this._register(editSessionOwner.take()) : undefined;
this.create();
this.visibleContext = visibleContext.bindTo(contextKeyService);
@@ -140,7 +145,8 @@ class PostEditWidget<T extends DocumentPasteEdit | DocumentDropEdit> extends Dis
const i = this.edits.allEdits.findIndex(edit => edit === item);
if (i !== this.edits.activeEditIndex) {
return this.onSelectNewEdit(i);
// Transfer the edits before undo disposes this widget.
return this.onSelectNewEdit(i, this._editSession?.take());
}
},
}, anchor, this.editor.getDomNode() ?? undefined, this.additionalActions);
@@ -169,7 +175,7 @@ export class PostEditWidgetManager<T extends DocumentPasteEdit | DocumentDropEdi
)(() => this.clear()));
}
public async applyEditAndShowIfNeeded(ranges: readonly Range[], edits: EditSet<T>, canShowWidget: boolean, resolve: (edit: T, token: CancellationToken) => Promise<T>, token: CancellationToken) {
public async applyEditAndShowIfNeeded(ranges: readonly Range[], edits: EditSet<T>, canShowWidget: boolean, resolve: (edit: T, token: CancellationToken) => Promise<T>, token: CancellationToken, editSessionOwner?: PasteEditSession) {
if (!ranges.length || !this._editor.hasModel()) {
return;
}
@@ -180,14 +186,19 @@ export class PostEditWidgetManager<T extends DocumentPasteEdit | DocumentDropEdi
return;
}
const onDidSelectEdit = async (newEditIndex: number) => {
const model = this._editor.getModel();
if (!model) {
return;
}
const onDidSelectEdit = async (newEditIndex: number, editSession: PasteEditSession | undefined) => {
try {
this.clear();
const model = this._editor.getModel();
if (!model) {
return;
}
await model.undo();
this.applyEditAndShowIfNeeded(ranges, { activeEditIndex: newEditIndex, allEdits: edits.allEdits }, canShowWidget, resolve, token);
await model.undo();
await this.applyEditAndShowIfNeeded(ranges, { activeEditIndex: newEditIndex, allEdits: edits.allEdits }, canShowWidget, resolve, token, editSession);
} finally {
editSession?.dispose();
}
};
const handleError = (e: Error, message: string) => {
@@ -197,7 +208,7 @@ export class PostEditWidgetManager<T extends DocumentPasteEdit | DocumentDropEdi
this._notificationService.error(message);
if (canShowWidget) {
this.show(ranges[0], edits, onDidSelectEdit);
this.show(ranges[0], edits, onDidSelectEdit, editSessionOwner);
}
};
@@ -241,15 +252,15 @@ export class PostEditWidgetManager<T extends DocumentPasteEdit | DocumentDropEdi
}
if (canShowWidget && editResult.isApplied && edits.allEdits.length > 1) {
this.show(editRange ?? primaryRange, edits, onDidSelectEdit);
this.show(editRange ?? primaryRange, edits, onDidSelectEdit, editSessionOwner);
}
}
public show(range: Range, edits: EditSet<T>, onDidSelectEdit: (newIndex: number) => void) {
public show(range: Range, edits: EditSet<T>, onDidSelectEdit: (newIndex: number, editSession: PasteEditSession | undefined) => void, editSessionOwner?: PasteEditSession) {
this.clear();
if (this._editor.hasModel()) {
this._currentWidget.value = this._instantiationService.createInstance(PostEditWidget<T>, this._id, this._editor, this._visibleContext, this._showCommand, range, edits, onDidSelectEdit, this._getAdditionalActions());
this._currentWidget.value = this._instantiationService.createInstance(PostEditWidget<T>, this._id, this._editor, this._visibleContext, this._showCommand, range, edits, onDidSelectEdit, this._getAdditionalActions(), editSessionOwner);
}
}
@@ -0,0 +1,142 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import sinon from 'sinon';
import { DeferredPromise } from '../../../../../base/common/async.js';
import { createStringDataTransferItem, VSDataTransfer } from '../../../../../base/common/dataTransfer.js';
import { HierarchicalKind } from '../../../../../base/common/hierarchicalKind.js';
import { mock } from '../../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
import { IProgressService } from '../../../../../platform/progress/common/progress.js';
import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';
import { IBulkEditService, ResourceEdit, ResourceTextEdit } from '../../../../browser/services/bulkEditService.js';
import { DocumentPasteEditProvider, WorkspaceEdit } from '../../../../common/languages.js';
import { withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js';
import { SnippetParser } from '../../../snippet/browser/snippetParser.js';
import { CopyPasteController } from '../../browser/copyPasteController.js';
suite('CopyPasteController - paste edit session', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
for (const endInteraction of ['dismiss', 'cursor', 'content', 'model', 'dispose', 'noSelector', 'singleEdit', 'cancelReplacement', 'failedUndo'] as const) {
let name = `resolves alternatives after the initial paste and releases edits on ${endInteraction}`;
switch (endInteraction) {
case 'noSelector': name = 'releases edits after paste when the selector is disabled'; break;
case 'singleEdit': name = 'releases edits after paste when there are no alternatives'; break;
case 'cancelReplacement': name = 'releases transferred edits when alternative resolution is cancelled'; break;
case 'failedUndo': name = 'releases transferred edits when undo fails'; break;
}
test(name, async () => {
await withAsyncTestCodeEditor('', { pasteAs: { enabled: true, showPasteSelector: endInteraction === 'noSelector' ? 'never' : 'afterPaste' } }, async (editor, _viewModel, instantiationService) => {
instantiationService.stub(createDecorator<{ add(): () => void }>('IEditorCancelService'), { add: () => () => { } });
let selectEdit: ((index: number) => Promise<void>) | undefined;
instantiationService.stub(IActionWidgetService, new class extends mock<IActionWidgetService>() {
override show: IActionWidgetService['show'] = (_user, _preview, items, delegate) => {
selectEdit = async index => { await delegate.onSelect(items[index].item!); };
};
override hide() { }
});
instantiationService.stub(IBulkEditService, new class extends mock<IBulkEditService>() {
override async apply(edit: ResourceEdit[] | WorkspaceEdit) {
const textEdits = (Array.isArray(edit) ? edit : edit.edits).map(edit => {
assert.ok(ResourceTextEdit.is(edit));
return { range: edit.textEdit.range, text: new SnippetParser().parse(edit.textEdit.text).toString() };
});
editor.getModel().pushStackElement();
editor.getModel().pushEditOperations(editor.getSelections(), textEdits, () => null);
editor.getModel().pushStackElement();
return { isApplied: true, ariaSummary: '' };
}
});
instantiationService.stub(IQuickInputService, new class extends mock<IQuickInputService>() { });
instantiationService.stub(IProgressService, new class extends mock<IProgressService>() { });
let released = 0;
let resolves = 0;
const resolveStarted = new DeferredPromise<void>();
const finishResolve = new DeferredPromise<void>();
const plainKind = new HierarchicalKind('text.plain');
const provider: DocumentPasteEditProvider = {
copyMimeTypes: [],
pasteMimeTypes: ['text/plain'],
providedPasteEditKinds: [plainKind],
async provideDocumentPasteEdits() {
return {
edits: [
{ title: 'Resolve test', insertText: 'UNRESOLVED', kind: new HierarchicalKind('test'), yieldTo: [{ kind: plainKind }] },
{ title: 'Plain text', insertText: 'PLAIN', kind: plainKind }
].slice(endInteraction === 'singleEdit' ? 1 : 0),
dispose: () => { ++released; }
};
},
async resolveDocumentPasteEdit(edit) {
assert.strictEqual(released, 0, 'Provider edits must still be cached during resolve');
++resolves;
if (endInteraction === 'cancelReplacement' && edit.title === 'Resolve test') {
resolveStarted.complete();
await finishResolve.p;
}
return { ...edit, insertText: edit.title === 'Resolve test' ? 'RESOLVED' : edit.insertText };
}
};
const controller = disposables.add(instantiationService.createInstance(CopyPasteController, editor));
const dataTransfer = new VSDataTransfer();
dataTransfer.append('text/plain', createStringDataTransferItem('PLAIN'));
// The test editor has no clipboard view, so start the inline paste directly.
// eslint-disable-next-line local/code-no-bracket-notation-for-identifiers
controller['doPasteInline']([provider], editor.getSelections(), dataTransfer, undefined, undefined);
await controller.finishedPaste();
if (endInteraction === 'noSelector' || endInteraction === 'singleEdit') {
assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'PLAIN', released: 1, resolves: 1 });
return;
}
assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'PLAIN', released: 0, resolves: 1 });
controller.changePasteType();
assert.ok(selectEdit);
if (endInteraction === 'failedUndo') {
const undo = sinon.stub(editor.getModel(), 'undo').rejects(new Error('Undo failed'));
try {
await assert.rejects(selectEdit(1), /Undo failed/);
} finally {
undo.restore();
}
controller.clearWidgets();
assert.strictEqual(released, 1);
return;
}
if (endInteraction === 'cancelReplacement') {
const selection = selectEdit(1);
await resolveStarted.p;
editor.getModel().setValue('changed');
finishResolve.complete();
await selection;
controller.clearWidgets();
assert.deepStrictEqual({ text: editor.getValue(), released }, { text: 'changed', released: 1 });
return;
}
await selectEdit(1);
assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'RESOLVED', released: 0, resolves: 2 });
controller.changePasteType();
await selectEdit(0);
assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'PLAIN', released: 0, resolves: 3 });
switch (endInteraction) {
case 'dismiss': controller.clearWidgets(); break;
case 'cursor': editor.setPosition({ lineNumber: 1, column: 2 }); break;
case 'content': editor.getModel().setValue('changed'); break;
case 'model': editor.setModel(null); break;
case 'dispose': controller.dispose(); break;
}
controller.clearWidgets();
assert.strictEqual(released, 1);
});
});
}
});
@@ -32,7 +32,7 @@ import { AGENT_HOST_SCHEME, agentHostAuthority, createAgentHostResourceUriMapper
import { AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js';
import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse } from '../common/state/protocol/messages.js';
import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js';
import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, isSessionStatusArchived, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js';
import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js';
import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js';
import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js';
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js';
@@ -1852,6 +1852,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
origin: chat.origin,
...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}),
...(isSessionStatusArchived(chat.status) || chat.archived === true ? { archived: true } : {}),
...(chat.status !== undefined ? { isRead: isSessionStatusRead(chat.status) } : {}),
...(chat.changes !== undefined ? { changes: chat.changes } : {}),
})),
// Carry durable host provenance for sessions first materialized from a listing.
@@ -193,6 +193,8 @@ export interface IAgentSessionChatMetadata {
readonly origin?: ChatOrigin;
readonly interactivity?: ChatInteractivity;
readonly archived?: boolean;
/** Exact chat read state when known; absence means the provider did not supply it. */
readonly isRead?: boolean;
readonly changes?: ChangesSummary;
}
@@ -1044,6 +1046,9 @@ export interface IAgentToolPendingConfirmationSignal {
export type AgentSubagentTaskModelSource = 'task_argument' | 'subagent_configuration' | 'custom_agent_definition' | 'unset';
/** `task` is a delegated subagent; `fusionPhase` is a presentation-only chat for one HydraFusion phase of the parent turn. */
export type AgentSubagentKind = 'task' | 'fusionPhase';
/**
* A subagent was spawned by a tool call. The host creates a child session
* silently and routes subsequent inner-tool events to it.
@@ -1059,6 +1064,10 @@ export interface IAgentSubagentStartedSignal {
readonly agentDisplayName: string;
readonly agentDescription?: string;
readonly taskModelSource?: AgentSubagentTaskModelSource;
/** Absent means `task`. */
readonly subagentKind?: AgentSubagentKind;
/** For telemetry, when the chat reports no usage of its own. */
readonly model?: string;
/**
* The spawning Task tool's short (typically 3-5 word) `description`
* input, e.g. "Review package.json structure". Distinct from
@@ -8,6 +8,18 @@ import { type AgentProvider } from './agent.js';
const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-';
const REMOTE_AGENT_HOST_SESSION_TYPE_PREFIX = 'remote-';
/** Identifies the Copilot agent family without changing the host's provider ID or implying runtime capabilities. */
export function isCopilotAgentHostProvider(provider: AgentProvider | undefined): boolean {
return provider === 'copilotcli' || provider === 'copilot';
}
/** Identifies local and remote Copilot agent-host session types, preserving their existing URI schemes. */
export function isCopilotAgentHostSessionType(sessionType: string): boolean {
const provider = parseAgentHostHarness(sessionType);
return provider !== undefined && isCopilotAgentHostProvider(provider)
&& (!isRemoteAgentHostSessionType(sessionType) || parseRemoteAgentHostSessionTypeAuthority(sessionType, provider) !== undefined);
}
/**
* Builds the unique per-connection identifier for a remote agent host.
*
@@ -988,6 +988,7 @@ export interface IAgentService {
* with {@link unsubscribe} when the subscription is released. When
* provided, `isActive` is checked before registering the subscriber so a
* request cancelled during asynchronous resolution cannot pin the resource.
* Callers must distinguish lost subscription ownership from a missing resource.
*/
subscribe(resource: URI, clientId: string, isActive?: () => boolean): Promise<IStateSnapshot>;
@@ -17,6 +17,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js';
import { RemoteAgentHostsEnabledSettingId } from './remoteAgentHostService.js';
import { IReplayedTaskHistory } from './taskEventReplay.js';
import { SessionStatus } from './state/sessionState.js';
import { SessionModelInfo } from './state/protocol/state.js';
/** Configuration key gating the cloud-sandbox connection path. Disabled by default. */
export const CloudSandboxEnabledSettingId = 'chat.agentHost.cloudSandbox.enabled';
@@ -122,6 +123,8 @@ export interface ICloudSandboxDiscoveredSession {
readonly updatedAt?: string;
/** Last reported activity; this does not establish environment availability or session flags. */
readonly status?: SessionStatus;
/** Mission Control's archive state, independent of the sandbox's AHP session flags. */
readonly isArchived?: boolean;
}
/** Build the synthesized remote-agent-host address for a sandbox environment. */
@@ -233,6 +236,11 @@ export interface ICloudSandboxConnectionRequest {
export const ICloudSandboxApiService = createDecorator<ICloudSandboxApiService>('cloudSandboxApiService');
export interface ICloudSandboxModelCatalog {
readonly models: readonly SessionModelInfo[];
readonly defaultModel?: string;
}
/** Account identity and control-plane APIs for sandbox credentials, discovery, and persisted history. */
export interface ICloudSandboxApiService {
readonly _serviceBrand: undefined;
@@ -243,6 +251,9 @@ export interface ICloudSandboxApiService {
/** Resolves an opaque, credential-free account key using the same identity as task requests. */
getAccountKey(): Promise<string | undefined>;
/** Account-authorized cloud model catalog, available before a sandbox is provisioned. */
listModels(token: CancellationToken): Promise<ICloudSandboxModelCatalog>;
/**
* Mint a fresh client Web PubSub connection token for a new logical connection. May resolve to a
* "waking" result the caller should retry.
@@ -277,6 +288,9 @@ export interface ICloudSandboxApiService {
/** Rename the Mission Control task without waking the sandbox. */
renameTask(taskId: string, title: string, token: CancellationToken): Promise<void>;
/** Archive or unarchive the Mission Control task without waking the sandbox. */
setTaskArchived(taskId: string, archived: boolean, token: CancellationToken): Promise<void>;
/**
* Read a task's persisted AHP history and fold it back into session and chat state. Served by
* Mission Control's mirror, so it works without the sandbox. `undefined` when there is none.
@@ -0,0 +1,7 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export type { ISlashCommandResource } from './vscode/agentSlashCommandOutputMeta.js';
export { readSlashCommandResource, toSlashCommandResourceMeta } from './vscode/agentSlashCommandOutputMeta.js';
@@ -13,6 +13,7 @@ export interface ICloudSandboxProject {
readonly path: string;
readonly git: boolean;
readonly status: 'ready' | 'cloning' | 'failed';
readonly progress?: number;
readonly remoteUrl?: string;
readonly error?: string;
}
@@ -37,6 +38,9 @@ function readProject(value: unknown): ICloudSandboxProject | undefined {
path: value.path,
git: value.git,
status,
progress: status === 'cloning' && typeof value.progress === 'number' && Number.isFinite(value.progress)
? Math.min(100, Math.max(0, value.progress))
: undefined,
remoteUrl: typeof value.remoteUrl === 'string' ? value.remoteUrl : undefined,
error: typeof value.error === 'string' ? value.error : undefined,
};
@@ -4,4 +4,4 @@
*--------------------------------------------------------------------------------------------*/
export type { McpServerSource } from './vscode/mcpCustomizationMeta.js';
export { readMcpServerSource, withMcpServerSourceMeta } from './vscode/mcpCustomizationMeta.js';
export { readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from './vscode/mcpCustomizationMeta.js';
@@ -16,20 +16,30 @@ export interface ISessionSandboxPolicy {
readonly allowDevToolAccess?: boolean;
readonly sandboxMcpServers?: boolean;
readonly sandboxLspServers?: boolean;
readonly authenticateGit?: boolean;
readonly authenticateGh?: boolean;
readonly failClosed?: boolean;
}
/** Missing or unsupported metadata carries no assertion about the host's policy. */
/** Reads trusted host policy metadata; missing or non-object metadata carries no policy assertion. */
export function readSessionSandboxPolicy(source: { readonly _meta?: Record<string, unknown> } | undefined): ISessionSandboxPolicy | undefined {
const value = source?._meta?.[sandboxPolicyKey];
if (!isObject(value)) {
return undefined;
}
const { enabled, allowBypass, allowOutbound, allowLocalNetwork, allowDevToolAccess, sandboxMcpServers, sandboxLspServers, failClosed } = value as Record<string, unknown>;
if (typeof enabled !== 'boolean' || (allowBypass !== undefined && typeof allowBypass !== 'boolean') || (allowOutbound !== undefined && typeof allowOutbound !== 'boolean') || (allowLocalNetwork !== undefined && typeof allowLocalNetwork !== 'boolean') || (allowDevToolAccess !== undefined && typeof allowDevToolAccess !== 'boolean') || (sandboxMcpServers !== undefined && typeof sandboxMcpServers !== 'boolean') || (sandboxLspServers !== undefined && typeof sandboxLspServers !== 'boolean') || (failClosed !== undefined && typeof failClosed !== 'boolean')) {
return undefined;
}
return { enabled, ...(allowBypass !== undefined ? { allowBypass } : {}), ...(allowOutbound !== undefined ? { allowOutbound } : {}), ...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}), ...(allowDevToolAccess !== undefined ? { allowDevToolAccess } : {}), ...(sandboxMcpServers !== undefined ? { sandboxMcpServers } : {}), ...(sandboxLspServers !== undefined ? { sandboxLspServers } : {}), ...(failClosed !== undefined ? { failClosed } : {}) };
const { enabled, allowBypass, allowOutbound, allowLocalNetwork, allowDevToolAccess, sandboxMcpServers, sandboxLspServers, authenticateGit, authenticateGh, failClosed } = value as ISessionSandboxPolicy;
return {
enabled,
...(allowBypass !== undefined ? { allowBypass } : {}),
...(allowOutbound !== undefined ? { allowOutbound } : {}),
...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}),
...(allowDevToolAccess !== undefined ? { allowDevToolAccess } : {}),
...(sandboxMcpServers !== undefined ? { sandboxMcpServers } : {}),
...(sandboxLspServers !== undefined ? { sandboxLspServers } : {}),
...(authenticateGit !== undefined ? { authenticateGit } : {}),
...(authenticateGh !== undefined ? { authenticateGh } : {}),
...(failClosed !== undefined ? { failClosed } : {}),
};
}
export function withSessionSandboxPolicy(meta: Record<string, unknown> | undefined, policy: ISessionSandboxPolicy | undefined): Record<string, unknown> {
@@ -43,6 +53,8 @@ export function withSessionSandboxPolicy(meta: Record<string, unknown> | undefin
...(policy.allowDevToolAccess !== undefined ? { allowDevToolAccess: policy.allowDevToolAccess } : {}),
...(policy.sandboxMcpServers !== undefined ? { sandboxMcpServers: policy.sandboxMcpServers } : {}),
...(policy.sandboxLspServers !== undefined ? { sandboxLspServers: policy.sandboxLspServers } : {}),
...(policy.authenticateGit !== undefined ? { authenticateGit: policy.authenticateGit } : {}),
...(policy.authenticateGh !== undefined ? { authenticateGh: policy.authenticateGh } : {}),
...(policy.failClosed !== undefined ? { failClosed: policy.failClosed } : {}),
} : undefined,
};
@@ -0,0 +1,35 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { isObject } from '../../../../../base/common/types.js';
import { URI } from '../../../../../base/common/uri.js';
const slashCommandResourceKey = 'vscode.slashCommandResource';
export interface ISlashCommandResource {
readonly resource: URI;
readonly preview: boolean;
}
/** Reads an optional resource to open for a user-invoked slash command. */
export function readSlashCommandResource(source: { readonly _meta?: Record<string, unknown> }): ISlashCommandResource | undefined {
const value = source._meta?.[slashCommandResourceKey];
if (!isObject(value)) {
return undefined;
}
const { resource, preview } = value as Record<string, unknown>;
if (typeof resource !== 'string' || typeof preview !== 'boolean') {
return undefined;
}
try {
return { resource: URI.parse(resource, true), preview };
} catch {
return undefined;
}
}
export function toSlashCommandResourceMeta(resource: URI, preview: boolean): Record<string, unknown> {
return { [slashCommandResourceKey]: { resource: resource.toString(), preview } };
}
@@ -5,6 +5,7 @@
import type { Mutable } from '../../../../../base/common/types.js';
import { hasAgentMetadata } from '../metadata.js';
import { ISandboxNetworkRestrictions, isSandboxNetworkRestrictions } from '../../../../sandbox/common/sandboxSettingsResolutionHelper.js';
/** Anything carrying a tool call's `_meta` bag (persisted state or wire actions). */
interface IHasToolCallMeta {
@@ -18,6 +19,8 @@ interface IHasToolCallMeta {
* wrong-typed values.
*/
export interface IToolCallMeta {
/** Trusted Copilot host snapshot for integrated-browser client tools. Absent for other tools and harnesses. */
readonly 'vscode.copilotSandboxNetworkRestrictions'?: ISandboxNetworkRestrictions;
readonly 'agentHost.sandboxBypass'?: boolean;
/**
* VS Code rendering hint. `terminal` routes the call to the command/output
@@ -62,6 +65,7 @@ export type AgentFusionPhaseStatus = typeof fusionPhaseStatuses[number];
const knownFusionPhaseStatuses: ReadonlySet<string> = new Set(fusionPhaseStatuses);
const toolCallMetaKeys = [
'vscode.copilotSandboxNetworkRestrictions',
'agentHost.sandboxBypass', 'toolKind', 'language', 'subagentDescription', 'subagentAgentName', 'subagentChatUri',
'mcpServerName', 'mcpToolName', 'autoApproveBySetting', 'autoApproveRuleResolvable', 'toolSearchCandidates', 'progressMessage', 'fusionPhase',
] as const;
@@ -167,6 +171,10 @@ export function readToolCallMeta(source: IHasToolCallMeta): IToolCallMeta {
return {};
}
const result: Mutable<IToolCallMeta> = {};
const networkRestrictions = meta['vscode.copilotSandboxNetworkRestrictions'];
if (isSandboxNetworkRestrictions(networkRestrictions)) {
result['vscode.copilotSandboxNetworkRestrictions'] = networkRestrictions;
}
if (typeof meta['agentHost.sandboxBypass'] === 'boolean') { result['agentHost.sandboxBypass'] = meta['agentHost.sandboxBypass']; }
if (isToolKind(meta.toolKind)) { result.toolKind = meta.toolKind; }
if (typeof meta.language === 'string') { result.language = meta.language; }
@@ -6,6 +6,8 @@
import type { McpServerCustomization } from '../../state/protocol/state.js';
const sourceKey = 'agentHost.mcpServerSource';
const displayNameKey = 'vscode.mcpServerDisplayName';
const maxDisplayNameLength = 512;
export type McpServerSource =
| 'user' // Defined in user-level configuration.
@@ -36,3 +38,22 @@ export function withMcpServerSourceMeta(meta: Record<string, unknown> | undefine
}
return { ...(meta ?? {}), [sourceKey]: source };
}
/** Reads an optional Connector catalog name without changing the MCP server's runtime identity. */
export function readMcpServerDisplayName(customization: McpServerCustomization | undefined): string | undefined {
return normalizeMcpServerDisplayName(customization?._meta?.[displayNameKey]);
}
/** Records an optional Connector catalog name while preserving every other metadata entry. */
export function withMcpServerDisplayNameMeta(meta: Record<string, unknown> | undefined, displayName: string | undefined): Record<string, unknown> | undefined {
const normalized = normalizeMcpServerDisplayName(displayName);
return normalized === undefined ? meta : { ...(meta ?? {}), [displayNameKey]: normalized };
}
function normalizeMcpServerDisplayName(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 && trimmed.length <= maxDisplayNameLength ? trimmed : undefined;
}
@@ -32,6 +32,8 @@ export const enum AgentHostSandboxKey {
AllowUnsandboxedCommands = 'allowUnsandboxedCommands',
SandboxMcpServers = 'sandboxMcpServers',
SandboxLspServers = 'sandboxLspServers',
AuthenticateGit = 'authenticateGit',
AuthenticateGh = 'authenticateGh',
AllowDevToolAccess = 'allowDevToolAccess',
UserConfiguredPaths = 'fileSystem.userConfiguredPaths',
LinuxFileSystem = 'fileSystem.linux',
@@ -50,6 +52,8 @@ export type ISandboxConfigValue = Partial<{
[AgentHostSandboxKey.AllowUnsandboxedCommands]: boolean;
[AgentHostSandboxKey.SandboxMcpServers]: boolean;
[AgentHostSandboxKey.SandboxLspServers]: boolean;
[AgentHostSandboxKey.AuthenticateGit]: boolean;
[AgentHostSandboxKey.AuthenticateGh]: boolean;
[AgentHostSandboxKey.AllowDevToolAccess]: boolean;
[AgentHostSandboxKey.UserConfiguredPaths]: IAgentSandboxUserConfiguredPaths;
[AgentHostSandboxKey.LinuxFileSystem]: IAgentSandboxFileSystemSetting;
@@ -97,6 +101,14 @@ export const sandboxConfigSchema = createSchema({
type: 'boolean',
title: localize('agentHost.config.sandbox.sandboxLspServers.title', "Sandbox LSP Servers"),
},
[AgentHostSandboxKey.AuthenticateGit]: {
type: 'boolean',
title: localize('agentHost.config.sandbox.authenticateGit.title', "Authenticate git"),
},
[AgentHostSandboxKey.AuthenticateGh]: {
type: 'boolean',
title: localize('agentHost.config.sandbox.authenticateGh.title', "Authenticate gh"),
},
[AgentHostSandboxKey.AllowDevToolAccess]: {
type: 'boolean',
title: localize('agentHost.config.sandbox.allowDevToolAccess.title', "Allow Dev Tool Access"),
@@ -166,6 +178,8 @@ export const sandboxSettingIdToAgentHostKey: Readonly<Record<string, AgentHostSa
[AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands]: AgentHostSandboxKey.AllowUnsandboxedCommands,
[AgentSandboxSettingId.AgentSandboxMcpServers]: AgentHostSandboxKey.SandboxMcpServers,
[AgentSandboxSettingId.AgentSandboxLspServers]: AgentHostSandboxKey.SandboxLspServers,
[AgentSandboxSettingId.AgentSandboxAuthenticateGit]: AgentHostSandboxKey.AuthenticateGit,
[AgentSandboxSettingId.AgentSandboxAuthenticateGh]: AgentHostSandboxKey.AuthenticateGh,
[AgentSandboxSettingId.AgentSandboxAllowDevToolAccess]: AgentHostSandboxKey.AllowDevToolAccess,
[AgentSandboxSettingId.AgentSandboxUserConfiguredPaths]: AgentHostSandboxKey.UserConfiguredPaths,
[AgentSandboxSettingId.AgentSandboxLinuxFileSystem]: AgentHostSandboxKey.LinuxFileSystem,
@@ -41,6 +41,7 @@ import {
type ChangesetState,
type ChatState,
type ChatSummary,
type ChatOrigin,
type CanvasState,
type ErrorInfo,
type ErrorResponsePart,
@@ -2029,12 +2030,14 @@ export const AH_META_AUTO_ARCHIVED_AT_DB_KEY = 'agentHost.autoArchivedAt';
export const AH_META_IS_DONE_DB_KEY = 'isDone';
/**
* Session-database metadata key recording whether a session has been read. This is
* the only durable representation of read state; the in-memory truth is
* {@link SessionStatus.IsRead}. The host owns it — no agent SDK tracks read state.
* Session-database metadata key recording the session aggregate read state. The
* in-memory truth is {@link SessionStatus.IsRead}; chat state is stored separately.
*/
export const AH_META_IS_READ_DB_KEY = 'isRead';
/** Session-database metadata key recording the default chat's independent read state. */
export const AH_META_DEFAULT_CHAT_IS_READ_DB_KEY = 'defaultChatIsRead';
/** Returns `status` with `flag` set or cleared. */
export function withSessionStatusFlag(status: SessionStatus, flag: SessionStatus, set: boolean): SessionStatus {
return set ? (status | flag) : (status & ~flag);
@@ -2045,6 +2048,16 @@ export function isSessionStatusRead(status: SessionStatus | undefined): boolean
return status !== undefined && (status & SessionStatus.IsRead) !== 0;
}
/**
* Whether a chat participates in the containing session's aggregate read state.
* Tool/subagent/hidden chats retain exact per-chat state without making the session unread.
*/
export function isChatInSessionReadAggregate(resource: ProtocolURI, origin?: ChatOrigin, interactivity?: ChatInteractivity): boolean {
return origin?.kind !== ChatOriginKind.Tool
&& interactivity !== ChatInteractivity.Hidden
&& !isSubagentChatUri(resource);
}
/** Whether the {@link SessionStatus.IsArchived} flag bit is set. */
export function isSessionStatusArchived(status: SessionStatus | undefined): boolean {
return status !== undefined && (status & SessionStatus.IsArchived) !== 0;
@@ -14,21 +14,16 @@
// shape. The envelopes are folded by the same `sessionReducer` / `chatReducer` the live
// subscriptions use, so a replayed session and a live one cannot drift.
import { equals } from '../../../base/common/objects.js';
import { ChunkEnvelope, Reassembler } from './webPubSub/chunking.js';
import { ActionEnvelope, ActionType, StateAction } from './state/protocol/common/actions.js';
import { SessionDefaultChatChangedAction } from './state/protocol/channels-session/actions.js';
import { chatReducer } from './state/protocol/channels-chat/reducer.js';
import { ChatState } from './state/protocol/channels-chat/state.js';
import { ChatOriginKind, ChatState } from './state/protocol/channels-chat/state.js';
import { sessionReducer } from './state/protocol/channels-session/reducer.js';
import { SessionLifecycle, SessionState, SessionStatus } from './state/protocol/channels-session/state.js';
import { ChatAction, SessionAction } from './state/sessionActions.js';
/**
* Highest transport sequence number that may legitimately appear *below* the expected next
* sequence. Mission Control re-hosts a dormant session on a fresh mirror process whose transport
* sequence restarts at 0 or 1 while `/events` continues with only the new actions; anything
* further back is a genuine gap.
*/
/** Highest starting sequence allowed for a new mirror epoch after exact duplicates are removed. */
const MAX_RESTART_EPOCH_INITIAL_SEQUENCE = 1;
/** A persisted history that could not be decoded. Distinct from a transport/HTTP failure. */
@@ -48,11 +43,8 @@ export interface IReplayedSession {
/** Folded chat-channel state, keyed by chat channel URI. */
readonly chats: ReadonlyMap<string, ChatState>;
/**
* Channel of the session's default chat, as the recorded history named it.
*
* Resolved from the history rather than derived locally: the host writes whatever channel
* convention it uses (today `<session>/chat`), which need not match the URI a client would
* build for the same chat.
* The host-announced default chat, or a sole recorded chat consistent with all recorded catalogue evidence.
* History without either retains the legacy `<session>/chat` fallback.
*/
readonly defaultChat: string;
/** Timestamp of the last persisted event, ISO 8601. */
@@ -75,6 +67,7 @@ export interface IReplayedTaskHistory {
/** Per-session accumulator used while decoding the transport layer. */
interface ISessionReplayState {
readonly envelopes: ActionEnvelope[];
readonly eventsBySeq: Map<number, Record<string, unknown>>;
modifiedAt: string;
nextSeq: number;
reassembler: Reassembler;
@@ -154,7 +147,8 @@ function seedChatState(chatChannel: string, modifiedAt: string): ChatState {
}
/**
* Decode the persisted transport layer into ordered envelopes, grouped by session.
* Decode the persisted transport layer into ordered envelopes, grouped by session, ignoring exact
* duplicate records within each mirror epoch.
*
* Throws on a genuine sequence gap or a corrupt record — a history that cannot be trusted must
* not be shown as if it were complete.
@@ -176,10 +170,13 @@ function decodeEvents(events: readonly unknown[]): Map<string, ISessionReplaySta
let entry = sessions.get(session);
if (!entry) {
entry = { envelopes: [], modifiedAt: at, nextSeq: seq, reassembler: new Reassembler(), abandonedChunkGroup: false };
entry = { envelopes: [], eventsBySeq: new Map(), modifiedAt: at, nextSeq: seq, reassembler: new Reassembler(), abandonedChunkGroup: false };
sessions.set(session, entry);
}
entry.modifiedAt = at;
// Persisted batches can overlap; never fold an already-seen delta or chunk twice.
if (equals(entry.eventsBySeq.get(seq), value)) {
continue;
}
const startsRestartEpoch = seq !== entry.nextSeq && seq < entry.nextSeq && seq <= MAX_RESTART_EPOCH_INITIAL_SEQUENCE;
if (seq !== entry.nextSeq && !startsRestartEpoch) {
@@ -192,9 +189,12 @@ function decodeEvents(events: readonly unknown[]): Map<string, ISessionReplaySta
// buffered at the restart is an action the previous epoch never finished emitting, so
// remember it — replacing the reassembler is what would otherwise lose that fact.
entry.abandonedChunkGroup ||= entry.reassembler.inFlightGroupCount > 0;
entry.eventsBySeq.clear();
entry.nextSeq = seq;
entry.reassembler = new Reassembler();
}
entry.eventsBySeq.set(seq, value);
entry.modifiedAt = at;
entry.nextSeq += 1;
let reassembled: unknown;
@@ -227,9 +227,7 @@ function decodeEvents(events: readonly unknown[]): Map<string, ISessionReplaySta
/**
* Fold one session's envelopes into session state plus a chat state per chat channel.
*
* Routed by **action type**, not channel scheme: recorded channels are whatever the host wrote
* (today `<session>/chat`, not the `ahp-chat://` URI a client builds), so matching on a scheme would
* silently drop every chat action. The live subscriptions route the same way.
* Routed by action type, not channel scheme, so host-defined chat URIs are preserved.
*
* A session may own several peer chats, so each chat channel found in the history gets its own fold
* — discovered from the envelopes rather than assumed, which keeps forked and peer chats intact.
@@ -237,19 +235,18 @@ function decodeEvents(events: readonly unknown[]): Map<string, ISessionReplaySta
function foldSession(session: string, entry: ISessionReplayState): IReplayedSession {
let state = seedSessionState();
const chats = new Map<string, ChatState>();
// The host announces its default chat via `session/defaultChatChanged`; until then the
// deterministic `<session>/chat` is the convention it writes.
let defaultChat = `${session}/chat`;
let hasChatCatalogueEvidence = false;
for (const envelope of entry.envelopes) {
const channel = envelope.channel;
const action: StateAction = envelope.action;
if (action.type.startsWith('session/') && channel === session) {
hasChatCatalogueEvidence ||= action.type === ActionType.SessionChatAdded
|| action.type === ActionType.SessionChatRemoved
|| action.type === ActionType.SessionChatUpdated
|| action.type === ActionType.SessionChatsReordered;
state = sessionReducer(state, action as SessionAction);
if (action.type === ActionType.SessionDefaultChatChanged) {
defaultChat = (action as SessionDefaultChatChangedAction).defaultChat || `${session}/chat`;
}
continue;
}
if (action.type.startsWith('chat/')) {
@@ -260,8 +257,13 @@ function foldSession(session: string, entry: ISessionReplayState): IReplayedSess
// are intentionally skipped.
}
// A session whose history never announced its chats still owns a default chat, so surface an
// empty one rather than a session that appears to have no conversation at all.
const [recordedChat] = chats.keys();
const unambiguousChat = chats.size === 1
&& (!hasChatCatalogueEvidence || state.chats.length === 1)
&& state.chats.every(chat =>
chat.resource === recordedChat && (!chat.origin || chat.origin.kind === ChatOriginKind.User))
? recordedChat : undefined;
const defaultChat = state.defaultChat || unambiguousChat || `${session}/chat`;
if (!chats.has(defaultChat)) {
chats.set(defaultChat, seedChatState(defaultChat, entry.modifiedAt));
}
@@ -134,6 +134,7 @@ export class AgentHostCatalogListReader {
origin: fromCatalogChatOrigin(chat.origin),
...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}),
...(chat.archived === true ? { archived: true } : {}),
...(chat.isRead !== undefined ? { isRead: chat.isRead } : {}),
...(chat.changes !== undefined ? { changes: chat.changes } : {}),
})),
...(meta !== undefined ? { _meta: meta } : {}),
@@ -15,7 +15,7 @@ import { SESSION_META_ARTIFACTS_KEY } from '../common/sessionArtifacts.js';
import { ChatInteractivity } from '../common/state/protocol/channels-chat/state.js';
import { SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY } from '../common/state/sessionState.js';
export const AGENT_HOST_CATALOG_PAYLOAD_VERSION = 1;
export const AGENT_HOST_CATALOG_PAYLOAD_VERSION = 2;
export const AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT = 10;
export const AGENT_HOST_CATALOG_ARTIFACT_LIMIT = 100;
export const AGENT_HOST_CATALOG_CHILD_LIMIT = 1000;
@@ -385,6 +385,7 @@ const chatValidator = plainObject(vObj({
origin: vOptionalProp(jsonValue()),
interactivity: vOptionalProp(vEnum(ChatInteractivity.Full, ChatInteractivity.ReadOnly, ChatInteractivity.Hidden)),
archived: vOptionalProp(vBoolean()),
isRead: vOptionalProp(vBoolean()),
inheritedTurnId: vOptionalProp(boundedString(AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT)),
workingDirectories: vOptionalProp(workingDirectoriesValidator),
changes: vOptionalProp(agentHostCatalogChangesValidator),
@@ -12,7 +12,7 @@ import { parseSessionArtifacts, readSessionArtifacts, SESSION_META_ARTIFACTS_KEY
import { getChatChangesSummaryMetadataKey, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js';
import { META_GIT_DATA_STATE, META_GIT_STATE, META_GITHUB_DATA_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js';
import { ChangesSummary, ChatInteractivity, ChatOrigin, ChatOriginKind } from '../common/state/protocol/state.js';
import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ISessionGitHubState, ISessionGitState, ISessionSourceControlState, parseSessionCreationReference, parseSessionFolderPickerDecision, parseSessionMultiRootMetadata, readSessionCreationReference, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionExternal, readSessionFolderPickerDecision, parseSessionGitHubData, parseSessionGitHubState, readSessionGitData, readSessionGitHubData, readSessionGitState, withMigratedSessionGitHubState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionStatus, SessionSummary } from '../common/state/sessionState.js';
import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ISessionGitHubState, ISessionGitState, ISessionSourceControlState, isChatInSessionReadAggregate, parseSessionCreationReference, parseSessionFolderPickerDecision, parseSessionMultiRootMetadata, readSessionCreationReference, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionExternal, readSessionFolderPickerDecision, parseSessionGitHubData, parseSessionGitHubState, readSessionGitData, readSessionGitHubData, readSessionGitState, withMigratedSessionGitHubState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionStatus, SessionSummary } from '../common/state/sessionState.js';
import { AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT, AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, agentHostCatalogChangesValidator, agentHostCatalogGitDataValidator, agentHostCatalogGitValidator } from './agentHostCatalogProjection.js';
import { IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js';
import { AGENT_HOST_TITLE_SOURCE_AUTO, AgentHostTitleSource, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js';
@@ -35,6 +35,7 @@ export interface ICatalogSourceState {
readonly origin?: ChatOrigin;
readonly interactivity?: ChatInteractivity;
readonly archived?: boolean;
readonly isRead?: boolean;
readonly inheritedTurnId?: string;
readonly workingDirectories?: readonly string[];
readonly changes?: ChangesSummary;
@@ -85,6 +86,7 @@ const sessionMetadata = {
title: stringSessionMetadataKey(SESSION_CUSTOM_TITLE_KEY),
titleSource: stringSessionMetadataKey(SESSION_CUSTOM_TITLE_SOURCE_KEY),
isRead: parsedSessionMetadataKey(AH_META_IS_READ_DB_KEY, value => value === 'true'),
defaultChatIsRead: parsedSessionMetadataKey(AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, value => value === 'true'),
isArchived: parsedSessionMetadataKey(AH_META_IS_ARCHIVED_DB_KEY, value => value === 'true'),
isDone: parsedSessionMetadataKey(AH_META_IS_DONE_DB_KEY, value => value === 'true'),
creationReference: parsedSessionMetadataKey(AH_META_CREATED_BY_SESSION_DB_KEY, parseSessionCreationReference),
@@ -200,9 +202,18 @@ export class AgentHostCatalogSourceResolver {
? persistedWorkspaceless
: readSessionWorkspaceless(state.meta) || persistedWorkspaceless;
const stateIsRead = (state.status & SessionStatus.IsRead) !== 0;
const isRead = preferPersistedMetadata && metadata[AH_META_IS_READ_DB_KEY] !== undefined
const persistedSessionIsRead = preferPersistedMetadata && metadata[AH_META_IS_READ_DB_KEY] !== undefined
? sessionMetadata.isRead.read(metadata) ?? false
: stateIsRead;
const chatIsRead = (chat: ICatalogSourceState['chats'][number]): boolean | undefined => preferPersistedMetadata
&& chat.kind === 'default'
&& sessionMetadata.defaultChatIsRead.has(metadata)
? sessionMetadata.defaultChatIsRead.read(metadata)
: chat.isRead;
const aggregateChats = state.chats.filter(chat => isChatInSessionReadAggregate(chat.uri, chat.origin, chat.interactivity));
const isRead = aggregateChats.length === 1 && aggregateChats[0].isRead !== undefined
? chatIsRead(aggregateChats[0]) ?? persistedSessionIsRead
: persistedSessionIsRead && !aggregateChats.some(chat => chatIsRead(chat) === false);
const persistedArchived = sessionMetadata.isArchived.read(metadata) ?? sessionMetadata.isDone.read(metadata);
const isArchived = preferPersistedMetadata && persistedArchived !== undefined
? persistedArchived
@@ -261,6 +272,7 @@ export class AgentHostCatalogSourceResolver {
// temporary restriction must not become permanent after a restart.
const interactivity = chat.interactivity === ChatInteractivity.ReadOnly && readChatInputState({ _meta: state.meta }, chat.uri)
? ChatInteractivity.Full : chat.interactivity;
const isRead = chatIsRead(chat);
const persistedChatChanges = readPersistedChanges(metadata[getChatChangesSummaryMetadataKey(chat.uri)]);
const chatChanges = preferPersistedMetadata
? persistedChatChanges ?? chat.changes
@@ -274,6 +286,7 @@ export class AgentHostCatalogSourceResolver {
origin: toCatalogChatOrigin(chat.origin),
...(interactivity !== undefined ? { interactivity } : {}),
...(chat.archived === true ? { archived: true } : {}),
...(isRead !== undefined ? { isRead } : {}),
...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}),
...(chat.workingDirectories !== undefined ? { workingDirectories: chat.workingDirectories } : {}),
...(chatChanges !== undefined ? { changes: chatChanges } : {}),
@@ -100,6 +100,7 @@ export interface IAgentHostDatabaseSessionV2 extends IAgentHostDatabaseSessionV2
export interface IAgentHostDatabaseSessionChat {
readonly chat: string;
readonly order: number;
readonly isRead?: boolean;
readonly archived?: boolean;
readonly providerData?: string;
readonly origin?: string;
@@ -281,6 +282,9 @@ const sessionChatCatalogSchemaSql = [
].join(';\n');
const CHAT_ARCHIVE_MIGRATION_VERSION = 12;
const CHAT_READ_MIGRATION_VERSION = 13;
const chatArchiveMigrationSql = 'ALTER TABLE session_chats ADD COLUMN archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))';
const chatReadMigrationSql = 'ALTER TABLE session_chats ADD COLUMN is_read INTEGER CHECK (is_read IN (0, 1))';
const migrations = [
{
@@ -328,23 +332,52 @@ const migrations = [
// Versions 6 through 11 were used by pre-release catalog schemas and are
// normalized above, so new migrations resume at 12.
version: CHAT_ARCHIVE_MIGRATION_VERSION,
sql: 'ALTER TABLE session_chats ADD COLUMN archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))',
sql: chatArchiveMigrationSql,
},
{
version: CHAT_READ_MIGRATION_VERSION,
sql: chatReadMigrationSql,
},
] as const;
async function normalizePreReleaseCatalogSchema(database: Database, currentVersion: number): Promise<number> {
if (currentVersion < 4 || currentVersion > 11 || !await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sessions_v2'`, [])) {
if (currentVersion < 4 || !await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sessions_v2'`, [])) {
return currentVersion;
}
const hasFinalCatalog = await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'session_chat_catalogs'`, [])
&& await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'session_chats'`, []);
if (hasFinalCatalog && currentVersion >= 5 && currentVersion < CHAT_ARCHIVE_MIGRATION_VERSION) {
if (hasFinalCatalog && currentVersion >= 5) {
const chatColumns = await all(database, 'PRAGMA table_info(session_chats)', []);
if (chatColumns.some(column => column.name === 'archived')) {
const hasArchived = chatColumns.some(column => column.name === 'archived');
const hasRead = chatColumns.some(column => column.name === 'is_read');
if (currentVersion >= CHAT_READ_MIGRATION_VERSION) {
// Temporary compatibility for development profiles shared by worktrees
// whose pre-release schema versions can advance independently.
if (!hasArchived) {
await exec(database, chatArchiveMigrationSql);
}
if (!hasRead) {
await exec(database, chatReadMigrationSql);
}
return currentVersion;
}
if (hasArchived && hasRead) {
await exec(database, `PRAGMA user_version = ${CHAT_READ_MIGRATION_VERSION}`);
return CHAT_READ_MIGRATION_VERSION;
}
if (hasRead) {
await exec(database, chatArchiveMigrationSql);
await exec(database, `PRAGMA user_version = ${CHAT_READ_MIGRATION_VERSION}`);
return CHAT_READ_MIGRATION_VERSION;
}
if (hasArchived) {
await exec(database, `PRAGMA user_version = ${CHAT_ARCHIVE_MIGRATION_VERSION}`);
return CHAT_ARCHIVE_MIGRATION_VERSION;
}
}
if (currentVersion >= CHAT_READ_MIGRATION_VERSION) {
return currentVersion;
}
const isPreReleaseVersion11 = currentVersion === 11;
if (hasFinalCatalog && currentVersion >= 5 && !isPreReleaseVersion11) {
return currentVersion;
@@ -1290,6 +1323,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
(SELECT value FROM metadata WHERE key = ?) AS legacy_mirrored_payload,
chat.chat_uri,
chat.chat_order,
chat.is_read,
chat.archived,
chat.provider_data,
chat.origin,
@@ -1309,6 +1343,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
chats: rows.filter(row => row.chat_uri !== null).map(row => ({
chat: row.chat_uri as string,
order: row.chat_order as number,
...(row.is_read === null ? {} : { isRead: row.is_read === 1 }),
...(row.archived === 1 ? { archived: true } : {}),
...(row.provider_data === null ? {} : { providerData: row.provider_data as string }),
...(row.origin === null ? {} : { origin: row.origin as string }),
@@ -1375,11 +1410,12 @@ export class AgentHostDatabase implements IAgentHostDatabase {
for (let offset = 0; offset < chats.length; offset += SESSION_CHAT_INSERT_BATCH_SIZE) {
const batch = chats.slice(offset, offset + SESSION_CHAT_INSERT_BATCH_SIZE);
await run(database, `INSERT INTO session_chats (
session_uri, chat_uri, chat_order, archived, provider_data, origin, inherited_turn_id
) VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')}`, batch.flatMap(chat => [
session_uri, chat_uri, chat_order, is_read, archived, provider_data, origin, inherited_turn_id
) VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`, batch.flatMap(chat => [
session,
chat.chat,
chat.order,
chat.isRead === undefined ? null : chat.isRead ? 1 : 0,
chat.archived === true ? 1 : 0,
chat.providerData ?? null,
chat.origin ?? null,
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { StopWatch } from '../../../base/common/stopwatch.js';
import type { IAgentTelemetryContext } from '../common/agent.js';
import type { AgentSubagentKind, IAgentTelemetryContext } from '../common/agent.js';
import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../common/meta/agentChatInputRequestMeta.js';
import type { ChatInputCompletedAction } from '../common/state/sessionActions.js';
@@ -31,6 +31,7 @@ export class AgentHostInputRequestTracker {
private readonly _stopWatchFactory: () => Pick<StopWatch, 'elapsed'> = () => StopWatch.create(true),
private readonly _getClientContext: (session: string, turnId: string) => IAgentHostClientTelemetryContext | undefined = () => undefined,
private readonly _getTelemetryContext: (session: string, turnId: string) => IAgentTelemetryContext | undefined = () => undefined,
private readonly _getSubagentKind: (session: string, turnId: string) => AgentSubagentKind | undefined = () => undefined,
) { }
inputRequested(provider: string, session: string, turnId: string, request: ChatInputRequest): void {
@@ -85,6 +86,7 @@ export class AgentHostInputRequestTracker {
telemetryContext: this._getTelemetryContext(timing.session, timing.turnId),
provider: timing.provider,
session: timing.session,
subagentKind: this._getSubagentKind(timing.session, timing.turnId),
requestId: timing.turnId,
questionCount: questions.length,
answeredCount,
@@ -13,7 +13,7 @@ import { ILogService } from '../../log/common/log.js';
import { ISessionDataService } from '../common/sessionDataService.js';
import type { AgentHostCatalogDatabaseReference } from './agentHostCatalogSyncService.js';
import { ChatOrigin } from '../common/state/protocol/state.js';
import { buildChatUri, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js';
import { AH_META_IS_READ_DB_KEY, buildChatUri, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js';
import { fromCatalogChatOrigin, toSerializableJsonValue } from './agentHostCatalogSourceResolver.js';
import { AGENT_HOST_CATALOG_CHILD_LIMIT } from './agentHostCatalogProjection.js';
import { IAgentHostDatabase } from './agentHostDatabase.js';
@@ -31,11 +31,13 @@ export const IAgentHostPeerChatPersistenceService = createDecorator<IAgentHostPe
export interface IAgentHostPeerChatPersistenceService {
readonly _serviceBrand: undefined;
setRead(session: URI, chat: URI, isRead: boolean): Promise<void>;
setArchived(session: URI, chat: URI, archived: boolean): Promise<void>;
}
export interface IPersistedPeerChat {
readonly uri: string;
readonly isRead?: boolean;
readonly archived?: boolean;
readonly providerData?: string;
readonly origin?: ChatOrigin;
@@ -389,6 +391,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
const next = entries.filter(entry => entry.uri !== chatUri);
next.push({
uri: chatUri,
...(existing?.isRead !== undefined ? { isRead: existing.isRead } : {}),
...(existing?.archived ? { archived: true } : {}),
...(providerData !== undefined ? { providerData } : {}),
...(effectiveOrigin !== undefined ? { origin: effectiveOrigin } : {}),
@@ -406,6 +409,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
const next = entries.filter(entry => entry.uri !== chatUri);
next.push({
uri: chatUri,
...(existing?.isRead !== undefined ? { isRead: existing.isRead } : {}),
...(existing?.archived ? { archived: true } : {}),
...(existing?.providerData !== undefined ? { providerData: existing.providerData } : {}),
...(existing?.origin !== undefined ? { origin: existing.origin } : {}),
@@ -424,6 +428,14 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
: entry));
}
setRead(session: URI, chat: URI, isRead: boolean): Promise<void> {
const chatUri = chat.toString();
return this._enqueueWrite(session, entries => entries.map(entry =>
entry.uri === chatUri
? { ...entry, isRead }
: entry));
}
remove(session: URI, chat: URI): Promise<void> {
const chatUri = chat.toString();
return this._enqueueWrite(session, entries => entries.filter(entry => entry.uri !== chatUri));
@@ -557,6 +569,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
private _catalogRows(entries: readonly IPersistedPeerChat[]): Array<{
readonly chat: string;
readonly order: number;
readonly isRead?: boolean;
readonly archived?: boolean;
readonly providerData?: string;
readonly origin?: string;
@@ -565,6 +578,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
return entries.map((entry, order) => ({
chat: entry.uri,
order,
...(entry.isRead !== undefined ? { isRead: entry.isRead } : {}),
...(entry.archived === true ? { archived: true } : {}),
...(entry.providerData !== undefined ? { providerData: entry.providerData } : {}),
...(entry.origin !== undefined ? { origin: this._stringifyOrigin(entry.origin) } : {}),
@@ -735,11 +749,15 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
}
try {
const metadata = await ref.object.getMetadataObject({
[AH_META_IS_READ_DB_KEY]: true,
[CHAT_PROVIDER_DATA_METADATA_KEY]: true,
[CHAT_ORIGIN_METADATA_KEY]: true,
[CHAT_INHERITED_TURN_METADATA_KEY]: true,
[CHAT_WORKING_DIRECTORIES_METADATA_KEY]: true,
});
const isRead = metadata[AH_META_IS_READ_DB_KEY] !== undefined
? metadata[AH_META_IS_READ_DB_KEY] === 'true'
: entry.isRead;
const origin = metadata[CHAT_ORIGIN_METADATA_KEY]
? this._parseOrigin(metadata[CHAT_ORIGIN_METADATA_KEY])
: metadata[CHAT_ORIGIN_METADATA_KEY] === '' ? undefined : entry.origin;
@@ -748,6 +766,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
: metadata[CHAT_WORKING_DIRECTORIES_METADATA_KEY] === '' ? undefined : entry.workingDirectories;
return {
uri: entry.uri,
...(isRead !== undefined ? { isRead } : {}),
...(metadata[CHAT_PROVIDER_DATA_METADATA_KEY] !== undefined
? metadata[CHAT_PROVIDER_DATA_METADATA_KEY] ? { providerData: metadata[CHAT_PROVIDER_DATA_METADATA_KEY] } : {}
: entry.providerData !== undefined ? { providerData: entry.providerData } : {}),
@@ -793,6 +812,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
const ref = this._sessionDataService.openDatabase(URI.parse(entry.uri));
try {
await ref.object.setMetadataValues({
...(entry.isRead !== undefined ? { [AH_META_IS_READ_DB_KEY]: entry.isRead ? 'true' : '' } : {}),
[CHAT_PROVIDER_DATA_METADATA_KEY]: entry.providerData ?? '',
[CHAT_ORIGIN_METADATA_KEY]: entry.origin === undefined ? '' : this._stringifyOrigin(entry.origin),
[CHAT_INHERITED_TURN_METADATA_KEY]: entry.inheritedTurnId ?? '',
@@ -826,6 +846,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
private _entriesFromCatalog(chats: readonly {
readonly chat: string;
readonly isRead?: boolean;
readonly providerData?: string;
readonly origin?: string;
readonly inheritedTurnId?: string;
@@ -833,6 +854,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
}[]): IPersistedPeerChat[] {
return chats.map(chat => ({
uri: chat.chat,
...(chat.isRead !== undefined ? { isRead: chat.isRead } : {}),
...(chat.archived ? { archived: true } : {}),
...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}),
...(chat.origin !== undefined ? { origin: this._parseOrigin(chat.origin) } : {}),
@@ -889,6 +911,10 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid archived state`);
continue;
}
if (value.isRead !== undefined && typeof value.isRead !== 'boolean') {
this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid read state`);
continue;
}
const originValue = toSerializableJsonValue(value.origin);
const origin = fromCatalogChatOrigin(originValue);
if (value.origin !== undefined && !origin) {
@@ -897,6 +923,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ
seen.add(value.uri);
result.push({
uri: value.uri,
...(typeof value.isRead === 'boolean' ? { isRead: value.isRead } : {}),
...(value.archived === true ? { archived: true } : {}),
...(typeof value.providerData === 'string' ? { providerData: value.providerData } : {}),
...(origin ? { origin } : {}),
@@ -13,7 +13,7 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js';
import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams, type SessionSummaryChanges } from '../common/state/sessionActions.js';
import type { IStateSnapshot } from '../common/state/sessionProtocol.js';
import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer } from '../common/state/sessionReducers.js';
import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, isSessionStatusArchived, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, ChatOriginKind, IHostBuildInfo, SessionStatus, ResponsePartKind, ToolCallStatus } from '../common/state/sessionState.js';
import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isChatInSessionReadAggregate, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, ChatOriginKind, IHostBuildInfo, SessionStatus, ResponsePartKind, ToolCallStatus } from '../common/state/sessionState.js';
import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js';
import { SessionConfigKey } from '../common/sessionConfigKeys.js';
import { parseChangesetUri } from '../common/changesetUri.js';
@@ -544,6 +544,7 @@ export class AgentHostStateManager extends Disposable {
&& chat.title === other.title
&& equals(chat.origin, other.origin)
&& chat.interactivity === other.interactivity
&& chat.status === other.status
&& structuralEquals(chat.changes, other.changes);
});
}
@@ -1074,6 +1075,16 @@ export class AgentHostStateManager extends Disposable {
this._summaryNotifier.applyAnnouncedChanges(session, { status });
}
/** Updates passive status metadata and optional exact chat read state for an unloaded session. */
setSurfacedSessionStatusAndChats(session: string, status: SessionStatus, chats?: SessionSummary['chats']): void {
if (!this._sessionStates.has(session) && this._summaryNotifier.getAnnounced(session)) {
this._summaryNotifier.applyAnnouncedChanges(session, {
status,
...(chats ? { chats } : {}),
});
}
}
/** Publishes or unpublishes a live session summary without changing its session state. */
setSessionSummaryPublished(session: string, published: boolean): void {
if (published) {
@@ -1171,7 +1182,7 @@ export class AgentHostStateManager extends Disposable {
* summary (e.g. adoptable-legacy), a `sessionSummaryChanged` delta is emitted
* so clients update the entry in place instead of dropping it.
*/
restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message; readonly defaultChatTitle?: string; readonly defaultChatModifiedAt?: string; readonly defaultChatWorkingDirectories?: readonly string[] }): SessionState {
restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message; readonly defaultChatTitle?: string; readonly defaultChatModifiedAt?: string; readonly defaultChatWorkingDirectories?: readonly string[]; readonly defaultChatIsRead?: boolean }): SessionState {
const key = summary.resource;
const existing = this._sessionStates.get(key);
if (existing) {
@@ -1185,7 +1196,7 @@ export class AgentHostStateManager extends Disposable {
};
const entry = this._newEntry(state, summary, SessionUse.Used);
this._sessionStates.set(key, entry);
this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle, options?.defaultChatModifiedAt, options?.defaultChatWorkingDirectories);
this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle, options?.defaultChatModifiedAt, options?.defaultChatWorkingDirectories, options?.defaultChatIsRead);
for (const chat of summary.chats ?? []) {
if (chat.resource === state.defaultChat || isDefaultChatUri(chat.resource)) {
continue;
@@ -1194,9 +1205,15 @@ export class AgentHostStateManager extends Disposable {
title: chat.title,
origin: chat.origin,
interactivity: chat.interactivity,
archived: chat.status === undefined ? undefined : isSessionStatusArchived(chat.status),
isRead: chat.status === undefined ? undefined : isSessionStatusRead(chat.status),
changes: chat.changes,
});
}
const restoredState = entry.state;
if (restoredState.chats.some(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity) && !isSessionStatusRead(chat.status))) {
restoredState.status = withSessionStatusFlag(restoredState.status, SessionStatus.IsRead, false);
}
// A session that was previously surfaced (e.g. announced as an
// adoptable-legacy session) is already known to clients with a different
// summary. Emit the delta so they update the entry in place — clearing the
@@ -1211,7 +1228,7 @@ export class AgentHostStateManager extends Disposable {
this._logService.trace(`[AgentHostStateManager] Restored session: ${key} (${turns.length} turns)`);
return state;
return restoredState;
}
/**
@@ -1226,7 +1243,7 @@ export class AgentHostStateManager extends Disposable {
* at creation/restore time, so the snapshot a client later receives on
* subscribe already reflects the default chat.
*/
private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message, defaultChatTitle?: string, modifiedAt?: string, workingDirectories?: readonly string[]): void {
private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message, defaultChatTitle?: string, modifiedAt?: string, workingDirectories?: readonly string[], isRead?: boolean): void {
const chatUri = buildDefaultChatUri(sessionKey);
const changes = summary.chats?.find(chat => chat.resource === chatUri)?.changes;
// Empty title means "inherit the session title"; a persisted independent
@@ -1238,6 +1255,9 @@ export class AgentHostStateManager extends Disposable {
...(workingDirectories !== undefined ? { workingDirectories: [...workingDirectories] } : {}),
...(changes !== undefined ? { changes } : {}),
};
if (isRead !== undefined) {
chatSummary.status = withSessionStatusFlag(chatSummary.status, SessionStatus.IsRead, isRead);
}
this._chatEntries.set(chatUri, {
session: sessionKey,
summary: chatSummary,
@@ -1297,7 +1317,7 @@ export class AgentHostStateManager extends Disposable {
const chatSummary: ChatSummary = {
...createDefaultChatSummary(this._toSummary(session, entry), chatUri),
title: options?.title ?? '',
status: SessionStatus.Idle,
status: SessionStatus.Idle | SessionStatus.IsRead,
...(options?.origin ? { origin: options.origin } : {}),
interactivity: options?.interactivity,
...(options?.workingDirectories !== undefined ? { workingDirectories: [...options.workingDirectories] } : {}),
@@ -1319,7 +1339,7 @@ export class AgentHostStateManager extends Disposable {
* creating conversation state. The state-manager-owned resolver installs a
* complete state only through {@link resolveChatState}.
*/
registerRestoredChatSummary(session: URI, chatUri: URI, options: { readonly title?: string; readonly modifiedAt?: string; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly archived?: boolean; readonly draft?: Message; readonly providerData?: string; readonly inheritedTurnId?: string; readonly workingDirectories?: readonly string[]; readonly changes?: ChangesSummary; readonly resolver?: RestoredChatResolver }): ChatSummary | undefined {
registerRestoredChatSummary(session: URI, chatUri: URI, options: { readonly title?: string; readonly modifiedAt?: string; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly archived?: boolean; readonly isRead?: boolean; readonly draft?: Message; readonly providerData?: string; readonly inheritedTurnId?: string; readonly workingDirectories?: readonly string[]; readonly changes?: ChangesSummary; readonly resolver?: RestoredChatResolver }): ChatSummary | undefined {
const entry = this._sessionStates.get(session);
if (!entry) {
this._logService.warn(`[AgentHostStateManager] registerRestoredChatSummary for unknown session: ${session}`);
@@ -1330,12 +1350,20 @@ export class AgentHostStateManager extends Disposable {
if (existing) {
const existingEntry = this._chatEntries.get(chatUri);
if (existingEntry && !existingEntry.state && options.resolver) {
let status = existing.status ?? SessionStatus.Idle;
const hasStatusChange = options.archived !== undefined || options.isRead !== undefined;
if (options.archived !== undefined) {
status = withSessionStatusFlag(status, SessionStatus.IsArchived, options.archived);
}
if (options.isRead !== undefined) {
status = withSessionStatusFlag(status, SessionStatus.IsRead, options.isRead);
}
const summary: ChatSummary = {
...existing,
...(options.modifiedAt !== undefined ? { modifiedAt: options.modifiedAt } : {}),
...(options.origin !== undefined ? { origin: options.origin } : {}),
interactivity: options.interactivity ?? existing.interactivity,
...(options.archived !== undefined ? { status: withSessionStatusFlag(existing.status ?? SessionStatus.Idle, SessionStatus.IsArchived, options.archived) } : {}),
...(hasStatusChange ? { status } : {}),
...(options.workingDirectories !== undefined ? { workingDirectories: [...options.workingDirectories] } : {}),
...(existing.changes === undefined && options.changes !== undefined ? { changes: options.changes } : {}),
};
@@ -1354,7 +1382,11 @@ export class AgentHostStateManager extends Disposable {
...createDefaultChatSummary(this._toSummary(session, entry), chatUri),
title: options.title ?? '',
...(options.modifiedAt !== undefined ? { modifiedAt: options.modifiedAt } : {}),
status: withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsArchived, options.archived === true),
status: withSessionStatusFlag(
withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsArchived, options.archived === true),
SessionStatus.IsRead,
options.isRead ?? true,
),
// A persisted catalog entry with no recorded origin is a plain
// user-created chat; keep the default rather than restoring it
// without provenance.
@@ -1909,6 +1941,17 @@ export class AgentHostStateManager extends Disposable {
config: preserveProviderBackedRootConfigValues(this._rootState, action.config),
};
}
if (action.type === ActionType.SessionIsReadChanged && action.isRead) {
const state = this._sessionStates.get(channel)?.state;
const aggregateChats = state?.chats.filter(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity)) ?? [];
if (aggregateChats.length > 1 && aggregateChats.some(chat => !isSessionStatusRead(chat.status))) {
action = { ...action, isRead: false };
} else if (aggregateChats.length === 1 && !isSessionStatusRead(aggregateChats[0].status)) {
this.dispatchServerAction(aggregateChats[0].resource, { type: ActionType.ChatIsReadChanged, isRead: true });
}
}
let sessionToMarkUnread: URI | undefined;
let singleChatToSynchronize: { readonly chat: URI; readonly isRead: boolean } | undefined;
// Apply to state
if (isRootAction(action)) {
// `RootConfigChanged` can be a true no-op: the reducer merges/replaces
@@ -1941,6 +1984,13 @@ export class AgentHostStateManager extends Disposable {
const summaryChanged = !this._summaryFieldsEqual(previousState, newState);
entry.state = newState;
this._synchronizeChatEntries(key, newState.chats);
const aggregateChats = newState.chats.filter(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity));
if (sessionAction.type === ActionType.SessionIsReadChanged && !sessionAction.isRead && aggregateChats.length === 1) {
const chat = aggregateChats[0];
if (isSessionStatusRead(chat.status)) {
singleChatToSynchronize = { chat: chat.resource, isRead: false };
}
}
if (previousState.title !== newState.title) {
this._onDidChangeSessionTitle.fire({ session: key, title: newState.title });
@@ -1986,6 +2036,12 @@ export class AgentHostStateManager extends Disposable {
this._pruneChatCanvases(channel, new Set(newChat.canvases?.map(canvas => canvas.resource) ?? []));
}
this._onChatStateChanged(sessionKey, channel, chat, newChat);
if (chatAction.type === ActionType.ChatIsReadChanged && !chatAction.isRead && isChatInSessionReadAggregate(channel, chatEntry.summary.origin, chatEntry.summary.interactivity)) {
const session = this._sessionStates.get(sessionKey)?.state;
if (session && isSessionStatusRead(session.status)) {
sessionToMarkUnread = sessionKey;
}
}
resultingState = newChat;
} else {
this._logService.warn(`[AgentHostStateManager] Action for unknown chat: ${channel}, type=${action.type}`);
@@ -2070,6 +2126,12 @@ export class AgentHostStateManager extends Disposable {
this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`);
this._onDidEmitEnvelope.fire(envelope);
if (singleChatToSynchronize) {
this.dispatchServerAction(singleChatToSynchronize.chat, { type: ActionType.ChatIsReadChanged, isRead: singleChatToSynchronize.isRead });
}
if (sessionToMarkUnread) {
this.dispatchServerAction(sessionToMarkUnread, { type: ActionType.SessionIsReadChanged, isRead: false });
}
return resultingState;
}
@@ -8,7 +8,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js';
import { TelemetryTrustedValue } from '../../telemetry/common/telemetryUtils.js';
import { hash } from '../../../base/common/hash.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { AgentSession, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type AgentTurnProviderSessionState, type IAgentTurnDiagnosticSnapshot, type IAgentTokenUsageSummary, type IAgentTelemetryContext } from '../common/agent.js';
import { AgentSession, type AgentSubagentKind, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type AgentTurnProviderSessionState, type IAgentTurnDiagnosticSnapshot, type IAgentTokenUsageSummary, type IAgentTelemetryContext } from '../common/agent.js';
import { isReasoningEffortLevel } from '../common/reasoningEffort.js';
import type { SessionMode } from '../common/agentHostSchema.js';
import { getTelemetryChatSessionId } from '../common/agentTelemetryCorrelation.js';
@@ -80,6 +80,10 @@ export interface IAgentHostEventTelemetry extends IAgentHostInitiatorTelemetry,
export type IAgentHostEventClassification = IAgentHostInitiatorClassification & IAgentHostCopilotSkuClassification;
export type IAgentHostSubagentKindClassification = {
subagentKind?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'What the subagent chat represents, set only when isSubagentSession is true: task for a delegated subagent, or fusionPhase for a presentation-only HydraFusion phase chat that shows part of the parent turn.' };
};
export interface IAgentHostExecutionModeChangedEvent extends IAgentHostEventTelemetry {
provider: string;
agentSessionId: string;
@@ -254,6 +258,7 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostEventTelemetry,
agentSessionId: string;
chatSessionId: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
turnId: string;
parentTurnId: string | undefined;
parentToolCallId: string | undefined;
@@ -301,7 +306,7 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostEventTelemetry,
modelCallCount: number;
}
export type IAgentHostTurnCompletedClassification = IAgentHostEventClassification & CodexAccountTelemetryClassification & {
export type IAgentHostTurnCompletedClassification = IAgentHostEventClassification & CodexAccountTelemetryClassification & IAgentHostSubagentKindClassification & {
hostRootTurnOrdinal?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'One-based root-turn ordinal over the agent host process lifetime, captured at turn start and excluding subagent turns.' };
hostProcessAgeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Agent host process age in milliseconds captured at root turn start, not completion.' };
titleGenerationStrategy?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The effective persisted automatic title generation strategy captured before sending the root turn: activeAgent, utility, or deferred.' };
@@ -363,6 +368,7 @@ export interface IAgentHostTurnFailedEvent extends IAgentHostEventTelemetry {
agentSessionId: string;
chatSessionId: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
turnId: string;
messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined;
failureStage: AgentHostTurnFailureStage;
@@ -375,7 +381,7 @@ export interface IAgentHostTurnFailedEvent extends IAgentHostEventTelemetry {
callstack: string | undefined;
}
export type IAgentHostTurnFailedClassification = IAgentHostEventClassification & {
export type IAgentHostTurnFailedClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & {
provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the failed agent host turn.' };
agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' };
chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' };
@@ -409,6 +415,7 @@ export interface IAgentHostTurnCompletedReport extends IAgentHostTurnAttributedR
titleGenerationStrategy?: AutomaticTitleGenerationStrategy;
provider: string;
session: string;
subagentKind?: AgentSubagentKind;
turnId: string;
parentTurnId: string | undefined;
parentToolCallId: string | undefined;
@@ -452,6 +459,7 @@ interface IRequestTokenUsageEvent extends IAgentHostEventTelemetry, Omit<IAgentT
parentTurnId?: string;
parentToolCallId?: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
model: string | TelemetryTrustedValue<string>;
selectedModel?: string | TelemetryTrustedValue<string>;
result: AgentHostTurnResult;
@@ -461,7 +469,7 @@ interface IRequestTokenUsageEvent extends IAgentHostEventTelemetry, Omit<IAgentT
reasoningEffortSource: 'sdkReported';
}
type RequestTokenUsageClassification = IAgentHostEventClassification & {
type RequestTokenUsageClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & {
provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Agent provider.' };
agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Opaque owning session identifier.' };
chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Opaque owning chat identifier.' };
@@ -563,6 +571,7 @@ export interface IAgentHostTurnHungEvent extends IAgentHostEventTelemetry, Parti
agentSessionId: string;
chatSessionId: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
turnId: string;
messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined;
hangReason: AgentHostTurnHangReason;
@@ -586,7 +595,7 @@ export interface IAgentHostTurnHungEvent extends IAgentHostEventTelemetry, Parti
permissionLevel: string | undefined;
}
export type IAgentHostTurnHungClassification = IAgentHostEventClassification & CodexAccountTelemetryClassification & {
export type IAgentHostTurnHungClassification = IAgentHostEventClassification & CodexAccountTelemetryClassification & IAgentHostSubagentKindClassification & {
provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the hung agent host turn.' };
agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' };
chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' };
@@ -620,6 +629,7 @@ export interface IAgentHostTurnHungReport extends IAgentHostTurnAttributedReport
providerTelemetryContext?: IAgentProviderTurnTelemetryContext;
provider: string;
session: string;
subagentKind?: AgentSubagentKind;
turnId: string;
messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined;
hangReason: AgentHostTurnHangReason;
@@ -646,6 +656,7 @@ export interface IAgentHostHungTurnCompletedEvent extends IAgentHostEventTelemet
agentSessionId: string;
chatSessionId: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
turnId: string;
messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined;
hangReason: AgentHostTurnHangReason;
@@ -655,7 +666,7 @@ export interface IAgentHostHungTurnCompletedEvent extends IAgentHostEventTelemet
timeAfterHangMs: number;
}
export type IAgentHostHungTurnCompletedClassification = IAgentHostEventClassification & {
export type IAgentHostHungTurnCompletedClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & {
provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the recovered agent host turn.' };
agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' };
chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' };
@@ -674,6 +685,7 @@ export type IAgentHostHungTurnCompletedClassification = IAgentHostEventClassific
export interface IAgentHostHungTurnCompletedReport extends IAgentHostTurnAttributedReport {
provider: string;
session: string;
subagentKind?: AgentSubagentKind;
turnId: string;
messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined;
hangReason: AgentHostTurnHangReason;
@@ -694,6 +706,7 @@ type IAgentHostLanguageModelToolInvokedClassification = LanguageModelToolInvoked
export interface IAgentHostToolInvokedReport extends IAgentHostTurnAttributedReport {
provider: string;
session: string;
subagentKind?: AgentSubagentKind;
turnId: string;
toolId: string;
toolSourceKind: string;
@@ -713,12 +726,13 @@ export type IAgentHostToolInvokedEvent = LanguageModelToolInvokedEvent & IAgentH
agentSessionId: string;
chatSessionId: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
errorCode: string | undefined;
msg: string | undefined;
mcpSourceKind?: McpServerSource;
};
export type IAgentHostToolInvokedClassification = Omit<LanguageModelToolInvokedClassification, 'provider' | 'chatSessionId' | 'owner' | 'comment'> & IAgentHostEventClassification & {
export type IAgentHostToolInvokedClassification = Omit<LanguageModelToolInvokedClassification, 'provider' | 'chatSessionId' | 'owner' | 'comment'> & IAgentHostEventClassification & IAgentHostSubagentKindClassification & {
provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host provider that invoked the tool.' };
agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier.' };
chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat identifier within the Agent Host session.' };
@@ -742,9 +756,10 @@ export interface IAgentHostAskQuestionsToolInvokedEvent extends IAgentHostEventT
provider: string;
agentSessionId: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
}
export type IAgentHostAskQuestionsToolInvokedClassification = IAgentHostEventClassification & {
export type IAgentHostAskQuestionsToolInvokedClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & {
requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the current request turn.' };
questionCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The total number of questions asked' };
answeredCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of questions that were answered' };
@@ -763,6 +778,7 @@ export type IAgentHostAskQuestionsToolInvokedClassification = IAgentHostEventCla
export interface IAgentHostAskQuestionsToolInvokedReport extends IAgentHostTurnAttributedReport {
provider: string;
session: string;
subagentKind?: AgentSubagentKind;
requestId: string;
questionCount: number;
answeredCount: number;
@@ -945,6 +961,7 @@ export interface IAgentHostToolCallStalledEvent extends IAgentHostEventTelemetry
provider: string;
agentSessionId: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication;
toolCallId: string;
toolId: string;
@@ -955,7 +972,7 @@ export interface IAgentHostToolCallStalledEvent extends IAgentHostEventTelemetry
export type AgentHostExecutorClientConnectionState = 'connected' | 'disconnected' | 'unknown';
export type IAgentHostToolCallStalledClassification = IAgentHostEventClassification & {
export type IAgentHostToolCallStalledClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & {
provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the stalled agent host tool call.' };
agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' };
isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the stalled tool call belongs to a subagent session.' };
@@ -972,6 +989,7 @@ export type IAgentHostToolCallStalledClassification = IAgentHostEventClassificat
export interface IAgentHostToolCallStalledReport extends IAgentHostTurnAttributedReport {
provider: string;
session: string;
subagentKind?: AgentSubagentKind;
blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication;
toolCallId: string;
toolId: string;
@@ -984,6 +1002,7 @@ export interface IAgentHostStalledToolCallCompletedEvent extends IAgentHostEvent
provider: string;
agentSessionId: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication;
toolCallId: string;
toolId: string;
@@ -994,7 +1013,7 @@ export interface IAgentHostStalledToolCallCompletedEvent extends IAgentHostEvent
timeAfterStallMs: number;
}
export type IAgentHostStalledToolCallCompletedClassification = IAgentHostEventClassification & {
export type IAgentHostStalledToolCallCompletedClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & {
provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the completed agent host tool call.' };
agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' };
isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the completed tool call belongs to a subagent session.' };
@@ -1013,6 +1032,7 @@ export type IAgentHostStalledToolCallCompletedClassification = IAgentHostEventCl
export interface IAgentHostStalledToolCallCompletedReport extends IAgentHostTurnAttributedReport {
provider: string;
session: string;
subagentKind?: AgentSubagentKind;
blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication;
toolCallId: string;
toolId: string;
@@ -1081,6 +1101,10 @@ export function toInitiatorTelemetry(clientContext: IAgentHostClientTelemetryCon
export const IAgentHostTelemetryReporter = createDecorator<AgentHostTelemetryReporter>('agentHostTelemetryReporter');
export function toSubagentKindTelemetry(isSubagent: boolean, kind: AgentSubagentKind | undefined): { subagentKind?: AgentSubagentKind } {
return isSubagent ? { subagentKind: kind ?? 'task' } : {};
}
export class AgentHostTelemetryReporter {
declare readonly _serviceBrand: undefined;
@@ -1479,8 +1503,9 @@ export class AgentHostTelemetryReporter {
restricted.sendInternalMSFTTelemetryEventForContext(context, 'request.repoInfo', internalMultiplexedProperties, measurements);
}
requestTokenUsage(report: { clientContext: IAgentHostClientTelemetryContext; telemetryContext?: IAgentTelemetryContext; provider: string; session: string; requestId: string; parentTurnId?: string; parentToolCallId?: string; selectedModel?: string; selectedModelTelemetryKind?: AgentHostModelTelemetryKind; modelTelemetryKind?: AgentHostModelTelemetryKind; result: AgentHostTurnResult; summary: IAgentTokenUsageSummary }): void {
requestTokenUsage(report: { clientContext: IAgentHostClientTelemetryContext; telemetryContext?: IAgentTelemetryContext; provider: string; session: string; requestId: string; parentTurnId?: string; parentToolCallId?: string; subagentKind?: AgentSubagentKind; selectedModel?: string; selectedModelTelemetryKind?: AgentHostModelTelemetryKind; modelTelemetryKind?: AgentHostModelTelemetryKind; result: AgentHostTurnResult; summary: IAgentTokenUsageSummary }): void {
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session);
const { model: usageModel, reasoningEffort, ...summary } = report.summary;
const model = usageModel === 'auto' ? 'unknown' : toTelemetryModel(usageModel, report.modelTelemetryKind) ?? 'unknown';
const selectedModel = toTelemetryModel(report.selectedModel, report.selectedModelTelemetryKind);
@@ -1495,7 +1520,8 @@ export class AgentHostTelemetryReporter {
requestId: report.requestId,
parentTurnId: report.parentTurnId,
parentToolCallId: report.parentToolCallId,
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
isSubagentSession: isSubagent,
...toSubagentKindTelemetry(isSubagent, report.subagentKind),
model,
...(selectedModel !== undefined ? { selectedModel } : {}),
...(effort !== undefined ? { reasoningEffort: effort } : {}),
@@ -1511,6 +1537,7 @@ export class AgentHostTelemetryReporter {
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
const chatSessionId = getTelemetryChatSessionId(report.session);
const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session);
const subagentKindTelemetry = toSubagentKindTelemetry(isSubagent, report.subagentKind);
const model = toTelemetryModel(report.model, report.modelTelemetryKind);
const providerStages = toProviderStageMeasurements(report.providerStageDurationsMs);
this._otelService?.emitTurnTiming({
@@ -1546,6 +1573,7 @@ export class AgentHostTelemetryReporter {
agentSessionId: AgentSession.id(session),
chatSessionId,
isSubagentSession: isSubagent,
...subagentKindTelemetry,
turnId: report.turnId,
parentTurnId: report.parentTurnId,
parentToolCallId: report.parentToolCallId,
@@ -1592,6 +1620,7 @@ export class AgentHostTelemetryReporter {
agentSessionId: AgentSession.id(session),
chatSessionId,
isSubagentSession: isSubagent,
...subagentKindTelemetry,
turnId: report.turnId,
messageOriginKind: report.messageOriginKind,
failureStage: report.failure.stage,
@@ -1613,6 +1642,7 @@ export class AgentHostTelemetryReporter {
*/
turnHung(report: IAgentHostTurnHungReport): void {
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session);
this._telemetryService.publicLog2<IAgentHostTurnHungEvent, IAgentHostTurnHungClassification>('agentHost.turnHung', {
...toInitiatorTelemetry(report.clientContext),
...report.telemetryContext,
@@ -1620,7 +1650,8 @@ export class AgentHostTelemetryReporter {
provider: report.provider,
agentSessionId: AgentSession.id(session),
chatSessionId: getTelemetryChatSessionId(report.session),
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
isSubagentSession: isSubagent,
...toSubagentKindTelemetry(isSubagent, report.subagentKind),
turnId: report.turnId,
messageOriginKind: report.messageOriginKind,
hangReason: report.hangReason,
@@ -1650,13 +1681,15 @@ export class AgentHostTelemetryReporter {
/** Paired recovery event for a turn previously reported by {@link turnHung}. */
hungTurnCompleted(report: IAgentHostHungTurnCompletedReport): void {
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session);
this._telemetryService.publicLog2<IAgentHostHungTurnCompletedEvent, IAgentHostHungTurnCompletedClassification>('agentHost.hungTurnCompleted', {
...toInitiatorTelemetry(report.clientContext),
...report.telemetryContext,
provider: report.provider,
agentSessionId: AgentSession.id(session),
chatSessionId: getTelemetryChatSessionId(report.session),
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
isSubagentSession: isSubagent,
...toSubagentKindTelemetry(isSubagent, report.subagentKind),
turnId: report.turnId,
messageOriginKind: report.messageOriginKind,
hangReason: report.hangReason,
@@ -1672,6 +1705,7 @@ export class AgentHostTelemetryReporter {
// previously emitted by `CopilotAgentSession`). Action signals are keyed
// by their chat-channel URI, so normalize it back to the session URI.
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session);
this._telemetryService.publicLog2<IAgentHostLanguageModelToolInvokedEvent, IAgentHostLanguageModelToolInvokedClassification>('languageModelToolInvoked', {
...toInitiatorTelemetry(report.clientContext),
...report.telemetryContext,
@@ -1694,7 +1728,8 @@ export class AgentHostTelemetryReporter {
result: report.result,
agentSessionId: AgentSession.id(session),
chatSessionId: getTelemetryChatSessionId(report.session),
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
isSubagentSession: isSubagent,
...toSubagentKindTelemetry(isSubagent, report.subagentKind),
toolId: report.toolId,
toolExtensionId: undefined,
toolSourceKind: report.toolSourceKind,
@@ -1713,6 +1748,7 @@ export class AgentHostTelemetryReporter {
askQuestionsToolInvoked(report: IAgentHostAskQuestionsToolInvokedReport): void {
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session);
this._telemetryService.publicLog2<IAgentHostAskQuestionsToolInvokedEvent, IAgentHostAskQuestionsToolInvokedClassification>('askQuestionsToolInvoked', {
...toInitiatorTelemetry(report.clientContext),
...report.telemetryContext,
@@ -1726,18 +1762,21 @@ export class AgentHostTelemetryReporter {
duration: report.duration,
provider: report.provider,
agentSessionId: AgentSession.id(session),
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
isSubagentSession: isSubagent,
...toSubagentKindTelemetry(isSubagent, report.subagentKind),
});
}
toolCallStalled(report: IAgentHostToolCallStalledReport): void {
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session);
this._telemetryService.publicLog2<IAgentHostToolCallStalledEvent, IAgentHostToolCallStalledClassification>('agentHost.toolCallStalled', {
...toInitiatorTelemetry(report.clientContext),
...report.telemetryContext,
provider: report.provider,
agentSessionId: AgentSession.id(session),
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
isSubagentSession: isSubagent,
...toSubagentKindTelemetry(isSubagent, report.subagentKind),
blockerKind: report.blockerKind,
toolCallId: report.toolCallId,
toolId: report.toolId,
@@ -1749,12 +1788,14 @@ export class AgentHostTelemetryReporter {
stalledToolCallCompleted(report: IAgentHostStalledToolCallCompletedReport): void {
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session);
this._telemetryService.publicLog2<IAgentHostStalledToolCallCompletedEvent, IAgentHostStalledToolCallCompletedClassification>('agentHost.stalledToolCallCompleted', {
...toInitiatorTelemetry(report.clientContext),
...report.telemetryContext,
provider: report.provider,
agentSessionId: AgentSession.id(session),
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
isSubagentSession: isSubagent,
...toSubagentKindTelemetry(isSubagent, report.subagentKind),
blockerKind: report.blockerKind,
toolCallId: report.toolCallId,
toolId: report.toolId,
@@ -8,7 +8,7 @@ import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js';
import { StopWatch } from '../../../base/common/stopwatch.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
import type { IAgentTelemetryContext } from '../common/agent.js';
import type { AgentSubagentKind, IAgentTelemetryContext } from '../common/agent.js';
import type { McpServerSource } from '../common/meta/mcpCustomizationMeta.js';
import { SessionInputRequestKind, type SessionToolAuthenticationRequest, type SessionToolClientExecutionRequest, type SessionToolConfirmationRequest } from '../common/state/protocol/state.js';
import { type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js';
@@ -56,6 +56,7 @@ interface IToolCallTiming {
modelResolvedFromUsage: boolean;
readonly clientContext: IAgentHostClientTelemetryContext | undefined;
readonly telemetryContext: IAgentTelemetryContext | undefined;
readonly subagentKind: AgentSubagentKind | undefined;
}
interface IStalledToolCall {
@@ -114,6 +115,7 @@ export class AgentHostToolCallTracker extends Disposable {
modelResolvedFromUsage: resolvedModel !== undefined,
clientContext: this._turnTracker.getClientTelemetryContext(session, turnId),
telemetryContext: this._turnTracker.getTelemetryContext(session, turnId),
subagentKind: this._turnTracker.getSubagentKind(session, turnId),
});
}
@@ -174,6 +176,7 @@ export class AgentHostToolCallTracker extends Disposable {
telemetryContext: timing.telemetryContext,
provider: timing.provider,
session: timing.session,
subagentKind: timing.subagentKind,
turnId: timing.turnId,
toolId: timing.toolId,
toolSourceKind: timing.toolSourceKind,
@@ -203,6 +206,7 @@ export class AgentHostToolCallTracker extends Disposable {
telemetryContext: timing.telemetryContext,
provider: timing.provider,
session: timing.session,
subagentKind: timing.subagentKind,
blockerKind: stalled.blockerKind,
toolCallId,
toolId: timing.toolId,
@@ -233,6 +237,7 @@ export class AgentHostToolCallTracker extends Disposable {
telemetryContext: this._toolCalls.get(toolCallKey)?.telemetryContext,
provider,
session,
subagentKind: this._toolCalls.get(toolCallKey)?.subagentKind,
blockerKind: request.kind,
toolCallId: request.toolCall.toolCallId,
toolId: request.toolCall.toolName,
@@ -10,7 +10,7 @@ import { Disposable, DisposableMap, toDisposable } from '../../../base/common/li
import { StopWatch } from '../../../base/common/stopwatch.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { URI } from '../../../base/common/uri.js';
import type { AgentModelCallFinishedOutcome, AgentSubagentTaskModelSource, IAgent, IAgentTelemetryContext, IAgentTokenUsageSummary, IAgentTurnDiagnosticSnapshot, IAgentTurnTokenUsage } from '../common/agent.js';
import type { AgentModelCallFinishedOutcome, AgentSubagentKind, AgentSubagentTaskModelSource, IAgent, IAgentTelemetryContext, IAgentTokenUsageSummary, IAgentTurnDiagnosticSnapshot, IAgentTurnTokenUsage } from '../common/agent.js';
import type { SessionMode } from '../common/agentHostSchema.js';
import { createUnknownAgentHostClientTelemetryContext, type AgentHostProviderSendStage, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder, type IAgentProviderTurnTelemetryContext } from '../common/agentHostTelemetry.js';
import { AgentHostClientType } from '../common/agentHostClientInfo.js';
@@ -82,6 +82,7 @@ interface ITurnTiming {
/** Who produced the message that started the turn, when known. */
readonly messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined;
readonly subagentTaskModelSource: AgentSubagentTaskModelSource | undefined;
readonly subagentKind: AgentSubagentKind | undefined;
readonly clientContext: IAgentHostClientTelemetryContext;
telemetryContext: IAgentTelemetryContext | undefined;
readonly providerTelemetryContext: IAgentProviderTurnTelemetryContext | undefined;
@@ -209,7 +210,7 @@ export class AgentHostTurnTracker extends Disposable {
}));
}
turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), initiatorClientId?: string, parentTurnId?: string, parentToolCallId?: string, messageOriginKind?: AgentHostMessageOriginTelemetryKind, subagentTaskModelSource?: AgentSubagentTaskModelSource, providerChat = URI.parse(session)): void {
turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), initiatorClientId?: string, parentTurnId?: string, parentToolCallId?: string, messageOriginKind?: AgentHostMessageOriginTelemetryKind, subagentTaskModelSource?: AgentSubagentTaskModelSource, providerChat = URI.parse(session), subagentKind?: AgentSubagentKind): void {
const key = this._key(session, turnId);
let rootTiming = this._rootTurnTimings.get(key);
const isNewRootTurn = !parentTurnId && !isSubagentChatUri(session) && !isSubagentSession(parseChatUri(session)?.session ?? session) && !rootTiming;
@@ -239,6 +240,7 @@ export class AgentHostTurnTracker extends Disposable {
interactionMode,
messageOriginKind,
subagentTaskModelSource,
subagentKind,
clientContext,
telemetryContext: agent.getTelemetryContext?.(),
providerTelemetryContext: captureProviderTurnTelemetryContext(agent),
@@ -643,6 +645,10 @@ export class AgentHostTurnTracker extends Disposable {
return this._turnTimings.get(this._key(session, turnId))?.providerTelemetryContext;
}
getSubagentKind(session: string, turnId: string): AgentSubagentKind | undefined {
return this._turnTimings.get(this._key(session, turnId))?.subagentKind;
}
getMessageOriginKind(session: string, turnId: string): AgentHostMessageOriginTelemetryKind | undefined {
return this._turnTimings.get(this._key(session, turnId))?.messageOriginKind;
}
@@ -719,6 +725,7 @@ export class AgentHostTurnTracker extends Disposable {
interactionMode: timing.interactionMode,
messageOriginKind: timing.messageOriginKind,
subagentTaskModelSource: timing.subagentTaskModelSource,
subagentKind: timing.subagentKind,
failure,
isMultiRoot: workspace?.isMultiRoot ?? false,
folderCount: workspace?.folderCount ?? 0,
@@ -739,6 +746,7 @@ export class AgentHostTurnTracker extends Disposable {
provider: timing.agent.id,
session: timing.session,
turnId,
subagentKind: timing.subagentKind,
messageOriginKind: timing.messageOriginKind,
hangReason: timing.lastHangReason,
result,
@@ -752,7 +760,7 @@ export class AgentHostTurnTracker extends Disposable {
this._reporter.requestTokenUsage({
clientContext: timing.clientContext, provider: timing.agent.id,
telemetryContext: timing.telemetryContext,
session, requestId: turnId, parentTurnId: timing.parentTurnId, parentToolCallId: timing.parentToolCallId,
session, requestId: turnId, parentTurnId: timing.parentTurnId, parentToolCallId: timing.parentToolCallId, subagentKind: timing.subagentKind,
selectedModel: timing.selectedModel, selectedModelTelemetryKind: timing.selectedModelTelemetryKind,
modelTelemetryKind: summary.model ? getModelTelemetryContext(timing.agent, summary.model).modelTelemetryKind : undefined,
result, summary,
@@ -847,6 +855,7 @@ export class AgentHostTurnTracker extends Disposable {
provider: timing.agent.id,
session: timing.session,
turnId: timing.turnId,
subagentKind: timing.subagentKind,
messageOriginKind: timing.messageOriginKind,
hangReason,
hadAnyProgress: timing.lastActivityKind !== TURN_ACTIVITY_NONE,
+81 -10
View File
@@ -41,7 +41,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f
import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';
import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, PendingMessageKind, TerminalClaimKind, TerminalLifecycleStatus, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type ChatState, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TerminalState, type TextRange, type ToolResultTerminalContent } from '../common/state/protocol/state.js';
import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js';
import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionComparisonMetadata, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, chatStorageUri, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSessionStatusArchived, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionGitData, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitData, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, getAllSessionRelatedPullRequestUrls, readSessionEhcliAdoptable, readSessionGitHubData, parseSessionGitHubData, parseSessionGitHubState, readSessionGitHubStateInput, withMigratedSessionGitHubState, withReplacedFolderGitHubState, SESSION_META_GITHUB_DATA_KEY, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type SessionSummaryMeta, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionComparisonMetadata, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, chatStorageUri, getErrorResponsePart, isAhpChatChannel, isChatInSessionReadAggregate, isChatReadOnly, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionGitData, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitData, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, getAllSessionRelatedPullRequestUrls, readSessionEhcliAdoptable, readSessionGitHubData, parseSessionGitHubData, parseSessionGitHubState, readSessionGitHubStateInput, withMigratedSessionGitHubState, withReplacedFolderGitHubState, SESSION_META_GITHUB_DATA_KEY, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type SessionSummaryMeta, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js';
import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js';
import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js';
@@ -153,6 +153,7 @@ interface IPassiveSessionMetadataUpdate {
readonly key: string;
readonly flag: SessionStatus;
readonly set: boolean;
readonly requestedSet: boolean;
}
interface IBackgroundPassiveSessionMetadataWrite {
@@ -408,6 +409,7 @@ interface ICatalogChat {
readonly origin?: ChatOrigin;
readonly interactivity?: ChatInteractivity;
readonly archived?: boolean;
readonly isRead?: boolean;
readonly inheritedTurnId?: string;
readonly workingDirectories?: readonly string[];
readonly changes?: ChangesSummary;
@@ -2190,7 +2192,11 @@ export class AgentService extends Disposable implements IAgentService {
kind: summary.defaultChat === chat.resource || isDefaultChatUri(chat.resource) ? 'default' : 'peer',
origin: chat.origin,
...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}),
...(isSessionStatusArchived(chat.status) || chat.archived === true ? { archived: true } : {}),
...(chat.status !== undefined ? {
archived: isSessionStatusArchived(chat.status),
isRead: isSessionStatusRead(chat.status),
} : {}),
...(chat.archived === true ? { archived: true } : {}),
...(chat.changes !== undefined ? { changes: chat.changes } : {}),
}));
}
@@ -2367,6 +2373,7 @@ export class AgentService extends Disposable implements IAgentService {
uri: buildDefaultChatUri(registered.session),
kind: 'default',
title: metadata.summary,
isRead: metadata.chats?.find(chat => chat.kind === 'default')?.isRead,
...(defaultChatWorkingDirectories !== undefined ? { workingDirectories: defaultChatWorkingDirectories } : {}),
},
...peers.map(peer => ({
@@ -2374,6 +2381,7 @@ export class AgentService extends Disposable implements IAgentService {
kind: 'peer' as const,
origin: peer.origin,
archived: peer.archived,
isRead: peer.isRead,
inheritedTurnId: peer.inheritedTurnId,
workingDirectories: peer.workingDirectories,
})),
@@ -2447,6 +2455,7 @@ export class AgentService extends Disposable implements IAgentService {
origin: chat.origin,
...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}),
...(isSessionStatusArchived(chat.status) && state.defaultChat !== chat.resource && !isDefaultChatUri(chat.resource) ? { archived: true } : {}),
isRead: isSessionStatusRead(chat.status),
inheritedTurnId: this._stateManager.getChatInheritedTurnId(chat.resource),
workingDirectories: chat.workingDirectories,
changes: chat.changes,
@@ -3200,12 +3209,14 @@ export class AgentService extends Disposable implements IAgentService {
uri: buildDefaultChatUri(metadata.session),
kind: 'default',
title: metadata.summary,
isRead: metadata.chats?.find(chat => chat.kind === 'default')?.isRead,
},
...peers.map(peer => ({
uri: peer.uri,
kind: 'peer' as const,
origin: peer.origin,
archived: peer.archived,
isRead: peer.isRead,
inheritedTurnId: peer.inheritedTurnId,
})),
],
@@ -3449,6 +3460,7 @@ export class AgentService extends Disposable implements IAgentService {
kind: 'peer' as const,
origin: peer.origin,
archived: peer.archived,
isRead: peer.isRead,
inheritedTurnId: peer.inheritedTurnId,
workingDirectories: peer.workingDirectories,
})),
@@ -6843,21 +6855,35 @@ export class AgentService extends Disposable implements IAgentService {
* first, so an absent surfaced summary can only mean "unknown".
*/
private async _applyPassiveSessionMetadata(session: string, action: IIsArchivedChangedAction | IIsReadChangedAction): Promise<boolean> {
if (!this._stateManager.getSurfacedSessionSummary(session)) {
const summary = this._stateManager.getSurfacedSessionSummary(session);
if (!summary) {
return false;
}
const sessionUri = URI.parse(session);
const [key, flag, set] = action.type === ActionType.SessionIsArchivedChanged
const [key, flag, requestedSet] = action.type === ActionType.SessionIsArchivedChanged
? [AH_META_IS_ARCHIVED_DB_KEY, SessionStatus.IsArchived, action.isArchived] as const
: [AH_META_IS_READ_DB_KEY, SessionStatus.IsRead, action.isRead] as const;
const aggregateChats = action.type === ActionType.SessionIsReadChanged
? summary.chats?.filter(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity)) ?? []
: [];
const set = requestedSet && aggregateChats.length > 1 && aggregateChats.some(chat => chat.status !== undefined && !isSessionStatusRead(chat.status))
? false
: requestedSet;
const soleChat = aggregateChats.length === 1 ? aggregateChats[0] : undefined;
await persistSessionMetadataValues(this._sessionDataService, session, {
[key]: set ? 'true' : '',
...(soleChat && isDefaultChatUri(soleChat.resource) ? { [AH_META_DEFAULT_CHAT_IS_READ_DB_KEY]: set ? 'true' : '' } : {}),
...(action.type === ActionType.SessionIsArchivedChanged && !action.isArchived ? { [AH_META_AUTO_ARCHIVED_AT_DB_KEY]: '' } : {}),
});
this._invalidateSessionList();
this._stateManager.setSurfacedSessionStatusFlag(session, flag, set);
const chats = soleChat
? summary.chats?.map(chat => chat.resource === soleChat.resource
? { ...chat, status: withSessionStatusFlag(chat.status ?? summary.status, SessionStatus.IsRead, set) }
: chat)
: undefined;
this._stateManager.setSurfacedSessionStatusAndChats(session, withSessionStatusFlag(summary.status, flag, set), chats);
const payloadDirty = this._markCatalogPayloadDirty(session);
this._queuePassiveSessionMetadataSynchronization(sessionUri, { key, flag, set });
this._queuePassiveSessionMetadataSynchronization(sessionUri, { key, flag, set, requestedSet });
await payloadDirty;
return true;
}
@@ -6947,9 +6973,34 @@ export class AgentService extends Disposable implements IAgentService {
let data = request.data;
const legacyMetadata = { ...request.legacyMetadata };
for (const update of updates) {
if (update.flag === SessionStatus.IsRead) {
const aggregateChats = data.chats.filter(chat => {
const isToolChat = typeof chat.origin === 'object'
&& chat.origin !== null
&& !Array.isArray(chat.origin)
&& chat.origin.kind === ChatOriginKind.Tool;
return !isToolChat && isChatInSessionReadAggregate(chat.uri, undefined, chat.interactivity);
});
const set = update.requestedSet && aggregateChats.length > 1 && aggregateChats.some(chat => chat.isRead === false)
? false
: update.set;
const soleChat = aggregateChats.length === 1 ? aggregateChats[0] : undefined;
data = {
...data,
isRead: set,
...(soleChat ? {
chats: data.chats.map(chat => chat.uri === soleChat.uri ? { ...chat, isRead: set } : chat),
} : {}),
};
legacyMetadata[update.key] = set ? 'true' : '';
if (soleChat?.kind === 'default') {
legacyMetadata[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY] = set ? 'true' : '';
}
continue;
}
data = {
...data,
...(update.flag === SessionStatus.IsArchived ? { isArchived: update.set } : { isRead: update.set }),
isArchived: update.set,
};
legacyMetadata[update.key] = update.set ? 'true' : '';
}
@@ -7481,12 +7532,18 @@ export class AgentService extends Disposable implements IAgentService {
private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext): void {
const origin = { clientId, clientSeq };
const chatState = action.type === ActionType.ChatIsArchivedChanged ? this._stateManager.getChatState(channel) : undefined;
const chatState = action.type === ActionType.ChatIsArchivedChanged || action.type === ActionType.ChatIsReadChanged
? this._stateManager.getChatState(channel)
: undefined;
if (action.type === ActionType.ChatIsArchivedChanged
&& (isDefaultChatUri(channel) || !chatState || chatState.origin?.kind === ChatOriginKind.Tool || chatState.origin?.kind === ChatOriginKind.SideChat)) {
this._stateManager.rejectClientAction(channel, action, origin, 'Only a known independently manageable non-default chat can be archived.');
return;
}
if (action.type === ActionType.ChatIsReadChanged && !chatState) {
this._stateManager.rejectClientAction(channel, action, origin, 'Only a known chat can change its read state.');
return;
}
if (action.type === ActionType.SessionIsArchivedChanged && !action.isArchived && this._sessionResidency.isBeingDisposed(sessionChannel)) {
this._stateManager.rejectClientAction(channel, action, origin, 'Cannot unarchive a session while it is being deleted.');
return;
@@ -8291,6 +8348,7 @@ export class AgentService extends Disposable implements IAgentService {
// Check for persisted metadata in the session database
let title = meta.summary ?? 'Session';
let isRead: boolean | undefined;
let defaultChatIsRead: boolean | undefined;
let isArchived: boolean | undefined;
let persistedConfigValues: Record<string, unknown> | undefined;
let changes: ChangesSummary | undefined;
@@ -8308,6 +8366,7 @@ export class AgentService extends Disposable implements IAgentService {
const m = await db.object.getMetadataObject({
customTitle: true,
[AH_META_IS_READ_DB_KEY]: true,
[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY]: true,
[AH_META_IS_ARCHIVED_DB_KEY]: true,
[AH_META_IS_DONE_DB_KEY]: true,
configValues: true,
@@ -8335,6 +8394,9 @@ export class AgentService extends Disposable implements IAgentService {
if (m[AH_META_IS_READ_DB_KEY] !== undefined) {
isRead = m[AH_META_IS_READ_DB_KEY] === 'true';
}
if (m[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY] !== undefined) {
defaultChatIsRead = m[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY] === 'true';
}
const persistedArchived = m[AH_META_IS_ARCHIVED_DB_KEY] ?? m[AH_META_IS_DONE_DB_KEY];
if (persistedArchived !== undefined) {
isArchived = persistedArchived === 'true';
@@ -8450,7 +8512,9 @@ export class AgentService extends Disposable implements IAgentService {
}
if (adoptionListVisible?.isRead !== undefined) {
isRead = adoptionListVisible.isRead;
defaultChatIsRead ??= adoptionListVisible.isRead;
}
defaultChatIsRead ??= isRead;
let turns: readonly Turn[];
try {
@@ -8532,6 +8596,7 @@ export class AgentService extends Disposable implements IAgentService {
defaultChatTitle,
defaultChatModifiedAt,
defaultChatWorkingDirectories,
defaultChatIsRead,
});
if (adoptionListVisible) {
const adoptionMetadata: Record<string, string> = {};
@@ -8541,6 +8606,7 @@ export class AgentService extends Disposable implements IAgentService {
}
if (adoptionListVisible.isRead !== undefined) {
adoptionMetadata[AH_META_IS_READ_DB_KEY] = adoptionListVisible.isRead ? 'true' : '';
adoptionMetadata[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY] = adoptionListVisible.isRead ? 'true' : '';
}
await this._persistListVisibleSessionState(session, adoptionMetadata);
}
@@ -8654,8 +8720,9 @@ export class AgentService extends Disposable implements IAgentService {
...(chat.workingDirectories !== undefined ? { workingDirectories: chat.workingDirectories } : {}),
})));
}
const entriesWithReadState = entries.map(entry => entry.isRead === undefined ? { ...entry, isRead: true } : entry);
const cachedWorkingDirectories = new Map(cached?.filter(chat => chat.kind === 'peer' && chat.workingDirectories !== undefined).map(chat => [chat.uri, chat.workingDirectories]));
const enrichedEntries = entries.map(entry => entry.workingDirectories === undefined && cachedWorkingDirectories.get(entry.uri) !== undefined
const enrichedEntries = entriesWithReadState.map(entry => entry.workingDirectories === undefined && cachedWorkingDirectories.get(entry.uri) !== undefined
? { ...entry, workingDirectories: cachedWorkingDirectories.get(entry.uri) }
: entry);
const toolChatUris = new Set([
@@ -8739,6 +8806,7 @@ export class AgentService extends Disposable implements IAgentService {
if (cached?.some(chat => chat.kind === 'peer')) {
const projectedPeers: IPersistedPeerChat[] = cached.filter(chat => chat.kind === 'peer').map(chat => ({
uri: chat.uri,
isRead: true,
...(chat.origin !== undefined ? { origin: chat.origin } : {}),
...(chat.archived === true ? { archived: true } : {}),
...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}),
@@ -8771,6 +8839,7 @@ export class AgentService extends Disposable implements IAgentService {
}
const entries: IPersistedPeerChat[] = legacy.map(chat => ({
uri: chat.uri.toString(),
isRead: true,
...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}),
}));
return this._peerChatStore.initialize(session, entries, database);
@@ -8806,6 +8875,7 @@ export class AgentService extends Disposable implements IAgentService {
inheritedTurnId: entry.inheritedTurnId,
workingDirectories: entry.workingDirectories ?? cachedChat?.workingDirectories,
archived: entry.archived,
isRead: entry.isRead,
changes: cachedChat?.changes,
};
}));
@@ -8813,7 +8883,7 @@ export class AgentService extends Disposable implements IAgentService {
if (!item) {
continue;
}
const { chatUri, title, draft, providerData, origin, interactivity, inheritedTurnId, workingDirectories, archived, changes } = item;
const { chatUri, title, draft, providerData, origin, interactivity, inheritedTurnId, workingDirectories, archived, isRead, changes } = item;
if (this._stateManager.getChatState(chatUri.toString())) {
continue;
}
@@ -8826,6 +8896,7 @@ export class AgentService extends Disposable implements IAgentService {
inheritedTurnId,
workingDirectories,
archived,
isRead,
changes,
resolver: currentProviderData => this._materializeRestoredPeerChat(session, chatUri, currentProviderData),
});
@@ -25,7 +25,7 @@ import { IAgentHostChatContributions, type ISendTurnMessageOptions } from '../co
import { AgentHostClientType } from '../common/agentHostClientInfo.js';
import { isRenameChatTool } from '../common/serverToolNames.js';
import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal, type AgentSubagentTaskModelSource, type IAgentModelCallCompletedSignal, type IAgentModelCallFinishedSignal } from '../common/agent.js';
import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal, type AgentSubagentKind, type AgentSubagentTaskModelSource, type IAgentModelCallCompletedSignal, type IAgentModelCallFinishedSignal, type IAgentSubagentStartedSignal } from '../common/agent.js';
import { isPresentationOnlyToolCall, readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js';
import { isAgentMergeMessage } from '../common/meta/agentMergeMessageMeta.js';
import { readAgentPermissionResponseMeta } from '../common/meta/agentPermissionResponseMeta.js';
@@ -172,6 +172,8 @@ interface ISubagentSessionRef {
readonly chatUri: ProtocolURI;
readonly turnStopWatch: StopWatch;
readonly taskModelSource: AgentSubagentTaskModelSource | undefined;
readonly subagentKind: AgentSubagentKind | undefined;
readonly model: string | undefined;
}
interface ISubagentParentTurnTelemetryContext {
@@ -311,6 +313,7 @@ export class AgentSideEffects extends Disposable {
undefined,
(session, turnId) => this._turnTracker.getClientTelemetryContext(session, turnId),
(session, turnId) => this._turnTracker.getTelemetryContext(session, turnId),
(session, turnId) => this._turnTracker.getSubagentKind(session, turnId),
);
this._permissionManager = this._register(this._instantiationService.createInstance(SessionPermissionManager, this._stateManager, {}));
this._register(this._stateManager.onDidSnapshotDefaultChatTitle(event => this._persistDefaultChatTitleSnapshot(event.session, event.chat, event.title)));
@@ -674,7 +677,7 @@ export class AgentSideEffects extends Disposable {
return;
}
if (signal.kind === 'subagent_started') {
this._handleSubagentStarted(signal.chat.toString(), signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription, signal.taskPrompt, signal.parentToolCallId, signal.taskModelSource);
this._handleSubagentStarted(signal);
this._drainPendingSubagentSignals(signal.chat.toString(), signal.toolCallId);
return;
}
@@ -923,10 +926,8 @@ export class AgentSideEffects extends Disposable {
if (!isSubagentChatUri(sessionKey)) {
this._turnTracker.updateBilledNanoAiu(sessionKey, action.turnId, usageMeta.copilotUsage?.totalNanoAiu);
}
if (action.usage.model && agent) {
const modelContext = getModelTelemetryContext(agent, action.usage.model);
this._turnTracker.updateModel(sessionKey, action.turnId, modelContext.model, modelContext.modelTelemetryKind);
this._toolCallTracker.updateTurnModel(sessionKey, action.turnId, modelContext.model, modelContext.modelTelemetryKind);
if (agent) {
this._updateTurnModel(agent, sessionKey, action.turnId, action.usage.model);
}
}
@@ -1171,25 +1172,18 @@ export class AgentSideEffects extends Disposable {
* before this runs, so this only drives the turn/tracking/parent content
* — it does not add the chat.
*
* `chatURI` is always the agent's top-level chat: the subagent is
* `signal.chat` is always the agent's top-level chat: the subagent is
* registered (and inner events routed) under it because inner-tool
* signals carry the top-level chat as their resource. `spawningToolParentId`,
* signals carry the top-level chat as their resource. `signal.parentToolCallId`,
* when set, is the tool call one level up from the spawning `toolCallId`
* — the tool call in whose (subagent) chat the spawning tool lives — and
* is used to route the discovery content block to that immediate parent
* chat. Since subagent chats are flat (keyed off the root session), this
* one-hop reference resolves the parent chat at any nesting depth.
*/
private _handleSubagentStarted(
chatURI: ProtocolURI,
toolCallId: string,
agentName: string,
agentDisplayName: string,
agentDescription?: string,
taskPrompt?: string,
spawningToolParentId?: string,
taskModelSource?: AgentSubagentTaskModelSource,
): void {
private _handleSubagentStarted(signal: IAgentSubagentStartedSignal): void {
const chatURI = signal.chat.toString();
const { toolCallId, agentName, agentDisplayName, agentDescription, taskPrompt, parentToolCallId: spawningToolParentId, taskModelSource, subagentKind, model } = signal;
const parentSessionUri = parseRequiredSessionUriFromChatUri(chatURI);
const subagentChatUri = buildSubagentChatUri(parentSessionUri, toolCallId);
const immediateParentChatUri = spawningToolParentId
@@ -1199,6 +1193,9 @@ export class AgentSideEffects extends Disposable {
const existing = this._subagentChats.get(chatURI, toolCallId);
if (existing) {
if (model && model !== existing.model) {
this._subagentChats.set({ ...existing, model }, chatURI, toolCallId);
}
this._resumeSubagentSession(chatURI, toolCallId, taskPrompt ? { text: taskPrompt, origin: { kind: MessageKind.User } } : undefined, immediateParentChatUri);
return;
}
@@ -1219,11 +1216,12 @@ export class AgentSideEffects extends Disposable {
const agent = this._options.getAgent(parentSessionUri);
if (agent) {
const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(parentSessionUri)?.config);
this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, taskModelSource, URI.parse(chatURI));
this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, taskModelSource, URI.parse(chatURI), subagentKind);
this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider');
this._updateTurnModel(agent, subagentChatUri, turnId, model);
}
this._subagentChats.set({ parentChatUri: chatURI, immediateParentChatUri, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false), taskModelSource }, chatURI, toolCallId);
this._subagentChats.set({ parentChatUri: chatURI, immediateParentChatUri, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false), taskModelSource, subagentKind, model }, chatURI, toolCallId);
// Dispatch the discovery content on the spawning tool call's own chat; the top-level chat is a no-op when nested.
if (parentTurnId) {
@@ -1295,14 +1293,25 @@ export class AgentSideEffects extends Disposable {
const agent = this._options.getAgent(subagent.sessionUri);
if (agent) {
const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(subagent.sessionUri)?.config);
this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, subagent.taskModelSource, URI.parse(parentChatURI));
this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, subagent.taskModelSource, URI.parse(parentChatURI), subagent.subagentKind);
this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider');
this._updateTurnModel(agent, subagent.chatUri, turnId, subagent.model);
}
this._subagentChats.set({ ...subagent, immediateParentChatUri: correlatedParentChatUri, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId);
}
private _updateTurnModel(agent: IAgent, chat: ProtocolURI, turnId: string, model: string | undefined): void {
if (!model) {
return;
}
const modelContext = getModelTelemetryContext(agent, model);
this._turnTracker.updateModel(chat, turnId, modelContext.model, modelContext.modelTelemetryKind);
this._toolCallTracker.updateTurnModel(chat, turnId, modelContext.model, modelContext.modelTelemetryKind);
}
private _getSubagentParentTurnTelemetryContext(immediateParentChatUri: ProtocolURI | undefined, fallbackParentChatUri: ProtocolURI): ISubagentParentTurnTelemetryContext {
const parentChatUri = immediateParentChatUri ?? fallbackParentChatUri;
immediateParentChatUri = immediateParentChatUri && this._turnOwningChat(immediateParentChatUri);
const parentChatUri = immediateParentChatUri ?? this._turnOwningChat(fallbackParentChatUri);
const parentTurnId = this._stateManager.getActiveTurnId(parentChatUri);
const parentSessionUri = parseRequiredSessionUriFromChatUri(parentChatUri);
const parentMessageOriginKind = parentTurnId ? this._turnTracker.getMessageOriginKind(parentChatUri, parentTurnId) : undefined;
@@ -1317,6 +1326,19 @@ export class AgentSideEffects extends Disposable {
};
}
/** A Fusion phase chat only presents part of its parent's turn, so that parent owns the work done in it. */
private _turnOwningChat(chatUri: ProtocolURI): ProtocolURI {
if (!isSubagentChatUri(chatUri)) {
return chatUri;
}
for (const subagent of this._subagentChats.values()) {
if (subagent.chatUri === chatUri && subagent.subagentKind === 'fusionPhase') {
return this._turnOwningChat(subagent.immediateParentChatUri ?? subagent.parentChatUri);
}
}
return chatUri;
}
/**
* Cancels child executions, retaining reusable routing only when requested by a parent turn cancellation.
*/
@@ -9,6 +9,7 @@ import { AdditionalWorktreeLifecycleContribution } from './additionalWorktreeLif
import { ChatArchiveContribution } from './chatArchive/chatArchiveContribution.js';
import { ChatDraftContribution } from './chatDraft/chatDraftContribution.js';
import { ChatInputContribution } from './chatInput/chatInputContribution.js';
import { ChatReadContribution } from './chatRead/chatReadContribution.js';
import { ChatSurfaceContribution } from './chatSurface/chatSurfaceContribution.js';
import { CheckpointAndChangesetContribution } from './checkpointAndChangeset/checkpointAndChangesetContribution.js';
import { ExternalSessionAdoptionContribution } from './externalSessionAdoption/externalSessionAdoptionContribution.js';
@@ -63,5 +64,6 @@ export function registerBuiltInChatContributions(
registrations.add(contributions.registerContribution(SessionFlagsContribution));
registrations.add(contributions.registerContribution(AdditionalWorktreeLifecycleContribution));
registrations.add(contributions.registerContribution(ChatArchiveContribution));
registrations.add(contributions.registerContribution(ChatReadContribution));
return registrations;
}
@@ -8,7 +8,8 @@ import { URI } from '../../../../../base/common/uri.js';
import { ILogService } from '../../../../log/common/log.js';
import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDispatchedAction } from '../../../common/agentHostChatContributionsService.js';
import { ActionType } from '../../../common/state/sessionActions.js';
import { isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../../../common/state/sessionState.js';
import { isAhpChatChannel, isDefaultChatUri, isSessionStatusRead, parseRequiredSessionUriFromChatUri } from '../../../common/state/sessionState.js';
import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js';
import { IAgentHostPeerChatPersistenceService } from '../../agentHostPeerChatStore.js';
/**
@@ -22,6 +23,7 @@ export class ChatArchiveContribution extends Disposable implements IAgentHostCha
constructor(
protected readonly _context: IAgentHostChatContributionContext,
@IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
@IAgentHostPeerChatPersistenceService private readonly _peerChatPersistenceService: IAgentHostPeerChatPersistenceService,
@ILogService private readonly _logService: ILogService,
) {
@@ -37,6 +39,9 @@ export class ChatArchiveContribution extends Disposable implements IAgentHostCha
}
const session = URI.parse(parseRequiredSessionUriFromChatUri(dispatched.channel));
const chat = URI.parse(dispatched.channel);
if (dispatched.action.isArchived && !isSessionStatusRead(this._stateManager.getChatState(dispatched.channel)?.status)) {
this._stateManager.dispatchServerAction(dispatched.channel, { type: ActionType.ChatIsReadChanged, isRead: true });
}
void this._peerChatPersistenceService.setArchived(session, chat, dispatched.action.isArchived).catch(error => {
this._logService.error(error, `[ChatArchiveContribution] Failed to persist archived state for ${dispatched.channel}`);
});
@@ -0,0 +1,48 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { URI } from '../../../../../base/common/uri.js';
import { ILogService } from '../../../../log/common/log.js';
import { ISessionDataService } from '../../../common/sessionDataService.js';
import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDispatchedAction } from '../../../common/agentHostChatContributionsService.js';
import { ActionType } from '../../../common/state/sessionActions.js';
import { AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../../../common/state/sessionState.js';
import { IAgentHostPeerChatPersistenceService } from '../../agentHostPeerChatStore.js';
import { persistSessionMetadata } from '../../shared/persistSessionMetadata.js';
/** Persists accepted chat read-state changes. */
export class ChatReadContribution extends Disposable implements IAgentHostChatContribution {
static readonly id = 'chatRead';
readonly order = 760;
constructor(
protected readonly _context: IAgentHostChatContributionContext,
@IAgentHostPeerChatPersistenceService private readonly _peerChatPersistenceService: IAgentHostPeerChatPersistenceService,
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
@ILogService private readonly _logService: ILogService,
) {
super();
}
onDidDispatchAction(dispatched: IDispatchedAction): void {
if (dispatched.rejectionReason !== undefined
|| dispatched.action.type !== ActionType.ChatIsReadChanged
|| !isAhpChatChannel(dispatched.channel)) {
return;
}
const sessionResource = parseRequiredSessionUriFromChatUri(dispatched.channel);
if (isDefaultChatUri(dispatched.channel)) {
persistSessionMetadata(this._sessionDataService, this._logService, sessionResource, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, dispatched.action.isRead ? 'true' : '');
return;
}
const session = URI.parse(sessionResource);
const chat = URI.parse(dispatched.channel);
void this._peerChatPersistenceService.setRead(session, chat, dispatched.action.isRead).catch(error => {
this._logService.error(error, `[ChatReadContribution] Failed to persist read state for ${dispatched.channel}`);
});
}
}
@@ -5,7 +5,7 @@
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { ActionType } from '../../../common/state/sessionActions.js';
import { SessionStatus } from '../../../common/state/sessionState.js';
import { isAhpChatChannel, isChatInSessionReadAggregate, SessionStatus } from '../../../common/state/sessionState.js';
import type { IAgentHostChatContribution, IAgentHostChatContributionContext, ITurnEnd } from '../../../common/agentHostChatContributionsService.js';
import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js';
@@ -29,9 +29,17 @@ export class MarkUnreadContribution extends Disposable implements IAgentHostChat
if (turn.reason.kind === 'localCommand' || turn.reason.kind === 'rejected') {
return;
}
// Route subagent turns to their owning session too (a background subagent
// can complete after the parent turn). Each client keeps its active session
// read; marking it unread is idempotent.
const session = this._stateManager.getSessionState(turn.session);
const chatSummary = isAhpChatChannel(turn.channel)
? session?.chats.find(chat => chat.resource === turn.channel)
: undefined;
const isKnownChat = !!chatSummary;
if (isKnownChat) {
this._stateManager.dispatchServerAction(turn.channel, { type: ActionType.ChatIsReadChanged, isRead: false });
}
if (!isChatInSessionReadAggregate(turn.channel, chatSummary?.origin, chatSummary?.interactivity)) {
return;
}
const status = this._stateManager.getSessionSummary(turn.session)?.status ?? 0;
if (!(status & SessionStatus.IsRead)) {
return;
@@ -44,6 +44,7 @@ import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../comm
import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js';
import { readCopilotShellAttachment, toCopilotBackgroundShellMeta } from '../../common/meta/copilotBackgroundWorkMeta.js';
import { getSessionSandboxConfig } from '../sessionSandbox.js';
import { getCopilotBrowserSandboxNetworkRestrictions } from './copilotSandboxPolicy.js';
import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostMcpToolRoutingEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js';
import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js';
import { AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, subagentChatTitle, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js';
@@ -113,6 +114,7 @@ import { CopilotMcpToolRoutingCache, getMcpRoutingCacheKey, type ICopilotMcpRout
import { getCopilotCustomizationCommandHandler } from './copilotCustomizationCommandDisplay.js';
import { renderCopilotSlashCommandOutput, type RuntimeSlashCommandInfo } from './copilotSlashCommand.js';
import { CopilotSandboxPolicyDisplay } from './copilotSandboxPolicyDisplay.js';
import { toSlashCommandResourceMeta } from '../../common/meta/agentSlashCommandOutputMeta.js';
import { createCopilotFailureCorrelation, reportCopilotModelCallFailure, reportCopilotSdkSessionError } from './copilotFailureTelemetry.js';
import { reportCopilotTodoStoreOperation } from './copilotTodoStoreTelemetry.js';
import { ModelCallTurnCorrelation } from './modelCallTurnCorrelation.js';
@@ -174,6 +176,10 @@ interface IPendingMcpAuthRequest {
readonly toolCalls: IMcpAuthToolCall[];
}
type SubagentTurnOutcome =
| { readonly kind: 'failed'; readonly message: string; readonly durationMs: number | undefined }
| { readonly kind: 'cancelled'; readonly durationMs: number | undefined };
interface IMcpAuthToolCall {
readonly turnId: string;
readonly toolCallId: string;
@@ -947,6 +953,7 @@ export class CopilotAgentSession extends Disposable {
private readonly _autoModeResolvedByToolCallId = new Map<string, NonNullable<UsageInfoMeta['autoModeResolved']>>();
private readonly _activeSubagentAgentIds = new Set<string>();
private readonly _subagentTaskCompletionSchedulers = this._register(new DisposableMap<string, RunOnceScheduler>());
private readonly _pendingSubagentLifecycleCompletions = new Map<string, { readonly toolCallId: string; readonly activityRevision: number | undefined; readonly outcome?: SubagentTurnOutcome }>();
private readonly _subagentTaskCompletionDelay: number;
/** Bumped when a child starts a model round so a task snapshot taken earlier cannot complete it; entries are dropped on completion. */
private readonly _subagentActivityRevisions = new Map<string, number>();
@@ -1275,6 +1282,7 @@ export class CopilotAgentSession extends Disposable {
/** One-shot SDK callbacks, keyed by request id; answering one delivers a token but does not confirm acceptance. */
private readonly _pendingMcpAuthRequests = new PendingRequestRegistry<McpAuthResult | null | undefined, IPendingMcpAuthRequest>();
private _requiresConnectorConfigurationRefresh = false;
private readonly _mcpServerDisplayNames = new Map<string, string>();
/**
* Retains challenge metadata and its latest callback id so token delivery can report Starting.
* Connected and needs-auth statuses remain the final lifecycle authority.
@@ -1830,6 +1838,7 @@ export class CopilotAgentSession extends Disposable {
private _completeSubagentTurn(agentId: string | undefined, toolCallId?: string): void {
if (agentId) {
this._subagentTaskCompletionSchedulers.deleteAndDispose(agentId);
this._pendingSubagentLifecycleCompletions.delete(agentId);
this._subagentActivityRevisions.delete(agentId);
if (!this._activeSubagentAgentIds.delete(agentId)) {
return;
@@ -1869,18 +1878,7 @@ export class CopilotAgentSession extends Disposable {
this._autoModeResolvedByToolCallId.delete(parentToolCallId);
}
private _scheduleSubagentTurnCompletion(agentId: string, toolCallId?: string): void {
if (!this._activeSubagentAgentIds.has(agentId)) {
return;
}
const parentToolCallId = toolCallId ?? this._parentToolCallIdsByAgentId.get(agentId);
if (!parentToolCallId) {
return;
}
if (this._subagentTaskCompletionDelay <= 0) {
this._completeSubagentTurn(agentId, parentToolCallId);
return;
}
private _getOrCreateSubagentCompletionScheduler(agentId: string): RunOnceScheduler {
let scheduler = this._subagentTaskCompletionSchedulers.get(agentId);
if (!scheduler) {
const confirmation = new RunOnceScheduler(() => {
@@ -1900,9 +1898,84 @@ export class CopilotAgentSession extends Disposable {
scheduler = confirmation;
this._subagentTaskCompletionSchedulers.set(agentId, scheduler);
}
return scheduler;
}
private _scheduleSubagentTurnCompletion(agentId: string, toolCallId?: string, outcome?: SubagentTurnOutcome): void {
if (!this._activeSubagentAgentIds.has(agentId)) {
return;
}
const parentToolCallId = toolCallId ?? this._parentToolCallIdsByAgentId.get(agentId);
if (!parentToolCallId) {
return;
}
if (this._subagentTaskCompletionDelay <= 0) {
this._settleSubagentTurn(agentId, parentToolCallId, outcome);
return;
}
const scheduler = this._getOrCreateSubagentCompletionScheduler(agentId);
scheduler.schedule();
}
private _scheduleSubagentLifecycleCompletion(agentId: string | undefined, toolCallId: string, outcome?: SubagentTurnOutcome): void {
const mappedAgentId = agentId ?? [...this._parentToolCallIdsByAgentId].find(([, parentToolCallId]) => parentToolCallId === toolCallId)?.[0];
if (!mappedAgentId) {
if (this._rootTurnIdBySubagentToolCallId.has(toolCallId)) {
this._emitSubagentOutcome(toolCallId, outcome);
this._completeSubagentTurn(undefined, toolCallId);
}
return;
}
if (!this._activeSubagentAgentIds.has(mappedAgentId)) {
return;
}
this._pendingSubagentLifecycleCompletions.set(mappedAgentId, {
toolCallId,
activityRevision: this._subagentActivityRevisions.get(mappedAgentId),
outcome,
});
this._getOrCreateSubagentCompletionScheduler(mappedAgentId).schedule();
}
/** Completes an active child turn, first ending it as failed or cancelled when it did not succeed. */
private _settleSubagentTurn(agentId: string, toolCallId: string, outcome: SubagentTurnOutcome | undefined): void {
if (this._activeSubagentAgentIds.has(agentId)) {
this._emitSubagentOutcome(toolCallId, outcome);
}
this._completeSubagentTurn(agentId, toolCallId);
}
/** Task status is authoritative; a pending lifecycle event only supplies details the status lacks. */
private _taskOutcome(task: Extract<Awaited<ReturnType<CopilotSession['rpc']['tasks']['list']>>['tasks'][number], { type: 'agent' }>): SubagentTurnOutcome | undefined {
const pending = this._pendingSubagentLifecycleCompletions.get(task.id)?.outcome;
switch (task.status) {
case 'failed':
return pending?.kind === 'failed' ? pending : { kind: 'failed', message: task.error ?? localize('copilotAgent.subagentFailed', "Subagent failed."), durationMs: task.activeTimeMs };
case 'cancelled':
return pending?.kind === 'cancelled' ? pending : { kind: 'cancelled', durationMs: task.activeTimeMs };
default:
return undefined;
}
}
/** Ends the child turn as failed or cancelled; the completion that follows then finds no active turn. */
private _emitSubagentOutcome(toolCallId: string, outcome: SubagentTurnOutcome | undefined): void {
if (outcome?.kind === 'failed') {
this._emitAction({
type: ActionType.ChatError,
turnId: this._turnId,
duration: outcome.durationMs ?? 0,
part: createErrorResponsePart({ errorType: 'subagentFailed', message: outcome.message }),
}, toolCallId);
} else if (outcome?.kind === 'cancelled') {
this._emitAction({
type: ActionType.ChatTurnCancelled,
turnId: this._turnId,
duration: outcome.durationMs ?? 0,
}, toolCallId);
}
}
private _observeTokenUsage(parentToolCallId: string | undefined, eventId: string, model: string | undefined, scope: 'direct-model' | 'compaction', tokens: UsageContext, reasoningEffort?: string, apiCallId?: string): void {
const recordId = apiCallId ? `api:${apiCallId}` : eventId ? `event:${eventId}` : undefined;
const identity = recordId ? `${scope}\0${recordId}` : undefined;
@@ -1960,10 +2033,12 @@ export class CopilotAgentSession extends Disposable {
return false;
}
this._publishBackgroundWork(tasks.tasks);
const agentTaskIds = new Set<string>();
for (const task of tasks.tasks) {
if (task.type !== 'agent') {
continue;
}
agentTaskIds.add(task.id);
const displayName = subagentChatTitle(task.description, task.displayName);
if (displayName && !this._subagentDisplayNamesByAgentId.get(task.id)?.trim()) {
this._subagentDisplayNamesByAgentId.set(task.id, displayName);
@@ -1974,14 +2049,29 @@ export class CopilotAgentSession extends Disposable {
if (task.status === 'idle' || task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
const scheduler = this._subagentTaskCompletionSchedulers.get(task.id);
if (scheduler && !scheduler.isScheduled()) {
this._completeSubagentTurn(task.id, task.toolCallId);
this._settleSubagentTurn(task.id, task.toolCallId, this._taskOutcome(task));
} else if (!scheduler) {
this._scheduleSubagentTurnCompletion(task.id, task.toolCallId);
this._scheduleSubagentTurnCompletion(task.id, task.toolCallId, this._taskOutcome(task));
}
} else {
this._pendingSubagentLifecycleCompletions.delete(task.id);
this._subagentTaskCompletionSchedulers.deleteAndDispose(task.id);
}
}
for (const [agentId, pending] of this._pendingSubagentLifecycleCompletions) {
if (agentTaskIds.has(agentId)) {
continue;
}
if (pending.activityRevision !== this._subagentActivityRevisions.get(agentId)) {
this._pendingSubagentLifecycleCompletions.delete(agentId);
this._subagentTaskCompletionSchedulers.deleteAndDispose(agentId);
continue;
}
const scheduler = this._subagentTaskCompletionSchedulers.get(agentId);
if (scheduler && !scheduler.isScheduled()) {
this._settleSubagentTurn(agentId, pending.toolCallId, pending.outcome);
}
}
return true;
});
}
@@ -2131,7 +2221,13 @@ export class CopilotAgentSession extends Disposable {
private _createToolCallMeta(toolName: string, parameters: Record<string, unknown> | undefined): Mutable<IToolCallMeta> {
const toolKind = getToolKind(toolName, parameters);
const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined;
const clientToolName = this._clientToolName(toolName);
const networkRestrictions = this._clientToolNames.has(clientToolName)
? getCopilotBrowserSandboxNetworkRestrictions(this._configurationService, this._ownerSessionUri.toString(), clientToolName) : undefined;
return {
...(networkRestrictions ? {
'vscode.copilotSandboxNetworkRestrictions': networkRestrictions,
} : {}),
toolKind,
language: toolKind === 'terminal' ? getShellLanguage(toolName) : undefined,
subagentDescription: subagentMeta?.description,
@@ -2503,6 +2599,19 @@ export class CopilotAgentSession extends Disposable {
}, parentToolCallId, trustedRootTurn);
}
private _emitSlashCommandOutput(content: string, meta?: Record<string, unknown>): void {
const turn = this._currentTurn.value;
if (!meta || !turn) {
this._emitMarkdownDelta(content, undefined, true);
return;
}
this._emitAction({
type: ActionType.ChatResponsePart,
turnId: turn.id,
part: { kind: ResponsePartKind.SystemNotification, content: { markdown: content }, _meta: meta },
}, undefined, true);
}
/** Emits a reasoning delta, similar to {@link _emitMarkdownDelta} but for reasoning parts. */
private _emitReasoningDelta(content: string, parentToolCallId?: string): void {
if (parentToolCallId === undefined && this._shouldDropLateRootTurnEvent('assistant.reasoning_delta')) {
@@ -3105,6 +3214,7 @@ export class CopilotAgentSession extends Disposable {
handleExitPlanModeRequest: this._guarded((request, invocation) => this._handleExitPlanModeRequest(request, invocation), { approved: false } satisfies CopilotExitPlanModeResponse, 'exit-plan-mode'),
handleUserInputRequest: this._guarded((request, invocation) => this._handleUserInputRequest(request, invocation), { answer: '', wasFreeform: true } satisfies UserInputResponse, 'user-input'),
handleElicitationRequest: this._guarded(context => this._handleElicitationRequest(context), { action: 'cancel' } satisfies ElicitationResult, 'elicitation'),
setMcpServerDisplayNames: displayNames => this._setMcpServerDisplayNames(displayNames),
handleMcpAuthRequest: this._guarded(request => this._handleMcpAuthRequest(request), { kind: 'cancelled' } satisfies McpAuthResult, 'mcp-auth'),
requestUnsandboxedCommandConfirmation: this._guarded(request => this._requestUnsandboxedCommandConfirmation(request), false, 'unsandboxed-command-confirmation'),
createClientSdkTools: toolSearchActive => this._createClientSdkTools(toolSearchActive),
@@ -3116,6 +3226,17 @@ export class CopilotAgentSession extends Disposable {
};
}
private _setMcpServerDisplayNames(displayNames: ReadonlyMap<string, string>): void {
this._mcpServerDisplayNames.clear();
for (const [serverName, displayName] of displayNames) {
this._mcpServerDisplayNames.set(serverName, displayName);
const state = this._mcpCustomizations.stateForServer(serverName);
if (state) {
this._mcpCustomizations.applyOne({ name: serverName, displayName, state });
}
}
}
/** Resolves only matching, currently pending SDK authentication callbacks. */
async resolveMcpAuthentication(params: AuthenticateParams): Promise<boolean> {
let resolved = false;
@@ -3199,6 +3320,7 @@ export class CopilotAgentSession extends Disposable {
});
this._mcpCustomizations.applyOne({
name: request.serverName,
displayName: this._mcpServerDisplayNames.get(request.serverName),
state: {
kind: McpServerStatus.AuthRequired,
...auth,
@@ -3720,14 +3842,17 @@ export class CopilotAgentSession extends Disposable {
}
const output = await runtimeSlashCommand.getOutput?.(slashCommand.rest, result);
const renderedOutput = output ? renderCopilotSlashCommandOutput(output) : undefined;
const outputMeta = output?.kind === 'link' && output.openInEditor
? toSlashCommandResourceMeta(output.resource, output.preview === true)
: undefined;
switch (result.kind) {
case 'text':
this._emitMarkdownDelta(renderedOutput ?? (result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)), undefined, true);
this._emitSlashCommandOutput(renderedOutput ?? (result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)), outputMeta);
break;
case 'completed': {
const message = renderedOutput ?? result.message;
if (message) {
this._emitMarkdownDelta(message, undefined, true);
this._emitSlashCommandOutput(message, outputMeta);
}
break;
}
@@ -6666,7 +6791,7 @@ export class CopilotAgentSession extends Disposable {
const telemetrySession = parentToolCallId
? URI.parse(buildSubagentSessionUri(this._storageUri.toString(), parentToolCallId))
: this.resourceUri;
reportCopilotTodoStoreOperation(this._telemetryService, telemetrySession, e.data.toolCallId, tracked.toolName, tracked.parameters, this._currentTurn.value?.clientContext);
reportCopilotTodoStoreOperation(this._telemetryService, telemetrySession, e.data.toolCallId, tracked.toolName, tracked.parameters, this._currentTurn.value?.clientContext, parentToolCallId && this._fusionPhaseLabels.has(parentToolCallId) ? 'fusionPhase' : undefined);
}
this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`);
this._reportToolApprovalIfNoPermission(e.data.toolCallId);
@@ -7042,6 +7167,14 @@ export class CopilotAgentSession extends Disposable {
this._updateSubagentModel(e.data.toolCallId, e.data.model);
}));
this._register(wrapper.onSubagentCompleted(e => {
this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, e.data.cancelled ? { kind: 'cancelled', durationMs: e.data.durationMs } : undefined);
}));
this._register(wrapper.onSubagentFailed(e => {
this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, { kind: 'failed', message: e.data.error, durationMs: e.data.durationMs });
}));
this._register(wrapper.onSubagentConfigured(e => {
const parentToolCallId = this._parentToolCallIdForSubagentEvent(e);
if (!parentToolCallId) {
@@ -7825,6 +7958,7 @@ export class CopilotAgentSession extends Disposable {
: {};
return {
name: server.name,
displayName: this._mcpServerDisplayNames.get(server.name),
state: this._translateSdkMcpStatus(server.name, server.status, server.error, hasPendingAuthentication),
...(server.status === 'pending' && !hasPendingAuthentication ? { allowAuthRequiredToStarting: true } : {}),
enabled: server.status !== 'disabled' && server.status !== 'not_configured',
@@ -8202,6 +8336,8 @@ export class CopilotAgentSession extends Disposable {
agentName: COPILOT_FUSION_PHASE_AGENT_NAME,
agentDisplayName: label,
taskDescription: label,
subagentKind: 'fusionPhase',
model: this._fusionProgress.getPhaseModel(toolCallId),
});
}
@@ -8418,6 +8554,13 @@ export class CopilotAgentSession extends Disposable {
this._logService.trace(`[Copilot:${sessionId}] Unhandled SDK event: ${safeStringify(loggedEvent)}`);
}));
this._register(wrapper.onIndexedSearch(e => {
const data = e.data.kind === 'startup' || e.data.kind === 'server_error'
? (({ errorMessage: _, ...data }) => data)(e.data)
: e.data;
this._logService.trace(`[Copilot:${sessionId}] session.indexed_search: ${safeStringify(data)}`);
}));
this._register(wrapper.onExtensionsLoaded(() => {
if (wrapper.canvasRuntimeEnabled) {
this._canvasProjectionReady = true;
@@ -8606,6 +8749,7 @@ export class CopilotAgentSession extends Disposable {
this._register(wrapper.onTurnStart(e => {
if (e.agentId) {
this._subagentActivityRevisions.set(e.agentId, (this._subagentActivityRevisions.get(e.agentId) ?? 0) + 1);
this._pendingSubagentLifecycleCompletions.delete(e.agentId);
this._subagentTaskCompletionSchedulers.deleteAndDispose(e.agentId);
}
const turn = this._currentTurn.value;
@@ -149,6 +149,11 @@ export class CopilotFusionProgress {
return this._phase ? getFusionPhaseToolCallId(this._phase.fusionId, this._phase.phaseId) : undefined;
}
getPhaseModel(toolCallId: string): string | undefined {
const toolCall = this._phaseTools.get(toolCallId);
return toolCall ? readToolCallMeta(toolCall).fusionPhase?.model : undefined;
}
reset(): void {
this._milestones.clear();
this._finishedFusions.clear();
@@ -209,6 +209,7 @@ type ModelCallTurnCorrelatedClassification = {
"arguments": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Telemetry-safe tool arguments; unsafe values are hashed by the runtime." },
"result_type": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Coarse tool result outcome, either SUCCESS or FAILURE." },
"invoke_outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Fine-grained invocation outcome: success, error, cancelled, or disabledByUser." },
"search_engine": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Search implementation used by the grep tool, either ripgrep or tgrep." },
"model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Model that requested the tool call." },
"tool_call_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier for this tool call, stable across correlated events." },
"turn_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier for the agent loop turn the tool was invoked in." },
@@ -224,12 +225,55 @@ type ModelCallTurnCorrelatedClassification = {
"interaction_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an interaction." },
"engagement_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an engagement." },
"duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Wall-clock duration of the tool execution in milliseconds.", "isMeasurement": true },
"result_token_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Token count of the model-facing tool result, measured with the model tokenizer. Present only for successful results.", "isMeasurement": true },
"result_token_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Local runtime estimate of the tokens in the tool's explicit model-facing text output (o200k_base scaled by a per-model multiplier); not an exact model-tokenizer count or billed usage. Reported only for successful text results up to 20 KiB without binary or structured-reference content; absence does not mean zero.", "isMeasurement": true },
"binary_result_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of binary result parts returned by the tool.", "isMeasurement": true },
"binary_result_total_bytes": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Total byte size of binary result parts returned by the tool.", "isMeasurement": true }
}
*/
/* __GDPR__
"copilotSdk/tgrep_startup": {
"owner": "bhavyaus",
"comment": "Reports the outcome and performance of an indexed-search startup attempt in the Copilot CLI runtime.",
"${include}": [ "${CopilotSdkForwardedTelemetry}" ],
"outcome": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Bounded outcome of the indexed-search startup attempt." },
"forced_by_env": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether indexed search was explicitly enabled through the environment." },
"warm_start": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the startup attempted to wait for index readiness." },
"disabled_reason": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Bounded configuration, policy, or workspace condition that disabled indexed search." },
"eligible": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the repository met the automatic indexing file-count threshold." },
"error_message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Indexed-search startup failure details, emitted only as restricted telemetry." },
"file_count": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of text files counted in the repository.", "isMeasurement": true },
"startup_duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Wall-clock duration of indexed-search startup in milliseconds.", "isMeasurement": true }
}
*/
/* __GDPR__
"copilotSdk/tgrep_server_error": {
"owner": "bhavyaus",
"comment": "Reports failures of the Copilot CLI indexed-search server.",
"${include}": [ "${CopilotSdkForwardedTelemetry}" ],
"error_type": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Bounded category of the indexed-search server failure." },
"error_message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Indexed-search server failure details, emitted only as restricted telemetry." },
"exit_code": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Indexed-search server process exit code when available.", "isMeasurement": true }
}
*/
/* __GDPR__
"copilotSdk/tgrep_incremental_indexing": {
"owner": "bhavyaus",
"comment": "Reports aggregate file counts and performance for incremental Copilot CLI indexed-search updates.",
"${include}": [ "${CopilotSdkForwardedTelemetry}" ],
"phase": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether workspace changes were detected or the incremental index update completed." },
"changed_file_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of modified files detected during the incremental update.", "isMeasurement": true },
"added_file_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of added files detected during the incremental update.", "isMeasurement": true },
"deleted_file_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of deleted files detected during the incremental update.", "isMeasurement": true },
"total_change_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Total number of changes detected during the incremental update.", "isMeasurement": true },
"walk_duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Workspace scan duration in milliseconds.", "isMeasurement": true },
"update_duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Index update duration in milliseconds.", "isMeasurement": true },
"total_duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Total incremental indexing duration in milliseconds.", "isMeasurement": true }
}
*/
/* __GDPR__
"copilotSdk/hydrafusion_route": {
"owner": "amunger",
@@ -45,8 +45,9 @@ export class CopilotSandboxDiagnostics extends Disposable {
const network = config.userPolicy?.network;
reasons = support.capabilities.filter(capability => !capability.supported && (
capability.name === 'shell'
|| capability.name === 'filesystem_enumeration'
|| capability.name === 'network' && network?.allowOutbound === true
|| capability.name === 'network_filtering' && network?.proxy !== undefined
|| capability.name === 'network_filtering'
|| capability.name === 'denied_paths' && !!config.userPolicy?.filesystem?.deniedPaths?.length
)).map(capability => capability.reason || localize('copilot.sandbox.capabilityUnavailable', "The sandbox capability '{0}' is unavailable on this host.", capability.name));
}
@@ -4,14 +4,34 @@
*--------------------------------------------------------------------------------------------*/
import type { SessionEvent } from '@github/copilot-sdk';
import { browserChatToolReferenceNames } from '../../../browserView/common/browserChatToolReferenceNames.js';
import type { ILogService } from '../../../log/common/log.js';
import type { ISessionSandboxPolicy } from '../sessionSandbox.js';
import { ISandboxNetworkRestrictions, SandboxSettingsResolutionHelper } from '../../../sandbox/common/sandboxSettingsResolutionHelper.js';
import type { IAgentConfigurationService } from '../agentConfigurationService.js';
import { getSessionSandboxConfig, type ISessionSandboxPolicy } from '../sessionSandbox.js';
/** Resolves session network restrictions only for Copilot's integrated-browser client tools. */
export function getCopilotBrowserSandboxNetworkRestrictions(configuration: IAgentConfigurationService, session: string, clientToolName: string): ISandboxNetworkRestrictions | undefined {
if (clientToolName !== 'list_browser_pages' && !browserChatToolReferenceNames.some(name => name === clientToolName)) {
return undefined;
}
const sandbox = getSessionSandboxConfig(configuration, session);
const network = SandboxSettingsResolutionHelper.getNetworkRestrictions(sandbox.enabled, sandbox.allowNetwork);
return {
sandboxEnabled: network.sandboxEnabled,
allowNetwork: network.allowNetwork,
allowedDomains: sandbox.allowedNetworkDomains ?? [],
deniedDomains: sandbox.deniedNetworkDomains ?? [],
};
}
/** Projects only resolved boolean sandbox fields; composition and validation remain runtime-owned. */
export function projectCopilotSandboxPolicy(data: Extract<SessionEvent, { type: 'session.managed_settings_resolved' }>['data'], sessionId: string, logService: ILogService): ISessionSandboxPolicy {
const settings = data.settings;
const sandboxValue = settings && typeof settings === 'object' && !Array.isArray(settings) ? settings.sandbox : undefined;
const sandbox = sandboxValue && typeof sandboxValue === 'object' && !Array.isArray(sandboxValue) ? sandboxValue : undefined;
const authValue = sandbox?.auth;
const auth = authValue && typeof authValue === 'object' && !Array.isArray(authValue) ? authValue : undefined;
const userPolicyValue = sandbox?.userPolicy;
const userPolicy = userPolicyValue && typeof userPolicyValue === 'object' && !Array.isArray(userPolicyValue) ? userPolicyValue : undefined;
const networkValue = userPolicy?.network;
@@ -29,6 +49,8 @@ export function projectCopilotSandboxPolicy(data: Extract<SessionEvent, { type:
...(typeof sandbox?.allowDevToolAccess === 'boolean' ? { allowDevToolAccess: sandbox.allowDevToolAccess } : {}),
...(typeof sandbox?.sandboxMcpServers === 'boolean' ? { sandboxMcpServers: sandbox.sandboxMcpServers } : {}),
...(typeof sandbox?.sandboxLspServers === 'boolean' ? { sandboxLspServers: sandbox.sandboxLspServers } : {}),
...(typeof auth?.git === 'boolean' ? { authenticateGit: auth.git } : {}),
...(typeof auth?.gh === 'boolean' ? { authenticateGh: auth.gh } : {}),
...(sandboxFailClosed ? { failClosed: true } : {}),
};
}
@@ -7,7 +7,6 @@ import { VSBuffer } from '../../../../base/common/buffer.js';
import { appendEscapedMarkdownInlineCode, escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../base/common/htmlContent.js';
import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js';
import { URI } from '../../../../base/common/uri.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { localize } from '../../../../nls.js';
import { IFileService } from '../../../files/common/files.js';
import { ILogService } from '../../../log/common/log.js';
@@ -16,12 +15,12 @@ import type { CopilotSlashCommandOutput, CopilotSlashCommandResult, ICopilotSlas
export const copilotSandboxPolicyCommand: RuntimeSlashCommandInfo = {
name: 'sandbox',
description: localize('copilotSlashCommand.sandboxPolicy', "Show the effective sandbox policy for this session"),
description: localize('copilotSlashCommand.sandboxPolicy', "Open the latest effective sandbox policy for this session in the editor"),
kind: 'builtin',
allowDuringAgentExecution: true,
input: {
hint: '',
choices: [{ name: 'policy', description: localize('copilotSlashCommand.sandboxPolicy', "Show the effective sandbox policy for this session") }],
choices: [{ name: 'policy', description: localize('copilotSlashCommand.sandboxPolicy', "Open the latest effective sandbox policy for this session in the editor") }],
},
};
@@ -49,7 +48,7 @@ export class CopilotSandboxPolicyDisplay {
throw new Error(localize('copilotSlashCommand.sandboxPolicyUnavailable', "The SDK did not return a sandbox policy."));
}
const markdown = formatSandboxPolicyMarkdown(text, result.kind !== 'text' || result.markdown === true);
const resource = URI.joinPath(this._sessionDataService.getSessionDataDir(this._storageUri), 'diagnostics', generateUuid(), 'sandbox-policy.md');
const resource = URI.joinPath(this._sessionDataService.getSessionDataDir(this._storageUri), 'diagnostics', 'sandbox-policy.md');
try {
await this._fileService.writeFile(resource, VSBuffer.fromString(markdown));
} catch (err) {
@@ -61,6 +60,7 @@ export class CopilotSandboxPolicyDisplay {
resource,
label: localize('copilotSlashCommand.openSandboxPolicy', "Open Sandbox Policy"),
preview: true,
openInEditor: true,
};
}
}
@@ -216,6 +216,7 @@ export interface ICopilotSessionRuntime {
handleExitPlanModeRequest(request: ExitPlanModeRequest, invocation: { sessionId: string }): Promise<ExitPlanModeResult>;
handleUserInputRequest(request: UserInputRequest, invocation: UserInputInvocation): Promise<UserInputResponse>;
handleElicitationRequest(context: ElicitationContext): Promise<ElicitationResult>;
setMcpServerDisplayNames(displayNames: ReadonlyMap<string, string>): void;
handleMcpAuthRequest(request: McpAuthRequest, context: McpAuthContext): Promise<McpAuthResponse>;
requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise<boolean>;
handlePreToolUse(input: PreToolUseHookInput): Promise<PreToolUseHookOutput>;
@@ -706,7 +707,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
}
};
if (plan.kind === 'create') {
return this._createSession(plan, config, sandboxConfig);
return this._createSession(plan, config, sandboxConfig, runtime);
}
let fallbackPlan = plan;
@@ -714,7 +715,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
const session = AgentSession.uri('copilotcli', plan.sessionId);
try {
const raw = await this._resumeSession(session, plan, config);
return this._finalizeSession(raw, sandboxConfig, plan, plan.fallback.model?.id, config);
return this._finalizeSession(raw, sandboxConfig, plan, plan.fallback.model?.id, config, runtime);
} catch (err) {
let resumeError = err;
const errCode = getCopilotSdkErrorCode(resumeError);
@@ -726,7 +727,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
this._logService.warn(`[Copilot:${plan.sessionId}] Stored custom agent '${plan.resolvedAgentName}' was not found; retrying resume without a custom agent`);
try {
const raw = await this._resumeSession(session, fallbackPlan, fallbackConfig);
return this._finalizeSession(raw, sandboxConfig, fallbackPlan, fallbackPlan.fallback.model?.id, fallbackConfig);
return this._finalizeSession(raw, sandboxConfig, fallbackPlan, fallbackPlan.fallback.model?.id, fallbackConfig, runtime);
} catch (retryErr) {
resumeError = retryErr;
this._logService.warn(`[Copilot:${plan.sessionId}] SDK resumeSession without custom agent failed: code=${getCopilotSdkErrorCode(retryErr)}, message=${getErrorMessage(retryErr)}`);
@@ -747,7 +748,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
model: fallbackPlan.fallback.model,
longContextWindow: fallbackPlan.fallback.longContextWindow,
freeLongContext: fallbackPlan.fallback.freeLongContext,
}, fallbackConfig, sandboxConfig);
}, fallbackConfig, sandboxConfig, runtime);
this._sessionOpenTelemetry.sdkResumeFallbackCreated(session);
this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`);
return wrapper;
@@ -776,7 +777,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
return this._otelService.withTraceContext(this._otelService.getSessionTraceContext(sessionId, sessionUri), fn);
}
private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise<void>): Promise<CopilotSessionWrapper> {
private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise<void>, runtime: ICopilotSessionRuntime): Promise<CopilotSessionWrapper> {
const raw = await this._withTraceContext(plan.sessionId, () => plan.client.createSession({
...config,
sessionId: plan.sessionId,
@@ -787,15 +788,15 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}),
workingDirectory: plan.workingDirectory?.fsPath,
}));
return this._finalizeSession(raw, sandboxConfig, plan, plan.model?.id, config);
return this._finalizeSession(raw, sandboxConfig, plan, plan.model?.id, config, runtime);
}
private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise<void>, plan: CopilotSessionLaunchPlan, modelId: string | undefined, config: ResumeSessionConfig): Promise<CopilotSessionWrapper> {
private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise<void>, plan: CopilotSessionLaunchPlan, modelId: string | undefined, config: ResumeSessionConfig, runtime: ICopilotSessionRuntime): Promise<CopilotSessionWrapper> {
plan.stageRecorder?.mark('finalize');
try {
await this._applyScriptSafety(raw, plan.sessionId);
await sandboxConfig(raw);
await this._reconcileCopilotConnectors(raw, plan);
runtime.setMcpServerDisplayNames(await this._reconcileCopilotConnectors(raw, plan));
} catch (err) {
// Nothing owns `raw` until it is wrapped below, so a fail-closed launch has
// to disconnect it here or the runtime keeps an orphaned session alive.
@@ -809,15 +810,16 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
return new CopilotSessionWrapper(raw, config.requestCanvasRenderer === true, { providers: config.providers, models: config.models }, this._logService);
}
private async _reconcileCopilotConnectors(session: CopilotSessionWrapper['session'], plan: CopilotSessionLaunchPlan): Promise<void> {
private async _reconcileCopilotConnectors(session: CopilotSessionWrapper['session'], plan: CopilotSessionLaunchPlan): Promise<ReadonlyMap<string, string>> {
const displayNames = new Map<string, string>();
if (this._configurationService.getRootValue(platformRootSchema, AgentHostMcpConnectorsEnabledConfigKey) !== true) {
return;
return displayNames;
}
try {
const capabilities = await session.rpc.connectors.getCapabilities();
if (capabilities.availability !== 'enabled') {
this._logService.info(`[Copilot:${plan.sessionId}] Connector MCP reconciliation unavailable: ${capabilities.availability}`);
return;
return displayNames;
}
const token = plan.githubCredentials.token;
const auth = await session.rpc.gitHubAuth.getStatus();
@@ -837,13 +839,18 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
}
if (!account?.selectionId) {
this._logService.warn(`[Copilot:${plan.sessionId}] Connector MCP reconciliation skipped because the session account could not be resolved`);
return;
return displayNames;
}
const status = await session.rpc.connectors.reconcile({ accountId: account.selectionId, refreshCatalog: true });
const connectorDisplayNames = new Map(status.catalog?.connectors.map(connector => [connector.name, connector.displayName.trim() || connector.name]));
for (const server of status.runtimeServers) {
displayNames.set(server.runtimeServerId, connectorDisplayNames.get(server.connectorName) ?? server.connectorName);
}
this._logService.info(`[Copilot:${plan.sessionId}] Reconciled ${status.runtimeServers.length} connector MCP server(s) through the Copilot runtime`);
} catch (error) {
this._logService.warn(`[Copilot:${plan.sessionId}] Connector MCP reconciliation failed; continuing without connector tools: ${getErrorMessage(error)}`);
}
return displayNames;
}
/**
@@ -230,6 +230,11 @@ export class CopilotSessionWrapper extends Disposable {
return this._onSessionInfo ??= this._sdkEvent('session.info');
}
private _onIndexedSearch: Event<SessionEventPayload<'session.indexed_search'>> | undefined;
get onIndexedSearch(): Event<SessionEventPayload<'session.indexed_search'>> {
return this._onIndexedSearch ??= this._sdkEvent('session.indexed_search');
}
private _onSessionWarning: Event<SessionEventPayload<'session.warning'>> | undefined;
get onSessionWarning(): Event<SessionEventPayload<'session.warning'>> {
return this._onSessionWarning ??= this._sdkEvent('session.warning');
@@ -13,7 +13,7 @@ export type CopilotSlashCommandResult = Awaited<ReturnType<CopilotSession['rpc']
export type CopilotSlashCommandOutput =
| { readonly kind: 'text'; readonly text: string; readonly markdown?: boolean }
| { readonly kind: 'link'; readonly resource: URI; readonly label: string; readonly preview?: boolean };
| { readonly kind: 'link'; readonly resource: URI; readonly label: string; readonly preview?: boolean; readonly openInEditor?: boolean };
/** Host-side behavior attached to a resolved command, separate from SDK catalog metadata. */
export interface ICopilotSlashCommandHandler {
@@ -5,10 +5,10 @@
import { URI } from '../../../../base/common/uri.js';
import type { ITelemetryService } from '../../../telemetry/common/telemetry.js';
import { AgentSession } from '../../common/agent.js';
import { AgentSession, type AgentSubagentKind } from '../../common/agent.js';
import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js';
import { isSubagentSession } from '../../common/state/sessionState.js';
import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js';
import { toInitiatorTelemetry, toSubagentKindTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry, type IAgentHostSubagentKindClassification } from '../agentHostTelemetryReporter.js';
type TodoStoreOperation = 'read' | 'write' | 'mixed';
type TodoStoreTarget = 'todos' | 'todo_deps' | 'both';
@@ -20,9 +20,10 @@ type TodoStoreOperationEvent = IAgentHostEventTelemetry & {
provider: string;
agentSessionId: string;
isSubagentSession: boolean;
subagentKind?: AgentSubagentKind;
};
type TodoStoreOperationClassification = IAgentHostEventClassification & {
type TodoStoreOperationClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & {
operation: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation read from, wrote to, or both read from and wrote to todo storage.' };
target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation referenced todo items, todo dependencies, or both.' };
toolCallId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the SQL tool call, used to correlate with generic tool telemetry.' };
@@ -43,19 +44,21 @@ interface ISqlToken {
readonly kind: 'identifier' | 'punctuation';
}
export function reportCopilotTodoStoreOperation(telemetryService: ITelemetryService, session: URI, toolCallId: string, toolName: string, toolInput: Readonly<Record<string, unknown>> | undefined, clientContext?: IAgentHostClientTelemetryContext): void {
export function reportCopilotTodoStoreOperation(telemetryService: ITelemetryService, session: URI, toolCallId: string, toolName: string, toolInput: Readonly<Record<string, unknown>> | undefined, clientContext?: IAgentHostClientTelemetryContext, subagentKind?: AgentSubagentKind): void {
const operation = getCopilotTodoStoreOperationData(toolName, toolInput);
if (!operation) {
return;
}
const isSubagent = isSubagentSession(session);
telemetryService.publicLog2<TodoStoreOperationEvent, TodoStoreOperationClassification>('todoStoreOperation', {
...toInitiatorTelemetry(clientContext),
...operation,
toolCallId,
provider: session.scheme,
agentSessionId: AgentSession.id(session),
isSubagentSession: isSubagentSession(session),
isSubagentSession: isSubagent,
...toSubagentKindTelemetry(isSubagent, subagentKind),
});
}
@@ -113,6 +113,7 @@ export interface SandboxSeatbeltPolicy {
*
* All platforms share enablement and user-configured paths; legacy per-OS paths are ignored.
* Optional toggles are forwarded only when supplied; absent values use runtime defaults.
* Credential authentication defaults to enabled and uses the effective Copilot-only preferences.
*
* `extraReadonlyPaths` grants read access to session attachments and generated
* shell init scripts when the effective sandbox is applied before each turn.
@@ -168,8 +169,8 @@ export function buildSandboxConfigForSdk(
...(allowDevToolAccess !== undefined ? { allowDevToolAccess } : {}),
...(allowBypass !== undefined ? { allowBypass } : {}),
auth: {
git: true,
gh: true,
git: sandbox?.[AgentHostSandboxKey.AuthenticateGit] ?? true,
gh: sandbox?.[AgentHostSandboxKey.AuthenticateGh] ?? true,
},
userPolicy: {
...(denied.size || readonly.size || readwrite.size ? {
@@ -57,7 +57,7 @@ import {
type SubscribeResult,
type ListSessionsResult,
} from '../common/state/sessionProtocol.js';
import { isAhpAutomationCatalogChannel, isAhpResourceWatchChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js';
import { isAhpAutomationCatalogChannel, isAhpResourceWatchChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, withSessionStatusFlag, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js';
import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js';
import { IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js';
import { AgentHostStateManager } from './agentHostStateManager.js';
@@ -118,6 +118,13 @@ function jsonRpcErrorFrom(id: number, err: unknown): JsonRpcResponse {
return jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, message);
}
/** AHP has no cancellation-specific code, but a cancelled request still needs an error response. */
class SubscriptionCancelledError extends ProtocolError {
constructor(channel: string) {
super(JSON_RPC_INTERNAL_ERROR, `Subscription cancelled: ${channel}`);
}
}
function shouldLogFailedRequest(method: string, params: unknown, err: unknown): boolean {
if (!(err instanceof ProtocolError) || err.code !== AhpErrorCodes.NotFound || !isFileResourceRead(method, params)) {
return true;
@@ -1613,7 +1620,11 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien
// is JSON over the wire, so narrowing at this boundary is safe.
return { snapshot: snapshot as SubscribeResult['snapshot'] };
} catch (err) {
if (!pendingSubscription.active && client.subscriptions.get(classified.uri) === pendingSubscription) {
// Losing request ownership cancels the subscription, not the resource.
if (client.subscriptions.get(classified.uri) !== pendingSubscription) {
throw new SubscriptionCancelledError(params.channel);
}
if (!pendingSubscription.active) {
client.subscriptions.delete(classified.uri);
}
if (err instanceof ProtocolError) {
@@ -1734,6 +1745,13 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien
title: chat.summary ?? '',
origin: chat.origin,
...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}),
...(chat.isRead !== undefined ? {
status: withSessionStatusFlag(
withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsArchived, chat.archived === true),
SessionStatus.IsRead,
chat.isRead,
),
} : {}),
...(chat.archived === true ? { archived: true } : {}),
...(chat.changes !== undefined ? { changes: chat.changes } : {}),
})),
@@ -1886,7 +1904,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien
this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`);
client.transport.send(jsonRpcSuccess(id, result ?? null));
}).catch(err => {
if (shouldLogFailedRequest(method, params, err)) {
if (err instanceof SubscriptionCancelledError) {
this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} cancelled`, err.message);
} else if (shouldLogFailedRequest(method, params, err)) {
this._logService.error(`[ProtocolServer] Request '${method}' failed`, err);
}
client.transport.send(jsonRpcErrorFrom(id, err));
@@ -38,7 +38,7 @@ export function getSessionSandboxConfig(configuration: IAgentConfigurationServic
}
/** Resolves the owning session's toggle overrides without changing global settings. */
export function getSessionSandboxOverrides(configuration: IAgentConfigurationService, session: string): Pick<ISandboxConfigValue, AgentHostSandboxKey.Enabled | AgentHostSandboxKey.AllowUnsandboxedCommands | AgentHostSandboxKey.AllowNetwork | AgentHostSandboxKey.AllowLocalNetwork | AgentHostSandboxKey.AllowDevToolAccess | AgentHostSandboxKey.SandboxMcpServers | AgentHostSandboxKey.SandboxLspServers> {
export function getSessionSandboxOverrides(configuration: IAgentConfigurationService, session: string): Pick<ISandboxConfigValue, AgentHostSandboxKey.Enabled | AgentHostSandboxKey.AllowUnsandboxedCommands | AgentHostSandboxKey.AllowNetwork | AgentHostSandboxKey.AllowLocalNetwork | AgentHostSandboxKey.AllowDevToolAccess | AgentHostSandboxKey.SandboxMcpServers | AgentHostSandboxKey.SandboxLspServers | AgentHostSandboxKey.AuthenticateGit | AgentHostSandboxKey.AuthenticateGh> {
session = resolveAgentHostSession(URI.parse(session)).toString();
const raw = configuration.getSessionConfigValues(session)?.[SessionConfigKey.SandboxEnabled];
const selection = platformSessionSchema.validate(SessionConfigKey.SandboxEnabled, raw) ? raw : undefined;
@@ -54,6 +54,8 @@ export function getSessionSandboxOverrides(configuration: IAgentConfigurationSer
const allowDevToolAccess = SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, policy?.allowDevToolAccess);
const sandboxMcpServers = SandboxSettingsResolutionHelper.resolveSandboxServers(undefined, policy?.sandboxMcpServers);
const sandboxLspServers = SandboxSettingsResolutionHelper.resolveSandboxServers(undefined, policy?.sandboxLspServers);
const authenticateGit = SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, policy?.authenticateGit);
const authenticateGh = SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, policy?.authenticateGh);
return {
...(enabled !== undefined ? {
[AgentHostSandboxKey.Enabled]: enabled,
@@ -64,5 +66,7 @@ export function getSessionSandboxOverrides(configuration: IAgentConfigurationSer
...(allowDevToolAccess !== undefined ? { [AgentHostSandboxKey.AllowDevToolAccess]: allowDevToolAccess } : {}),
...(sandboxMcpServers !== undefined ? { [AgentHostSandboxKey.SandboxMcpServers]: sandboxMcpServers } : {}),
...(sandboxLspServers !== undefined ? { [AgentHostSandboxKey.SandboxLspServers]: sandboxLspServers } : {}),
...(authenticateGit !== undefined ? { [AgentHostSandboxKey.AuthenticateGit]: authenticateGit } : {}),
...(authenticateGh !== undefined ? { [AgentHostSandboxKey.AuthenticateGh]: authenticateGh } : {}),
};
}
@@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js';
import { AgentSession } from '../../common/agent.js';
import { ActionType } from '../../common/state/protocol/common/actions.js';
import { isCustomizationEnabled } from '../../common/customizationEnablement.js';
import { McpServerSource, readMcpServerSource, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js';
import { McpServerSource, readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js';
import { CustomizationLoadStatus, CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js';
import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js';
import { parseChatUri } from '../../common/state/sessionState.js';
@@ -26,6 +26,8 @@ import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostState
export interface ISdkMcpServer {
/** Server name (used both as the customization name and the channel suffix). */
readonly name: string;
/** Optional Connector catalog name that does not participate in runtime identity. */
readonly displayName?: string;
readonly source?: McpServerSource;
/** Configuration file URI. Omitted on lifecycle updates; null clears a previously known source. */
readonly sourceUri?: string | null;
@@ -131,6 +133,7 @@ export interface IMcpCustomizationControllerOptions {
interface ILiveEntry {
readonly serverName: string;
readonly displayName?: string;
readonly state: McpServerState;
readonly enabled: boolean;
readonly publishedId: string;
@@ -223,7 +226,7 @@ export class McpCustomizationController extends Disposable {
if (entry.topLevelId === undefined) {
continue;
}
out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.state, entry.enabled, readMcpServerSource(entry.topLevelCustomization), entry.topLevelCustomization?.uri));
out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.displayName, entry.state, entry.enabled, readMcpServerSource(entry.topLevelCustomization), entry.topLevelCustomization?.uri));
}
return out;
}
@@ -359,6 +362,7 @@ export class McpCustomizationController extends Disposable {
const previous = this._live.get().get(server.name);
const state = this._stateForUpdate(previous?.state, server.state, server.allowAuthRequiredToStarting === true);
const enabled = server.enabled ?? previous?.enabled ?? true;
const displayName = server.displayName ?? previous?.displayName;
// Once promoted to a top-level entry, stay top-level for the
// session — flipping back to a child mid-stream would orphan the
// previously-published top-level id.
@@ -372,8 +376,8 @@ export class McpCustomizationController extends Disposable {
const childContainerId = published?.childContainerId ?? (retainChildIdentity ? previous?.childContainerId : undefined);
if (childId !== undefined) {
const stateChanged = force || previous?.topLevelId !== undefined || previous?.publishedId !== childId || previous?.childContainerId !== childContainerId || !equals(previous?.state, state);
if (stateChanged || previous?.enabled !== enabled) {
this._setLiveEntry(server.name, { serverName: server.name, state, enabled, publishedId: childId, topLevelId: undefined, childContainerId }, tx);
if (stateChanged || previous?.enabled !== enabled || previous?.displayName !== displayName) {
this._setLiveEntry(server.name, { serverName: server.name, displayName, state, enabled, publishedId: childId, topLevelId: undefined, childContainerId }, tx);
}
if (!stateChanged) {
return;
@@ -390,10 +394,11 @@ export class McpCustomizationController extends Disposable {
}
const source = server.source ?? readMcpServerSource(previous?.topLevelCustomization);
const sourceUri = server.sourceUri !== undefined ? server.sourceUri : previous?.topLevelCustomization?.uri;
const customization = this._buildTopLevel(topLevelId, server.name, state, enabled, source, sourceUri);
const customization = this._buildTopLevel(topLevelId, server.name, displayName, state, enabled, source, sourceUri);
const resolvedDisplayName = readMcpServerDisplayName(customization);
const customizationChanged = force || previous?.topLevelId !== topLevelId || !equals(previous?.topLevelCustomization, customization);
if (customizationChanged || previous?.enabled !== enabled) {
this._setLiveEntry(server.name, { serverName: server.name, state, enabled, publishedId: topLevelId, topLevelId, topLevelCustomization: customization }, tx);
this._setLiveEntry(server.name, { serverName: server.name, displayName: resolvedDisplayName, state, enabled, publishedId: topLevelId, topLevelId, topLevelCustomization: customization }, tx);
}
if (!customizationChanged) {
return;
@@ -511,7 +516,7 @@ export class McpCustomizationController extends Disposable {
return buildMcpChannel(this._chatUri, serverName);
}
private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean, source?: McpServerSource, sourceUri?: string | null): McpServerCustomization {
private _buildTopLevel(id: string, serverName: string, displayName: string | undefined, state: McpServerState, enabled: boolean, source?: McpServerSource, sourceUri?: string | null): McpServerCustomization {
const channel = this._buildChannel(serverName, state);
const owningPluginUri = this.pluginMcpServerSources?.get(serverName);
// Per AHP spec, `mcpApp` is a static capability declaration —
@@ -525,7 +530,7 @@ export class McpCustomizationController extends Disposable {
const existing = getMcpServerCustomizations(this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? [])
.find(customization => customization.id === id);
// `SessionCustomizationUpdated` replaces the whole customization, so keep opaque entries owned by others.
const meta = withMcpServerSourceMeta(existing?._meta, source);
const meta = withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, source), displayName);
const uri = (sourceUri === undefined ? existing?.uri : sourceUri) ?? this._mintTopLevelId(serverName);
const customization: McpServerCustomization = {
type: CustomizationType.McpServer,
@@ -5,11 +5,36 @@
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, parseAgentHostHarness, parseRemoteAgentHostHarness, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js';
import { findRemoteAgentHostSessionTypeAuthority, isCopilotAgentHostProvider, isCopilotAgentHostSessionType, isRemoteAgentHostSessionType, parseAgentHostHarness, parseRemoteAgentHostHarness, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js';
suite('agentHostSessionType', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('recognizes both Copilot provider IDs without matching other providers', () => {
assert.deepStrictEqual(
['copilotcli', 'copilot', 'claude', 'codex', 'copilotcloud', undefined].map(isCopilotAgentHostProvider),
[true, true, false, false, false, false],
);
});
test('recognizes local and remote Copilot session types without changing their schemes', () => {
assert.deepStrictEqual([
'agent-host-copilotcli',
'agent-host-copilot',
'remote-dev-box-copilotcli',
'remote-cloudsandbox_environment-one-copilot',
'agent-host-claude',
'remote-dev-box-codex',
'copilotcli',
'copilot',
'copilotcloud',
'remote--copilot',
'remote-dev-box-copilot-other',
].map(isCopilotAgentHostSessionType), [
true, true, true, true, false, false, false, false, false, false, false,
]);
});
test('remoteAgentHostSessionTypeId pins the wire format', () => {
assert.deepStrictEqual([
remoteAgentHostSessionTypeId('foo', 'copilot'),
@@ -9,10 +9,11 @@ import { type AgentFusionPhaseStatus, isPresentationOnlyToolCall, readToolCallMe
import { AgentSystemNotificationKind, type AgentFusionProgressStatus, readAgentSystemNotificationMeta, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js';
import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js';
import { readSessionSandboxPolicy, withSessionSandboxPolicy } from '../../common/meta/agentSandboxPolicyMeta.js';
import { readSlashCommandResource, toSlashCommandResourceMeta } from '../../common/meta/agentSlashCommandOutputMeta.js';
import { readChatInputState, withChatInputState } from '../../common/meta/agentHostChatInputState.js';
import { createEditorInlineChatInstruction, createTerminalChatInstruction, readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js';
import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js';
import { readMcpServerSource, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js';
import { readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js';
import { getCommandArgumentHint, getCompletionAction, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js';
import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readSessionComparisonMetadata, readUsageInfoMeta, withSessionComparisonMetadata, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js';
import { McpServerStatus, type McpServerCustomization, type SessionModelInfo, type SimpleMessageAttachment } from '../../common/state/protocol/state.js';
@@ -40,6 +41,31 @@ suite('Agent host _meta readers', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('Copilot network restrictions round trip and reject malformed or absent metadata', () => {
const restrictions = { sandboxEnabled: true, allowNetwork: false, allowedDomains: ['example.com'], deniedDomains: [] };
const key = 'vscode.copilotSandboxNetworkRestrictions';
assert.deepStrictEqual([
readToolCallMeta(toolCall(toToolCallMeta({ [key]: restrictions })))[key],
readToolCallMeta(toolCall(undefined))[key],
readToolCallMeta(toolCall({ [key]: { ...restrictions, sandboxEnabled: 'true' } }))[key],
readToolCallMeta(toolCall({ [key]: { ...restrictions, deniedDomains: [5] } }))[key],
], [restrictions, undefined, undefined, undefined]);
});
test('validates slash command resource metadata', () => {
const resource = URI.parse('agenthost-content:///reports/sandbox-policy.md');
assert.deepStrictEqual([
readSlashCommandResource({ _meta: toSlashCommandResourceMeta(resource, true) }),
...[undefined, null, [], {}, { resource: 1, preview: true }, { resource: resource.toString(), preview: 'true' }, { resource: '/reports/policy.md', preview: true }]
.map(value => readSlashCommandResource({ _meta: { 'vscode.slashCommandResource': value } })),
readSlashCommandResource({}),
].map(value => value ? { resource: value.resource.toString(), preview: value.preview } : undefined), [
{ resource: resource.toString(), preview: true },
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
undefined,
]);
});
suite('session sandbox policy', () => {
const policies = [
undefined,
@@ -111,8 +137,8 @@ suite('Agent host _meta readers', () => {
})?.role, 'judge');
});
test('validates MCP configuration sources and merges them into open metadata', () => {
const read = (meta: Record<string, unknown> | undefined) => readMcpServerSource({
test('validates MCP presentation metadata and preserves opaque entries', () => {
const server = (meta: Record<string, unknown> | undefined) => ({
type: CustomizationType.McpServer,
id: 'server',
uri: 'mcp-top-level:server',
@@ -121,18 +147,21 @@ suite('Agent host _meta readers', () => {
_meta: meta,
} satisfies McpServerCustomization);
const opaque = { 'test.opaque': 'kept' };
const merged = withMcpServerDisplayNameMeta(withMcpServerSourceMeta(opaque, 'user'), ' Mail ');
assert.deepStrictEqual({
sources: [
...(['user', 'workspace', 'plugin', 'builtin', 'managed'] as const).map(source => read(withMcpServerSourceMeta(undefined, source))),
...[undefined, 'unknown', 1, {}, ['user']].map(source => read({ 'agentHost.mcpServerSource': source })),
read(undefined),
...(['user', 'workspace', 'plugin', 'builtin', 'managed'] as const).map(source => readMcpServerSource(server(withMcpServerSourceMeta(undefined, source)))),
...[undefined, 'unknown', 1, {}, ['user']].map(source => readMcpServerSource(server({ 'agentHost.mcpServerSource': source }))),
readMcpServerSource(server(undefined)),
],
replaced: withMcpServerSourceMeta(withMcpServerSourceMeta(opaque, 'user'), 'workspace'),
displayNames: [' Mail ', '', 'x'.repeat(513), 1].map(displayName => readMcpServerDisplayName(server({ 'vscode.mcpServerDisplayName': displayName }))),
merged,
unchanged: withMcpServerSourceMeta(opaque, undefined) === opaque,
}, {
sources: ['user', 'workspace', 'plugin', 'builtin', 'managed', undefined, undefined, undefined, undefined, undefined, undefined],
replaced: { 'test.opaque': 'kept', 'agentHost.mcpServerSource': 'workspace' },
displayNames: ['Mail', undefined, undefined, undefined],
merged: { 'test.opaque': 'kept', 'agentHost.mcpServerSource': 'user', 'vscode.mcpServerDisplayName': 'Mail' },
unchanged: true,
});
});
@@ -0,0 +1,34 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { readCloudSandboxCloneResult, readCloudSandboxProjects } from '../../common/meta/cloudSandboxProjectMeta.js';
suite('CloudSandboxProjectMeta', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('reads finite clone percentages, clamps their range, and ignores progress after cloning', () => {
const project = { id: 'checkout', path: '/checkout', git: true };
assert.deepStrictEqual({
cloning: [undefined, 0, 58, 100, -1, 101, NaN, Infinity, '58'].map(progress =>
readCloudSandboxCloneResult({ project: { ...project, status: 'cloning', progress } })?.progress),
otherStates: [undefined, 'ready', 'failed'].map(status =>
readCloudSandboxCloneResult({ project: { ...project, status, progress: 58 } })?.progress),
root: readCloudSandboxProjects({
agents: [],
_meta: { 'copilot.projectManagement': { available: true } },
config: {
schema: { type: 'object', properties: {} },
values: { copilot: { projects: [{ ...project, status: 'cloning', progress: 58 }] } },
},
})?.map(project => ({ status: project.status, progress: project.progress })),
}, {
cloning: [undefined, 0, 58, 100, 0, 100, undefined, undefined, undefined],
otherStates: [undefined, undefined, undefined],
root: [{ status: 'cloning', progress: 58 }],
});
});
});
@@ -11,11 +11,7 @@ import { parseTaskEventsResponse, replayTaskAhpEvents, TaskEventReplayError } fr
const SESSION_A = 'ahp-session:/aaaaaaaa-0000-4000-8000-000000000001';
const SESSION_B = 'ahp-session:/bbbbbbbb-0000-4000-8000-000000000002';
/**
* The default chat channel as Mission Control's recorded frames name it. Deliberately NOT
* `buildDefaultChatUri`: the host writes `<session>/chat`, while a client builds
* `ahp-chat://default/<base64>` for the same chat. Replay must accept what was recorded.
*/
/** The legacy default chat convention; replay must also accept arbitrary host-provided channels. */
function defaultChat(sessionId: string): string {
return `${sessionId}/chat`;
}
@@ -181,6 +177,86 @@ suite('Task event replay', () => {
['t1', 't2', 't3']);
});
test('ignores overlapping batches and interleaved duplicate deltas', () => {
const chat = defaultChat(SESSION_A);
const events = [
event(SESSION_A, 1926, chat, turnStarted('t1', 'hello')),
event(SESSION_A, 1927, chat, { type: 'chat/responsePart', turnId: 't1', part: { kind: 'markdown', id: 'p1', content: '' } }),
event(SESSION_A, 1928, chat, { type: 'chat/delta', turnId: 't1', partId: 'p1', content: 'first ' }),
event(SESSION_A, 1929, chat, { type: 'chat/delta', turnId: 't1', partId: 'p1', content: 'second ' }),
event(SESSION_A, 1930, chat, { type: 'chat/delta', turnId: 't1', partId: 'p1', content: 'third' }),
event(SESSION_A, 1931, chat, turnComplete('t1')),
];
const history = replayTaskAhpEvents([
...events.slice(0, 4),
...structuredClone(events.slice(2, 4)),
events[4],
structuredClone(events[2]),
events[5],
]);
assert.deepStrictEqual(
{
truncated: history?.truncated,
turns: history?.sessions[0].chats.get(chat)?.turns.map(t => ({ id: t.id, responseParts: t.responseParts })),
},
{ truncated: false, turns: [{ id: 't1', responseParts: [{ kind: 'markdown', id: 'p1', content: 'first second third' }] }] });
});
test('ignores duplicate chunks without disrupting an in-flight group', () => {
const chat = defaultChat(SESSION_A);
const chunks = chunkedEvents(SESSION_A, 0, chat, turnStarted('t1', 'chunked'), 3);
const history = replayTaskAhpEvents([
chunks[0],
structuredClone(chunks[0]),
chunks[1],
structuredClone(chunks[0]),
chunks[2],
...structuredClone(chunks),
event(SESSION_A, 3, chat, turnComplete('t1')),
]);
assert.deepStrictEqual(
{ truncated: history?.truncated, turns: history?.sessions[0].chats.get(chat)?.turns.map(t => t.id) },
{ truncated: false, turns: ['t1'] });
});
test('does not rewind modifiedAt when skipping an older duplicate', () => {
const first = event(SESSION_A, 2, SESSION_A, titleChanged('first'));
const later = { ...event(SESSION_A, 3, SESSION_A, titleChanged('later')), at: '2026-08-04T13:00:00.000Z' };
const history = replayTaskAhpEvents([first, later, structuredClone(first)]);
assert.deepStrictEqual(
history?.sessions.map(s => ({ title: s.state.title, modifiedAt: s.modifiedAt })),
[{ title: 'later', modifiedAt: later.at }]);
});
test('clears duplicate tracking at a mirror restart', () => {
const repeated = event(SESSION_A, 2, SESSION_A, titleChanged('same in both epochs'));
const history = replayTaskAhpEvents([
event(SESSION_A, 1, SESSION_A, titleChanged('first epoch')),
repeated,
event(SESSION_A, 3, SESSION_A, titleChanged('before restart')),
event(SESSION_A, 1, SESSION_A, titleChanged('second epoch')),
structuredClone(repeated),
event(SESSION_A, 3, SESSION_A, titleChanged('after restart')),
]);
assert.deepStrictEqual(
{ truncated: history?.truncated, title: history?.sessions[0].state.title },
{ truncated: false, title: 'after restart' });
});
test('rejects conflicting records instead of treating them as duplicates', () => {
const first = event(SESSION_A, 2, SESSION_A, titleChanged('first'));
for (const conflicting of [
event(SESSION_A, 2, SESSION_A, titleChanged('different payload')),
{ ...first, at: '2026-08-04T13:00:00.000Z' },
]) {
assert.throws(() => replayTaskAhpEvents([first, conflicting]), TaskEventReplayError);
}
});
test('reports truncation from an epoch whose reassembler was replaced by a restart', () => {
// The restart installs a fresh reassembler, so the incomplete group from the first epoch is
// no longer buffered — the loss has to be remembered or the transcript reads as whole.
@@ -239,6 +315,142 @@ suite('Task event replay', () => {
{ defaultChat: announced, turns: ['t1'] });
});
test('uses the only recorded chat when the default was not announced', () => {
const chat = 'ahp-chat:/90b9344c160a544093d7a3ebf4089e3f';
const history = replayTaskAhpEvents(completedTurn(SESSION_A, 0, chat, 't1', 'hello'));
const session = history?.sessions[0];
assert.deepStrictEqual({
defaultChat: session?.defaultChat,
chats: [...(session?.chats.keys() ?? [])],
turns: session?.chats.get(session.defaultChat)?.turns.map(turn => turn.id),
}, { defaultChat: chat, chats: [chat], turns: ['t1'] });
});
for (const origin of [
{ kind: 'tool', chat: 'ahp-chat:/main', toolCallId: 'tool-1' },
{ kind: 'sideChat', chat: 'ahp-chat:/main', turnId: 't0' },
{ kind: 'fork', chat: 'ahp-chat:/main', turnId: 't0' },
]) {
test(`does not infer the default from a sole recorded ${origin.kind} chat`, () => {
const peer = 'ahp-chat:/peer';
const history = replayTaskAhpEvents([
event(SESSION_A, 0, SESSION_A, {
type: 'session/chatAdded',
summary: { resource: peer, title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', origin },
}),
...completedTurn(SESSION_A, 1, peer, 't1', 'peer conversation'),
]);
const session = history?.sessions[0];
assert.deepStrictEqual({
defaultChat: session?.defaultChat,
turns: session?.chats.get(session.defaultChat)?.turns,
peerTurns: session?.chats.get(peer)?.turns.map(turn => turn.id),
}, { defaultChat: defaultChat(SESSION_A), turns: [], peerTurns: ['t1'] });
});
}
test('does not infer a default when the catalogue advertises another chat', () => {
const history = replayTaskAhpEvents([
event(SESSION_A, 0, SESSION_A, {
type: 'session/chatAdded',
summary: { resource: 'ahp-chat:/main', title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', origin: { kind: 'user' } },
}),
...completedTurn(SESSION_A, 1, 'ahp-chat:/peer', 't1', 'peer conversation'),
]);
const session = history?.sessions[0];
assert.deepStrictEqual({
defaultChat: session?.defaultChat,
turns: session?.chats.get(session.defaultChat)?.turns,
}, { defaultChat: defaultChat(SESSION_A), turns: [] });
});
for (const kind of ['user', 'tool', 'sideChat', 'fork']) {
test(`does not infer the default from a removed ${kind} chat`, () => {
const chat = 'ahp-chat:/removed';
const history = replayTaskAhpEvents([
event(SESSION_A, 0, SESSION_A, {
type: 'session/chatAdded',
summary: {
resource: chat, title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z',
origin: { kind, chat: 'ahp-chat:/main', turnId: 't0', toolCallId: 'tool-1' },
},
}),
...completedTurn(SESSION_A, 1, chat, 't1', 'removed conversation'),
event(SESSION_A, 3, SESSION_A, { type: 'session/chatRemoved', chat }),
]);
const session = history?.sessions[0];
assert.deepStrictEqual({
catalogue: session?.state.chats,
defaultChat: session?.defaultChat,
turns: session?.chats.get(session.defaultChat)?.turns,
}, { catalogue: [], defaultChat: defaultChat(SESSION_A), turns: [] });
});
}
test('honours a chat removal even when its addition was not recorded', () => {
const chat = 'ahp-chat:/removed';
const history = replayTaskAhpEvents([
...completedTurn(SESSION_A, 0, chat, 't1', 'removed conversation'),
event(SESSION_A, 2, SESSION_A, { type: 'session/chatRemoved', chat }),
]);
const session = history?.sessions[0];
assert.deepStrictEqual({
defaultChat: session?.defaultChat,
turns: session?.chats.get(session.defaultChat)?.turns,
}, { defaultChat: defaultChat(SESSION_A), turns: [] });
});
test('uses the sole recorded user chat advertised in the catalogue', () => {
const chat = 'ahp-chat:/main';
const history = replayTaskAhpEvents([
event(SESSION_A, 0, SESSION_A, {
type: 'session/chatAdded',
summary: { resource: chat, title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', origin: { kind: 'user' } },
}),
...completedTurn(SESSION_A, 1, chat, 't1', 'main conversation'),
]);
const session = history?.sessions[0];
assert.deepStrictEqual({
defaultChat: session?.defaultChat,
turns: session?.chats.get(session.defaultChat)?.turns.map(turn => turn.id),
}, { defaultChat: chat, turns: ['t1'] });
});
test('does not replace an announced empty default with a peer chat', () => {
const announced = 'ahp-chat:/main';
const peer = 'ahp-chat:/peer';
const history = replayTaskAhpEvents([
event(SESSION_A, 0, SESSION_A, { type: 'session/defaultChatChanged', defaultChat: announced }),
...completedTurn(SESSION_A, 1, peer, 't1', 'peer conversation'),
]);
const session = history?.sessions[0];
assert.deepStrictEqual({
defaultChat: session?.defaultChat,
turns: session?.chats.get(session.defaultChat)?.turns,
peerTurns: session?.chats.get(peer)?.turns.map(turn => turn.id),
}, { defaultChat: announced, turns: [], peerTurns: ['t1'] });
});
test('preserves separate chats when the recorded default is ambiguous', () => {
const history = replayTaskAhpEvents([
...completedTurn(SESSION_A, 0, 'ahp-chat:/first', 't1', 'first conversation'),
...completedTurn(SESSION_A, 2, 'ahp-chat:/second', 't2', 'second conversation'),
]);
const session = history?.sessions[0];
assert.deepStrictEqual({
defaultChat: session?.defaultChat,
turns: [...(session?.chats.values() ?? [])].map(chat => chat.turns.map(turn => turn.id)),
}, { defaultChat: defaultChat(SESSION_A), turns: [['t1'], ['t2'], []] });
});
test('surfaces an empty default chat for a session with no chat history', () => {
const history = replayTaskAhpEvents([event(SESSION_A, 0, SESSION_A, titleChanged('no chat yet'))]);
@@ -713,7 +713,7 @@ suite('AgentHostProtocolClient', () => {
workingDirectories: [URI.file('/home/user/.copilot/chats/quick-1').toString()],
chats: [
{ resource: 'agent-chat://copilotcli/quick-1/default', title: 'Quick Chat' },
{ resource: 'agent-chat://copilotcli/quick-1/peer', title: 'Peer Chat', archived: true, interactivity: ChatInteractivity.Hidden },
{ resource: 'agent-chat://copilotcli/quick-1/peer', title: 'Peer Chat', status: SessionStatus.Idle | SessionStatus.IsArchived, interactivity: ChatInteractivity.Hidden },
],
defaultChat: 'agent-chat://copilotcli/quick-1/default',
_meta: withSessionWorkspaceless(undefined, true),
@@ -733,7 +733,7 @@ suite('AgentHostProtocolClient', () => {
workingDirectories: [toAgentHostUri(URI.file('/home/user/.copilot/chats/quick-1'), agentHostAuthority('test.example:1234'))],
chats: [
{ chat: 'agent-chat://copilotcli/quick-1/default', summary: 'Quick Chat', kind: 'default', origin: undefined },
{ chat: 'agent-chat://copilotcli/quick-1/peer', summary: 'Peer Chat', kind: 'peer', origin: undefined, interactivity: ChatInteractivity.Hidden, archived: true },
{ chat: 'agent-chat://copilotcli/quick-1/peer', summary: 'Peer Chat', kind: 'peer', origin: undefined, interactivity: ChatInteractivity.Hidden, archived: true, isRead: false },
],
}]);
});
@@ -97,8 +97,8 @@ suite('AgentHostCatalogListReader', () => {
[SESSION_META_EHCLI_ADOPTABLE_KEY]: true,
},
chats: [
{ uri: `${session.toString()}/chat/default`, order: 0, kind: 'default', summary: 'Catalog title', titleSource: 'user' },
{ uri: `${session.toString()}/chat/peer`, order: 1, kind: 'peer', summary: 'Peer title', titleSource: 'agent', origin: { kind: 'fork', chat: `${session.toString()}/chat/default`, turnId: 'turn-1' }, interactivity: ChatInteractivity.Hidden, archived: true },
{ uri: `${session.toString()}/chat/default`, order: 0, kind: 'default', summary: 'Catalog title', titleSource: 'user', isRead: true },
{ uri: `${session.toString()}/chat/peer`, order: 1, kind: 'peer', summary: 'Peer title', titleSource: 'agent', origin: { kind: 'fork', chat: `${session.toString()}/chat/default`, turnId: 'turn-1' }, interactivity: ChatInteractivity.Hidden, archived: true, isRead: false },
],
};
@@ -191,6 +191,7 @@ suite('AgentHostCatalogListReader', () => {
origin: chat.origin,
...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}),
...(chat.archived === true ? { archived: true } : {}),
...(chat.isRead !== undefined ? { isRead: chat.isRead } : {}),
})),
catalogChats: data.chats,
});
@@ -146,7 +146,7 @@ suite('AgentHostCatalogProjection', () => {
ok: true,
value: { data: typedData, payload: encoded.payload },
},
payload: '{"data":{"_meta":{"agentHost/createdBySession":{"chat":"agent-chat://test/parent/default","session":"agent-session://test/parent","turnId":"turn-1"},"agentHost/sessionArtifacts":[{"id":"artifact-1","isArtifact":true,"label":"Catalog payload","link":"https://github.com/microsoft/vscode/pull/1","type":"pullRequest"}],"ehcliAdoptable":true,"ehcliAdopted":true,"git":{"branchName":"feature/catalog","hasGitHubRemote":true,"incomingChanges":2},"github":{"issueUrls":["https://github.com/microsoft/vscode/issues/2"],"owner":"microsoft","pullRequestUrls":["https://github.com/microsoft/vscode/pull/1"],"repo":"vscode"},"multiRoot":{"workspaceFile":"file:///workspace/project.code-workspace"},"vscode.folderPicker":{"hidden":true,"primary":"file:///workspace"},"vscode.sourceControl":{"latestOutcome":"merge","merge":{"commit":"0123456789abcdef"}},"workspaceless":true},"changes":{"additions":12,"deletions":4,"files":2},"chats":[{"kind":"default","order":0,"origin":{"kind":"default","metadata":{"a":1,"b":2}},"summary":"Main","titleSource":"auto","uri":"agent-chat://test/session/default"},{"archived":true,"kind":"peer","order":1,"origin":{"kind":"subagent"},"summary":"Peer","titleSource":"agent","uri":"agent-chat://test/session/peer"}],"isArchived":false,"isChatBacking":false,"isRead":true,"modifiedTime":1720000000000,"project":{"displayName":"workspace","uri":"file:///workspace"},"summary":"Implement opaque catalog payload","titleSource":"user","workingDirectories":["file:///workspace","file:///workspace/secondary"]},"payloadVersion":1}',
payload: '{"data":{"_meta":{"agentHost/createdBySession":{"chat":"agent-chat://test/parent/default","session":"agent-session://test/parent","turnId":"turn-1"},"agentHost/sessionArtifacts":[{"id":"artifact-1","isArtifact":true,"label":"Catalog payload","link":"https://github.com/microsoft/vscode/pull/1","type":"pullRequest"}],"ehcliAdoptable":true,"ehcliAdopted":true,"git":{"branchName":"feature/catalog","hasGitHubRemote":true,"incomingChanges":2},"github":{"issueUrls":["https://github.com/microsoft/vscode/issues/2"],"owner":"microsoft","pullRequestUrls":["https://github.com/microsoft/vscode/pull/1"],"repo":"vscode"},"multiRoot":{"workspaceFile":"file:///workspace/project.code-workspace"},"vscode.folderPicker":{"hidden":true,"primary":"file:///workspace"},"vscode.sourceControl":{"latestOutcome":"merge","merge":{"commit":"0123456789abcdef"}},"workspaceless":true},"changes":{"additions":12,"deletions":4,"files":2},"chats":[{"kind":"default","order":0,"origin":{"kind":"default","metadata":{"a":1,"b":2}},"summary":"Main","titleSource":"auto","uri":"agent-chat://test/session/default"},{"archived":true,"kind":"peer","order":1,"origin":{"kind":"subagent"},"summary":"Peer","titleSource":"agent","uri":"agent-chat://test/session/peer"}],"isArchived":false,"isChatBacking":false,"isRead":true,"modifiedTime":1720000000000,"project":{"displayName":"workspace","uri":"file:///workspace"},"summary":"Implement opaque catalog payload","titleSource":"user","workingDirectories":["file:///workspace","file:///workspace/secondary"]},"payloadVersion":2}',
hash: hashAgentHostCatalogPayload(encoded.payload),
});
});
@@ -15,7 +15,7 @@ import { readRemoteSessionOrigin, REMOTE_SESSION_ORIGIN_METADATA_KEY, withRemote
import { readSessionInitiator, SESSION_INITIATOR_METADATA_KEY, withSessionInitiator } from '../../common/meta/agentSessionInitiatorMeta.js';
import { parseSessionArtifacts, SessionArtifactType, SESSION_META_ARTIFACTS_KEY, withSessionArtifacts } from '../../common/sessionArtifacts.js';
import { ChatInteractivity, ChatOriginKind } from '../../common/state/protocol/state.js';
import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionSourceControlOutcome, SessionStatus, withSessionCreationReference, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless } from '../../common/state/sessionState.js';
import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionSourceControlOutcome, SessionStatus, withSessionCreationReference, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless } from '../../common/state/sessionState.js';
import { AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, encodeAgentHostCatalogPayload } from '../../node/agentHostCatalogProjection.js';
import { AgentHostCatalogSourceResolver, CHAT_BACKING_METADATA_KEY, ICatalogSourceState } from '../../node/agentHostCatalogSourceResolver.js';
import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js';
@@ -176,6 +176,68 @@ suite('AgentHostCatalogSourceResolver', () => {
}, { interactivity: [ChatInteractivity.Full, ChatInteractivity.ReadOnly], archived: [true, undefined], input: undefined });
});
test('uses persisted default chat read state only during persistence-first reconciliation', async () => {
const state: ICatalogSourceState = {
...sourceState(),
chats: [
{ ...sourceState().chats[0], isRead: true },
{ uri: `${chat}/peer`, kind: 'peer', isRead: false },
],
};
const resolver = createResolver({
[AH_META_IS_READ_DB_KEY]: 'true',
[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY]: '',
});
const persisted = await resolver.buildCatalogSyncRequest(session, state, {}, true);
const live = await resolver.buildCatalogSyncRequest(session, state, {}, false);
assert.deepStrictEqual({
persistedSession: persisted.data.isRead,
liveSession: live.data.isRead,
persisted: persisted.data.chats.map(entry => entry.isRead),
live: live.data.chats.map(entry => entry.isRead),
}, {
persistedSession: false,
liveSession: false,
persisted: [false, false],
live: [true, false],
});
});
test('excludes unread tool chats from session read reconciliation', async () => {
const state: ICatalogSourceState = {
...sourceState(),
status: SessionStatus.IsRead,
chats: [
{ ...sourceState().chats[0], isRead: true },
{
uri: `${chat}/tool`,
kind: 'peer',
isRead: false,
origin: { kind: ChatOriginKind.Tool, chat, toolCallId: 'tool-call' },
},
],
};
const resolver = createResolver({
[AH_META_IS_READ_DB_KEY]: 'true',
[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY]: 'true',
});
const persisted = await resolver.buildCatalogSyncRequest(session, state, {}, true);
const live = await resolver.buildCatalogSyncRequest(session, state, {}, false);
assert.deepStrictEqual({
persistedSession: persisted.data.isRead,
liveSession: live.data.isRead,
chats: live.data.chats.map(entry => entry.isRead),
}, {
persistedSession: true,
liveSession: true,
chats: [true, false],
});
});
test('projects remote origins from live and persisted state without losing exact chat or depth', async () => {
const live = { session: 'remote-host-copilotcli:/parent', chat: 'remote-host-copilotcli:/parent#peer', depth: 2 };
const persisted = { session: 'agent-host-copilotcli:/origin', chat: 'agent-host-copilotcli:/origin#original', depth: 3 };
@@ -14,6 +14,7 @@ import { join } from '../../../../base/common/path.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseSessionV2Envelope } from '../../node/agentHostDatabase.js';
import { AGENT_HOST_CATALOG_PAYLOAD_VERSION } from '../../node/agentHostCatalogProjection.js';
function openDatabase(path: string): Promise<Database> {
return new Promise((resolve, reject) => {
@@ -39,7 +40,7 @@ function close(database: Database): Promise<void> {
function createPayload(session: string, sourceRevision: number, isChatBacking = false): string {
return stableStringify({
payloadVersion: 1,
payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION,
data: {
modifiedTime: 100 + sourceRevision,
summary: `Title ${sourceRevision}`,
@@ -69,7 +70,7 @@ function createEnvelope(
session,
sessionGeneration,
sourceRevision,
payloadVersion: 1,
payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION,
payloadHash: createHash('sha256').update(payload, 'utf8').digest('hex'),
verified: true,
payload,
@@ -235,7 +236,7 @@ suite('AgentHostDatabase sessions_v2', () => {
sessionV2ForeignKeys,
sessionChatColumns: sessionChatColumns.map(row => row.name),
}, {
version: [{ user_version: 12 }],
version: [{ user_version: 13 }],
tables: ['metadata', 'session_chat_catalogs', 'session_chats', 'sessions', 'sessions_v2'],
sessionColumns: ['session_uri', 'provider', 'start_time', 'external', 'registration_source', 'modified_time'],
sessionV2Columns: [
@@ -243,7 +244,7 @@ suite('AgentHostDatabase sessions_v2', () => {
'source_revision', 'payload_version', 'payload_hash', 'verified', 'payload', 'is_chat_backing', 'modified_time',
],
sessionV2ForeignKeys: [],
sessionChatColumns: ['session_uri', 'chat_uri', 'chat_order', 'provider_data', 'origin', 'inherited_turn_id', 'archived'],
sessionChatColumns: ['session_uri', 'chat_uri', 'chat_order', 'provider_data', 'origin', 'inherited_turn_id', 'archived', 'is_read'],
});
} finally {
@@ -262,7 +263,7 @@ suite('AgentHostDatabase sessions_v2', () => {
const before = await database.getSessionChatCatalog(session);
const firstResult = await database.replaceSessionChatCatalog(session, [
{ chat: 'ahp-chat://first', order: 0, providerData: 'first', origin: '{"kind":"user"}' },
{ chat: 'ahp-chat://first', order: 0, isRead: false, providerData: 'first', origin: '{"kind":"user"}' },
{ chat: 'ahp-chat://second', order: 1, inheritedTurnId: 'turn-1' },
], undefined);
if (firstResult.status !== 'applied') {
@@ -302,7 +303,7 @@ suite('AgentHostDatabase sessions_v2', () => {
revision: 1,
legacyMirroredRevision: 0,
chats: [
{ chat: 'ahp-chat://first', order: 0, providerData: 'first', origin: '{"kind":"user"}' },
{ chat: 'ahp-chat://first', order: 0, isRead: false, providerData: 'first', origin: '{"kind":"user"}' },
{ chat: 'ahp-chat://second', order: 1, inheritedTurnId: 'turn-1' },
],
},
@@ -534,7 +535,7 @@ suite('AgentHostDatabase sessions_v2', () => {
directCurrent: await upgraded.getSessionV2Registration(direct),
}, {
version,
schemaVersion: [{ user_version: 12 }],
schemaVersion: [{ user_version: 13 }],
foreignKeys: [],
published: undefined,
directLegacy: undefined,
@@ -581,7 +582,7 @@ suite('AgentHostDatabase sessions_v2', () => {
version: await all(rawDatabase, 'PRAGMA user_version'),
tables: (await all(rawDatabase, `SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)).map(row => row.name),
}, {
version: [{ user_version: 12 }],
version: [{ user_version: 13 }],
tables: ['metadata', 'session_chat_catalogs', 'session_chats', 'sessions', 'sessions_v2'],
});
} finally {
@@ -674,7 +675,45 @@ suite('AgentHostDatabase sessions_v2', () => {
legacyMirroredRevision: 0,
chats: [{ chat: 'ahp-chat://peer', order: 0, providerData: 'peer' }],
},
version: [{ user_version: 12 }],
version: [{ user_version: 13 }],
});
});
test('repairs missing chat columns without lowering a future schema version', async () => {
const path = join(temporaryDirectory!, 'agent-host-future-v17.db');
database = new AgentHostDatabase(path);
await database.registerSessionV2('session://future-v17', { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false });
await database.replaceSessionChatCatalog('session://future-v17', [
{ chat: 'ahp-chat://peer', order: 0, providerData: 'peer' },
], undefined);
await database.close();
database = undefined;
const futureDatabase = await openDatabase(path);
await exec(futureDatabase, `ALTER TABLE session_chats DROP COLUMN is_read;
ALTER TABLE session_chats ADD COLUMN parent_chat TEXT;
ALTER TABLE session_chats ADD COLUMN storage_resource TEXT;
PRAGMA user_version = 17`);
await close(futureDatabase);
database = new AgentHostDatabase(path);
const catalog = await database.getSessionChatCatalog('session://future-v17');
await database.close();
database = undefined;
const repairedDatabase = await openDatabase(path);
const version = await all(repairedDatabase, 'PRAGMA user_version');
const columns = (await all(repairedDatabase, 'PRAGMA table_info(session_chats)')).map(row => row.name);
await close(repairedDatabase);
assert.deepStrictEqual({ catalog, version, columns }, {
catalog: {
revision: 1,
legacyMirroredRevision: 0,
chats: [{ chat: 'ahp-chat://peer', order: 0, providerData: 'peer' }],
},
version: [{ user_version: 17 }],
columns: ['session_uri', 'chat_uri', 'chat_order', 'provider_data', 'origin', 'inherited_turn_id', 'archived', 'parent_chat', 'storage_resource', 'is_read'],
});
});
@@ -712,7 +751,7 @@ suite('AgentHostDatabase sessions_v2', () => {
external: false,
source: 'explicit',
},
version: [{ user_version: 12 }],
version: [{ user_version: 13 }],
marker: [{ name: 'future_v6_marker' }],
});
}).timeout(10_000);
@@ -913,7 +952,7 @@ suite('AgentHostDatabase sessions_v2', () => {
/payloadHash must match payload/,
);
await assert.rejects(
database.upsertSessionV2(createEnvelope(session, 'generation-1', 3, { payload: '{"payloadVersion":1,"data":{}}' }), 'generation-1'),
database.upsertSessionV2(createEnvelope(session, 'generation-1', 3, { payload: `{"payloadVersion":${AGENT_HOST_CATALOG_PAYLOAD_VERSION},"data":{}}` }), 'generation-1'),
/Catalog payload is invalid/,
);
await assert.rejects(
@@ -6,7 +6,7 @@
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js';
import { AgentSession, type IAgentTelemetryContext } from '../../common/agent.js';
import { AgentSession, type AgentSubagentKind, type IAgentTelemetryContext } from '../../common/agent.js';
import { AgentHostClientType } from '../../common/agentHostClientInfo.js';
import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js';
import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js';
@@ -43,11 +43,11 @@ suite('AgentHostInputRequestTracker', () => {
const rootChat = buildDefaultChatUri(rootSession);
const subagentChat = buildSubagentChatUri(rootSession, 'subagent-tool');
function createTracker(clientContext?: IAgentHostClientTelemetryContext, telemetryContext?: IAgentTelemetryContext): { telemetry: CapturingTelemetryService; tracker: AgentHostInputRequestTracker } {
function createTracker(clientContext?: IAgentHostClientTelemetryContext, telemetryContext?: IAgentTelemetryContext, getSubagentKind?: (session: string) => AgentSubagentKind | undefined): { telemetry: CapturingTelemetryService; tracker: AgentHostInputRequestTracker } {
const telemetry = new CapturingTelemetryService();
return {
telemetry,
tracker: new AgentHostInputRequestTracker(new AgentHostTelemetryReporter(telemetry), () => ({ elapsed: () => 25 }), () => clientContext, () => telemetryContext),
tracker: new AgentHostInputRequestTracker(new AgentHostTelemetryReporter(telemetry), () => ({ elapsed: () => 25 }), () => clientContext, () => telemetryContext, getSubagentKind),
};
}
@@ -222,16 +222,22 @@ suite('AgentHostInputRequestTracker', () => {
assert.deepStrictEqual(telemetry.events, []);
});
test('emits subagent identifiers', () => {
const { telemetry, tracker } = createTracker();
test('emits subagent identifiers and what the subagent chat represents', () => {
const phaseChat = buildSubagentChatUri(rootSession, 'fusion:fusion-1:phase-1');
const { telemetry, tracker } = createTracker(undefined, undefined, session => session === phaseChat ? 'fusionPhase' : undefined);
const request: ChatInputRequest = withChatInputRequestPurpose({ id: 'request-1', questions: [] }, ChatInputRequestPurpose.AskUser);
tracker.inputRequested('mock', subagentChat, 'turn-1', request);
tracker.inputCompleted(subagentChat, accept(request.id), completedState(subagentChat, 'turn-1', request));
for (const chat of [subagentChat, phaseChat]) {
tracker.inputRequested('mock', chat, 'turn-1', request);
tracker.inputCompleted(chat, accept(request.id), completedState(chat, 'turn-1', request));
}
assert.deepStrictEqual(telemetry.events.map(event => {
const data = event.data as { agentSessionId: string; isSubagentSession: boolean };
return { agentSessionId: data.agentSessionId, isSubagentSession: data.isSubagentSession };
}), [{ agentSessionId: 'root-session', isSubagentSession: true }]);
const data = event.data as { agentSessionId: string; isSubagentSession: boolean; subagentKind?: AgentSubagentKind };
return { agentSessionId: data.agentSessionId, isSubagentSession: data.isSubagentSession, subagentKind: data.subagentKind };
}), [
{ agentSessionId: 'root-session', isSubagentSession: true, subagentKind: 'task' },
{ agentSessionId: 'root-session', isSubagentSession: true, subagentKind: 'fusionPhase' },
]);
});
});
@@ -10,7 +10,7 @@ import { generateUuid } from '../../../../base/common/uuid.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import { ChatOriginKind } from '../../common/state/protocol/state.js';
import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js';
import { AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js';
import { AGENT_HOST_CATALOG_CHILD_LIMIT } from '../../node/agentHostCatalogProjection.js';
import { AgentHostDatabase } from '../../node/agentHostDatabase.js';
import { AgentHostPeerChatStore, CHAT_ORIGIN_METADATA_KEY, CHAT_PROVIDER_DATA_METADATA_KEY, PEER_CHATS_METADATA_KEY } from '../../node/agentHostPeerChatStore.js';
@@ -929,6 +929,30 @@ suite('AgentHostPeerChatStore', () => {
});
});
test('persists read state across updates and restores', async () => {
const { store, databaseFor } = createPerResourceStore();
await store.upsert(session, first, 'old', origin, 'inherited-turn');
await store.setRead(session, first, false);
await store.upsert(session, first, 'refreshed');
const unread = await store.tryRead(session);
const centralUnread = (await orchestrator.getSessionChatCatalog(session.toString()))?.chats.find(chat => chat.chat === first.toString())?.isRead;
const compatibilityUnread = await databaseFor(first).getMetadata(AH_META_IS_READ_DB_KEY);
await store.setRead(session, first, true);
const read = await store.tryRead(session);
const centralRead = (await orchestrator.getSessionChatCatalog(session.toString()))?.chats.find(chat => chat.chat === first.toString())?.isRead;
const compatibilityRead = await databaseFor(first).getMetadata(AH_META_IS_READ_DB_KEY);
assert.deepStrictEqual({ unread, centralUnread, compatibilityUnread, read, centralRead, compatibilityRead }, {
unread: [{ uri: first.toString(), isRead: false, providerData: 'refreshed', origin, inheritedTurnId: 'inherited-turn' }],
centralUnread: false,
compatibilityUnread: '',
read: [{ uri: first.toString(), isRead: true, providerData: 'refreshed', origin, inheritedTurnId: 'inherited-turn' }],
centralRead: true,
compatibilityRead: 'true',
});
});
test('persists and reads the explicit empty sentinel', async () => {
const database = new TestSessionDatabase();
const store = createStore(database);
@@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
import { NullLogService } from '../../../log/common/log.js';
import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js';
import { ChangesetStatus, ChatInputQuestionKind, ChatInputResponseKind, ChatInteractivity, ChatOriginKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js';
import { ChangesetStatus, ChatInputQuestionKind, ChatInputResponseKind, ChatInteractivity, ChatOriginKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSessionStatusRead, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js';
import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js';
import { BackgroundWorkKind, type BackgroundShellWork } from '../../common/state/protocol/channels-chat/state.js';
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
@@ -232,6 +232,123 @@ suite('AgentHostStateManager', () => {
assert.strictEqual(envelopes[0].origin, undefined);
});
test('does not mark a multi-chat session read while any chat is unread', () => {
manager.createSession(makeSessionSummary());
const peer = buildChatUri(sessionUri, 'peer');
manager.addChat(sessionUri, peer);
manager.dispatchServerAction(peer, { type: ActionType.ChatIsReadChanged, isRead: false });
manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true });
const state = manager.getSessionState(sessionUri)!;
assert.deepStrictEqual({
sessionIsRead: !!(state.status & SessionStatus.IsRead),
defaultChatIsRead: !!(manager.getChatState(sessionChatUri)!.status & SessionStatus.IsRead),
peerIsRead: !!(manager.getChatState(peer)!.status & SessionStatus.IsRead),
catalog: state.chats.map(chat => ({
resource: chat.resource,
isRead: !!(chat.status & SessionStatus.IsRead),
})),
}, {
sessionIsRead: false,
defaultChatIsRead: false,
peerIsRead: false,
catalog: [
{ resource: sessionChatUri, isRead: false },
{ resource: peer, isRead: false },
],
});
});
test('marking a chat unread also marks its session unread', () => {
manager.createSession(makeSessionSummary());
const peer = buildChatUri(sessionUri, 'peer');
manager.addChat(sessionUri, peer);
manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatIsReadChanged, isRead: true });
manager.dispatchServerAction(peer, { type: ActionType.ChatIsReadChanged, isRead: true });
manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true });
manager.dispatchServerAction(peer, { type: ActionType.ChatIsReadChanged, isRead: false });
assert.deepStrictEqual({
sessionIsRead: isSessionStatusRead(manager.getSessionState(sessionUri)!.status),
defaultChatIsRead: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status),
peerIsRead: isSessionStatusRead(manager.getChatState(peer)!.status),
}, {
sessionIsRead: false,
defaultChatIsRead: true,
peerIsRead: false,
});
});
test('tool chats retain exact read state without affecting the session aggregate', () => {
manager.createSession(makeSessionSummary());
manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true });
const tool = buildChatUri(sessionUri, 'tool');
manager.addChat(sessionUri, tool, {
origin: { kind: ChatOriginKind.Tool, chat: sessionChatUri, toolCallId: 'tool-call' },
});
manager.dispatchServerAction(tool, { type: ActionType.ChatIsReadChanged, isRead: false });
const afterToolUnread = {
session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status),
defaultChat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status),
tool: isSessionStatusRead(manager.getChatState(tool)!.status),
};
manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: false });
const afterSessionUnread = {
session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status),
defaultChat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status),
tool: isSessionStatusRead(manager.getChatState(tool)!.status),
};
assert.deepStrictEqual({ afterToolUnread, afterSessionUnread }, {
afterToolUnread: { session: true, defaultChat: true, tool: false },
afterSessionUnread: { session: false, defaultChat: false, tool: false },
});
});
test('hidden chats retain exact read state without affecting the session aggregate', () => {
manager.createSession(makeSessionSummary());
manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true });
const hidden = buildChatUri(sessionUri, 'hidden');
manager.addChat(sessionUri, hidden, { interactivity: ChatInteractivity.Hidden });
manager.dispatchServerAction(hidden, { type: ActionType.ChatIsReadChanged, isRead: false });
assert.deepStrictEqual({
session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status),
defaultChat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status),
hidden: isSessionStatusRead(manager.getChatState(hidden)!.status),
}, {
session: true,
defaultChat: true,
hidden: false,
});
});
test('session read state synchronizes its sole default chat', () => {
manager.createSession(makeSessionSummary());
manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true });
const afterRead = {
session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status),
chat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status),
};
manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: false });
assert.deepStrictEqual({
afterRead,
afterUnread: {
session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status),
chat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status),
},
}, {
afterRead: { session: true, chat: true },
afterUnread: { session: false, chat: false },
});
});
test('emits session title changes and suppresses no-op assignments', () => {
manager.createSession(makeSessionSummary());
@@ -755,6 +872,44 @@ suite('AgentHostStateManager', () => {
assert.strictEqual((chatState?.turns[0].responseParts[0] as MarkdownResponsePart).content, 'world');
});
test('restoreSession marks an inconsistent multi-chat aggregate unread', () => {
const peer = buildChatUri(sessionUri, 'peer');
const state = manager.restoreSession({
...makeSessionSummary(),
status: SessionStatus.Idle | SessionStatus.IsRead,
chats: [{ resource: peer, title: 'Peer', status: SessionStatus.Idle | SessionStatus.IsRead }],
}, [], { defaultChatIsRead: false });
assert.deepStrictEqual({
sessionIsRead: isSessionStatusRead(state.status),
defaultChatIsRead: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status),
peerIsRead: isSessionStatusRead(state.chats.find(chat => chat.resource === peer)!.status),
}, {
sessionIsRead: false,
defaultChatIsRead: false,
peerIsRead: true,
});
});
test('restoreSession excludes hidden unread chats from the session aggregate', () => {
const hidden = buildChatUri(sessionUri, 'hidden');
const state = manager.restoreSession({
...makeSessionSummary(),
status: SessionStatus.Idle | SessionStatus.IsRead,
chats: [{ resource: hidden, title: 'Hidden', interactivity: ChatInteractivity.Hidden, status: SessionStatus.Idle }],
}, [], { defaultChatIsRead: true });
assert.deepStrictEqual({
sessionIsRead: isSessionStatusRead(state.status),
defaultChatIsRead: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status),
hiddenIsRead: isSessionStatusRead(state.chats.find(chat => chat.resource === hidden)!.status),
}, {
sessionIsRead: true,
defaultChatIsRead: true,
hiddenIsRead: false,
});
});
test('restoreSession returns existing state for duplicate session', () => {
const existing = manager.createSession(makeSessionSummary());
@@ -933,6 +1088,42 @@ suite('AgentHostStateManager', () => {
});
});
test('emits sessionSummaryChanged when compact chat status changes', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
const chat = buildChatUri(sessionUri, 'compact-status');
manager.createSession(makeSessionSummary());
manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady });
manager.addChat(sessionUri, chat, { title: 'Peer' });
await new Promise(r => setTimeout(r, 150));
const notifications: INotification[] = [];
disposables.add(manager.onDidEmitNotification(notification => notifications.push(notification)));
manager.dispatchServerAction(chat, { type: ActionType.ChatIsArchivedChanged, isArchived: true });
await new Promise(r => setTimeout(r, 150));
const changes = notifications
.filter(notification => notification.type === NotificationType.SessionSummaryChanged)
.map(notification => notification.changes);
assert.deepStrictEqual(changes, [{
chats: [
{
resource: buildDefaultChatUri(sessionUri),
title: 'Test',
origin: { kind: MessageKind.User },
status: SessionStatus.Idle,
},
{
resource: chat,
title: 'Peer',
origin: { kind: MessageKind.User },
status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived,
archived: true,
},
],
}]);
});
});
test('coalesces multiple summary changes into one notification', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
manager.createSession(makeSessionSummary());
@@ -1249,7 +1440,7 @@ suite('AgentHostStateManager', () => {
chatResources: [buildDefaultChatUri(sessionUri), peerChat].sort(),
summaryChats: [
{ resource: buildDefaultChatUri(sessionUri), title: 'Test', origin: { kind: MessageKind.User }, status: SessionStatus.Idle },
{ resource: peerChat, title: 'Peer', origin: { kind: MessageKind.User }, status: SessionStatus.Idle },
{ resource: peerChat, title: 'Peer', origin: { kind: MessageKind.User }, status: SessionStatus.Idle | SessionStatus.IsRead },
],
defaultChat: buildDefaultChatUri(sessionUri),
peerTurns: 0,
@@ -1569,14 +1760,14 @@ suite('AgentHostStateManager', () => {
resource: peerChat,
title: 'Peer',
origin: { kind: ChatOriginKind.User },
status: SessionStatus.Idle | SessionStatus.IsArchived,
status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived,
archived: true,
},
sessionArchived: false,
action: {
type: ActionType.SessionChatUpdated,
chat: peerChat,
changes: { status: SessionStatus.Idle | SessionStatus.IsArchived, activity: undefined },
changes: { status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, activity: undefined },
},
});
});
@@ -2184,7 +2375,7 @@ suite('AgentHostStateManager', () => {
...makeSessionSummary(),
chats: [
{ resource: defaultChat, title: '' },
{ resource: peerChat, title: 'Peer', status: SessionStatus.Idle },
{ resource: peerChat, title: 'Peer', status: SessionStatus.Idle | SessionStatus.IsRead },
],
defaultChat,
};
@@ -2197,7 +2388,7 @@ suite('AgentHostStateManager', () => {
assert.strictEqual(
manager.getSessionState(sessionUri)?.chats.find(chat => chat.resource === peerChat)?.status,
SessionStatus.Idle | SessionStatus.IsArchived,
SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived,
);
});
@@ -2410,9 +2601,10 @@ suite('AgentHostStateManager', () => {
resource: chat.resource,
title: chat.title,
interactivity: chat.interactivity,
status: chat.status,
})), [
{ resource: sessionChatUri, title: 'Test', interactivity: undefined },
{ resource: peerChat, title: 'Hidden peer', interactivity: ChatInteractivity.Hidden },
{ resource: sessionChatUri, title: 'Test', interactivity: undefined, status: SessionStatus.Idle },
{ resource: peerChat, title: 'Hidden peer', interactivity: ChatInteractivity.Hidden, status: SessionStatus.Idle | SessionStatus.IsRead },
]);
});
@@ -25,7 +25,7 @@ import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelatio
import { withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js';
import { CustomizationType, McpServerStatus, SessionInputRequestKind, type McpServerCustomization } from '../../common/state/protocol/state.js';
import { ActionType, type ChatAction } from '../../common/state/sessionActions.js';
import { buildDefaultChatUri, MessageKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js';
import { buildDefaultChatUri, buildSubagentChatUri, MessageKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js';
import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js';
import { IAgentHostChatContributions } from '../../common/agentHostChatContributionsService.js';
import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js';
@@ -292,6 +292,7 @@ suite('AgentSideEffects — tool call telemetry', () => {
[IAgentHostClientConnectionService, clientConnectionService],
[IAgentHostPeerChatPersistenceService, {
_serviceBrand: undefined,
setRead: async () => { },
setArchived: async () => { },
}],
[ISessionWorkspaceConversionService, {
@@ -392,6 +393,30 @@ suite('AgentSideEffects — tool call telemetry', () => {
}]);
});
test('classifies tool calls in subagent chats by subagent kind and reports the phase model', () => {
setupSession();
agent.setModels([{ provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }]);
startTurn('turn-1');
const runSubagentTool = (toolCallId: string, phase?: { subagentKind: 'fusionPhase'; model: string }) => {
const chatUri = buildSubagentChatUri(sessionUri, toolCallId);
stateManager.addChat(sessionKey, chatUri);
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId, agentName: 'agent', agentDisplayName: 'Agent', ...phase });
const turnId = stateManager.getActiveTurnId(chatUri) ?? 'missing-subagent-turn';
const fireOnChat = (action: ChatAction) => agent.fireProgress({ kind: 'action', resource: URI.parse(chatUri), action });
fireOnChat({ type: ActionType.ChatToolCallStart, turnId, toolCallId: `${toolCallId}-view`, toolName: 'view', displayName: 'view' });
fireOnChat({ type: ActionType.ChatToolCallComplete, turnId, toolCallId: `${toolCallId}-view`, result: { success: true, pastTenseMessage: 'viewed' } });
agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId });
};
runSubagentTool('call-task');
runSubagentTool('fusion:fusion-1:phase-1', { subagentKind: 'fusionPhase', model: 'gpt-5.5' });
completeTurn('turn-1');
assert.deepStrictEqual(agentHostToolEvents().map(({ data }) => ({ toolCallId: data.toolCallId, isSubagentSession: data.isSubagentSession, subagentKind: data.subagentKind, model: data.model })), [
{ toolCallId: 'call-task-view', isSubagentSession: true, subagentKind: 'task', model: undefined },
{ toolCallId: 'fusion:fusion-1:phase-1-view', isSubagentSession: true, subagentKind: 'fusionPhase', model: { trusted: true, value: 'gpt-5.5' } },
]);
});
test('attributes tool telemetry to the initiating turn client', () => {
setupSession();
const clientContext: IAgentHostClientTelemetryContext = {
Loaded 100 of 429 files, more files were not shown because too many files have changed in this diff. Show more