diff --git a/.npmrc b/.npmrc index afaf9fccd338b..025e4b04d2b88 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.8.7" -ms_build_id="13841579" +target="39.8.8" +ms_build_id="13870025" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 6aacfecc747bb..9a9269e00131b 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -2e2a3533f9969ded3b11eb0baa5357abeb652975d9bcaea0b0725c9bd0866061 *chromedriver-v39.8.7-darwin-arm64.zip -c74882bcbdd53f6e8cd65906809ac446bb032dce3ce8f109e2376d49b9b394ee *chromedriver-v39.8.7-darwin-x64.zip -a8caf72372eb47deb336dc440eb183c30d228b3fef5349dac7571d86103f117c *chromedriver-v39.8.7-linux-arm64.zip -5d5f02b2e28e8328435d2fd83207098e69dc3e5fecbbbdc2612792370ab2c4ec *chromedriver-v39.8.7-linux-armv7l.zip -e336dc2dce9d11d44f6eb5b5cc655d3311a9a109ea184625da3ac51181c3ad27 *chromedriver-v39.8.7-linux-x64.zip -8c795231a7d143cb242083e466763007609ffa63b65f6c7a8b46c4e95bf04748 *chromedriver-v39.8.7-mas-arm64.zip -5033c9550cb25a228fee9ecb2179f4258847585ee1d8609aec14f42d5aeb654b *chromedriver-v39.8.7-mas-x64.zip -9834624a8f92bec9931a8b74ce2e1195e0527f61f88c18367129371cfeb7d87b *chromedriver-v39.8.7-win32-arm64.zip -fffd2a04a1e3a9d0b2aeb47044c308c6ef9e361f23b2d120e19f507f4de53e1c *chromedriver-v39.8.7-win32-ia32.zip -7b25598ba3db1b0df6253e7233ef68e4cb9764aa7f62d854fe3c620edfbb2a7c *chromedriver-v39.8.7-win32-x64.zip -ca234cdbdf5cd724adaf5079d860a3d510d8cdfbff7c9392d7b6b0e6948593f7 *electron-api.json -62fe91fbbe83d68e713ee48d1dbbad06dc3f2be739eb649002e390a585a4639d *electron-v39.8.7-darwin-arm64-dsym-snapshot.zip -c837b62c12dc16dd41244fbc2c4f8c97ceb93d56ae6a41064782f6876bf01ac0 *electron-v39.8.7-darwin-arm64-dsym.zip -e4d96c888bbe699e7be9dc90aa39b64dd23dbe3f42d86f354c490f77a9fb6c41 *electron-v39.8.7-darwin-arm64-symbols.zip -86fa117ba10e36149ca33d7c22de2cfc3fb7490ca88b07ce953d2efe1f2a41cd *electron-v39.8.7-darwin-arm64.zip -4182678fadb19e0d9be6b7411e18a1e8e5801d14c99fcb5faa5d8a32f3af2cab *electron-v39.8.7-darwin-x64-dsym-snapshot.zip -765c509e1f3090bdf9610e9b618264bf8251ac708b2d7ffb4550ce75f036a6aa *electron-v39.8.7-darwin-x64-dsym.zip -52804dec7a659502d4d2df194d4a14abc70dad315cff001712100feb640c589d *electron-v39.8.7-darwin-x64-symbols.zip -5dfe5559fd283c3962221c674b30a5b986895b644b1b4bc179e0c7673a14f1cf *electron-v39.8.7-darwin-x64.zip -bdc78aa93b64543885997c16e198270a2b8b8b955db3956f491681c01134f925 *electron-v39.8.7-linux-arm64-debug.zip -8581d058382d70afd48bc0d1ace4189ada18770b5ebe1347adc667e30bb81650 *electron-v39.8.7-linux-arm64-symbols.zip -fd721650a0e25829b76d307e944383be828533cdddd53e44a0b772e96e3e019b *electron-v39.8.7-linux-arm64.zip -d17f1d655ca2b056da6b8ba5e59368e3061d38450e3616e5e9faa2a4e0cbbff6 *electron-v39.8.7-linux-armv7l-debug.zip -22b4ed4f566432ff040491caae6d926d4623d24d28e96e5f818245433dab93d4 *electron-v39.8.7-linux-armv7l-symbols.zip -5d0a75a53cdba1ecfc678910084802fe500f13f470310ae1d2c66840d3c7390b *electron-v39.8.7-linux-armv7l.zip -b2e5d0c1025204aa0f026996490a4b33fff7e89b88eee995c88399eed4439951 *electron-v39.8.7-linux-x64-debug.zip -5868e2cadc566968692b44bc9e2aa5815eec2b7852c4dc8474719bc90f0ae689 *electron-v39.8.7-linux-x64-symbols.zip -233b2775f1c46e5ebd5afeb4fb95ce9fda61229bad20aef1031468eb54b3656e *electron-v39.8.7-linux-x64.zip -350782483b59fe6a96ecf90b4095b7f5b2f941030e946140490697f29c94f85e *electron-v39.8.7-mas-arm64-dsym-snapshot.zip -6f850ac7faf11413513bf916b336053d5f73d262220e6b4cd88f2be79a902c26 *electron-v39.8.7-mas-arm64-dsym.zip -c505efd13d3b328f662d6853bfc13c8683bd1dd06113d403d8a58fdc0c82fd3d *electron-v39.8.7-mas-arm64-symbols.zip -bd27cbfa54c1f816bd865b134d9b10cfbc7631adb7c21ade60d98d100e83a745 *electron-v39.8.7-mas-arm64.zip -ac83e48c77a745e19e78b0feca136af2e8d309d6a584ea18d2d86c33258517ea *electron-v39.8.7-mas-x64-dsym-snapshot.zip -30c8f8a7a810b39408e4e19ec6ec42ac47aa945be6085f31b9977743f001cbea *electron-v39.8.7-mas-x64-dsym.zip -cda7da0f54c8a13fa8426320f688e51b2c4a9581998876d7d22346d6a81d4f69 *electron-v39.8.7-mas-x64-symbols.zip -818b0d948d09f73deb55de108799e963ff8ea432f81574c8000c5377b55e4119 *electron-v39.8.7-mas-x64.zip -e6c7fb13390a59e40bc5a26ec1d90370c2a055c964b01eddbf97520dc93f5571 *electron-v39.8.7-win32-arm64-pdb.zip -7e1c2becc143e2af3d59cc7832fb32f5208fef866fbe729e13ff58da67d68744 *electron-v39.8.7-win32-arm64-symbols.zip -86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.7-win32-arm64-toolchain-profile.zip -798a54b33d0841098428809fca3aa1332b46c9858e6bb2d415a8a7ac09784f4e *electron-v39.8.7-win32-arm64.zip -772a636300d4196205d57cb486ac6b49c6209138e78ce2a3ba97bb822855be22 *electron-v39.8.7-win32-ia32-pdb.zip -5d3680f53a0abbf9e4caec9abf9fcf1728aa5dfb71d323c2dccf7161e10f10c9 *electron-v39.8.7-win32-ia32-symbols.zip -86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.7-win32-ia32-toolchain-profile.zip -669b5dd7aee565b594f0f786304827f6fa4c40710fa71b6101b3356f50f68f2a *electron-v39.8.7-win32-ia32.zip -ebcb34179d1bda0b8be55354fb9a21a7d45093653475a23a6c84535b8e279e1d *electron-v39.8.7-win32-x64-pdb.zip -8c386ea127b2944832053519badcb596e34d84adf7efb9f96822dd751f018a51 *electron-v39.8.7-win32-x64-symbols.zip -86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.7-win32-x64-toolchain-profile.zip -272b94970b8c7669c2367a2bd9e52a673665aaf33eb5e54e32ca7551497859b2 *electron-v39.8.7-win32-x64.zip -d8c5d7fd580c05250687262c700ac4ab20c3dc366e06887a99d806079393a14e *electron.d.ts -966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.7-darwin-arm64.zip -acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.7-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.7-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.7-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.7-linux-x64.zip -966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.7-mas-arm64.zip -acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.7-mas-x64.zip -de8643e5d52bcbb39432d1d32d93a9609cd98418a603dda05b7bbac6156f7c9b *ffmpeg-v39.8.7-win32-arm64.zip -c2be0960b6325757e401fa9926a86421cafb050a41ecdf925f657adce091d114 *ffmpeg-v39.8.7-win32-ia32.zip -c03a89f7acdb6abc22829ab241fcaf55842c463e58fc5fcc1405326cd3d0ba29 *ffmpeg-v39.8.7-win32-x64.zip -e06e1bd83d8d9641f614048bd60da15a6237f050bb345a62eaedab9fbeb98d33 *hunspell_dictionaries.zip -8b5d4ca6ff70993a688414ba64b5acf34c924f480b08d4399491fd96bf060b1e *libcxx-objects-v39.8.7-linux-arm64.zip -c40682d02395a3c585f07905a2e5a9ef9fd307a2c92cc1a76f23114cfe95564b *libcxx-objects-v39.8.7-linux-armv7l.zip -4c6e0a4ebc40cb2db5361b40deb5acbd693d082f4bb58d84b6baf0280163d603 *libcxx-objects-v39.8.7-linux-x64.zip -4269d16db215839a546e3f72d17f450ea5623539e85d3cab85d13f47e14e60f5 *libcxx_headers.zip -06659d8c13cf63ef52ee06be71be0e4d83612c577539f630c97274cbe1ec9ad2 *libcxxabi_headers.zip -222f0e94b6e61b336a437e33a7815ff70d6aaafa504e14dfc3667c2aea84c3c2 *mksnapshot-v39.8.7-darwin-arm64.zip -15fef5c087e84569d7539ad95f51501995aed6149890bff9a05a4445e123c010 *mksnapshot-v39.8.7-darwin-x64.zip -7ca75c9fa6a9be45298532b7718644e53b54ff2572f2208739f5eb8e4aeb1358 *mksnapshot-v39.8.7-linux-arm64-x64.zip -6706f623f0be74d69159ed47642bffbf3e9c37730c7025a99492afbbce94b524 *mksnapshot-v39.8.7-linux-armv7l-x64.zip -923926bb76fbaec25780fc202a662af9d02ce75dc0cbe81ae926000be75b7214 *mksnapshot-v39.8.7-linux-x64.zip -815ac4296876b9fb769eeb75ab8c542e913d1a6eb6f5dd4669099ffe6ee3d4dd *mksnapshot-v39.8.7-mas-arm64.zip -dbbccaf64c18da3d41c22de222d187b1b30ca3016778a962751f177a79d0d4be *mksnapshot-v39.8.7-mas-x64.zip -d21ff9be73f0307bc67d8d62b8df5d50c0a9b7cb0f7ea3f12683af051dfad994 *mksnapshot-v39.8.7-win32-arm64-x64.zip -e15e35f5952e88b115e30e5e8be9a003926acafaafc2f70363e5f41a2449e26a *mksnapshot-v39.8.7-win32-ia32.zip -100471d1064189d03f68b81a68f289b06b231911ab42c715aec956d0a4a11df4 *mksnapshot-v39.8.7-win32-x64.zip +b131777645f9e6b1052d12b0b4d5eb172d9e05d1b12ef971d99beac5b309dce9 *chromedriver-v39.8.8-darwin-arm64.zip +062061f78f7b0080eab586cd70cc4e75fc6cb2a301a1d33b34f7df09850b18b8 *chromedriver-v39.8.8-darwin-x64.zip +6d630e294ff7e75fb8dbe95e01709018dbe80384eafe40a9a90fed4d2b7a5902 *chromedriver-v39.8.8-linux-arm64.zip +03a2fad20d0496c082b73b6fc0253d3e2ef8fb6e6e3be2d9059b03bc4a8b6efb *chromedriver-v39.8.8-linux-armv7l.zip +10be127695b948e76f2bbd3f84252a1e87c5f058b1319da9d24487a011d2172f *chromedriver-v39.8.8-linux-x64.zip +6d0e77fb39439a81e45a258758a59d9e256993e6c2daefe1ea01bbf101cf9266 *chromedriver-v39.8.8-mas-arm64.zip +715678eb9b675196f23a52fe9bd350f9700c11a0b0515ca2b7cf2c00cb2acca4 *chromedriver-v39.8.8-mas-x64.zip +e03ab0405f38fb872910f8e9fb6950c720408897c1ee2394b033b6451bad4899 *chromedriver-v39.8.8-win32-arm64.zip +3eadc1375b2d6e104d870fd433033f95395b6ce99deced1508d3afcfa8bc383a *chromedriver-v39.8.8-win32-ia32.zip +22c6b161002be93c253f884a3a9fb55748cf4377d4a556305164adf6253032b1 *chromedriver-v39.8.8-win32-x64.zip +01f250008f92766399af70867aeb95f86a42c76f0fff90c292e162c05dda60d9 *electron-api.json +36cbb1476b3233f1b6eba0d097fb24da07ae422af5aa5eb468f261414d23ec47 *electron-v39.8.8-darwin-arm64-dsym-snapshot.zip +403e2b9036425388a84a354fa9d07e61a1b8a4ad7eb8e8806f26b06450c4bd3d *electron-v39.8.8-darwin-arm64-dsym.zip +427302b282566cc7f6cd6c6318674e78f62b4bfa633916db030676c302e2ac58 *electron-v39.8.8-darwin-arm64-symbols.zip +041eeca31b24f7fa812ec4ab94d450c7774dea46701b210810efb2a0b01c7fd4 *electron-v39.8.8-darwin-arm64.zip +90df3fa4b65ffcb3996073db8aa10f6c0ad968fe2273987ed7041c3980d78e8c *electron-v39.8.8-darwin-x64-dsym-snapshot.zip +e165b2ad086646bf5b1516792f673caf8aaaa39219db5020db727f8830fff310 *electron-v39.8.8-darwin-x64-dsym.zip +a3c4083ea199f0d8c8b4d6aada677a20ceb7c08abad32986c366a231b7748e38 *electron-v39.8.8-darwin-x64-symbols.zip +26df42ac32504a29b3accb1e180d06500f08b224def6fdef643aca4948cbd9e8 *electron-v39.8.8-darwin-x64.zip +0d57ac3400de3d919be936c0269916354600476ec4f1d97a41c76f6b30d62a51 *electron-v39.8.8-linux-arm64-debug.zip +1a2bfcd28975c84ab4c9d88cb4d31e8dce9818619466795a3f43ffe08a71019f *electron-v39.8.8-linux-arm64-symbols.zip +b90477f7c2da4d61cde64cf3e55fcd98d63934b4d9d05e603dedbfc3a070f723 *electron-v39.8.8-linux-arm64.zip +b9f317e67e26a2a30302077021e43e9ad72de2afb572d51a7c2fdcd4149a44d8 *electron-v39.8.8-linux-armv7l-debug.zip +78af9c546b0eb29a2e2544df27dc8fdb6df08758e983ec8de746a774cbf760fb *electron-v39.8.8-linux-armv7l-symbols.zip +ca68134f99583c6969ed60d480ec2806dde7d4d1767f54150eff4f05456b4eab *electron-v39.8.8-linux-armv7l.zip +d4b93269f11c912eb72c3669165b81a4a64b108f9e7fe4ca7b3617486b071ba8 *electron-v39.8.8-linux-x64-debug.zip +69ef942bfc73cd29d9bdc59c1352153be1a9ed5a1977c992f66163cb26530907 *electron-v39.8.8-linux-x64-symbols.zip +6979c13291472608623eafa75de850472b1c7072dbb5e5f38f306efacfcb3083 *electron-v39.8.8-linux-x64.zip +54494a304b149ee1330da42e79e3c814b92cba1dfa2b71f3c09308107967d1bb *electron-v39.8.8-mas-arm64-dsym-snapshot.zip +cae7ee02bace1784176de5c87a3435ae5b13937d9b9b3ce750354a64192181c0 *electron-v39.8.8-mas-arm64-dsym.zip +51494321140d1e77269d6ded608e30b438892b396648a784918fa8b9a3d69829 *electron-v39.8.8-mas-arm64-symbols.zip +f539090fc817cac51013675d6c410d496196d770d635ff92c9c68efd5104aa42 *electron-v39.8.8-mas-arm64.zip +7b6630399a95221bc0806d044c41f938791baecb0034e1fbd8b19ace318c575b *electron-v39.8.8-mas-x64-dsym-snapshot.zip +cf15380b1c13f8332c8d5fca44d98ef0570ebba76954b58cadd800dfe2f68bc0 *electron-v39.8.8-mas-x64-dsym.zip +86b44d622a77c8bfa6c95eb3e2ef4db491bb729a63b5f575e4f641a0838fcc2f *electron-v39.8.8-mas-x64-symbols.zip +871950743b6f77d1cc085a159386efb6e024f9f0d6417ecceb0ed1dadf8c22ea *electron-v39.8.8-mas-x64.zip +eb3331dd51c1087ad7a6a9398af78274fd3d0d5ab2e9747de1e206e9256b9589 *electron-v39.8.8-win32-arm64-pdb.zip +06436fc2204d90751c5fa6fec7f9dcd0e6a52ca824aed16d57dad5a82ef3fd16 *electron-v39.8.8-win32-arm64-symbols.zip +86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.8-win32-arm64-toolchain-profile.zip +0ee0d326ab71aa0b6a4922510ea2c9c825fbf609db6d0e7f13a37f391dd7da6b *electron-v39.8.8-win32-arm64.zip +38d60984dd8b4c4e57b223d4509cf6f98b109c7a0d0d2fdc01fad4472ba2950e *electron-v39.8.8-win32-ia32-pdb.zip +ffce54a60c7c10959a9355e19437bdcfd40012091e0741e9f0120a63ac0caa33 *electron-v39.8.8-win32-ia32-symbols.zip +86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.8-win32-ia32-toolchain-profile.zip +aa252a5946b78d903e1dee22872cf94918a2b69803078967dbc25c2d537df506 *electron-v39.8.8-win32-ia32.zip +d9b9d1f832211c853666a4d7fafaf2ccc1542bfaee0b3f0f9409a580871c5ff1 *electron-v39.8.8-win32-x64-pdb.zip +8edac431de4a75f0758ecf03040f1e95b15955d5a3177409f55d5e5233a74947 *electron-v39.8.8-win32-x64-symbols.zip +86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.8-win32-x64-toolchain-profile.zip +657d5dc5a71b6aaa506bf83f311cd95a3f5221fcd2c258c108bcdf2043214c86 *electron-v39.8.8-win32-x64.zip +8f4d940c30332c7001977dec227364274b04d0c0c63e54e06b621d47c7444129 *electron.d.ts +966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.8-darwin-arm64.zip +acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.8-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.8-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.8-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.8-linux-x64.zip +966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.8-mas-arm64.zip +acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.8-mas-x64.zip +c803619834812960d1252d173017cb6eaca761474d2ac6853220a3d3e0581a21 *ffmpeg-v39.8.8-win32-arm64.zip +1e9db6bd93a3e2ae8dc4ecb2852a7bb823e2cce2d24f426c8a2147b2a5ad71db *ffmpeg-v39.8.8-win32-ia32.zip +3fbcaa872ea7c5ad3e55777293d31f35fec12570be684e44c795fc0b62d113ad *ffmpeg-v39.8.8-win32-x64.zip +4c4e6394a5f6a3096afabfbdfaeeab18e488942a9688bb71b87e8c348aa7ae6a *hunspell_dictionaries.zip +1f0d43d4c416e1672bfa8d4fd9b4f963183cebab35b01ca66485abb18844cd37 *libcxx-objects-v39.8.8-linux-arm64.zip +4803952122557ce486223ac82585a759de77352b63fcc62514eadc1c356eef8d *libcxx-objects-v39.8.8-linux-armv7l.zip +ffe241fedc47e564210f293c6881e32af2b6ab49619ae7e7d232e9434cefea59 *libcxx-objects-v39.8.8-linux-x64.zip +d222c4130da916204964a2dbaa5d66195d8d1bb0e4ae0121563d6bd90bda63d0 *libcxx_headers.zip +34e4b44f9c5e08b557a2caed55456ce7690abab910196a783a2a47b58d2b9ac9 *libcxxabi_headers.zip +0b46b53037ec0c1539eee04897c094db24af21af37c2677184ef7f57eb315a9b *mksnapshot-v39.8.8-darwin-arm64.zip +d042d23961d413262a88b99d3360f89956d77b5fb5e3210bc920811e0b0c0a97 *mksnapshot-v39.8.8-darwin-x64.zip +8154b15b5910f310f3f6996e2f43ad307c4a871cb9983e21ea324fde956784b5 *mksnapshot-v39.8.8-linux-arm64-x64.zip +b3656ffe35d7bdb49633fef7bdab96f08098e36173d41ed481cd68f1afe083c7 *mksnapshot-v39.8.8-linux-armv7l-x64.zip +74c500a9ce98b84287e32ff51513af05dd149ea5178fa531be0674597f45ce0f *mksnapshot-v39.8.8-linux-x64.zip +abc569f0321c8acff8207662c35f22944c41dafce527d56a921179738b217b74 *mksnapshot-v39.8.8-mas-arm64.zip +7bc374665fa6067c8ea7e48596b3876f3073dc3ea782186a0a52029308b7dfeb *mksnapshot-v39.8.8-mas-x64.zip +e8a2c01287ece06c04fabd5646fc479b9dd24e6e41b515c1f6a73fbba32d324b *mksnapshot-v39.8.8-win32-arm64-x64.zip +9c44883b3cbea58a36c676a109f1e5797bc6b08a21c0c979447384d405d6c7b5 *mksnapshot-v39.8.8-win32-ia32.zip +6d041d5829b53fb8838d7898ac0b96c293276f869503ed425b10651989b27ce2 *mksnapshot-v39.8.8-win32-x64.zip diff --git a/cgmanifest.json b/cgmanifest.json index 41466b8883d7e..5dfcbbfadcaa2 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "2d7e11a76ca841e08e31eb0121056d875f731f30", - "tag": "39.8.7" + "commitHash": "c0435f7a9fc498b1ece041d209c6c74d4a7e201b", + "tag": "39.8.8" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.8.7" + "version": "39.8.8" }, { "component": { diff --git a/eslint.config.js b/eslint.config.js index 8b6235587f818..ca35a088c17ef 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -159,21 +159,7 @@ export default tseslint.config( } ], 'jsdoc/no-types': 'warn', - 'local/code-no-static-self-ref': 'warn' - } - }, - // vscode TS - { - files: [ - 'src/**/*.ts', - ], - languageOptions: { - parser: tseslint.parser, - }, - plugins: { - '@typescript-eslint': tseslint.plugin, - }, - rules: { + 'local/code-no-static-self-ref': 'warn', '@typescript-eslint/naming-convention': [ 'warn', { diff --git a/extensions/copilot/.esbuild.ts b/extensions/copilot/.esbuild.ts index c5c11434b850b..68dd47035789c 100644 --- a/extensions/copilot/.esbuild.ts +++ b/extensions/copilot/.esbuild.ts @@ -183,7 +183,6 @@ const nodeExtHostBuildOptions = { { in: './src/platform/parser/node/parserWorker.ts', out: 'worker2' }, { in: './src/platform/tokenizer/node/tikTokenizerWorker.ts', out: 'tikTokenizerWorker' }, { in: './src/platform/diff/node/diffWorkerMain.ts', out: 'diffWorker' }, - { in: './src/platform/tfidf/node/tfidfWorker.ts', out: 'tfidfWorker' }, { in: './src/extension/chatSessions/copilotcli/node/copilotCLITodoWorker.ts', out: 'copilotCLITodoWorker' }, { in: './src/extension/onboardDebug/node/copilotDebugWorker/index.ts', out: 'copilotDebugCommand' }, { in: './src/extension/chatSessions/vscode-node/copilotCLIShim.ts', out: 'copilotCLIShim' }, diff --git a/extensions/copilot/.vscodeignore b/extensions/copilot/.vscodeignore index b506880f18ba5..85fb8d7f7ca14 100644 --- a/extensions/copilot/.vscodeignore +++ b/extensions/copilot/.vscodeignore @@ -9,7 +9,6 @@ assets/walkthroughs/** !dist/*.bpe !dist/*.tiktoken !dist/node_modules/** -!dist/tfidfWorker.js !dist/worker2.js !dist/tikTokenizerWorker.js !dist/diffWorker.js diff --git a/extensions/copilot/eslint.config.mjs b/extensions/copilot/eslint.config.mjs index 4006797351e92..d5bbb8e4f2708 100644 --- a/extensions/copilot/eslint.config.mjs +++ b/extensions/copilot/eslint.config.mjs @@ -489,9 +489,6 @@ export default tseslint.config( './src/platform/test/node/telemetry.ts', './src/platform/test/node/testWorkbenchService.ts', './src/platform/testing/common/nullWorkspaceMutationManager.ts', - './src/platform/tfidf/node/tfidf.ts', - './src/platform/tfidf/node/tfidfMessaging.ts', - './src/platform/tfidf/node/tfidfWorker.ts', './src/platform/thinking/common/thinking.ts', './src/platform/tokenizer/node/tikTokenizerWorker.ts', './src/platform/tokenizer/node/tokenizer.ts', diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index fe1430a79a711..233ae18e424f1 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -681,6 +681,10 @@ export class ClaudeChatSessionItemController extends Disposable { lastRequestEnded: session.lastRequestEnded, }; item.iconPath = new vscode.ThemeIcon('claude'); + if (session.cwd) { + // Agents app needs this to decide the working directory for the session + item.metadata = { workingDirectoryPath: session.cwd }; + } return item; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index ea01bc986cca6..2bd2a3126ab37 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -1409,6 +1409,30 @@ describe('ClaudeChatSessionItemController', () => { // timing.created is derived from created expect(item!.timing!.created).toBe(new Date('2024-06-01T12:00:00Z').getTime()); }); + + it('sets metadata with workingDirectoryPath when session has cwd', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'cwd-session', + label: 'CWD Session', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: 'my-project', + cwd: '/home/user/my-project', + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + await controller.updateItemStatus('cwd-session', ChatSessionStatus.InProgress, 'Prompt'); + + const item = getItem('cwd-session'); + expect(item!.metadata).toEqual({ workingDirectoryPath: '/home/user/my-project' }); + }); + + it('does not set metadata when session has no cwd', async () => { + await controller.updateItemStatus('no-cwd-session', ChatSessionStatus.InProgress, 'Prompt'); + + const item = getItem('no-cwd-session'); + expect(item!.metadata).toBeUndefined(); + }); }); // #endregion diff --git a/extensions/copilot/src/extension/tools/node/createFileTool.tsx b/extensions/copilot/src/extension/tools/node/createFileTool.tsx index 80577db3b921c..e9559f8689210 100644 --- a/extensions/copilot/src/extension/tools/node/createFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/createFileTool.tsx @@ -21,7 +21,7 @@ import { extname } from '../../../util/vs/base/common/resources'; import { count } from '../../../util/vs/base/common/strings'; import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { Position as ExtPosition, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelToolResult, MarkdownString, TextEdit } from '../../../vscodeTypes'; +import { Position as ExtPosition, Range as ExtRange, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelToolResult, MarkdownString, TextEdit } from '../../../vscodeTypes'; import { CodeBlockProcessor } from '../../codeBlocks/node/codeBlockProcessor'; import { IBuildPromptContext } from '../../prompt/common/intents'; import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; @@ -114,7 +114,15 @@ export class CreateFileTool implements ICopilotTool { this.sendTelemetry(options.chatRequestId, modelId, fileExtension); } else { const content = removeLeadingFilepathComment(options.input.content, languageId, options.input.filePath); - this._promptContext.stream.textEdit(uri, TextEdit.insert(new ExtPosition(0, 0), content)); + // When the file has been deleted from disk but VS Code still holds a stale + // in-memory doc with content, use a full-document replace so the old buffer + // is overwritten rather than prepended to (https://github.com/microsoft/vscode/issues/311043). + if (!fileExists && doc && doc.getText().length > 0) { + const lastLine = doc.lineCount - 1; + this._promptContext.stream.textEdit(uri, TextEdit.replace(new ExtRange(0, 0, lastLine, doc.lineAt(lastLine).text.length), content)); + } else { + this._promptContext.stream.textEdit(uri, TextEdit.insert(new ExtPosition(0, 0), content)); + } this._promptContext.stream.textEdit(uri, true); this.sendTelemetry(options.chatRequestId, modelId, fileExtension); return new LanguageModelToolResult([ diff --git a/extensions/copilot/src/platform/tfidf/node/test/tfidf.spec.ts b/extensions/copilot/src/platform/tfidf/node/test/tfidf.spec.ts deleted file mode 100644 index 09eeae8c50098..0000000000000 --- a/extensions/copilot/src/platform/tfidf/node/test/tfidf.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { suite, test } from 'vitest'; -import { URI } from '../../../../util/vs/base/common/uri'; -import { Range } from '../../../../util/vs/editor/common/core/range'; -import { FileChunk } from '../../../chunking/common/chunk'; -import { PersistentTfIdf, TfIdfDoc } from '../tfidf'; - -/** - * Generates all permutations of an array. - * - * This is useful for testing to make sure order does not effect the result. - */ -function permutate(arr: T[]): T[][] { - if (arr.length === 0) { - return [[]]; - } - - const result: T[][] = []; - - for (let i = 0; i < arr.length; i++) { - const rest = [...arr.slice(0, i), ...arr.slice(i + 1)]; - const permutationsRest = permutate(rest); - for (let j = 0; j < permutationsRest.length; j++) { - result.push([arr[i], ...permutationsRest[j]]); - } - } - - return result; -} - -function assertPathsEqual(result: readonly FileChunk[], expected: readonly string[], docs?: TfIdfDoc[]) { - assert.deepStrictEqual(result.map(x => x.file.path), expected, - docs ? `Failed for doc order: ${docs.map(x => x.uri.path)}` : undefined); -} - -suite('TF-IDF', () => { - test('Search should return nothing when empty', async () => { - const tfidf = new PersistentTfIdf(':memory:'); - tfidf; - assertPathsEqual(await tfidf.search('something'), []); - }); - - test('Search should return nothing for term not in documents ', async () => { - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate([ - testFile('A', 'cat dog fish'), - ]); - assertPathsEqual(await tfidf.search('elephant'), []); - }); - - test('Should return document with exact match', async () => { - for (const docs of permutate([ - testFile('A', 'cat dog cat'), - testFile('B', 'cat fish'), - ])) { - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate(docs); - assertPathsEqual(await tfidf.search('dog'), ['/A'], docs); - } - }); - - test('Should return document with more matches first', async () => { - for (const docs of permutate([ - testFile('/A', 'cat dog cat'), - testFile('/B', 'cat fish'), - testFile('/C', 'frog'), - ])) { - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate(docs); - assertPathsEqual(await tfidf.search('cat'), ['/A', '/B'], docs); - } - }); - - test('Should return document with more matches first when term appears in all documents', async () => { - for (const docs of permutate([ - testFile('/A', 'cat dog cat cat'), - testFile('/B', 'cat fish'), - testFile('/C', 'frog cat cat'), - ])) { - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate(docs); - assertPathsEqual(await tfidf.search('cat'), ['/A', '/C', '/B'], docs); - } - }); - - test('Should weigh less common term higher', async () => { - for (const docs of permutate([ - testFile('A', 'cat dog cat'), - testFile('B', 'fish'), - testFile('C', 'cat cat cat cat'), - testFile('D', 'cat fish') - ])) { - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate(docs); - assertPathsEqual(await tfidf.search('cat the dog'), ['/A', '/C', '/D'], docs); - } - }); - - test('Should ignore case and punctuation', async () => { - for (const docs of permutate([ - testFile('/A', 'Cat doG.cat'), - testFile('/B', 'cAt fiSH'), - testFile('/C', 'frOg'), - ])) { - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate(docs); - assertPathsEqual(await tfidf.search('. ,CaT! '), ['/A', '/B'], docs); - } - }); - - test('Should match on camelCase words', async () => { - for (const docs of permutate([ - testFile('/A', 'catDog cat'), - testFile('/B', 'fishCatFish'), - testFile('/C', 'frogcat'), - ])) { - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate(docs); - assertPathsEqual(await tfidf.search('catDOG'), ['/A', '/B'], docs); - } - }); - - test('Should not match document after delete', async () => { - const docA = testFile('/A', 'cat dog cat'); - const docB = testFile('/B', 'cat fish'); - const docC = testFile('/C', 'frog'); - - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate([docA, docB, docC]); - assertPathsEqual(await tfidf.search('cat'), ['/A', '/B']); - - tfidf.delete([docA.uri]); - assertPathsEqual(await tfidf.search('cat'), ['/B']); - - tfidf.delete([docC.uri]); - assertPathsEqual(await tfidf.search('cat'), ['/B']); - - tfidf.delete([docB.uri]); - assertPathsEqual(await tfidf.search('cat'), []); - }); - - test('Should match for snake_case', async () => { - const docA = testFile('/A', 'cat_dog cat _dog cat_'); - const docB = testFile('/B', 'fish cat bird_horse'); - const docC = testFile('/C', 'fish'); - - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate([docA, docB, docC]); - assertPathsEqual(await tfidf.search('cat'), ['/A', '/B']); - assertPathsEqual(await tfidf.search('cat_dog'), ['/A', '/B']); - assertPathsEqual(await tfidf.search('_dog'), ['/A']); - assertPathsEqual(await tfidf.search('cat_'), ['/A', '/B']); - assertPathsEqual(await tfidf.search('fish_cat'), ['/A', '/B', '/C']); - assertPathsEqual(await tfidf.search('man_bear_pig'), []); - - // Make sure snake case is broken up too for searches - assertPathsEqual(await tfidf.search('bird_horse'), ['/B']); - assertPathsEqual(await tfidf.search('bird'), ['/B']); - assertPathsEqual(await tfidf.search('horse'), ['/B']); - }); - - test('Should match with leading/trailing underscores', async () => { - const docA = testFile('/A', '_cat dog_'); - const docB = testFile('/B', 'fish'); - - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate([docA, docB]); - assertPathsEqual(await tfidf.search('cat'), ['/A']); - assertPathsEqual(await tfidf.search('_cat'), ['/A']); - assertPathsEqual(await tfidf.search('cat_'), ['/A']); - assertPathsEqual(await tfidf.search('dog'), ['/A']); - assertPathsEqual(await tfidf.search('_dog'), ['/A']); - assertPathsEqual(await tfidf.search('dog_'), ['/A']); - }); - - test('Should match words with digits', async () => { - const docA = testFile('/A', 'cat2 dog'); - const docB = testFile('/B', 'fish cat2fish bi2rd'); - const docC = testFile('/C', 'fish3'); - - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate([docA, docB, docC]); - assertPathsEqual(await tfidf.search('cat2'), ['/A']); - assertPathsEqual(await tfidf.search('cat2fish'), ['/B']); - assertPathsEqual(await tfidf.search('fish'), ['/B', '/C']); // Should also match fish3 - }); - - test('Should match words using $', async () => { - const docA = testFile('/A', '$cat dog'); - const docB = testFile('/B', 'fish cat'); - const docC = testFile('/C', 'cat$ dog$cat'); - - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate([docA, docB, docC]); - assertPathsEqual(await tfidf.search('$cat'), ['/A']); - assertPathsEqual(await tfidf.search('dog$cat'), ['/C']); - }); - - test('Should match on function calls', async () => { - const docA = testFile('/A', 'cat() dog'); - const docB = testFile('/B', 'fish cat'); - const docC = testFile('/C', 'fish'); - - const tfidf = new PersistentTfIdf(':memory:'); - await tfidf.addOrUpdate([docA, docB, docC]); - assertPathsEqual(await tfidf.search('cat()'), ['/A', '/B']); - assertPathsEqual(await tfidf.search('cat'), ['/A', '/B']); - }); -}); - -function testFile(path: string, content: string): TfIdfDoc { - const uri = URI.file(path); - return { - uri, - async getContentVersionId() { return '123'; }, - async getChunks() { - return [{ file: uri, text: content, rawText: content, range: Range.lift({ startColumn: 0, startLineNumber: 0, endColumn: 0, endLineNumber: 0 }) }]; - }, - }; -} diff --git a/extensions/copilot/src/platform/tfidf/node/tfidf.ts b/extensions/copilot/src/platform/tfidf/node/tfidf.ts deleted file mode 100644 index 278ddebd0ac67..0000000000000 --- a/extensions/copilot/src/platform/tfidf/node/tfidf.ts +++ /dev/null @@ -1,576 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import fs from 'fs'; -import sql from 'node:sqlite'; -import path from 'path'; -import { GlobIncludeOptions, shouldInclude } from '../../../util/common/glob'; -import { Limiter } from '../../../util/vs/base/common/async'; -import { Iterable } from '../../../util/vs/base/common/iterator'; -import { ResourceMap, ResourceSet } from '../../../util/vs/base/common/map'; -import { Schemas } from '../../../util/vs/base/common/network'; -import { URI } from '../../../util/vs/base/common/uri'; -import { Range } from '../../../util/vs/editor/common/core/range'; -import { FileChunk } from '../../chunking/common/chunk'; - -type SparseEmbedding = Map; -type TermFrequencies = Record; - -function countRecordFrom(values: Iterable): Record { - const map = Object.create(null); - for (const value of values) { - map[value] = (map[value] ?? 0) + 1; - } - return map; -} - -/** - * Count how many times each term (word) appears in a string. - */ -function termFrequencies(input: string): TermFrequencies { - return countRecordFrom(splitTerms(input)); -} - -/** - * Break a string into terms (words). - */ -function* splitTerms(input: string): Iterable { - const normalize = (word: string) => word.toLowerCase(); - - // Only match on words that are at least 3 characters long and start with a letter - for (const [word] of input.matchAll(/(?(); - parts.add(normalize(word)); - - const subParts: string[] = []; - const camelParts = word.split(/(?<=[a-z$])(?=[A-Z])/g); - if (camelParts.length > 1) { - subParts.push(...camelParts); - } - - const snakeParts = word.split('_'); - if (snakeParts.length > 1) { - subParts.push(...snakeParts); - } - - const nonDigitPrefixMatch = word.match(/^([\D]+)\p{Number}+$/u); - if (nonDigitPrefixMatch) { - subParts.push(nonDigitPrefixMatch[1]); - } - - for (const part of subParts) { - // Require at least 3 letters in the sub parts - if (part.length > 2 && /[\p{Alphabetic}_$]{3,}/gu.test(part)) { - parts.add(normalize(part)); - } - } - - yield* parts; - } -} - -/** - * A very simple heap implementation that keeps the top `maxSize` elements. - */ -class SimpleHeap { - - private readonly store: Array<{ readonly score: number; readonly value: T }> = []; - - constructor( - private readonly maxSize: number, - private minScore = -Infinity, - ) { } - - toArray(maxSpread?: number): T[] { - if (this.store.length && typeof maxSpread === 'number') { - const minScore = this.store.at(0)!.score * (1.0 - maxSpread); - return this.store.filter(x => x.score >= minScore).map(x => x.value); - } - return this.store.map(x => x.value); - } - - add(score: number, value: T) { - if (score <= this.minScore) { - return; - } - - const index = this.store.findIndex(entry => entry.score < score); - this.store.splice(index >= 0 ? index : this.store.length, 0, { score, value }); - while (this.store.length > this.maxSize) { - this.store.pop(); - } - - if (this.store.length === this.maxSize) { - this.minScore = this.store.at(-1)?.score ?? this.minScore; - } - } -} - -interface DocumentChunkEntry { - readonly chunk: FileChunk; - readonly tf: TermFrequencies; -} - -export interface TfIdfDoc { - readonly uri: URI; - getContentVersionId(): Promise; - getChunks(): Promise>; -} - -export interface TfIdfSearchOptions { - /** Glob pattern for files to include/exclude */ - readonly globPatterns?: GlobIncludeOptions; - - /** Maximum number of results to return. If not specified returns as many results as possible */ - readonly maxResults?: number; - - /** - * Maximum range of result scores. - * - * This is a multiplier. With a value of `0.7` for instance, all returned results must have a score >= `results[0].score * (1 - 0.7)` - */ - readonly maxSpread?: number; -} - -interface TfIdfDocData { - readonly contentVersionId: string; - readonly chunks: readonly DocumentChunkEntry[]; -} - -/** - * Implementation of tf-idf (term frequency–inverse document frequency) for a set of documents where - * each document contains one or more chunks of text. - * - * This implementation uses SQLite to store the documents and their chunks. This lets us scale up to a large - * number of documents and chunks. - */ -export class PersistentTfIdf { - - private readonly db!: sql.DatabaseSync; - - constructor(dbPath: URI | ':memory:') { - const syncOptions: sql.DatabaseSyncOptions = { - open: true, - enableForeignKeyConstraints: true - }; - - if (dbPath !== ':memory:' && dbPath.scheme === Schemas.file) { - try { - fs.mkdirSync(path.dirname(dbPath.fsPath), { recursive: true }); - this.db = new sql.DatabaseSync(dbPath.fsPath, syncOptions); - } catch (e) { - console.error('Failed to open SQLite database on disk. Trying memory db', e); - } - } - - // Try falling back to an in-memory database - if (!this.db) { - this.db = new sql.DatabaseSync(':memory:', syncOptions); - } - - this.db.exec(` - PRAGMA journal_mode = OFF; - PRAGMA synchronous = 0; - PRAGMA cache_size = 1000000; - PRAGMA locking_mode = EXCLUSIVE; - PRAGMA temp_store = MEMORY; - `); - - this.db.exec(` - CREATE TABLE IF NOT EXISTS Documents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uri TEXT, - contentVersionId TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS Chunks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - documentId INTEGER NOT NULL, - text TEXT NOT NULL, - startLineNumber INTEGER NOT NULL, - startColumn INTEGER NOT NULL, - endLineNumber INTEGER NOT NULL, - endColumn INTEGER NOT NULL, - isFullFile INTEGER NOT NULL, - termFrequencies BLOB NOT NULL, -- JSONB object storing term frequencies - FOREIGN KEY (documentId) REFERENCES Documents(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS ChunkOccurrences ( - term TEXT PRIMARY KEY, - chunkCount INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_documents_uri ON Documents(uri); - CREATE INDEX IF NOT EXISTS idx_chunks_documentId ON Chunks(documentId); - `); - } - - /** - * @returns a list of URIs that are out of sync and need to be re-indexed. - */ - initialize(workspaceDocsIn: Iterable<{ readonly uri: URI; readonly contentId: string }>): { deletedDocs: ResourceSet; newDocs: ResourceSet; outOfSyncDocs: ResourceSet } { - const inDocsToContentIds = new ResourceMap(); - for (const { uri, contentId } of workspaceDocsIn) { - inDocsToContentIds.set(uri, contentId); - } - - const allDbDocs = this.db.prepare( - 'SELECT * FROM Documents' - ).all(); - - const dbDocsToContentIds = new ResourceMap(); - for (const docEntry of allDbDocs) { - try { - const uri = URI.parse(docEntry.uri as string); - dbDocsToContentIds.set(uri, docEntry.contentVersionId as string); - } catch (e) { - console.error(`Failed to parse URI from database entry: ${docEntry.uri}`, e); - } - } - - // Build list of documents that are out of sync, new, or deleted - const deletedDocs = new ResourceSet(); - const outOfSyncDocs = new ResourceSet(); - - for (const [dbDocUri, dbDocContentId] of dbDocsToContentIds) { - const inDocContentId = inDocsToContentIds.get(dbDocUri); - if (!inDocContentId) { - // Document is not in the workspace anymore - deletedDocs.add(dbDocUri); - } else if (inDocContentId !== dbDocContentId) { - outOfSyncDocs.add(dbDocUri); - } - } - - // Any new docs in the input that aren't in the db - const newDocs = new ResourceSet(); - for (const uri of inDocsToContentIds.keys()) { - if (!dbDocsToContentIds.has(uri)) { - newDocs.add(uri); - } - } - - this.delete(Array.from(deletedDocs)); - - return { outOfSyncDocs, newDocs, deletedDocs }; - } - - private async isUpToDate(toCheck: TfIdfDoc): Promise { - return this.getDocContentVersionId(toCheck.uri) === await toCheck.getContentVersionId(); - } - - private getDocContentVersionId(uri: URI): string | undefined { - const result = this.db.prepare( - 'SELECT contentVersionId FROM Documents WHERE uri = ?' - ).get(uri.toString()); - return result?.contentVersionId as string | undefined; - } - - public async addOrUpdate(documents: readonly TfIdfDoc[]): Promise { - const chunkLimiter = new Limiter>(20); - try { - const toUpdate = await Promise.all(documents.map(async doc => { - try { - if (await this.isUpToDate(doc)) { - return; - } - - return { - uri: doc.uri, - getDoc: async () => { - const chunks: Array = []; - for (const chunk of await chunkLimiter.queue(() => doc.getChunks())) { - // TODO: See if we can compute the tf lazily - // The challenge is that we need to also update the `chunkOccurrences` - // and all of those updates need to get flushed before the real tfidf of - // anything is computed. - const tf = termFrequencies(chunk.text); - chunks.push({ chunk, tf }); - } - return ({ contentVersionId: await doc.getContentVersionId(), chunks }); - } - }; - } catch { - // noop - } - })); - - await this.addOrUpdateDocs(toUpdate.filter((doc): doc is any => !!doc)); - } finally { - chunkLimiter.dispose(); - } - } - - public delete(uris: Iterable): void { - this.db.exec('BEGIN TRANSACTION'); - for (const uri of uris) { - const doc = this.getDoc(uri); - if (!doc) { - continue; - } - - this.db.prepare(` - DELETE FROM Documents WHERE uri = ? - `).run(uri.toString()); - - this._cachedChunkCount = undefined; - - const allOccurrences = countRecordFrom(doc.chunks.flatMap(chunk => Object.keys(chunk.tf))); - - for (const [term, count] of Object.entries(allOccurrences)) { - this.db.prepare(` - UPDATE ChunkOccurrences - SET chunkCount = chunkCount - ? - WHERE term = ?; - `).run(count, term); - } - } - this.db.exec('COMMIT'); - - this.db.prepare(` - DELETE FROM ChunkOccurrences - WHERE chunkCount < 1; - `).run(); - } - - public get fileCount(): number { - return this.db.prepare( - `SELECT COUNT(*) as count FROM Documents` - ).get()!.count as number | undefined ?? 0; - } - - /** - * Rank the documents by their cosine similarity to a set of search queries. - */ - public async search(query: string, options?: TfIdfSearchOptions): Promise { - const heap = new SimpleHeap(options?.maxResults ?? Infinity, -Infinity); - - const queryEmbeddings = this.computeEmbeddings(query); - if (!queryEmbeddings.size) { - return []; - } - - const idfCache = new Map(); - for (const entry of await this.getAllChunksWithTerms(Array.from(queryEmbeddings.keys()))) { - if (!shouldInclude(entry.chunk.file, options?.globPatterns)) { - continue; - } - - const score = this.score(entry, queryEmbeddings, idfCache); - if (score > 0) { - heap.add(score, entry.chunk); - } - } - - return heap.toArray(options?.maxSpread); - } - - private computeEmbeddings(input: string): SparseEmbedding { - const tf = termFrequencies(input); - return this.computeTfidf(tf); - } - - private score(chunk: DocumentChunkEntry, queryEmbedding: SparseEmbedding, idfCache: Map): number { - // Compute the dot product between the chunk's embedding and the query embedding - - // Note that the chunk embedding is computed lazily on a per-term basis. - // This lets us skip a large number of calculations because the majority - // of chunks do not share any terms with the query. - - let sum = 0; - for (const [term, termTfidf] of queryEmbedding.entries()) { - const chunkTf = chunk.tf[term]; - if (!chunkTf) { - // Term does not appear in chunk so it has no contribution - continue; - } - - let chunkIdf = idfCache.get(term); - if (typeof chunkIdf !== 'number') { - chunkIdf = this.idf(term); - idfCache.set(term, chunkIdf); - } - - const chunkTfidf = chunkTf * chunkIdf; - sum += chunkTfidf * termTfidf; - } - return sum; - } - - private idf(term: string): number { - const chunkOccurrences = this.getChunkOccurrences(term) ?? 0; - return chunkOccurrences > 0 - ? Math.log((this.getChunkCount() + 1) / chunkOccurrences) - : 0; - } - - private computeTfidf(termFrequencies: TermFrequencies): SparseEmbedding { - const embedding = new Map(); - for (const [word, occurrences] of Object.entries(termFrequencies)) { - const idf = this.idf(word); - if (idf > 0) { - embedding.set(word, occurrences * idf); - } - } - return embedding; - } - - private _cachedChunkCount: number | undefined; - - private getChunkCount(): number { - if (typeof this._cachedChunkCount === 'number') { - return this._cachedChunkCount; - } - - const result = this.db.prepare( - 'SELECT COUNT(*) as count FROM Chunks' - ).get(); - return result?.count as number | undefined ?? 0; - } - - private getChunkOccurrences(term: string): number { - const result = this.db.prepare( - 'SELECT chunkCount FROM ChunkOccurrences WHERE term = ?' - ).get(term); - return result?.chunkCount as number | undefined ?? 0; - } - - private async addOrUpdateDocs(docs: Iterable<{ uri: URI; getDoc(): Promise }>): Promise { - this._cachedChunkCount = undefined; - - // Track this for the entire set of documents so we can do a single update - const allChunkOccurrences: Record = Object.create(null); - - const processBatch = (docs: ReadonlyArray<{ uri: URI; doc: TfIdfDocData }>) => { - // Delete existing documents - // This should also clear the chunks and terms due to the foreign key constraints - this.delete(docs.map(doc => doc.uri)); - - this.db.exec('BEGIN TRANSACTION'); - try { - for (const { uri, doc } of docs) { - // Add new the document - const docId = this.db.prepare( - 'INSERT OR REPLACE INTO Documents (uri, contentVersionId) VALUES (?, ?)' - ) - .run(uri.toString(), doc.contentVersionId) - .lastInsertRowid; - - // Insert new chunks - const insertChunkOp = this.db.prepare( - 'INSERT INTO Chunks (documentId, text, startLineNumber, startColumn, endLineNumber, endColumn, isFullFile, termFrequencies) VALUES (?, ?, ?, ?, ?, ?, ?, jsonb(?))' - ); - - for (const chunk of doc.chunks) { - insertChunkOp.run( - docId, - chunk.chunk.text, - chunk.chunk.range.startLineNumber, - chunk.chunk.range.startColumn, - chunk.chunk.range.endLineNumber, - chunk.chunk.range.endColumn, - chunk.chunk.isFullFile ? 1 : 0, - JSON.stringify(chunk.tf), - ); - - for (const term of Object.keys(chunk.tf)) { - allChunkOccurrences[term] = (allChunkOccurrences[term] ?? 0) + 1; - } - } - } - - this.db.exec('COMMIT'); - } catch (e) { - this.db.exec('ROLLBACK'); - throw e; - } - }; - - const batchSize = 200; - const batch: Array<{ uri: URI; doc: TfIdfDocData }> = []; - for (const doc of docs) { - batch.push({ uri: doc.uri, doc: await doc.getDoc() }); - if (batch.length >= batchSize) { - processBatch(batch); - batch.length = 0; - } - } - - // Process any remaining documents - processBatch(batch); - - // Update occurrences list - const insertOccurrencesOp = this.db.prepare(` - INSERT INTO ChunkOccurrences (term, chunkCount) - VALUES (?, ?) - ON CONFLICT(term) DO UPDATE SET chunkCount = chunkCount + ?; - `); - - this.db.exec('BEGIN TRANSACTION'); - for (const [term, count] of Object.entries(allChunkOccurrences)) { - insertOccurrencesOp.run(term, count, count); - } - this.db.exec('COMMIT'); - } - - private getDoc(uri: URI): TfIdfDocData | undefined { - const doc = this.db.prepare( - 'SELECT id, contentVersionId FROM Documents WHERE uri = ?' - ).get(uri.toString()); - if (!doc) { - return undefined; - } - - const chunks = this.db.prepare( - 'SELECT text, startLineNumber, startColumn, endLineNumber, endColumn, isFullFile, json(termFrequencies) as termFrequencies FROM Chunks WHERE documentId = ?' - ).all(doc.id); - return { - contentVersionId: doc.contentVersionId as string, - chunks: chunks.map(row => { - return this.reviveDocumentChunkEntry({ ...row, uri: uri.toString() }); - }) - }; - } - - private async getAllChunksWithTerms(searchTerms: readonly string[]): Promise> { - if (!searchTerms.length) { - return []; - } - - const chunkResults = this.db.prepare(` - SELECT c.id, c.documentId, c.text, c.startLineNumber, c.startColumn, c.endLineNumber, c.endColumn, c.isFullFile, - json(c.termFrequencies) as termFrequencies, d.uri - FROM Chunks c - JOIN Documents d ON c.documentId = d.id - WHERE EXISTS ( - SELECT 1 FROM json_each(c.termFrequencies) - WHERE json_each.key IN (${searchTerms.map(_ => `?`).join(',')}) - ) - `).all(...searchTerms); - - return Iterable.map(chunkResults, row => this.reviveDocumentChunkEntry(row)); - } - - private reviveDocumentChunkEntry(row: any): DocumentChunkEntry { - return { - tf: JSON.parse(row.termFrequencies as string), - get chunk() { - return { - file: URI.isUri(row.uri) ? row.uri : URI.parse(row.uri as string), - text: row.text as string, - rawText: row.text, - range: new Range( - row.startLineNumber as number, - row.startColumn as number, - row.endLineNumber as number, - row.endColumn as number - ), - isFullFile: Boolean(row.isFullFile) - }; - } - }; - } -} diff --git a/extensions/copilot/src/platform/tfidf/node/tfidfMessaging.ts b/extensions/copilot/src/platform/tfidf/node/tfidfMessaging.ts deleted file mode 100644 index db83f6da6f7aa..0000000000000 --- a/extensions/copilot/src/platform/tfidf/node/tfidfMessaging.ts +++ /dev/null @@ -1,30 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - - -export function rewriteObject(value: any, transform: (obj: object) => object | undefined): any { - if (!value) { - return value; - } - - if (Array.isArray(value)) { - return value.map(x => rewriteObject(x, transform)); - } - - if (typeof value === 'object') { - const t = transform(value); - if (t) { - return t; - } - - const newValue: { [key: string]: any } = {}; - for (const key in value) { - newValue[key] = rewriteObject(value[key], transform); - } - return newValue; - } - - return value; -} diff --git a/extensions/copilot/src/platform/tfidf/node/tfidfWorker.ts b/extensions/copilot/src/platform/tfidf/node/tfidfWorker.ts deleted file mode 100644 index 050bb5855efef..0000000000000 --- a/extensions/copilot/src/platform/tfidf/node/tfidfWorker.ts +++ /dev/null @@ -1,249 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { MessagePort, parentPort, workerData } from 'worker_threads'; -import { createRpcProxy, RcpResponseHandler, RpcProxy, RpcRequest, RpcResponse } from '../../../util/node/worker'; -import { CancellationToken } from '../../../util/vs/base/common/cancellation'; -import { Iterable } from '../../../util/vs/base/common/iterator'; -import { Lazy } from '../../../util/vs/base/common/lazy'; -import { ResourceMap } from '../../../util/vs/base/common/map'; -import { StopWatch } from '../../../util/vs/base/common/stopwatch'; -import { URI, UriComponents } from '../../../util/vs/base/common/uri'; -import { IRange, Range } from '../../../util/vs/editor/common/core/range'; -import { FileChunk } from '../../chunking/common/chunk'; -import { NaiveChunker } from '../../chunking/node/naiveChunker'; -import { NullTelemetryService } from '../../telemetry/common/nullTelemetryService'; -import { TokenizationEndpoint, TokenizerProvider } from '../../tokenizer/node/tokenizer'; -import { PersistentTfIdf, TfIdfDoc, TfIdfSearchOptions } from './tfidf'; -import { rewriteObject } from './tfidfMessaging'; - -export interface TfIdfWorkerData { - readonly endpoint: TokenizationEndpoint; - readonly dbPath: ':memory:' | UriComponents; -} - -type Values = T[keyof T]; - -type Methods = { - [K in keyof T]: T[K] extends ((...args: any[]) => any) ? T[K] : never; -}; - -type Message = Values<{ - [K in keyof Api]: Api[K] extends ((...args: any[]) => any) ? { id: number; fn: K; args: Parameters } : never; -}>; - -function isIRange(obj: any): obj is IRange { - return obj && typeof obj.startLineNumber === 'number' && typeof obj.startColumn === 'number' && typeof obj.endLineNumber === 'number' && typeof obj.endColumn === 'number'; -} - -function serialize(value: any): any { - return rewriteObject(value, obj => { - if (URI.isUri(obj)) { - return { $mid: 'uri', ...obj }; - } - if (isIRange(obj)) { - return { $mid: 'range', ...obj } as IRange; - } - }); -} - -function revive(value: T): T { - return rewriteObject(value, (obj: any) => { - if (obj['$mid'] === 'uri') { - return URI.from(obj as any); - } - }); -} - -export interface WorkerFileDoc { - readonly uri: URI; - readonly hash: string; - readonly content: string; -} - -type TfIdfOperation = 'update' | 'delete'; - -class Host { - private readonly _handler = new RcpResponseHandler(); - - public readonly proxy: RpcProxy; - - constructor(port: MessagePort, impl: TfidfWorker) { - this.proxy = createRpcProxy((name, args) => { - const { id, result } = this._handler.createHandler(); - port.postMessage({ id, fn: name, args } satisfies RpcRequest); - return result; - }); - - port.on('message', async (msg: Message | RpcRequest) => { - if ('fn' in msg) { - try { - const res = await ((impl as any)[msg.fn] as any)(...revive(msg.args)); - port.postMessage({ id: msg.id, res: serialize(res) } satisfies RpcResponse); - } catch (err) { - port.postMessage({ id: msg.id, err } satisfies RpcResponse); - } - } else { - this._handler.handleResponse(msg); - } - }); - } -} - -export interface TfidfSearchResults { - results: readonly FileChunk[]; - telemetry: { - readonly fileCount: number; - readonly updatedFileCount: number; - - readonly updateTime: number; - readonly searchTime: number; - }; -} - -export interface TfIdfInitializeTelemetry { - readonly outOfSyncFileCount: number; - readonly newFileCount: number; - readonly deletedFileCount: number; -} - -class TfidfWorker { - - private readonly _tfIdf: PersistentTfIdf; - private readonly _pendingChanges = new ResourceMap(); - - private readonly _chunker: NaiveChunker; - - private readonly _host: Host; - - constructor(port: MessagePort, workerData: TfIdfWorkerData) { - this._tfIdf = new PersistentTfIdf(workerData.dbPath === ':memory:' ? ':memory:' : URI.from(workerData.dbPath)); - this._chunker = new NaiveChunker(workerData.endpoint, new TokenizerProvider(false, new NullTelemetryService())); - this._host = new Host(port, this); - } - - initialize(workspaceDocsIn: ReadonlyArray<{ uri: UriComponents; contentId: string }>): TfIdfInitializeTelemetry { - const { outOfSyncDocs, newDocs, deletedDocs } = this._tfIdf.initialize(workspaceDocsIn.map(entry => ({ - uri: URI.from(entry.uri), - contentId: entry.contentId, - }))); - - // Defer actually updating any out of sync docs until we need to do a search - for (const uri of Iterable.concat(outOfSyncDocs, newDocs)) { - this._pendingChanges.set(uri, 'update'); - } - - return { - newFileCount: newDocs.size, - outOfSyncFileCount: outOfSyncDocs.size, - deletedFileCount: deletedDocs.size - }; - } - - addOrUpdate(documents: readonly UriComponents[]): void { - for (const uri of documents) { - const revivedUri = URI.from(uri); - this._pendingChanges.set(revivedUri, 'update'); - } - } - - delete(uris: readonly UriComponents[]): void { - for (const uri of uris) { - const revivedUri = URI.from(uri); - this._pendingChanges.set(revivedUri, 'delete'); - } - } - - async search(query: string, options?: TfIdfSearchOptions): Promise { - const sw = new StopWatch(); - - const updatedFileCount = this._pendingChanges.size; - await this._flushPendingChanges(); - const updateTime = sw.elapsed(); - - sw.reset(); - const results = await this._tfIdf.search(query, options); - const searchTime = sw.elapsed(); - - return { - results: results, - telemetry: { - fileCount: this._tfIdf.fileCount, - updatedFileCount, - updateTime, - searchTime, - } - }; - } - - private async _flushPendingChanges(): Promise { - if (!this._pendingChanges.size) { - return; - } - - const toDelete = Array.from( - Iterable.filter(this._pendingChanges.entries(), ([_uri, op]) => op === 'delete'), - ([uri]) => uri - ); - this._tfIdf.delete(toDelete); - - const updatedDocs = Array.from( - Iterable.filter(this._pendingChanges.entries(), ([_uri, op]) => op === 'update'), - ([uri]): TfIdfDoc => { - const contentVersionId = new Lazy(() => this._host.proxy.getContentVersionId(uri)); - return { - uri: uri, - getContentVersionId: () => contentVersionId.value, - getChunks: async () => this.getRawNaiveChunks(uri, await this._host.proxy.readFile(uri), CancellationToken.None) - }; - } - ); - - if (updatedDocs.length) { - await this._tfIdf.addOrUpdate(updatedDocs); - } - - this._pendingChanges.clear(); - } - - private async getRawNaiveChunks(uri: URI, text: string, token: CancellationToken): Promise> { - try { - const naiveChunks = await this._chunker.chunkFile(uri, text, {}, token); - return Iterable.map(naiveChunks, (e): FileChunk => { - return { - file: uri, - text: e.text, - rawText: e.rawText, - range: Range.lift(e.range), - isFullFile: e.isFullFile - }; - }); - } catch (e) { - console.error(`Could not chunk: ${uri}`, e); - return []; - } - } -} - -export type TfidfWorkerApi = Methods; - -export interface TfidfHostApi { - getContentVersionId(uri: URI): Promise; - readFile(uri: URI): Promise; -} - -// #region Main - -const port = parentPort; -if (!port) { - throw new Error(`This module should only be used in a worker thread.`); -} - -if (!workerData) { - throw new Error(`Expected 'workerData' to be provided to the worker thread.`); -} - -new TfidfWorker(port, workerData as TfIdfWorkerData); - -// #endregion diff --git a/package-lock.json b/package-lock.json index 2b87eabaf230b..5ddc165b06578 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,7 +113,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.7", + "electron": "39.8.8", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -7765,9 +7765,9 @@ "dev": true }, "node_modules/electron": { - "version": "39.8.7", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.7.tgz", - "integrity": "sha512-B3TmzbUEeIvrhJ0QcoFp8/tgnVA3vsm0wkdYWzC22hsk9zTVqkzyrrz40cjd0nMTTIrGWxxfDO2tdQTCMe9Bjw==", + "version": "39.8.8", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.8.tgz", + "integrity": "sha512-24MMLdwCg8xwlWzDxC1XZU2MqW0Xx2UPxt9EU15vMDoRSMHPqmi/BgRCEINXZaBLfFSeXEKGpg+QlBRdL5uwaw==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index 29c86e384376a..f9665ffa74777 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.118.0", - "distro": "cccc9754dda0a01ea6eaa276472ad5e169f80d31", + "distro": "d9cb505fab31e4606739394220a6892cac0e7046", "author": { "name": "Microsoft Corporation" }, @@ -192,7 +192,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.7", + "electron": "39.8.8", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 70bdbae725dc9..c83ff0399dfa5 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -180,7 +180,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC * Create a new session on the remote agent host. */ async createSession(config?: IAgentCreateSessionConfig): Promise { - const provider = config?.provider ?? 'copilot'; + const provider = config?.provider; + if (!provider) { + throw new Error('Cannot create remote agent host session without a provider.'); + } const session = AgentSession.uri(provider, generateUuid()); await this._sendRequest('createSession', { session: session.toString(), diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts index 457ef7ad2c5f7..556e6bd342cb5 100644 --- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts +++ b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts @@ -11,6 +11,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { connectionTokenQueryName } from '../../../base/common/network.js'; import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; import type { IClientTransport } from '../common/state/sessionTransport.js'; +import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; // ---- Client transport ------------------------------------------------------- @@ -31,6 +32,7 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans readonly onOpen = this._onOpen.event; private _ws: WebSocket | undefined; + private _malformedFrames = 0; get isOpen(): boolean { return this._ws?.readyState === WebSocket.OPEN; @@ -94,13 +96,45 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans // Wire up long-lived listeners after connection ws.addEventListener('message', (event: MessageEvent) => { + if (typeof event.data !== 'string') { + this._malformedFrames++; + if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { + const dataType = event.data instanceof ArrayBuffer ? 'ArrayBuffer' : event.data instanceof Blob ? 'Blob' : typeof event.data; + const byteLen = event.data instanceof ArrayBuffer ? event.data.byteLength : event.data instanceof Blob ? event.data.size : 0; + console.warn( + `[WebSocketClientTransport] Non-string frame #${this._malformedFrames} (type=${dataType}, bytes=${byteLen})` + ); + } + if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { + console.warn( + `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.` + ); + this._ws?.close(4002, 'malformed-frames'); + } + return; + } + const text = event.data; + let message: IProtocolMessage; try { - const text = typeof event.data === 'string' ? event.data : ''; - const message = JSON.parse(text) as IProtocolMessage; - this._onMessage.fire(message); - } catch { - // Malformed message - drop. + message = JSON.parse(text) as IProtocolMessage; + } catch (err) { + this._malformedFrames++; + if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { + const preview = text.length > 80 ? text.slice(0, 80) + '…' : text; + console.warn( + `[WebSocketClientTransport] Malformed frame #${this._malformedFrames} (len=${text.length}): ${preview}`, + err instanceof Error ? err.message : String(err) + ); + } + if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { + console.warn( + `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.` + ); + this._ws?.close(4002, 'malformed-frames'); + } + return; } + this._onMessage.fire(message); }); ws.addEventListener('close', () => { diff --git a/src/vs/platform/agentHost/common/transportConstants.ts b/src/vs/platform/agentHost/common/transportConstants.ts new file mode 100644 index 0000000000000..1da5a8c7bfaf6 --- /dev/null +++ b/src/vs/platform/agentHost/common/transportConstants.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared constants for agent-host protocol transports. Kept in a common + * module so browser, electron-browser, and sessions-layer transports all + * apply the same malformed-frame policy without duplicating values. + */ + +/** + * Force-close the transport once more than this many malformed inbound + * frames have been observed. A handful of bad frames can be tolerated + * (e.g. a proxy momentarily corrupts a message), but a sustained stream + * almost always indicates a protocol mismatch or a broken relay, and is + * best surfaced as a hard disconnect so the reconnect loop can take over. + */ +export const MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD = 10; + +/** Cap warn-level logs per connection for malformed frames to avoid spam. */ +export const MALFORMED_FRAMES_LOG_CAP = 5; diff --git a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts index 6a6341b72ee35..bc9b5948a04ad 100644 --- a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts +++ b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts @@ -8,6 +8,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; import type { IProtocolTransport } from '../common/state/sessionTransport.js'; import type { ITunnelAgentHostMainService, ITunnelRelayMessage } from '../common/tunnelAgentHost.js'; +import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; /** * A protocol transport that relays messages through the shared process @@ -24,6 +25,8 @@ export class TunnelRelayTransport extends Disposable implements IProtocolTranspo private readonly _onClose = this._register(new Emitter()); readonly onClose = this._onClose.event; + private _malformedFrames = 0; + constructor( private readonly _connectionId: string, private readonly _tunnelService: ITunnelAgentHostMainService, @@ -32,14 +35,28 @@ export class TunnelRelayTransport extends Disposable implements IProtocolTranspo // Listen for relay messages from the shared process this._register(this._tunnelService.onDidRelayMessage((msg: ITunnelRelayMessage) => { - if (msg.connectionId === this._connectionId) { - try { - const parsed = JSON.parse(msg.data) as IProtocolMessage; - this._onMessage.fire(parsed); - } catch { - // Malformed message — drop + if (msg.connectionId !== this._connectionId) { + return; + } + let parsed: IProtocolMessage; + try { + parsed = JSON.parse(msg.data) as IProtocolMessage; + } catch (err) { + this._malformedFrames++; + if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { + const preview = msg.data.length > 80 ? msg.data.slice(0, 80) + '…' : msg.data; + console.warn( + `[TunnelRelayTransport] Malformed frame #${this._malformedFrames} (len=${msg.data.length}): ${preview}`, + err instanceof Error ? err.message : String(err) + ); + } + if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { + console.warn('[TunnelRelayTransport] Malformed frame threshold exceeded; closing relay.'); + this._tunnelService.disconnect(this._connectionId).catch(() => { /* best effort */ }); } + return; } + this._onMessage.fire(parsed); })); // Listen for relay close diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 66c0b60fd2151..bde08bb9ade6c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -129,7 +129,7 @@ function prependAnnouncementToFirstAssistantMessage( * Agent provider backed by the Copilot SDK {@link CopilotClient}. */ export class CopilotAgent extends Disposable implements IAgent { - readonly id = 'copilot' as const; + readonly id = 'copilotcli' as const; private static readonly _BRANCH_COMPLETION_LIMIT = 25; private readonly _onDidSessionProgress = this._register(new Emitter()); @@ -178,7 +178,7 @@ export class CopilotAgent extends Disposable implements IAgent { getDescriptor(): IAgentDescriptor { return { - provider: 'copilot', + provider: 'copilotcli', displayName: 'Copilot CLI', description: 'Copilot SDK agent running in a dedicated process', }; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 8c3d7563ca735..182198d3ab11c 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -386,19 +386,25 @@ export class ProtocolServerHandler extends Disposable { }, listSessions: async () => { const sessions = await this._agentService.listSessions(); - const items = sessions.map(s => ({ - resource: s.session.toString(), - provider: AgentSession.provider(s.session) ?? 'copilot', - title: s.summary ?? 'Session', - status: s.status ?? SessionStatus.Idle, - createdAt: s.startTime, - modifiedAt: s.modifiedTime, - ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), - model: s.model, - workingDirectory: s.workingDirectory?.toString(), - isRead: s.isRead, - isDone: s.isDone, - })); + const items = sessions.map(s => { + const provider = AgentSession.provider(s.session); + if (!provider) { + throw new Error(`Agent session URI has no provider scheme: ${s.session.toString()}`); + } + return { + resource: s.session.toString(), + provider, + title: s.summary ?? 'Session', + status: s.status ?? SessionStatus.Idle, + createdAt: s.startTime, + modifiedAt: s.modifiedTime, + ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), + model: s.model, + workingDirectory: s.workingDirectory?.toString(), + isRead: s.isRead, + isDone: s.isDone, + }; + }); return { items }; }, resolveSessionConfig: async (_client, params) => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 4c9a9a1e1aad6..72578eed5d793 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -219,7 +219,7 @@ function createTestAgent(disposables: Pick, options?: { } function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService): CopilotAgentSession { - const sessionUri = AgentSession.uri('copilot', 'test-session-1'); + const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); const shellManager = instantiationService.createInstance(ShellManager, sessionUri, undefined); const wrapperFactory: SessionWrapperFactory = async () => new CopilotSessionWrapper(new MockCopilotSession() as unknown as CopilotSession); return (agent as unknown as { @@ -336,7 +336,7 @@ suite('CopilotAgent', () => { test('listSessions only returns sessions with a database', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); - const ownedSession = AgentSession.uri('copilot', 'owned'); + const ownedSession = AgentSession.uri('copilotcli', 'owned'); const ownedDb = sessionDataService.openDatabase(ownedSession); ownedDb.dispose(); @@ -353,7 +353,7 @@ suite('CopilotAgent', () => { test('listSessions reads stored metadata from sessions with a database', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); - const legacySession = AgentSession.uri('copilot', 'legacy'); + const legacySession = AgentSession.uri('copilotcli', 'legacy'); const legacyDb = sessionDataService.openDatabase(legacySession); await legacyDb.object.setMetadata('copilot.workingDirectory', URI.file('/workspace').toString()); legacyDb.dispose(); @@ -414,7 +414,7 @@ suite('CopilotAgent', () => { const customizations: ICustomizationRef[] = [{ uri: 'file:///plugin-a', displayName: 'Plugin A' }]; await assert.rejects( agent.createSession({ - session: AgentSession.uri('copilot', 'test-session'), + session: AgentSession.uri('copilotcli', 'test-session'), workingDirectory: URI.file('/workspace'), activeClient: { clientId: 'client-1', @@ -443,7 +443,7 @@ suite('CopilotAgent', () => { await assert.rejects( agent.createSession({ - session: AgentSession.uri('copilot', 'test-session-2'), + session: AgentSession.uri('copilotcli', 'test-session-2'), workingDirectory: URI.file('/workspace'), }), (err: Error) => /sentinel/.test(err.message), @@ -478,7 +478,7 @@ suite('CopilotAgent', () => { test('emits announcement live as a delta on first sendMessage and persists it for restore via getSessionMessages', async () => { const sessionId = 'wt-session'; - const session = AgentSession.uri('copilot', sessionId); + const session = AgentSession.uri('copilotcli', sessionId); const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); @@ -554,7 +554,7 @@ suite('CopilotAgent', () => { test('does not announce or persist branch metadata when isolation is not worktree', async () => { const sessionId = 'no-wt-session'; - const session = AgentSession.uri('copilot', sessionId); + const session = AgentSession.uri('copilotcli', sessionId); const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); diff --git a/src/vs/platform/encryption/electron-main/encryptionMainService.ts b/src/vs/platform/encryption/electron-main/encryptionMainService.ts index 1b4b1e05f28c7..93987884ac9f4 100644 --- a/src/vs/platform/encryption/electron-main/encryptionMainService.ts +++ b/src/vs/platform/encryption/electron-main/encryptionMainService.ts @@ -4,15 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import { safeStorage as safeStorageElectron, app } from 'electron'; -import { isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { join } from '../../../base/common/path.js'; +import { INodeProcess, isMacintosh, isWindows } from '../../../base/common/platform.js'; import { KnownStorageProvider, IEncryptionMainService, PasswordStoreCLIOption } from '../common/encryptionService.js'; +import { getDefaultUserDataPath } from '../../environment/node/userDataPath.js'; import { ILogService } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; // These APIs are currently only supported in our custom build of electron so // we need to guard against them not being available. interface ISafeStorageAdditionalAPIs { setUsePlainTextEncryption(usePlainText: boolean): void; getSelectedStorageBackend(): string; + initWithExistingKey(localStatePath: string): boolean; } const safeStorage: typeof import('electron').safeStorage & Partial = safeStorageElectron; @@ -21,7 +25,8 @@ export class EncryptionMainService implements IEncryptionMainService { _serviceBrand: undefined; constructor( - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IProductService private readonly productService: IProductService ) { // if this commandLine switch is set, the user has opted in to using basic text encryption if (app.commandLine.getSwitchValue('password-store') === PasswordStoreCLIOption.basic) { @@ -29,6 +34,40 @@ export class EncryptionMainService implements IEncryptionMainService { safeStorage.setUsePlainTextEncryption?.(true); this.logService.trace('[EncryptionMainService] set usePlainTextEncryption to true'); } + + if (isWindows && (process as INodeProcess).isEmbeddedApp) { + this.initializeWithHostEncryptionKey(); + } + } + + private initializeWithHostEncryptionKey(): void { + if (!safeStorage.initWithExistingKey) { + this.logService.trace('[EncryptionMainService] initWithExistingKey API is not available'); + return; + } + + // embedded.win32SiblingExeBasename is derived from the host app's product.nameShort + // at build time, which is also the folder name used for the host's user data path. + const hostProductName = this.productService.embedded?.win32SiblingExeBasename; + if (!hostProductName) { + this.logService.warn('[EncryptionMainService] Host product name not available in embedded product config'); + return; + } + + const hostUserDataPath = getDefaultUserDataPath(hostProductName); + const localStatePath = join(hostUserDataPath, 'Local State'); + + this.logService.info(`[EncryptionMainService] Initializing encryption with host app key from: ${localStatePath}`); + try { + const result = safeStorage.initWithExistingKey(localStatePath); + if (result) { + this.logService.info('[EncryptionMainService] Successfully initialized encryption with host app key'); + } else { + this.logService.error('[EncryptionMainService] Failed to initialize encryption with host app key'); + } + } catch (e) { + this.logService.error('[EncryptionMainService] Error initializing encryption with host app key:', e); + } } async encrypt(value: string): Promise { diff --git a/src/vs/platform/environment/node/userDataPath.ts b/src/vs/platform/environment/node/userDataPath.ts index 3082ef58ad064..be9d71b601bdd 100644 --- a/src/vs/platform/environment/node/userDataPath.ts +++ b/src/vs/platform/environment/node/userDataPath.ts @@ -60,7 +60,7 @@ function doGetUserDataPath(cliArgs: NativeParsedArgs, productName: string): stri } // 2. Support global VSCODE_APPDATA environment variable - let appDataPath = process.env['VSCODE_APPDATA']; + const appDataPath = process.env['VSCODE_APPDATA']; if (appDataPath) { return join(appDataPath, productName); } @@ -74,6 +74,16 @@ function doGetUserDataPath(cliArgs: NativeParsedArgs, productName: string): stri return cliPath; } + return getDefaultUserDataPath(productName); +} + +/** + * Returns the default user data path for a given product name using + * the platform-specific application data directory. + */ +export function getDefaultUserDataPath(productName: string): string { + let appDataPath: string | undefined; + // 4. Otherwise check per platform switch (process.platform) { case 'win32': diff --git a/src/vs/sessions/SESSIONS_PROVIDER.md b/src/vs/sessions/SESSIONS_PROVIDER.md index dcc1f640213d7..1063819e6c0c5 100644 --- a/src/vs/sessions/SESSIONS_PROVIDER.md +++ b/src/vs/sessions/SESSIONS_PROVIDER.md @@ -79,6 +79,8 @@ The common session interface exposed by all providers. It is a self-contained fa - `repositories: ISessionRepository[]` — One or more repositories - `requiresWorkspaceTrust: boolean` — Whether workspace trust is required to operate +**Workspace label and session grouping:** The sessions list groups sessions by `workspace.label`. Sessions whose workspace is `undefined` or whose label is empty appear under "Unknown". For the `CopilotChatSessionsProvider`, the workspace label is derived from session metadata via `getRepositoryName()` (in `agentSessionsViewer.ts`), which checks these metadata keys in priority order: `remoteAgentHost`, `owner`+`name`, `repositoryNwo`, `repository`, `repositoryUrl`, `repositoryPath`, `worktreePath`, `workingDirectoryPath`, then `badge`. Extension-side `ChatSessionContentProvider` implementations must set `item.metadata` with at least `workingDirectoryPath` (for local sessions) so that sessions are grouped correctly. + **`ISessionRepository`** — A repository within a workspace: - `uri: URI` — Source repository URI (`file://` or `github-remote-file://`) - `workingDirectory: URI | undefined` — Worktree or checkout path diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts index 75d449d5fd9f5..ed404d3272de3 100644 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -114,8 +114,18 @@ export function logChangesViewReviewCommentAdded(telemetryService: ITelemetrySer // --- Tunnel agent host connect --- -export type TunnelConnectErrorCategory = 'relayConnectionFailed' | 'auth' | 'network' | 'other'; -export type TunnelConnectFailureReason = 'hostOffline' | 'maxAttemptsReached'; +export type TunnelConnectErrorCategory = + | 'relayConnectionFailed' + | 'auth' + | 'authExpired' + | 'network' + | 'other'; + +export type TunnelConnectFailureReason = + | 'hostOffline' + | 'maxAttemptsReached' + | 'auth' + | 'authExpired'; type TunnelConnectAttemptEvent = { isReconnect: boolean; @@ -132,7 +142,7 @@ type TunnelConnectAttemptClassification = { attempt: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Attempt number within the current connect session (1-based).' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Duration of this individual attempt in milliseconds.' }; success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether this individual attempt succeeded.' }; - errorCategory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Category of error when the attempt failed (relayConnectionFailed, auth, network, other); empty on success.' }; + errorCategory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Category of error when the attempt failed (relayConnectionFailed, auth, authExpired, network, other); empty on success.' }; }; export function logTunnelConnectAttempt(telemetryService: ITelemetryService, data: { isReconnect: boolean; attempt: number; durationMs: number; success: boolean; errorCategory?: TunnelConnectErrorCategory }): void { @@ -160,7 +170,7 @@ type TunnelConnectResolvedClassification = { totalAttempts: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of attempts made before resolution.' }; totalDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total elapsed time from session start to resolution in milliseconds.' }; success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the connect session ultimately succeeded.' }; - failureReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Reason the session terminated without connecting (hostOffline, maxAttemptsReached); empty on success.' }; + failureReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Reason the session terminated without connecting (hostOffline, maxAttemptsReached, auth, authExpired); empty on success.' }; }; export function logTunnelConnectResolved(telemetryService: ITelemetryService, data: { isReconnect: boolean; totalAttempts: number; totalDurationMs: number; success: boolean; failureReason?: TunnelConnectFailureReason }): void { diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index a8bbc82367d44..cb8b0bdc4e46a 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -16,7 +16,7 @@ import { localize } from '../../../../nls.js'; import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { IResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; -import type { IFileEdit, IModelSelection, ISessionConfigPropertySchema, ISessionState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import type { IFileEdit, IModelSelection, IRootState, ISessionConfigPropertySchema, ISessionState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; @@ -52,8 +52,6 @@ export interface IAgentHostAdapterOptions { readonly mapDiffUri?: (uri: URI) => URI; } -const DEFAULT_AGENT_PROVIDER = 'copilot'; - /** * Adapts an {@link IAgentSessionMetadata} into an {@link ISession} for the * sessions UI. A single concrete class for both local and remote agent @@ -96,7 +94,11 @@ export class AgentHostSessionAdapter implements ISession { private readonly _options: IAgentHostAdapterOptions, ) { const rawId = AgentSession.id(metadata.session); - this.agentProvider = AgentSession.provider(metadata.session) ?? DEFAULT_AGENT_PROVIDER; + const agentProvider = AgentSession.provider(metadata.session); + if (!agentProvider) { + throw new Error(`Agent session URI has no provider scheme: ${metadata.session.toString()}`); + } + this.agentProvider = agentProvider; this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` }); this.sessionId = `${providerId}:${this.resource.toString()}`; this.providerId = providerId; @@ -300,14 +302,61 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Provider-level authentication-pending observable used to derive `loading` for sessions. */ protected abstract get authenticationPending(): IObservable; - /** Build an adapter for the given metadata. Subclass picks resource scheme, logical type, and adapter options. */ - protected abstract createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter; + /** + * Subclass-specific portion of the adapter options. Base fills in + * the bits that are uniform across hosts (`icon`, `loading`, + * `mapDiffUri`) from the corresponding hooks. + */ + protected abstract _adapterOptions(): Pick; + + /** Build an adapter for the given metadata. */ + protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { + const provider = AgentSession.provider(meta.session); + if (!provider) { + throw new Error(`Agent session URI has no provider scheme: ${meta.session.toString()}`); + } + return new AgentHostSessionAdapter(meta, this.id, this.resourceSchemeForProvider(provider), provider, { + icon: this.icon, + loading: this.authenticationPending, + mapDiffUri: this._diffUriMapper(), + ...this._adapterOptions(), + }); + } + + /** + * Computes the URI resource scheme used to route session URIs to this + * provider's content provider for a given agent provider name. Local + * uses `agent-host-${provider}`; remote uses a per-connection scheme. + * + * The resource scheme is host-specific and exists purely for content + * provider routing. The logical {@link ISession.sessionType} is the + * agent provider name itself, so the same agent (e.g. `copilotcli`) + * appears under one shared session type across hosts. + */ + protected abstract resourceSchemeForProvider(provider: string): string; + + /** Format the human-readable label for a session type entry (e.g. `Copilot [Local]`). */ + protected abstract _formatSessionTypeLabel(agentLabel: string): string; - /** Resolve a UI session-type id to the URI scheme used for new session resources. */ - protected abstract resourceSchemeForSessionType(sessionTypeId: string): string; + /** + * Reconcile {@link _sessionTypes} against the agents advertised by the + * host's root state, firing {@link onDidChangeSessionTypes} only if the + * id/label set actually changed. + */ + protected _syncSessionTypesFromRootState(rootState: IRootState): void { + const next = rootState.agents.map((agent): ISessionType => ({ + id: agent.provider, + label: this._formatSessionTypeLabel(agent.displayName?.trim() || agent.provider), + icon: this.icon, + })); - /** Reverse of {@link resourceSchemeForSessionType} for the agent provider name. */ - protected abstract agentProviderFromSessionType(sessionType: string): string; + const prev = this._sessionTypes; + if (prev.length === next.length && prev.every((t, i) => t.id === next[i].id && t.label === next[i].label)) { + return; + } + this._sessionTypes = next; + this._onDidChangeSessionTypes.fire(); + } abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace; @@ -395,7 +444,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement throw new Error('Workspace has no repository URI'); } - const resourceScheme = this.resourceSchemeForSessionType(sessionType.id); + const resourceScheme = this.resourceSchemeForProvider(sessionType.id); const resource = URI.from({ scheme: resourceScheme, path: `/untitled-${generateUuid()}` }); const status = observableValue(this, SessionStatus.Untitled); const title = observableValue(this, ''); @@ -444,7 +493,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._currentNewSessionStatus = status; this._currentNewSessionModelId = modelId; this._currentNewSessionLoading = loading; - const agentProvider = this.agentProviderFromSessionType(sessionType.id); + const agentProvider = sessionType.id; this._newSessionWorkspaces.set(session.sessionId, workspaceUri); this._newSessionAgentProviders.set(session.sessionId, agentProvider); this._newSessionConfigs.set(session.sessionId, { schema: { type: 'object', properties: {} }, values: {} }); @@ -806,7 +855,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _getAgentProviderForSession(sessionId: string): string { - return this._newSessionAgentProviders.get(sessionId) ?? DEFAULT_AGENT_PROVIDER; + const provider = this._newSessionAgentProviders.get(sessionId); + if (!provider) { + throw new Error(`No agent provider tracked for new session: ${sessionId}`); + } + return provider; } // -- Lazy session-state subscription seeding ----------------------------- diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts index 05215dd4e21cd..b1bbd469e9fe5 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -10,36 +10,26 @@ import { basename } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { AgentSession, IAgentConnection, IAgentHostService, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; -import type { IRootState } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { IAgentConnection, IAgentHostService, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; +import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; -import { ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; +import { ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; const LOCAL_PROVIDER_ID = 'local-agent-host'; - -/** - * Derives the session type / URI scheme from an agent provider name. - * Must match the type string registered by AgentHostContribution - * (`agent-host-${agent.provider}`). - */ -function sessionTypeForProvider(provider: string): string { - return `agent-host-${provider}`; -} +const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; /** * Local-window sessions provider backed by the in-process * {@link IAgentHostService}. A thin subclass of * {@link BaseAgentHostSessionsProvider} that supplies the local-only * variation: a built-in connection that is always present, session-type - * synchronization from the local agent host's `rootState`, a - * contributions-based session-type fallback for the pre-hydration window, - * and a local file-picker browse action. + * synchronization from the local agent host's `rootState`, and a local + * file-picker browse action. */ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvider { @@ -50,12 +40,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide private readonly _localLabel = localize('localAgentHostSessionTypeLocation', "Local"); private readonly _localDescription = new MarkdownString(this._localLabel); - private _hasRootStateSnapshot = false; - - override get sessionTypes(): readonly ISessionType[] { - const rootStateValue = this._agentHostService.rootState.value; - return this._hasRootStateSnapshot || rootStateValue !== undefined ? this._sessionTypes : this._getSessionTypesFromContributions(); - } constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @@ -79,16 +63,11 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide this._attachConnectionListeners(this._agentHostService, this._store); const rootStateValue = this._agentHostService.rootState.value; - if (rootStateValue !== undefined) { - this._hasRootStateSnapshot = true; - } if (rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); } this._register(this._agentHostService.rootState.onDidChange(rootState => { - const didHydrate = !this._hasRootStateSnapshot; - this._hasRootStateSnapshot = true; - this._syncSessionTypesFromRootState(rootState, didHydrate); + this._syncSessionTypesFromRootState(rootState); })); // Eagerly populate the session cache once authentication has settled. @@ -115,57 +94,29 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide protected get authenticationPending(): IObservable { return this._agentHostService.authenticationPending; } - protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { - const agentProvider = AgentSession.provider(meta.session) ?? 'copilot'; - const sessionType = sessionTypeForProvider(agentProvider); - return new AgentHostSessionAdapter(meta, this.id, sessionType, sessionType, { - icon: this.icon, - description: this._localDescription, - loading: this._agentHostService.authenticationPending, - buildWorkspace: (project, workingDirectory) => LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectory), - }); - } - - protected resourceSchemeForSessionType(sessionTypeId: string): string { - return sessionTypeId; + /** + * Local resource scheme: `agent-host-${provider}`. Must match the type + * string registered by AgentHostContribution. Distinct from the logical + * {@link ISession.sessionType}, which is the agent provider name itself + * (e.g. `copilotcli`) so the same agent shares one session type across + * local and remote hosts. + */ + protected resourceSchemeForProvider(provider: string): string { + return `${LOCAL_RESOURCE_SCHEME_PREFIX}${provider}`; } - protected agentProviderFromSessionType(sessionType: string): string { - const prefix = 'agent-host-'; - return sessionType.startsWith(prefix) ? sessionType.substring(prefix.length) : sessionType; - } - - // -- Session type sync from root state ----------------------------------- - - private _syncSessionTypesFromRootState(rootState: IRootState, forceFire = false): void { - const next = rootState.agents.map((agent): ISessionType => ({ - id: sessionTypeForProvider(agent.provider), - label: this._formatSessionTypeLabel(agent.displayName || agent.provider), - icon: Codicon.vm, - })); - - const prev = this._sessionTypes; - if (!forceFire && prev.length === next.length && prev.every((t, i) => t.id === next[i].id && t.label === next[i].label)) { - return; - } - this._sessionTypes = next; - this._onDidChangeSessionTypes.fire(); + protected _adapterOptions() { + return { + description: this._localDescription, + buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined) => + LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectory), + }; } - private _formatSessionTypeLabel(agentLabel: string): string { + protected _formatSessionTypeLabel(agentLabel: string): string { return localize('localAgentHostSessionType', "{0} [{1}]", agentLabel, this._localLabel); } - private _getSessionTypesFromContributions(): ISessionType[] { - return this._chatSessionsService.getAllChatSessionContributions() - .filter(contribution => contribution.type.startsWith('agent-host-')) - .map((contribution): ISessionType => ({ - id: contribution.type, - label: this._formatSessionTypeLabel(contribution.displayName), - icon: Codicon.vm, - })); - } - // -- Workspaces ---------------------------------------------------------- static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined): ISessionWorkspace | undefined { diff --git a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 3d9ab80ad55a7..0aac0143df763 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -40,7 +40,7 @@ class MockAgentHostService extends mock() { private readonly _onDidNotification = new Emitter(); override readonly onDidNotification = this._onDidNotification.event; private readonly _onDidRootStateChange = new Emitter(); - private _rootStateValue: IRootState | Error | undefined = { agents: [{ provider: 'copilot', displayName: 'Copilot', description: '', models: [] } as IAgentInfo] }; + private _rootStateValue: IRootState | Error | undefined = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo] }; override readonly rootState: IAgentSubscription; override readonly clientId = 'test-local-client'; @@ -178,7 +178,7 @@ class MockAgentHostService extends mock() { function createSession(id: string, opts?: { provider?: string; summary?: string; model?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata { return { - session: AgentSession.uri(opts?.provider ?? 'copilot', id), + session: AgentSession.uri(opts?.provider ?? 'copilotcli', id), startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, @@ -189,7 +189,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; } function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [ - { type: 'agent-host-copilot', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined }, + { type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined }, ], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean }): LocalAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); @@ -230,7 +230,7 @@ async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, se } function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; model?: string; modelConfig?: Record; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { - const provider = opts?.provider ?? 'copilot'; + const provider = opts?.provider ?? 'copilotcli'; const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ type: NotificationType.SessionAdded, @@ -248,7 +248,7 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: }); } -function fireSessionRemoved(agentHost: MockAgentHostService, rawId: string, provider = 'copilot'): void { +function fireSessionRemoved(agentHost: MockAgentHostService, rawId: string, provider = 'copilotcli'): void { const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ type: NotificationType.SessionRemoved, @@ -279,72 +279,53 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(provider.id, 'local-agent-host'); assert.ok(provider.label.length > 0); assert.strictEqual(provider.sessionTypes.length, 1); - assert.strictEqual(provider.sessionTypes[0].id, 'agent-host-copilot'); + // The logical sessionType id is the agent provider name itself, so + // the same agent (e.g. `copilotcli`) shares one session type across + // local and remote hosts and the standalone Copilot CLI provider. + assert.strictEqual(provider.sessionTypes[0].id, 'copilotcli'); assert.strictEqual(provider.sessionTypes[0].label, 'Copilot [Local]'); }); test('session types update when the local host advertises additional agents', () => { const provider = createProvider(disposables, agentHost); assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ - { id: 'agent-host-copilot', label: 'Copilot [Local]' }, + { id: 'copilotcli', label: 'Copilot [Local]' }, ]); let changes = 0; disposables.add(provider.onDidChangeSessionTypes!(() => changes++)); agentHost.setAgents([ - { provider: 'copilot', displayName: 'Copilot', description: '', models: [] } as IAgentInfo, + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo, { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as IAgentInfo, ]); assert.strictEqual(changes, 1); + // The logical sessionType id is the agent provider name itself. assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ - { id: 'agent-host-copilot', label: 'Copilot [Local]' }, - { id: 'agent-host-openai', label: 'OpenAI [Local]' }, + { id: 'copilotcli', label: 'Copilot [Local]' }, + { id: 'openai', label: 'OpenAI [Local]' }, ]); }); - test('falls back to registered agent-host contributions before rootState is hydrated', () => { + test('reports no session types before rootState hydrates', () => { agentHost.clearRootState(); - const provider = createProvider(disposables, agentHost, [ - { type: 'agent-host-openai', name: 'openai', displayName: 'OpenAI', description: 'test', icon: undefined }, - ]); - - assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ - { id: 'agent-host-openai', label: 'OpenAI [Local]' }, - ]); - }); - - test('does not use contribution fallback when rootState advertises no agents', () => { - agentHost.setAgents([]); - const provider = createProvider(disposables, agentHost, [ - { type: 'agent-host-openai', name: 'openai', displayName: 'OpenAI', description: 'test', icon: undefined }, - ]); + const provider = createProvider(disposables, agentHost); assert.deepStrictEqual(provider.sessionTypes, []); }); - test('fires session type change when rootState hydrates from fallback to no agents', () => { - agentHost.clearRootState(); - const provider = createProvider(disposables, agentHost, [ - { type: 'agent-host-openai', name: 'openai', displayName: 'OpenAI', description: 'test', icon: undefined }, - ]); - assert.strictEqual(provider.sessionTypes.length, 1); - - let changes = 0; - disposables.add(provider.onDidChangeSessionTypes!(() => changes++)); + test('reports no session types when rootState advertises no agents', () => { agentHost.setAgents([]); + const provider = createProvider(disposables, agentHost); - assert.strictEqual(changes, 1); assert.deepStrictEqual(provider.sessionTypes, []); }); - test('does not use contribution fallback after rootState resolves to an error', () => { + test('reports no session types after rootState resolves to an error', () => { agentHost.clearRootState(); - const provider = createProvider(disposables, agentHost, [ - { type: 'agent-host-openai', name: 'openai', displayName: 'OpenAI', description: 'test', icon: undefined }, - ]); - assert.strictEqual(provider.sessionTypes.length, 1); + const provider = createProvider(disposables, agentHost); + assert.deepStrictEqual(provider.sessionTypes, []); agentHost.setRootStateError(); @@ -531,7 +512,7 @@ suite('LocalAgentHostSessionsProvider', () => { await timeout(0); const session = provider.getSessions().find(s => s.title.get() === 'Model Session'); - assert.strictEqual(session?.modelId.get(), 'agent-host-copilot:claude-sonnet-4.5'); + assert.strictEqual(session?.modelId.get(), 'agent-host-copilotcli:claude-sonnet-4.5'); })); test('uses model metadata from session added notification', () => { @@ -539,7 +520,7 @@ suite('LocalAgentHostSessionsProvider', () => { fireSessionAdded(agentHost, 'notif-model', { title: 'Notif Model Session', model: 'gpt-5' }); const session = provider.getSessions().find(s => s.title.get() === 'Notif Model Session'); - assert.strictEqual(session?.modelId.get(), 'agent-host-copilot:gpt-5'); + assert.strictEqual(session?.modelId.get(), 'agent-host-copilotcli:gpt-5'); }); test('setModel updates existing session model and dispatches raw model', () => { @@ -549,12 +530,12 @@ suite('LocalAgentHostSessionsProvider', () => { const session = provider.getSessions().find(s => s.title.get() === 'Set Model Session'); assert.ok(session); - provider.setModel(session!.sessionId, 'agent-host-copilot:new-model'); + provider.setModel(session!.sessionId, 'agent-host-copilotcli:new-model'); - assert.strictEqual(session!.modelId.get(), 'agent-host-copilot:new-model'); + assert.strictEqual(session!.modelId.get(), 'agent-host-copilotcli:new-model'); assert.deepStrictEqual(agentHost.dispatchedActions.at(-1)?.action, { type: ActionType.SessionModelChanged, - session: AgentSession.uri('copilot', 'set-model').toString(), + session: AgentSession.uri('copilotcli', 'set-model').toString(), model: { id: 'new-model' }, }); }); @@ -566,11 +547,11 @@ suite('LocalAgentHostSessionsProvider', () => { const session = provider.getSessions().find(s => s.title.get() === 'Set Model Config Session'); assert.ok(session); - provider.setModel(session!.sessionId, 'agent-host-copilot:configured-model'); + provider.setModel(session!.sessionId, 'agent-host-copilotcli:configured-model'); assert.deepStrictEqual(agentHost.dispatchedActions.at(-1)?.action, { type: ActionType.SessionModelChanged, - session: AgentSession.uri('copilot', 'set-model-config').toString(), + session: AgentSession.uri('copilotcli', 'set-model-config').toString(), model: { id: 'configured-model', config: { thinkingLevel: 'high' } }, }); }); @@ -630,7 +611,7 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(agentHost.disposedSessions.length, 1); const disposedUri = agentHost.disposedSessions[0]; - assert.strictEqual(AgentSession.provider(disposedUri), 'copilot'); + assert.strictEqual(AgentSession.provider(disposedUri), 'copilotcli'); assert.strictEqual(AgentSession.id(disposedUri), 'del-sess'); assert.strictEqual(provider.getSessions().find(s => s.title.get() === 'To Delete'), undefined); }); @@ -652,7 +633,7 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(dispatched.action.type, ActionType.SessionTitleChanged); assert.strictEqual((dispatched.action as { title: string }).title, 'New Title'); const actionSession = (dispatched.action as { session: string }).session; - assert.strictEqual(AgentSession.provider(actionSession), 'copilot'); + assert.strictEqual(AgentSession.provider(actionSession), 'copilotcli'); assert.strictEqual(AgentSession.id(actionSession), 'rename-sess'); assert.strictEqual(dispatched.clientId, 'test-local-client'); }); @@ -692,7 +673,7 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.fireAction({ action: { type: ActionType.SessionTitleChanged, - session: AgentSession.uri('copilot', 'echo-sess').toString(), + session: AgentSession.uri('copilotcli', 'echo-sess').toString(), title: 'Server Title', }, serverSeq: 1, @@ -717,14 +698,14 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.fireAction({ action: { type: ActionType.SessionModelChanged, - session: AgentSession.uri('copilot', 'model-change').toString(), + session: AgentSession.uri('copilotcli', 'model-change').toString(), model: { id: 'new-model' } satisfies IModelSelection, }, serverSeq: 1, origin: undefined, } as IActionEnvelope); - assert.strictEqual(target!.modelId.get(), 'agent-host-copilot:new-model'); + assert.strictEqual(target!.modelId.get(), 'agent-host-copilotcli:new-model'); assert.strictEqual(changes.length, 1); assert.strictEqual(changes[0].changed.length, 1); }); @@ -747,7 +728,7 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.fireAction({ action: { type: 'session/turnComplete', - session: AgentSession.uri('copilot', 'turn-sess').toString(), + session: AgentSession.uri('copilotcli', 'turn-sess').toString(), }, serverSeq: 1, origin: undefined, @@ -905,12 +886,12 @@ suite('LocalAgentHostSessionsProvider', () => { values: { autoApprove: 'default', isolation: 'worktree' }, }; const fakeState: ISessionState = { - summary: { resource: AgentSession.uri('copilot', 'seed-1').toString(), provider: 'copilot', title: 'Seeded Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, + summary: { resource: AgentSession.uri('copilotcli', 'seed-1').toString(), provider: 'copilotcli', title: 'Seeded Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, lifecycle: SessionLifecycle.Ready, turns: [], config, }; - agentHost.setSessionState('seed-1', 'copilot', fakeState); + agentHost.setSessionState('seed-1', 'copilotcli', fakeState); await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default'); @@ -934,7 +915,7 @@ suite('LocalAgentHostSessionsProvider', () => { // Trigger lazy subscription provider.getSessionConfig(session!.sessionId); - const sessionUriStr = AgentSession.uri('copilot', 'seed-2').toString(); + const sessionUriStr = AgentSession.uri('copilotcli', 'seed-2').toString(); assert.strictEqual(agentHost.sessionSubscribeCounts.get(sessionUriStr), 1); assert.strictEqual(agentHost.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0); diff --git a/src/vs/sessions/contrib/chat/browser/agentHostModelPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts similarity index 81% rename from src/vs/sessions/contrib/chat/browser/agentHostModelPicker.ts rename to src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts index 57785b2cb9c1d..38c6a51d4782d 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostModelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts @@ -3,24 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun, observableValue } from '../../../../base/common/observable.js'; -import * as nls from '../../../../nls.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { type ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { type IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; -import { ModelPickerActionItem, type IModelPickerDelegate } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; -import { ActiveSessionProviderIdContext } from '../../../common/contextkeys.js'; -import { type ISession } from '../../../services/sessions/common/session.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { Menus } from '../../../browser/menus.js'; +import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { autorun, observableValue } from '../../../../../base/common/observable.js'; +import * as nls from '../../../../../nls.js'; +import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; +import { type ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { ModelPickerActionItem, type IModelPickerDelegate } from '../../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; +import { ActiveSessionProviderIdContext } from '../../../../common/contextkeys.js'; +import { type ISession } from '../../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { Menus } from '../../../../browser/menus.js'; const IsActiveSessionAgentHost = ContextKeyExpr.or( ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host'), diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerActionItem.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerActionItem.ts new file mode 100644 index 0000000000000..930693cd99d16 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerActionItem.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { autorun } from '../../../../../base/common/observable.js'; +import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { PermissionPickerActionItem } from '../../../../../workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.js'; +import { AgentHostPermissionPickerDelegate } from './agentHostPermissionPickerDelegate.js'; + +/** + * Agent host wrapper around the workbench {@link PermissionPickerActionItem} + * for use in the running chat widget's secondary toolbar + * (`MenuId.ChatInputSecondary`). Owns its + * {@link AgentHostPermissionPickerDelegate} and reactively hides itself when + * the active session's `autoApprove` schema doesn't match the well-known + * shape. + */ +export class AgentHostPermissionPickerActionItem extends PermissionPickerActionItem { + + private readonly _delegate: AgentHostPermissionPickerDelegate; + + constructor( + action: MenuItemAction, + pickerOptions: IChatInputPickerOptions, + @IInstantiationService instantiationService: IInstantiationService, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IKeybindingService keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, + @ITelemetryService telemetryService: ITelemetryService, + @IConfigurationService configurationService: IConfigurationService, + @IDialogService dialogService: IDialogService, + @IOpenerService openerService: IOpenerService, + @IStorageService storageService: IStorageService, + ) { + const delegate = instantiationService.createInstance(AgentHostPermissionPickerDelegate); + super( + action, + delegate, + pickerOptions, + actionWidgetService, + keybindingService, + contextKeyService, + telemetryService, + configurationService, + dialogService, + openerService, + storageService, + ); + this._delegate = this._register(delegate); + + // The base widget's label is rendered on demand via `refresh()`. Keep it + // in sync with the delegate's level observable. + this._register(autorun(reader => { + delegate.currentPermissionLevel.read(reader); + this.refresh(); + })); + } + + override render(container: HTMLElement): void { + super.render(container); + // The active session can change while this view item is alive (the + // `IActionViewItemService` factory only runs once per render), so gate + // visibility reactively rather than at construction time. + this._register(autorun(reader => { + const visible = this._delegate.isApplicable.read(reader); + if (this.element) { + this.element.style.display = visible ? '' : 'none'; + } + })); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts new file mode 100644 index 0000000000000..75d660a448ecc --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; +import { derived, IObservable, IReader, observableSignal } from '../../../../../base/common/observable.js'; +import { ISessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { IPermissionPickerDelegate } from '../../../../contrib/copilotChatSessions/browser/permissionPicker.js'; +import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; + +/** + * The well-known session-config property name for tool auto-approval. The + * Agent Host Protocol's session-config schema is intentionally generic — only + * this property *name* (and the enum values below) is a convention shared + * across implementations that want to opt into VS Code's unified + * permission-picker UI. Agents that don't advertise this exact shape fall + * back to the generic per-property picker. + */ +export const AUTO_APPROVE_PROPERTY = 'autoApprove'; + +/** + * The set of enum values the unified permission picker understands for the + * `autoApprove` property. Mirrors `ChatPermissionLevel` in + * `vs/workbench/contrib/chat/common/constants.ts`. + * + * `autopilot` is optional (an agent may choose not to advertise it). + * `default` is required as the baseline level. + */ +const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet = new Set(['default', 'autoApprove', 'autopilot']); +const REQUIRED_AUTO_APPROVE_VALUE = 'default'; + +/** + * Returns `true` when an `autoApprove` session-config property uses the + * shape the unified permission picker expects: a string enum that is a + * subset of `default | autoApprove | autopilot` and contains at least + * `default`. + * + * Callers use this to decide whether to render the unified + * {@link PermissionPicker} (with its built-in warning dialogs, autopilot + * gating, and policy enforcement) or fall back to the generic per-property + * picker. + */ +export function isWellKnownAutoApproveSchema(schema: ISessionConfigPropertySchema): boolean { + if (schema.type !== 'string' || !Array.isArray(schema.enum) || schema.enum.length === 0) { + return false; + } + if (!schema.enum.includes(REQUIRED_AUTO_APPROVE_VALUE)) { + return false; + } + return schema.enum.every(value => KNOWN_AUTO_APPROVE_VALUES.has(value)); +} + +/** + * {@link IPermissionPickerDelegate} backed by the active session's AHP + * `autoApprove` config property. + * + * - `currentPermissionLevel` derives from the active session's + * `provider.getSessionConfig(...).values.autoApprove`, recomputed when the + * active session changes or when any agent-host provider fires + * `onDidChangeSessionConfig`. + * - `setPermissionLevel(level)` calls `provider.setSessionConfigValue(sessionId, + * 'autoApprove', level)` for the active session's provider. + * - `isApplicable` is `true` only when the active session's `autoApprove` + * schema matches the well-known shape, so the picker hides itself for + * non-conforming agents (which fall back to the generic per-property + * picker) and when no agent-host session is active. + */ +export class AgentHostPermissionPickerDelegate extends Disposable implements IPermissionPickerDelegate { + + /** Fires every time any agent-host provider's session config changes. */ + private readonly _configChangedSignal = observableSignal('agentHostPermissionPicker.configChanged'); + private readonly _providerSubscriptions = this._register(new DisposableMap()); + + readonly currentPermissionLevel: IObservable; + readonly isApplicable: IObservable; + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + this._watchProviders(this._sessionsProvidersService.getProviders()); + this._register(this._sessionsProvidersService.onDidChangeProviders(e => { + for (const provider of e.removed) { + this._providerSubscriptions.deleteAndDispose(provider.id); + } + this._watchProviders(e.added); + this._configChangedSignal.trigger(undefined); + })); + + this.currentPermissionLevel = derived(this, reader => this._readLevel(reader)); + this.isApplicable = derived(this, reader => this._readIsWellKnown(reader)); + } + + setPermissionLevel(level: ChatPermissionLevel): void { + const session = this._sessionsManagementService.activeSession.get(); + if (!session) { + return; + } + const provider = this._getProvider(session.providerId); + if (!provider) { + return; + } + provider.setSessionConfigValue(session.sessionId, AUTO_APPROVE_PROPERTY, level) + .catch(() => { /* best-effort */ }); + } + + private _readLevel(reader: IReader): ChatPermissionLevel { + this._configChangedSignal.read(reader); + const session = this._sessionsManagementService.activeSession.read(reader); + if (!session) { + return ChatPermissionLevel.Default; + } + const provider = this._getProvider(session.providerId); + if (!provider) { + return ChatPermissionLevel.Default; + } + const value = provider.getSessionConfig(session.sessionId)?.values[AUTO_APPROVE_PROPERTY]; + return isChatPermissionLevel(value) ? value : ChatPermissionLevel.Default; + } + + private _readIsWellKnown(reader: IReader): boolean { + this._configChangedSignal.read(reader); + const session = this._sessionsManagementService.activeSession.read(reader); + if (!session) { + return false; + } + const provider = this._getProvider(session.providerId); + if (!provider) { + return false; + } + const schema = provider.getSessionConfig(session.sessionId)?.schema.properties[AUTO_APPROVE_PROPERTY]; + return !!schema && isWellKnownAutoApproveSchema(schema); + } + + private _getProvider(providerId: string): IAgentHostSessionsProvider | undefined { + const provider = this._sessionsProvidersService.getProvider(providerId); + return provider && isAgentHostProvider(provider) ? provider : undefined; + } + + private _watchProviders(providers: readonly ISessionsProvider[]): void { + for (const provider of providers) { + if (!isAgentHostProvider(provider) || this._providerSubscriptions.has(provider.id)) { + continue; + } + this._providerSubscriptions.set(provider.id, provider.onDidChangeSessionConfig(() => { + this._configChangedSignal.trigger(undefined); + })); + } + } +} diff --git a/src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts similarity index 53% rename from src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts rename to src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index f9528deea19f1..d1de554901d65 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -3,37 +3,41 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import './media/agentHostSessionConfigPicker.css'; -import * as dom from '../../../../base/browser/dom.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; -import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { Delayer } from '../../../../base/common/async.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; -import Severity from '../../../../base/common/severity.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { AgentHostSessionConfigBranchNameHintKey } from '../../../../platform/agentHost/common/agentService.js'; -import type { ISessionConfigPropertySchema, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; -import { ChatContextKeyExprs } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { Menus } from '../../../browser/menus.js'; -import { ActiveSessionProviderIdContext } from '../../../common/contextkeys.js'; -import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import type { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; -import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import '../media/agentHostSessionConfigPicker.css'; +import * as dom from '../../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Delayer } from '../../../../../base/common/async.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, observableValue } from '../../../../../base/common/observable.js'; +import Severity from '../../../../../base/common/severity.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { IActionViewItemService, type IActionViewItemFactory } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { AgentHostSessionConfigBranchNameHintKey } from '../../../../../platform/agentHost/common/agentService.js'; +import type { ISessionConfigPropertySchema, ISessionConfigValueItem } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatContextKeyExprs } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; +import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { Menus } from '../../../../browser/menus.js'; +import { ActiveSessionProviderIdContext } from '../../../../common/contextkeys.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; +import { PermissionPicker } from '../../../copilotChatSessions/browser/permissionPicker.js'; +import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPickerActionItem.js'; +import { AgentHostPermissionPickerDelegate, AUTO_APPROVE_PROPERTY, isWellKnownAutoApproveSchema } from './agentHostPermissionPickerDelegate.js'; const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, /^agenthost-/); const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host'); @@ -121,12 +125,6 @@ function renderPickerTrigger(slot: HTMLElement, disabled: boolean, disposables: return trigger; } -/** - * Special-cased property name for auto-approve session config. - * Used to apply confirmation dialogs and policy enforcement. - */ -const AUTO_APPROVE_PROPERTY = 'autoApprove'; - // Track whether auto-approve warnings have been shown this VS Code session const shownAutoApproveWarnings = new Set(); @@ -285,7 +283,15 @@ class AgentHostSessionConfigPicker extends Disposable { } for (const [property, schema] of Object.entries(resolvedConfig.schema.properties)) { - if (property === AgentHostSessionConfigBranchNameHintKey || property === AUTO_APPROVE_PROPERTY) { + if (property === AgentHostSessionConfigBranchNameHintKey) { + continue; + } + // When the autoApprove property uses the well-known schema, the + // workbench `PermissionPickerActionItem` (registered separately for + // `Menus.NewSessionControl`) handles it — skip it here to avoid + // double-rendering. Non-conforming schemas still fall through to + // the generic per-property picker below. + if (property === AUTO_APPROVE_PROPERTY && isWellKnownAutoApproveSchema(schema)) { continue; } const value = resolvedConfig.values[property] ?? schema.default; @@ -404,8 +410,11 @@ interface IConfigPickerWidget extends IDisposable { } class PickerActionViewItem extends BaseActionViewItem { - constructor(private readonly _picker: IConfigPickerWidget) { + constructor(private readonly _picker: IConfigPickerWidget, disposable?: IDisposable) { super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }); + if (disposable) { + this._register(disposable); + } } override render(container: HTMLElement): void { @@ -423,25 +432,58 @@ class AgentHostSessionConfigPickerContribution extends Disposable implements IWo constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); this._register(actionViewItemService.register( Menus.NewSessionRepositoryConfig, 'sessions.agentHost.sessionConfigPicker', - () => new PickerActionViewItem(instantiationService.createInstance(AgentHostSessionConfigPicker)), + () => new PickerActionViewItem(this._instantiationService.createInstance(AgentHostSessionConfigPicker)), )); this._register(actionViewItemService.register( Menus.NewSessionControl, NEW_SESSION_APPROVE_PICKER_ID, - () => new PickerActionViewItem(instantiationService.createInstance(AgentHostNewSessionApprovePicker)), + () => this._createNewSessionPermissionPicker(), )); this._register(actionViewItemService.register( MenuId.ChatInputSecondary, RUNNING_SESSION_CONFIG_PICKER_ID, - () => new PickerActionViewItem(instantiationService.createInstance(AgentHostRunningSessionConfigPicker)), + this._createRunningSessionPermissionPickerFactory(), )); } + + /** + * On the new-chat page (left of the toolbar), use the sessions + * {@link PermissionPicker} so the styling matches the surrounding sessions + * pickers (font size, padding, icon size). + */ + private _createNewSessionPermissionPicker(): PickerActionViewItem { + const delegate = this._instantiationService.createInstance(AgentHostPermissionPickerDelegate); + const picker = this._instantiationService.createInstance(PermissionPicker, delegate); + return new PickerActionViewItem(picker, delegate); + } + + /** + * Inside a running chat widget (`ChatInputSecondary`), use the workbench + * {@link PermissionPickerActionItem} so it matches the rest of the + * chat-input secondary toolbar (which is what the extension-host CLI + * already uses). + */ + private _createRunningSessionPermissionPickerFactory(): IActionViewItemFactory { + return (action, _options, instantiationService) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + const pickerOptions: IChatInputPickerOptions = { + hideChevrons: observableValue('hideChevrons', false), + }; + return instantiationService.createInstance( + AgentHostPermissionPickerActionItem, + action, + pickerOptions, + ); + }; + } } // ---- New session auto-approve picker (left side, NewSessionControl) ---- @@ -466,160 +508,6 @@ registerAction2(class extends Action2 { override async run(): Promise { } }); -/** - * Renders the auto-approve picker in the new session welcome view (left side). - * Only renders the autoApprove property from the session config. - */ -class AgentHostNewSessionApprovePicker extends Disposable { - private readonly _renderDisposables = this._register(new DisposableStore()); - private readonly _providerListeners = this._register(new DisposableMap()); - private _container: HTMLElement | undefined; - - constructor( - @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IDialogService private readonly _dialogService: IDialogService, - @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, - @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, - ) { - super(); - - this._register(autorun(reader => { - const session = this._sessionsManagementService.activeSession.read(reader); - if (session) { - session.loading.read(reader); - } - this._render(); - })); - - this._register(this._sessionsProvidersService.onDidChangeProviders(e => { - for (const provider of e.removed) { - this._providerListeners.deleteAndDispose(provider.id); - } - this._watchProviders(e.added); - this._render(); - })); - this._watchProviders(this._sessionsProvidersService.getProviders()); - } - - private _watchProviders(providers: readonly ISessionsProvider[]): void { - for (const provider of providers) { - if (!isAgentHostProvider(provider) || this._providerListeners.has(provider.id)) { - continue; - } - this._providerListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._render())); - } - } - - render(container: HTMLElement): void { - this._container = dom.append(container, dom.$('.sessions-chat-agent-host-config')); - this._render(); - } - - private _render(): void { - if (!this._container) { - return; - } - - this._renderDisposables.clear(); - dom.clearNode(this._container); - - const session = this._sessionsManagementService.activeSession.get(); - const rawProvider = session ? this._sessionsProvidersService.getProvider(session.providerId) : undefined; - const provider = rawProvider && isAgentHostProvider(rawProvider) ? rawProvider : undefined; - const config = session && provider?.getSessionConfig(session.sessionId); - // `getSessionConfig` may return undefined for sessions whose config - // hasn't been seeded yet (e.g. opened from list, no in-window create). - // The provider lazily acquires a session-state subscription and will fire - // `onDidChangeSessionConfig` once the snapshot arrives, re-rendering us. - if (!session || !provider || !config) { - return; - } - - const schema = config.schema.properties[AUTO_APPROVE_PROPERTY]; - if (!schema) { - return; - } - - const value = config.values[AUTO_APPROVE_PROPERTY] ?? schema.default; - const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot')); - const trigger = renderPickerTrigger(slot, false, this._renderDisposables, () => this._showPicker(provider, session.sessionId, schema, trigger)); - this._renderTrigger(trigger, schema, value); - } - - private _renderTrigger(trigger: HTMLElement, schema: ISessionConfigPropertySchema, value: string | undefined): void { - dom.clearNode(trigger); - const icon = getConfigIcon(AUTO_APPROVE_PROPERTY, value); - if (icon) { - dom.append(trigger, renderIcon(icon)); - } - const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); - const label = this._getLabel(schema, value); - labelSpan.textContent = label; - trigger.setAttribute('aria-label', localize('agentHostNewSessionApprove.triggerAria', "{0}: {1}", schema.title, label)); - dom.append(trigger, renderIcon(Codicon.chevronDown)); - applyAutoApproveTriggerStyles(trigger, AUTO_APPROVE_PROPERTY, value); - } - - private async _showPicker(provider: IAgentHostSessionsProvider, sessionId: string, schema: ISessionConfigPropertySchema, trigger: HTMLElement): Promise { - if (this._actionWidgetService.isVisible) { - return; - } - - const rawItems = (schema.enum ?? []).map((value, index) => ({ - value, - label: schema.enumLabels?.[index] ?? value, - description: schema.enumDescriptions?.[index], - })); - - const { items, policyRestricted } = applyAutoApproveFiltering(rawItems, AUTO_APPROVE_PROPERTY, this._configurationService); - - if (items.length === 0) { - return; - } - - const currentValue = provider.getSessionConfig(sessionId)?.values[AUTO_APPROVE_PROPERTY]; - const actionItems = toActionItems(AUTO_APPROVE_PROPERTY, items, currentValue, policyRestricted); - - const delegate: IActionListDelegate = { - onSelect: async item => { - this._actionWidgetService.hide(); - - if (item.value === 'autoApprove' || item.value === 'autopilot') { - const confirmed = await confirmAutoApproveLevel(item.value, this._dialogService); - if (!confirmed) { - return; - } - } - - provider.setSessionConfigValue(sessionId, AUTO_APPROVE_PROPERTY, item.value).catch(() => { /* best-effort */ }); - }, - onHide: () => trigger.focus(), - }; - - this._actionWidgetService.show( - `agentHostNewSessionConfig.${AUTO_APPROVE_PROPERTY}`, - false, - actionItems, - delegate, - trigger, - undefined, - [], - { - getAriaLabel: item => item.label ?? '', - getWidgetAriaLabel: () => localize('agentHostNewSessionApprove.ariaLabel', "{0} Picker", schema.title), - }, - ); - } - - private _getLabel(schema: ISessionConfigPropertySchema, value: string | undefined): string { - if (typeof value === 'string') { - const index = schema.enum?.indexOf(value) ?? -1; - return index >= 0 ? schema.enumLabels?.[index] ?? value : value; - } - return schema.title; - } -} // ---- Running session config picker (ChatInputSecondary) ---- @@ -643,160 +531,5 @@ registerAction2(class extends Action2 { override async run(): Promise { } }); -/** - * Renders a single picker trigger in the titlebar for the auto-approve session - * config property during a running agent host session. - */ -class AgentHostRunningSessionConfigPicker extends Disposable { - private readonly _renderDisposables = this._register(new DisposableStore()); - private readonly _providerListeners = this._register(new DisposableMap()); - private _container: HTMLElement | undefined; - - constructor( - @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IDialogService private readonly _dialogService: IDialogService, - @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, - @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, - ) { - super(); - - this._register(autorun(reader => { - const session = this._sessionsManagementService.activeSession.read(reader); - if (session) { - session.loading.read(reader); - } - this._render(); - })); - - this._register(this._sessionsProvidersService.onDidChangeProviders(e => { - for (const provider of e.removed) { - this._providerListeners.deleteAndDispose(provider.id); - } - this._watchProviders(e.added); - this._render(); - })); - this._watchProviders(this._sessionsProvidersService.getProviders()); - } - - private _watchProviders(providers: readonly ISessionsProvider[]): void { - for (const provider of providers) { - if (!isAgentHostProvider(provider) || this._providerListeners.has(provider.id)) { - continue; - } - this._providerListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._render())); - } - } - - render(container: HTMLElement): void { - this._container = dom.append(container, dom.$('.sessions-chat-agent-host-config')); - this._render(); - } - - private _render(): void { - if (!this._container) { - return; - } - - this._renderDisposables.clear(); - dom.clearNode(this._container); - - const session = this._sessionsManagementService.activeSession.get(); - const rawProvider = session ? this._sessionsProvidersService.getProvider(session.providerId) : undefined; - const provider = rawProvider && isAgentHostProvider(rawProvider) ? rawProvider : undefined; - const config = session && provider?.getSessionConfig(session.sessionId); - // See note in `AgentHostNewSessionApprovePicker._render`: `config` may be - // undefined until the lazy session-state subscription hydrates and the - // provider fires `onDidChangeSessionConfig`. - if (!session || !provider || !config) { - return; - } - - // Only render session-mutable properties (i.e., autoApprove) - for (const [property, schema] of Object.entries(config.schema.properties)) { - if (!schema.sessionMutable) { - continue; - } - const value = config.values[property] ?? schema.default; - const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot')); - const trigger = renderPickerTrigger(slot, false, this._renderDisposables, () => this._showPicker(provider, session.sessionId, property, schema, trigger)); - this._renderTrigger(trigger, schema, value, property); - } - } - - private _renderTrigger(trigger: HTMLElement, schema: ISessionConfigPropertySchema, value: string | undefined, property: string): void { - dom.clearNode(trigger); - const icon = getConfigIcon(property, value); - if (icon) { - dom.append(trigger, renderIcon(icon)); - } - const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); - const label = this._getLabel(schema, value); - labelSpan.textContent = label; - trigger.setAttribute('aria-label', localize('agentHostRunningSessionConfig.triggerAria', "{0}: {1}", schema.title, label)); - dom.append(trigger, renderIcon(Codicon.chevronDown)); - applyAutoApproveTriggerStyles(trigger, property, value); - } - - private async _showPicker(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: ISessionConfigPropertySchema, trigger: HTMLElement): Promise { - if (this._actionWidgetService.isVisible) { - return; - } - - const rawItems = (schema.enum ?? []).map((value, index) => ({ - value, - label: schema.enumLabels?.[index] ?? value, - description: schema.enumDescriptions?.[index], - })); - - const { items, policyRestricted } = applyAutoApproveFiltering(rawItems, property, this._configurationService); - const isAutoApproveProperty = property === AUTO_APPROVE_PROPERTY; - - if (items.length === 0) { - return; - } - - const currentValue = provider.getSessionConfig(sessionId)?.values[property]; - const actionItems = toActionItems(property, items, currentValue, policyRestricted); - - const delegate: IActionListDelegate = { - onSelect: async item => { - this._actionWidgetService.hide(); - - if (isAutoApproveProperty && (item.value === 'autoApprove' || item.value === 'autopilot')) { - const confirmed = await confirmAutoApproveLevel(item.value, this._dialogService); - if (!confirmed) { - return; - } - } - - provider.setSessionConfigValue(sessionId, property, item.value).catch(() => { /* best-effort */ }); - }, - onHide: () => trigger.focus(), - }; - - this._actionWidgetService.show( - `agentHostRunningSessionConfig.${property}`, - false, - actionItems, - delegate, - trigger, - undefined, - [], - { - getAriaLabel: item => item.label ?? '', - getWidgetAriaLabel: () => localize('agentHostRunningSessionConfig.ariaLabel', "{0} Picker", schema.title), - }, - ); - } - - private _getLabel(schema: ISessionConfigPropertySchema, value: string | undefined): string { - if (typeof value === 'string') { - const index = schema.enum?.indexOf(value) ?? -1; - return index >= 0 ? schema.enumLabels?.[index] ?? value : value; - } - return schema.title; - } -} registerWorkbenchContribution2(AgentHostSessionConfigPickerContribution.ID, AgentHostSessionConfigPickerContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css b/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css index e3a2b3b1528eb..359c012db4cb1 100644 --- a/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css +++ b/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css @@ -12,19 +12,3 @@ .sessions-chat-agent-host-config:empty { display: none; } - -.sessions-chat-agent-host-config .action-label.warning { - color: var(--vscode-problemsWarningIcon-foreground); -} - -.sessions-chat-agent-host-config .action-label.warning .codicon { - color: var(--vscode-problemsWarningIcon-foreground) !important; -} - -.sessions-chat-agent-host-config .action-label.info { - color: var(--vscode-problemsInfoIcon-foreground); -} - -.sessions-chat-agent-host-config .action-label.info .codicon { - color: var(--vscode-problemsInfoIcon-foreground) !important; -} diff --git a/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts new file mode 100644 index 0000000000000..a014ef88e119f --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts @@ -0,0 +1,212 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IResolveSessionConfigResult, ISessionConfigPropertySchema } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema } from '../../../browser/agentHost/agentHostPermissionPickerDelegate.js'; +import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../../services/sessions/common/sessionsManagement.js'; + +const PROVIDER_ID = 'local-agent-host'; +const SESSION_ID = 'local-agent-host:s1'; + +function makeWellKnownConfig(value: string | undefined): IResolveSessionConfigResult { + return { + schema: { + type: 'object', + properties: { + autoApprove: { + title: 'Auto Approve', + description: '', + type: 'string', + enum: ['default', 'autoApprove', 'autopilot'], + sessionMutable: true, + }, + }, + }, + values: value === undefined ? {} : { autoApprove: value }, + } as IResolveSessionConfigResult; +} + +class FakeProvider implements Pick { + readonly id: string = PROVIDER_ID; + private readonly _onDidChange = new Emitter(); + readonly onDidChangeSessionConfig: Event = this._onDidChange.event; + + config: IResolveSessionConfigResult | undefined; + readonly setCalls: Array<[string, string, string]> = []; + + getSessionConfig(_sessionId: string): IResolveSessionConfigResult | undefined { + return this.config; + } + async setSessionConfigValue(sessionId: string, property: string, value: string): Promise { + this.setCalls.push([sessionId, property, value]); + } + fireChange(sessionId: string = SESSION_ID): void { + this._onDidChange.fire(sessionId); + } + dispose(): void { + this._onDidChange.dispose(); + } +} + +interface ITestRig { + readonly delegate: AgentHostPermissionPickerDelegate; + readonly provider: FakeProvider; + readonly activeSessionObs: ReturnType>; +} + +function setup(store: Pick, activeSession: IActiveSession | undefined, configValue?: string): ITestRig { + const provider = new FakeProvider(); + store.add({ dispose: () => provider.dispose() }); + if (configValue !== undefined) { + provider.config = makeWellKnownConfig(configValue); + } + const onDidChangeProviders = store.add(new Emitter()); + const sessionsProvidersService = new (class extends mock() { + override readonly onDidChangeProviders = onDidChangeProviders.event; + override getProviders(): ISessionsProvider[] { return [provider as unknown as ISessionsProvider]; } + override getProvider(id: string): T | undefined { + return id === provider.id ? (provider as unknown as T) : undefined; + } + })(); + const activeSessionObs = observableValue('activeSession', activeSession); + const sessionsManagementService = new (class extends mock() { + override readonly activeSession = activeSessionObs; + })(); + + const insta = store.add(new TestInstantiationService()); + insta.set(ISessionsManagementService, sessionsManagementService); + insta.set(ISessionsProvidersService, sessionsProvidersService); + + const delegate = store.add(insta.createInstance(AgentHostPermissionPickerDelegate)); + return { delegate, provider, activeSessionObs }; +} + +function makeActiveSession(): IActiveSession { + return { providerId: PROVIDER_ID, sessionId: SESSION_ID } as IActiveSession; +} + +suite('AgentHostPermissionPickerDelegate', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns Default when there is no active session', () => { + const { delegate } = setup(store, undefined); + + assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Default); + }); + + test('returns Default when the active session has no config seeded yet', () => { + const { delegate } = setup(store, makeActiveSession()); + + assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Default); + }); + + test('reflects the active session\'s autoApprove value and updates on provider change', () => { + const { delegate, provider } = setup(store, makeActiveSession(), 'autoApprove'); + + assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.AutoApprove); + + provider.config = makeWellKnownConfig('autopilot'); + provider.fireChange(); + assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Autopilot); + + provider.config = makeWellKnownConfig('default'); + provider.fireChange(); + assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Default); + }); + + test('falls back to Default when the stored value is unrecognized', () => { + const { delegate } = setup(store, makeActiveSession(), 'something-else'); + + assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Default); + }); + + test('setPermissionLevel writes through to the active session\'s provider', () => { + const { delegate, provider } = setup(store, makeActiveSession(), 'default'); + + delegate.setPermissionLevel(ChatPermissionLevel.AutoApprove); + delegate.setPermissionLevel(ChatPermissionLevel.Autopilot); + + assert.deepStrictEqual(provider.setCalls, [ + [SESSION_ID, 'autoApprove', 'autoApprove'], + [SESSION_ID, 'autoApprove', 'autopilot'], + ]); + }); + + test('setPermissionLevel is a no-op when there is no active session', () => { + const { delegate, provider } = setup(store, undefined); + + delegate.setPermissionLevel(ChatPermissionLevel.AutoApprove); + + assert.deepStrictEqual(provider.setCalls, []); + }); + + test('isApplicable reacts to active session and config changes', () => { + const { delegate, provider, activeSessionObs } = setup(store, undefined); + + // No active session → false + assert.strictEqual(delegate.isApplicable.get(), false); + + // Active session, no config seeded → false + activeSessionObs.set(makeActiveSession(), undefined); + assert.strictEqual(delegate.isApplicable.get(), false); + + // Active session with well-known schema → true + provider.config = makeWellKnownConfig('default'); + provider.fireChange(); + assert.strictEqual(delegate.isApplicable.get(), true); + + // Active session cleared → false (covers the 'back to new chat view' regression) + activeSessionObs.set(undefined, undefined); + assert.strictEqual(delegate.isApplicable.get(), false); + }); +}); + +suite('isWellKnownAutoApproveSchema', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function schema(overrides: Partial = {}): ISessionConfigPropertySchema { + return { + title: 'Auto Approve', + description: 'desc', + type: 'string', + enum: ['default', 'autoApprove', 'autopilot'], + ...overrides, + } as ISessionConfigPropertySchema; + } + + test('matches the canonical three-value enum', () => { + assert.strictEqual(isWellKnownAutoApproveSchema(schema()), true); + }); + + test('matches a subset that still contains "default"', () => { + assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: ['default', 'autoApprove'] })), true); + assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: ['default'] })), true); + }); + + test('rejects schemas missing the required "default" value', () => { + assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: ['autoApprove', 'autopilot'] })), false); + }); + + test('rejects schemas with unknown enum values', () => { + assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: ['default', 'custom'] })), false); + }); + + test('rejects non-string types and missing/empty enums', () => { + assert.strictEqual(isWellKnownAutoApproveSchema(schema({ type: 'number' as 'string' })), false); + assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: undefined })), false); + assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: [] })), false); + }); +}); diff --git a/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index b94223b688f6a..f62bc4c2fc10e 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -2,7 +2,7 @@ **File:** `src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts` -The default sessions provider, registered with ID `'default-copilot'`. Wraps the existing agent session infrastructure into the extensible provider model. Supports two session types: **Copilot CLI** (local) and **Copilot Cloud** (remote). +The default sessions provider, registered with ID `'default-copilot'`. Wraps the existing agent session infrastructure into the extensible provider model. Supports three session types: **Copilot CLI** (local), **Copilot Cloud** (remote), and **Claude** (local, gated by `sessions.chatSessions.claude.enabled`). ## Registration @@ -28,7 +28,7 @@ class DefaultSessionsProviderContribution extends Disposable { | `id` | `'default-copilot'` | | `label` | `'Copilot Chat'` | | `icon` | `Codicon.copilot` | -| `sessionTypes` | `[CopilotCLISessionType, CopilotCloudSessionType]` | +| `sessionTypes` | `[CopilotCLISessionType, CopilotCloudSessionType]` (+ `ClaudeCodeSessionType` when enabled) | ## Browse Actions @@ -53,6 +53,12 @@ When `createNewSession(workspace)` is called, the provider creates one of two co - Provides `getModelOptionGroup()`, `getOtherOptionGroups()` for UI to render provider-specific pickers - Watches context key changes to dynamically show/hide option groups +**`ClaudeCodeNewSession`** — For Claude agent sessions (local `file://` workspaces): +- Implements `ISession` with simplified configuration (Claude manages its own worktrees and branches) +- No-ops for `setIsolationMode()` and `setBranch()` +- `setOption()` writes to `selectedOptions` map; options are propagated to `IChatSessionsService` during `_sendFirstChat()` via `updateSessionOptions()` +- Gated by the `sessions.chatSessions.claude.enabled` setting (default: `false`) + ## `AgentSessionAdapter` — Wrapping Existing Sessions Adapts an existing `IAgentSession` from the chat layer into the `ISession` facade: @@ -72,7 +78,7 @@ The provider maintains a `Map` cache keyed by resou ## Send Flow -1. Validate the session is a current new session (`CopilotCLISession` or `RemoteNewSession`) +1. Validate the session is a current new session (`CopilotCLISession`, `RemoteNewSession`, or `ClaudeCodeNewSession`) 2. For the first chat, call `_sendFirstChat()`: a. Resolve mode, permission level, and send options from session configuration b. Open the chat widget via `IChatWidgetService.openSession()` @@ -84,3 +90,70 @@ The provider maintains a `Map` cache keyed by resou 3. For subsequent chats (if `capabilities.supportsMultipleChats` enabled on the session), call `_sendSubsequentChat()` 4. Wrap the new agent session as `AgentSessionAdapter` and return it 5. Clear the current new session reference + +## New-Session Picker Contribution Model + +**File:** `src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts` + +The welcome/new-session view (`NewChatInputWidget`) renders three toolbar menus for configuration pickers. Each picker requires a three-part registration: + +1. **Menu action** — `registerAction2()` with a `when` clause gating it to the correct session type +2. **Action view item** — `actionViewItemService.register()` to provide a custom widget instead of a button +3. **Picker widget** — A `Disposable` class with a `render(container)` method, wrapped in `PickerActionViewItem` + +### Toolbar Menus + +| Menu | Purpose | Examples | +|------|---------|----------| +| `Menus.NewSessionConfig` | Session configuration (mode, model) | `ModePicker`, `CloudModelPicker`, local model picker | +| `Menus.NewSessionControl` | Session controls (permissions) | `PermissionPicker`, `ClaudePermissionModePicker` | +| `Menus.NewSessionRepositoryConfig` | Repository configuration | `IsolationPicker`, `BranchPicker` | + +### Context Key Gating + +Each picker action uses a `when` clause to show only for the correct session type: + +| Expression | Matches | +|------------|---------| +| `IsActiveSessionCopilotChatCLI` | Copilot CLI sessions | +| `IsActiveSessionCopilotChatCloud` | Copilot Cloud sessions | +| `IsActiveSessionCopilotChatClaudeCode` | Claude sessions | + +These are composed from `ActiveSessionTypeContext` (the session type ID) and `ActiveSessionProviderIdContext` (the provider ID). + +### Adding a New Picker + +```typescript +// 1. Register the menu action with a when clause +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.myPicker', + title: localize2('myPicker', "My Picker"), + f1: false, + menu: [{ + id: Menus.NewSessionControl, // or NewSessionConfig, NewSessionRepositoryConfig + group: 'navigation', + order: 1, + when: IsActiveSessionCopilotChatCLI, // gate to session type + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + +// 2. Register the action view item (in CopilotPickerActionViewItemContribution) +this._register(actionViewItemService.register( + Menus.NewSessionControl, 'sessions.defaultCopilot.myPicker', + () => { + const picker = instantiationService.createInstance(MyPicker); + return new PickerActionViewItem(picker); + }, +)); +``` + +### Current Limitations + +The picker model is currently **hardcoded per session type**. Each session type that needs pickers must register its own actions and widgets with appropriate `when` clauses. For example, the Copilot CLI permission picker (`PermissionPicker`) and the Claude permission mode picker (`ClaudePermissionModePicker`) are separate, hardcoded widgets even though they serve a similar purpose. + +Ideally, pickers would be **generic and contributable** — a session type would declare its option groups (as the Claude extension already does via `IChatSessionsService.setOptionGroupsForSessionType()`), and the welcome view would dynamically render pickers from those groups without needing per-type widget classes. The active-session chat widget (`chatInputPart.ts`) already has this generic infrastructure via `createChatSessionPickerWidgets()`, but the welcome view does not yet use it. Until the welcome view adopts this pattern, new session types must follow the hardcoded approach above. diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts new file mode 100644 index 0000000000000..9f56fca68a8ab --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../platform/actionWidget/browser/actionList.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; + +const PERMISSION_MODE_OPTION_ID = 'permissionMode'; + +interface IClaudePermissionModeItem { + readonly id: string; + readonly label: string; + readonly description: string; + readonly icon: ThemeIcon; +} + +const permissionModes: IClaudePermissionModeItem[] = [ + { + id: 'default', + label: localize('claude.permissionMode.default', "Ask Before Edits"), + description: localize('claude.permissionMode.default.description', "Claude asks for approval before making changes"), + icon: Codicon.shield, + }, + { + id: 'acceptEdits', + label: localize('claude.permissionMode.acceptEdits', "Edit Automatically"), + description: localize('claude.permissionMode.acceptEdits.description', "Claude edits files without asking"), + icon: Codicon.edit, + }, + { + id: 'plan', + label: localize('claude.permissionMode.plan', "Plan Mode"), + description: localize('claude.permissionMode.plan.description', "Claude creates a plan before making changes"), + icon: Codicon.lightbulb, + }, +]; + +export class ClaudePermissionModePicker extends Disposable { + + private _currentModeId = 'acceptEdits'; + private _triggerElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + } + + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(trigger); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); + + return slot; + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const items: IActionListItem[] = permissionModes.map(mode => ({ + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: mode.icon }, + item: mode, + label: mode.label, + description: mode.description, + disabled: false, + })); + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + this._selectMode(item); + }, + onHide: () => { triggerElement.focus(); }, + }; + + const listOptions: IActionListOptions = { descriptionBelow: true, minWidth: 255 }; + this.actionWidgetService.show( + 'claudePermissionModePicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getWidgetAriaLabel: () => localize('claudePermissionModePicker.ariaLabel', "Permission Mode"), + }, + listOptions, + ); + } + + private _selectMode(mode: IClaudePermissionModeItem): void { + this._currentModeId = mode.id; + this._updateTriggerLabel(this._triggerElement); + + const session = this.sessionsManagementService.activeSession.get(); + if (!session) { + return; + } + const provider = this.sessionsProvidersService.getProvider(session.providerId); + if (provider instanceof CopilotChatSessionsProvider) { + provider.getSession(session.sessionId)?.setOption?.(PERMISSION_MODE_OPTION_ID, { id: mode.id, name: mode.label }); + } + } + + private _updateTriggerLabel(trigger: HTMLElement | undefined): void { + if (!trigger) { + return; + } + + dom.clearNode(trigger); + const currentMode = permissionModes.find(m => m.id === this._currentModeId) ?? permissionModes[1]; + + dom.append(trigger, renderIcon(currentMode.icon)); + const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = currentMode.label; + dom.append(trigger, renderIcon(Codicon.chevronDown)); + + trigger.ariaLabel = localize('claudePermissionModePicker.triggerAriaLabel', "Pick Permission Mode, {0}", currentMode.label); + } +} diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts index b5ce2480e972b..e93478001fe04 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -6,7 +6,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING, CLAUDE_CODE_ENABLED_SETTING } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; import '../../copilotChatSessions/browser/copilotChatSessionsActions.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -24,6 +24,12 @@ Registry.as(ConfigurationExtensions.Configuration).regis tags: ['preview'], description: localize('sessions.github.copilot.multiChatSessions', "Whether to enable multiple chats within a single session in the Copilot Chat sessions provider."), }, + [CLAUDE_CODE_ENABLED_SETTING]: { + type: 'boolean', + default: false, + tags: ['experimental', 'onExp'], + description: localize('sessions.chatSessions.claude.enabled', "NOTE: This is HIGHLY experimental and under active development! Whether to enable Claude agent sessions in the sessions provider."), + }, }, }); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index a4e7655a33fa3..8f907b8c64ecb 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -23,7 +23,7 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; -import { COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE, ISession } from '../../../services/sessions/common/session.js'; +import { CLAUDE_CODE_SESSION_TYPE, COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE, ISession } from '../../../services/sessions/common/session.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; @@ -31,13 +31,16 @@ import { IsolationPicker } from './isolationPicker.js'; import { BranchPicker } from './branchPicker.js'; import { ModePicker } from './modePicker.js'; import { CloudModelPicker } from './modelPicker.js'; -import { PermissionPicker } from './permissionPicker.js'; +import { CopilotPermissionPickerDelegate, PermissionPicker } from './permissionPicker.js'; +import { ClaudePermissionModePicker } from './claudePermissionModePicker.js'; const IsActiveSessionCopilotCLI = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLI_SESSION_TYPE); const IsActiveSessionCopilotCloud = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLOUD_SESSION_TYPE); const IsActiveCopilotChatSessionProvider = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, COPILOT_PROVIDER_ID); const IsActiveSessionCopilotChatCLI = ContextKeyExpr.and(IsActiveSessionCopilotCLI, IsActiveCopilotChatSessionProvider); const IsActiveSessionCopilotChatCloud = ContextKeyExpr.and(IsActiveSessionCopilotCloud, IsActiveCopilotChatSessionProvider); +const IsActiveSessionClaudeCode = ContextKeyExpr.equals(ActiveSessionTypeContext.key, CLAUDE_CODE_SESSION_TYPE); +const IsActiveSessionCopilotChatClaudeCode = ContextKeyExpr.and(IsActiveSessionClaudeCode, IsActiveCopilotChatSessionProvider); // -- Actions -- @@ -148,6 +151,23 @@ registerAction2(class extends Action2 { override async run(): Promise { /* handled by action view item */ } }); +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.claudePermissionModePicker', + title: localize2('claudePermissionModePicker', "Permission Mode"), + f1: false, + menu: [{ + id: Menus.NewSessionControl, + group: 'navigation', + order: 1, + when: IsActiveSessionCopilotChatClaudeCode, + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + // -- Helper -- /** @@ -274,7 +294,15 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor this._register(actionViewItemService.register( Menus.NewSessionControl, 'sessions.defaultCopilot.permissionPicker', () => { - const picker = instantiationService.createInstance(PermissionPicker); + const delegate = instantiationService.createInstance(CopilotPermissionPickerDelegate); + const picker = instantiationService.createInstance(PermissionPicker, delegate); + return new PickerActionViewItem(picker, delegate); + }, + )); + this._register(actionViewItemService.register( + Menus.NewSessionControl, 'sessions.defaultCopilot.claudePermissionModePicker', + () => { + const picker = instantiationService.createInstance(ClaudePermissionModePicker); return new PickerActionViewItem(picker); }, )); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 3db5f674e747a..c93edc1ff4999 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -22,7 +22,7 @@ import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatSessionStatus, IChatSessionFileChange, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ISessionType, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; +import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ClaudeCodeSessionType, ISessionType, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; @@ -99,6 +99,7 @@ export interface ICopilotChatSession { setModelId(modelId: string): void; setMode(chatMode: IChatMode | undefined): void; + setOption?(optionId: string, value: IChatSessionProviderOptionItem | string): void; readonly gitRepository?: IGitRepository; readonly branches: IObservable; @@ -112,6 +113,9 @@ export const COPILOT_PROVIDER_ID = 'default-copilot'; /** Setting key controlling whether the Copilot provider supports multiple chats per session. */ export const COPILOT_MULTI_CHAT_SETTING = 'sessions.github.copilot.multiChatSessions'; +/** Setting key controlling whether Claude agent sessions are available. */ +export const CLAUDE_CODE_ENABLED_SETTING = 'sessions.chatSessions.claude.enabled'; + const REPOSITORY_OPTION_ID = 'repository'; const PARENT_SESSION_OPTION_ID = 'parentSessionId'; @@ -119,7 +123,11 @@ const BRANCH_OPTION_ID = 'branch'; const ISOLATION_OPTION_ID = 'isolation'; const AGENT_OPTION_ID = 'agent'; -type NewSession = CopilotCLISession | RemoteNewSession; +type NewSession = CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession; + +function isNewSession(session: ICopilotChatSession): session is NewSession { + return session instanceof CopilotCLISession || session instanceof RemoteNewSession || session instanceof ClaudeCodeNewSession; +} /** * Local new session for Background agent sessions. @@ -664,6 +672,137 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession update(_session: IAgentSession): void { } } +/** + * New session for Claude agent sessions. + * Implements {@link ICopilotChatSession} (session facade) and provides + * pre-send configuration methods for the new-session flow. + * Simpler than {@link CopilotCLISession} because the Claude agent manages + * its own worktrees and branches at runtime. + */ +class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession { + + // -- ISessionData fields -- + + readonly id: string; + readonly providerId: string; + readonly sessionType: string; + readonly icon: ThemeIcon; + readonly createdAt: Date; + + private readonly _title = observableValue(this, ''); + readonly title: IObservable = this._title; + + private readonly _updatedAt = observableValue(this, new Date()); + readonly updatedAt: IObservable = this._updatedAt; + + private readonly _status = observableValue(this, SessionStatus.Untitled); + readonly status: IObservable = this._status; + + private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default); + readonly permissionLevel: IObservable = this._permissionLevel; + + private readonly _workspaceData = observableValue(this, undefined); + readonly workspace: IObservable = this._workspaceData; + + readonly changes: IObservable = observableValue(this, []); + + private readonly _modelIdObservable = observableValue(this, undefined); + readonly modelId: IObservable = this._modelIdObservable; + + private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); + readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable; + + readonly loading: IObservable = observableValue(this, false); + + private readonly _isArchived = observableValue(this, false); + readonly isArchived: IObservable = this._isArchived; + readonly isRead: IObservable = observableValue(this, true); + readonly description: IObservable = constObservable(undefined); + readonly lastTurnEnd: IObservable = constObservable(undefined); + readonly gitHubInfo: IObservable = constObservable(undefined); + readonly branch: IObservable = constObservable(undefined); + readonly isolationMode: IObservable = constObservable(undefined); + readonly branches: IObservable = constObservable([]); + readonly gitRepository?: IGitRepository | undefined; + + // -- New session configuration fields -- + + private _modelId: string | undefined; + private _mode: IChatMode | undefined; + + readonly target = AgentSessionProviders.Claude; + readonly selectedOptions = new Map(); + + get selectedModelId(): string | undefined { return this._modelId; } + get chatMode(): IChatMode | undefined { return this._mode; } + get query(): string | undefined { return undefined; } + get attachedContext(): IChatRequestVariableEntry[] | undefined { return undefined; } + get disabled(): boolean { return false; } + + constructor( + readonly resource: URI, + readonly sessionWorkspace: ISessionWorkspace, + providerId: string, + ) { + super(); + this.id = `${providerId}:${resource.toString()}`; + this.providerId = providerId; + this.sessionType = AgentSessionProviders.Claude; + this.icon = ClaudeCodeSessionType.icon; + this.createdAt = new Date(); + + this._workspaceData.set(sessionWorkspace, undefined); + } + + setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { + if (typeof value === 'string') { + this.selectedOptions.set(optionId, { id: value, name: value }); + } else { + this.selectedOptions.set(optionId, value); + } + } + + setPermissionLevel(level: ChatPermissionLevel): void { + this._permissionLevel.set(level, undefined); + } + + setIsolationMode(_mode: IsolationMode): void { + // No-op — Claude agent manages its own worktrees + } + + setBranch(_branch: string | undefined): void { + // No-op — Claude agent manages branches at runtime + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + this._modelIdObservable.set(modelId, undefined); + } + + setTitle(title: string): void { + this._title.set(title, undefined); + } + + setStatus(status: SessionStatus): void { + this._status.set(status, undefined); + } + + setArchived(archived: boolean): void { + this._isArchived.set(archived, undefined); + } + + setMode(mode: IChatMode | undefined): void { + this._mode = mode; + if (mode) { + this._modeObservable.set({ id: mode.id, kind: mode.kind }, undefined); + } else { + this._modeObservable.set(undefined, undefined); + } + } + + update(_session: IAgentSession): void { } +} + /** * Maps the existing {@link ChatSessionStatus} to the new {@link SessionStatus}. */ @@ -828,6 +967,8 @@ class AgentSessionAdapter implements ICopilotChatSession { return CopilotCLISessionType.icon; case AgentSessionProviders.Cloud: return CopilotCloudSessionType.icon; + case AgentSessionProviders.Claude: + return ClaudeCodeSessionType.icon; default: return session.icon; } @@ -1046,8 +1187,16 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly id = COPILOT_PROVIDER_ID; readonly label = localize('copilotChatSessionsProvider', "Copilot Chat"); readonly icon = Codicon.copilot; - readonly sessionTypes: readonly ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; - readonly onDidChangeSessionTypes = Event.None; + get sessionTypes(): readonly ISessionType[] { + const types: ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; + if (this._claudeEnabled) { + types.push(ClaudeCodeSessionType); + } + return types; + } + + private readonly _onDidChangeSessionTypes = this._register(new Emitter()); + readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; private readonly _onDidChangeSessions = this._register(new Emitter()); readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; @@ -1056,7 +1205,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; /** Cache of adapted sessions, keyed by resource URI string. */ - private readonly _sessionCache = new Map(); + private readonly _sessionCache = new Map(); /** Cache of ISession wrappers, keyed by session group ID. */ private readonly _sessionGroupCache = new Map(); @@ -1065,6 +1214,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private readonly _groupModel: SessionsGroupModel; private readonly _multiChatEnabled: boolean; + private _claudeEnabled: boolean; readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; @@ -1080,14 +1230,26 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IStorageService storageService: IStorageService, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly configurationService: IConfigurationService, @ILogService private readonly logService: ILogService, @IGitHubService private readonly gitHubService: IGitHubService, ) { super(); this._groupModel = this._register(new SessionsGroupModel(storageService)); - this._multiChatEnabled = configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? true; + this._multiChatEnabled = this.configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? true; + this._claudeEnabled = this.configurationService.getValue(CLAUDE_CODE_ENABLED_SETTING) ?? false; + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING)) { + const claudeEnabled = this.configurationService.getValue(CLAUDE_CODE_ENABLED_SETTING) ?? false; + if (this._claudeEnabled !== claudeEnabled) { + this._claudeEnabled = claudeEnabled; + this._onDidChangeSessionTypes.fire(); + this._refreshSessionCache(); + } + } + })); this.browseActions = [ { @@ -1116,7 +1278,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { return [CopilotCloudSessionType]; } - return [CopilotCLISessionType]; + const types: ISessionType[] = [CopilotCLISessionType]; + if (this._claudeEnabled) { + types.push(ClaudeCodeSessionType); + } + return types; } getSessions(): ISession[] { @@ -1171,8 +1337,15 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._chatToSession(session); } + if (sessionTypeId === ClaudeCodeSessionType.id) { + const resource = URI.from({ scheme: AgentSessionProviders.Claude, path: `/untitled-${generateUuid()}` }); + const session = this.instantiationService.createInstance(ClaudeCodeNewSession, resource, workspace, this.id); + this._currentNewSession = session; + return this._chatToSession(session); + } + if (sessionTypeId !== CopilotCLISessionType.id) { - throw new Error('Only Copilot CLI sessions can be created for non-GitHub repositories'); + throw new Error(`Unsupported session type '${sessionTypeId}' for local workspaces`); } const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); const session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id); @@ -1198,7 +1371,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Temp session that hasn't been committed — archive it in-place // so the user can still review whatever content was produced. const chatSession = this._findChatSession(sessionId); - if (chatSession && (chatSession instanceof CopilotCLISession || chatSession instanceof RemoteNewSession)) { + if (chatSession && isNewSession(chatSession)) { chatSession.setArchived(true); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] }); return; @@ -1214,7 +1387,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Temp session that hasn't been committed — unarchive it in-place const chatSession = this._findChatSession(sessionId); - if (chatSession && (chatSession instanceof CopilotCLISession || chatSession instanceof RemoteNewSession)) { + if (chatSession && isNewSession(chatSession)) { chatSession.setArchived(false); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] }); } @@ -1264,7 +1437,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions await this.commandService.executeCommand('github.copilot.cli.sessions.setTitle', { resource: chatUri }, title); return; } - throw new Error('Renaming is only supported for Copilot CLI sessions'); + if (agentSession?.providerType === AgentSessionProviders.Claude) { + await this.commandService.executeCommand('github.copilot.claude.sessions.rename', { resource: chatUri }, title); + return; + } + throw new Error('Renaming is not supported for this session type'); } async deleteChat(sessionId: string, chatUri: URI): Promise { @@ -1398,7 +1575,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * Sends the first chat for a newly created session. * Adds the temp session to the cache, waits for commit, then replaces it. */ - private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession, options: ISendRequestOptions): Promise { + private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession, options: ISendRequestOptions): Promise { const { query, attachedContext } = options; @@ -1701,6 +1878,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error('Multiple chats per session is not supported for cloud sessions'); } + if (chat.sessionType === AgentSessionProviders.Claude) { + throw new Error('Multiple chats per session is not supported for Claude sessions'); + } + const workspace = chat.workspace.get(); if (!workspace) { throw new Error('Chat session has no associated workspace'); @@ -1918,7 +2099,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const removedSession = this._chatToSession(chatSession); this._sessionGroupCache.delete(chatSession.id); this._onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] }); - if (chatSession instanceof CopilotCLISession || chatSession instanceof RemoteNewSession) { + if (isNewSession(chatSession)) { chatSession.dispose(); } } @@ -1930,7 +2111,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions for (const session of this.agentSessionsService.model.sessions) { if (session.providerType !== AgentSessionProviders.Background - && session.providerType !== AgentSessionProviders.Cloud) { + && session.providerType !== AgentSessionProviders.Cloud + && session.providerType !== AgentSessionProviders.Claude) { + continue; + } + + if (session.providerType === AgentSessionProviders.Claude && !this._claudeEnabled) { continue; } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts index 9507116fa346e..ad080429eb714 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts @@ -5,8 +5,8 @@ import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../platform/actionWidget/browser/actionList.js'; @@ -23,8 +23,35 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { URI } from '../../../../base/common/uri.js'; import { CopilotChatSessionsProvider } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; -// Track whether warnings have been shown this VS Code session -const shownWarnings = new Set(); +/** + * Strategy for the per-provider parts of {@link PermissionPicker}: how to read + * back the current level (if at all), whether the picker should be visible + * given the active session, and where to write the user's selection. + * + * Implementations live with the provider they back (e.g. + * {@link CopilotPermissionPickerDelegate} below for the default Copilot + * provider, or `AgentHostPermissionPickerDelegate` in the agent-host folder). + */ +export interface IPermissionPickerDelegate { + /** + * If provided, the picker's trigger label reactively tracks this. If + * omitted, the picker manages its own internal state and starts at + * {@link ChatPermissionLevel.Default}. + */ + readonly currentPermissionLevel?: IObservable; + + /** + * If provided, the picker hides itself when this is `false`. Used by + * delegates whose applicability depends on the active session. + */ + readonly isApplicable?: IObservable; + + /** + * Called after the user selects a level (and any required confirmation + * dialog has been accepted). + */ + setPermissionLevel(level: ChatPermissionLevel): void; +} interface IPermissionItem { readonly level?: ChatPermissionLevel; @@ -33,45 +60,23 @@ interface IPermissionItem { readonly checked: boolean; } -export class PermissionPicker extends Disposable { +// Track whether warnings have been shown this VS Code session +const shownWarnings = new Set(); - private readonly _onDidChangeLevel = this._register(new Emitter()); - readonly onDidChangeLevel: Event = this._onDidChangeLevel.event; +export class PermissionPicker extends Disposable { private _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default; private _triggerElement: HTMLElement | undefined; private readonly _renderDisposables = this._register(new DisposableStore()); - get permissionLevel(): ChatPermissionLevel { - return this._currentLevel; - } - - set permissionLevel(level: ChatPermissionLevel) { - this._currentLevel = level; - this._updateTriggerLabel(this._triggerElement); - } - constructor( + private readonly _delegate: IPermissionPickerDelegate, @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @IConfigurationService private readonly configurationService: IConfigurationService, @IDialogService private readonly dialogService: IDialogService, @IOpenerService private readonly openerService: IOpenerService, - @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, - @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, ) { super(); - - // Write permission level to the active session data when it changes - this._register(this.onDidChangeLevel(level => { - const session = this.sessionsManagementService.activeSession.get(); - if (!session) { - return; - } - const provider = this.sessionsProvidersService.getProvider(session.providerId); - if (provider instanceof CopilotChatSessionsProvider) { - provider.getSession(session.sessionId)?.setPermissionLevel(level); - } - })); } render(container: HTMLElement): HTMLElement { @@ -108,6 +113,21 @@ export class PermissionPicker extends Disposable { } })); + const currentPermissionLevel = this._delegate.currentPermissionLevel; + if (currentPermissionLevel) { + this._renderDisposables.add(autorun(reader => { + this._currentLevel = currentPermissionLevel.read(reader); + this._updateTriggerLabel(trigger); + })); + } + + const isApplicable = this._delegate.isApplicable; + if (isApplicable) { + this._renderDisposables.add(autorun(reader => { + slot.style.display = isApplicable.read(reader) ? '' : 'none'; + })); + } + return slot; } @@ -274,7 +294,7 @@ export class PermissionPicker extends Disposable { this._currentLevel = level; this._updateTriggerLabel(this._triggerElement); - this._onDidChangeLevel.fire(level); + this._delegate.setPermissionLevel(level); } private _updateTriggerLabel(trigger: HTMLElement | undefined): void { @@ -311,3 +331,31 @@ export class PermissionPicker extends Disposable { trigger.classList.toggle('info', this._currentLevel === ChatPermissionLevel.AutoApprove); } } + +/** + * Default-Copilot {@link IPermissionPickerDelegate}: writes the user's chosen + * level back to the active {@link CopilotChatSessionsProvider} session. + * + * Does not provide `currentPermissionLevel` or `isApplicable`, so the picker + * manages its own state and is always visible (visibility is gated at the menu + * contribution level via `when` clauses). + */ +export class CopilotPermissionPickerDelegate extends Disposable implements IPermissionPickerDelegate { + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + } + + setPermissionLevel(level: ChatPermissionLevel): void { + const session = this._sessionsManagementService.activeSession.get(); + if (!session) { + return; + } + const provider = this._sessionsProvidersService.getProvider(session.providerId); + if (provider instanceof CopilotChatSessionsProvider) { + provider.getSession(session.sessionId)?.setPermissionLevel(level); + } + } +} diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/claudePermissionModePicker.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/claudePermissionModePicker.test.ts new file mode 100644 index 0000000000000..00faf84b69676 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/claudePermissionModePicker.test.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { CopilotChatSessionsProvider, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; +import { ClaudePermissionModePicker } from '../../browser/claudePermissionModePicker.js'; + +interface IPermissionModeItem { + readonly id: string; + readonly label: string; +} + +function showPicker(container: HTMLElement): void { + const trigger = container.querySelector('a.action-label'); + assert.ok(trigger); + trigger.click(); +} + +function createPicker( + disposables: DisposableStore, + opts?: { + setOptionSpy?: (optionId: string, value: { id: string; name: string }) => void; + hasActiveSession?: boolean; + }, +): { picker: ClaudePermissionModePicker; actionWidgetItems: IActionListItem[]; onSelect: (item: IPermissionModeItem) => void } { + const instantiationService = disposables.add(new TestInstantiationService()); + const actionWidgetItems: IActionListItem[] = []; + let capturedOnSelect: ((item: IPermissionModeItem) => void) | undefined; + + const setOptionSpy = opts?.setOptionSpy ?? (() => { }); + const hasActiveSession = opts?.hasActiveSession ?? true; + + const activeSession = hasActiveSession ? { + providerId: 'default-copilot', + sessionId: 'session-id', + loading: observableValue('loading', false), + } as unknown as IActiveSession : undefined; + + const mockSession: Partial = { + setOption: setOptionSpy as ICopilotChatSession['setOption'], + }; + + const provider = Object.assign(Object.create(CopilotChatSessionsProvider.prototype), { + getSession: () => mockSession, + }); + + instantiationService.stub(IActionWidgetService, { + isVisible: false, + hide: () => { }, + show: (_id: string, _supportsPreview: boolean, items: IActionListItem[], delegate: { onSelect: (item: T) => void }) => { + actionWidgetItems.splice(0, actionWidgetItems.length, ...(items as IActionListItem[])); + capturedOnSelect = delegate.onSelect as (item: IPermissionModeItem) => void; + }, + }); + instantiationService.stub(ISessionsManagementService, { + activeSession: observableValue('activeSession', activeSession), + } as unknown as ISessionsManagementService); + instantiationService.stub(ISessionsProvidersService, { + onDidChangeProviders: Event.None, + getProviders: () => [], + getProvider: () => provider, + } as unknown as ISessionsProvidersService); + + const picker = disposables.add(instantiationService.createInstance(ClaudePermissionModePicker)); + + return { + picker, + actionWidgetItems, + get onSelect() { return capturedOnSelect!; }, + }; +} + +suite('ClaudePermissionModePicker', () => { + const disposables = new DisposableStore(); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('shows all three permission modes', () => { + const { picker, actionWidgetItems } = createPicker(disposables); + const container = document.createElement('div'); + picker.render(container); + showPicker(container); + + assert.deepStrictEqual( + actionWidgetItems.map(item => ({ id: item.item?.id, label: item.label })), + [ + { id: 'default', label: 'Ask Before Edits' }, + { id: 'acceptEdits', label: 'Edit Automatically' }, + { id: 'plan', label: 'Plan Mode' }, + ], + ); + }); + + test('selecting a mode updates the trigger label', () => { + const result = createPicker(disposables); + const container = document.createElement('div'); + result.picker.render(container); + showPicker(container); + + result.onSelect({ id: 'plan', label: 'Plan Mode' } as IPermissionModeItem); + + const labelSpan = container.querySelector('span.sessions-chat-dropdown-label'); + assert.ok(labelSpan); + assert.strictEqual(labelSpan.textContent, 'Plan Mode'); + }); + + test('selecting a mode calls setOption on the session', () => { + const calls: { optionId: string; value: { id: string; name: string } }[] = []; + const result = createPicker(disposables, { + setOptionSpy: (optionId, value) => calls.push({ optionId, value }), + }); + const container = document.createElement('div'); + result.picker.render(container); + showPicker(container); + + result.onSelect({ id: 'default', label: 'Ask Before Edits' } as IPermissionModeItem); + + assert.deepStrictEqual(calls, [{ + optionId: 'permissionMode', + value: { id: 'default', name: 'Ask Before Edits' }, + }]); + }); + + test('selecting a mode does not throw when no active session', () => { + const result = createPicker(disposables, { hasActiveSession: false }); + const container = document.createElement('div'); + result.picker.render(container); + showPicker(container); + + assert.doesNotThrow(() => result.onSelect({ id: 'plan', label: 'Plan Mode' } as IPermissionModeItem)); + }); + + test('trigger has correct aria label', () => { + const { picker } = createPicker(disposables); + const container = document.createElement('div'); + picker.render(container); + + const trigger = container.querySelector('a.action-label'); + assert.ok(trigger); + // Default mode is 'acceptEdits' → "Edit Automatically" + assert.ok(trigger.ariaLabel?.includes('Edit Automatically')); + }); +}); diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 5dd5aaadd5ac2..db3ee3ffebb93 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -10,7 +10,7 @@ import { DisposableStore, toDisposable } from '../../../../../base/common/lifecy import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -30,8 +30,8 @@ import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common import { IChatAgentData } from '../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js'; -import { CopilotCLISessionType, SessionStatus } from '../../../../services/sessions/common/session.js'; -import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js'; +import { ClaudeCodeSessionType, CopilotCLISessionType, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { CLAUDE_CODE_ENABLED_SETTING, CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; // ---- Helpers ---------------------------------------------------------------- @@ -107,12 +107,21 @@ class MockAgentSessionsModel { function createProvider( disposables: DisposableStore, model: MockAgentSessionsModel, - opts?: { multiChatEnabled?: boolean }, + opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean }, ): CopilotChatSessionsProvider { + return createProviderWithConfig(disposables, model, opts).provider; +} + +function createProviderWithConfig( + disposables: DisposableStore, + model: MockAgentSessionsModel, + opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean }, +): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService } { const instantiationService = disposables.add(new TestInstantiationService()); const configService = new TestConfigurationService(); configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', opts?.multiChatEnabled ?? true); + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, opts?.claudeEnabled ?? false); instantiationService.stub(IConfigurationService, configService); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); @@ -171,7 +180,7 @@ function createProvider( instantiationService.stub(IInstantiationService, instantiationService); const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); - return provider; + return { provider, configService }; } // ---- Provider factory for send/cancel tests --------------------------------- @@ -188,12 +197,13 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: () => Promise, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }> }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean }, ): CopilotChatSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); const configService = new TestConfigurationService(); configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', true); + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, opts?.claudeEnabled ?? false); instantiationService.stub(ILogService, NullLogService); instantiationService.stub(IConfigurationService, configService); @@ -263,6 +273,83 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(provider.sessionTypes.length, 2); }); + test('sessionTypes includes Claude when setting is enabled', () => { + const provider = createProvider(disposables, model, { claudeEnabled: true }); + assert.strictEqual(provider.sessionTypes.length, 3); + assert.ok(provider.sessionTypes.some(t => t.id === ClaudeCodeSessionType.id)); + }); + + test('onDidChangeSessionTypes fires when claude setting changes', () => { + const { provider, configService } = createProviderWithConfig(disposables, model); + assert.strictEqual(provider.sessionTypes.length, 2); + + let fired = false; + disposables.add(provider.onDidChangeSessionTypes(() => { fired = true; })); + + // Enable claude via config change + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, true); + configService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]), + change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] }, + affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING, + }); + + assert.ok(fired, 'onDidChangeSessionTypes should have fired'); + assert.strictEqual(provider.sessionTypes.length, 3); + }); + + test('toggling claude setting refreshes sessions list', () => { + const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' }); + model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude })); + + const { provider, configService } = createProviderWithConfig(disposables, model); + assert.strictEqual(provider.getSessions().length, 0, 'Claude sessions should be hidden when disabled'); + + // Enable Claude + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, true); + configService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]), + change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] }, + affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING, + }); + + assert.strictEqual(provider.getSessions().length, 1, 'Claude sessions should appear after enabling'); + + // Disable Claude + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, false); + configService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]), + change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] }, + affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING, + }); + + assert.strictEqual(provider.getSessions().length, 0, 'Claude sessions should disappear after disabling'); + }); + + // ---- getSessionTypes ------- + + test('getSessionTypes returns Claude for local workspace when enabled', () => { + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const types = provider.getSessionTypes(URI.file('/test/project')); + assert.ok(types.some(t => t.id === ClaudeCodeSessionType.id)); + }); + + test('getSessionTypes does not return Claude for local workspace when disabled', () => { + const provider = createProvider(disposables, model); + const types = provider.getSessionTypes(URI.file('/test/project')); + assert.ok(!types.some(t => t.id === ClaudeCodeSessionType.id)); + }); + + test('getSessionTypes returns only Cloud for remote workspace regardless of claude setting', () => { + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const types = provider.getSessionTypes(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, path: '/owner/repo' })); + assert.strictEqual(types.length, 1); + assert.ok(!types.some(t => t.id === ClaudeCodeSessionType.id)); + }); + // ---- Session listing ------- test('getSessions returns empty array initially', () => { @@ -282,7 +369,7 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 2); }); - test('getSessions ignores non-Background/Cloud sessions', () => { + test('getSessions ignores non-Background/Cloud/Claude sessions', () => { const bgResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/bg-session' }); const localResource = URI.from({ scheme: AgentSessionProviders.Local, path: '/local-session' }); model.addSession(createMockAgentSession(bgResource)); @@ -294,6 +381,26 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 1); }); + test('getSessions includes Claude agent sessions when enabled', () => { + const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' }); + model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude })); + + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + }); + + test('getSessions excludes Claude agent sessions when disabled', () => { + const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' }); + model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 0); + }); + test('onDidChangeSessions fires when agent model changes', () => { const provider = createProvider(disposables, model); provider.getSessions(); // Initialize cache @@ -378,6 +485,17 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false); }); + test('claude sessions do not have supportsMultipleChats capability', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Claude })); + + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false); + }); + // ---- Session listing & grouping ------- test('each session has exactly one chat initially', () => { @@ -654,6 +772,130 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(provider.browseActions[1].providerId, COPILOT_PROVIDER_ID); }); + // ---- Claude session creation ------- + + function makeClaudeInFlightProvider(): { provider: CopilotChatSessionsProvider; cancelRequest: () => void } { + let resolveComplete!: () => void; + let resolveCreated!: (r: IChatResponseModel) => void; + const responseCompletePromise = new Promise(r => { resolveComplete = r; }); + const responseCreatedPromise = new Promise(r => { resolveCreated = r; }); + + const provider = createProviderForSendTests(disposables, model, async () => ({ + kind: 'sent' as const, + data: { + responseCompletePromise, + responseCreatedPromise, + agent: new class extends mock() { }(), + } as IChatSendRequestData, + }), { claudeEnabled: true }); + + return { + provider, + cancelRequest: () => { + resolveCreated({ isCanceled: true } as unknown as IChatResponseModel); + resolveComplete(); + }, + }; + } + + function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise { + return new Promise(resolve => { + const d = provider.onDidChangeSessions(e => { + if (e.added.length > 0) { + d.dispose(); + resolve(); + } + }); + }); + } + + test('createNewSession with Claude type creates a session', async () => { + const { provider, cancelRequest } = makeClaudeInFlightProvider(); + const workspace = URI.file('/test/project'); + + const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); + + assert.ok(session); + assert.strictEqual(session.sessionType, ClaudeCodeSessionType.id); + assert.strictEqual(session.status.get(), SessionStatus.Untitled); + + // Send and clean up so the session enters the cache and can be disposed + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + await added; + cancelRequest(); + await assert.doesNotReject(sendPromise); + await provider.deleteSession(session.sessionId); + }); + + test('archiveSession archives a Claude temp session', async () => { + const { provider, cancelRequest } = makeClaudeInFlightProvider(); + const workspace = URI.file('/test/project'); + const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + await added; + + await provider.archiveSession(session.sessionId); + assert.strictEqual(provider.getSessions()[0].isArchived.get(), true); + + cancelRequest(); + await assert.doesNotReject(sendPromise); + + // Clean up + await provider.deleteSession(session.sessionId); + }); + + test('unarchiveSession unarchives a Claude temp session', async () => { + const { provider, cancelRequest } = makeClaudeInFlightProvider(); + const workspace = URI.file('/test/project'); + const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + await added; + + await provider.archiveSession(session.sessionId); + assert.strictEqual(provider.getSessions()[0].isArchived.get(), true); + + await provider.unarchiveSession(session.sessionId); + assert.strictEqual(provider.getSessions()[0].isArchived.get(), false); + + cancelRequest(); + await assert.doesNotReject(sendPromise); + + // Clean up + await provider.deleteSession(session.sessionId); + }); + + // ---- Rename ------- + + test('renameChat delegates to claude rename command', async () => { + const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' }); + model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude })); + + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 1); + + // Should not throw — delegates to ICommandService.executeCommand + await provider.renameChat(sessions[0].sessionId, claudeResource, 'New Title'); + }); + + test('renameChat throws for unsupported session type', async () => { + const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/cloud-session' }); + model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + await assert.rejects( + () => provider.renameChat(sessions[0].sessionId, resource, 'New Title'), + /not supported/, + ); + }); + // ---- Uncommitted temp session cleanup ------------------------------------ suite('uncommitted temp session cleanup', () => { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 0f12071dbdc3f..518c56e1df459 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -642,4 +642,4 @@ Registry.as(ConfigurationExtensions.Configuration).regis // Side-effect registrations for the remote agent host feature import './remoteAgentHostActions.js'; -import '../../chat/browser/agentHostModelPicker.js'; +import '../../chat/browser/agentHost/agentHostModelPicker.js'; diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index c4c81f5a9c7e4..a487bf9d254a2 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -26,34 +26,9 @@ import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/ import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; -import { COPILOT_CLI_SESSION_TYPE, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; +import { ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; import { remoteAgentHostSessionTypeId } from '../common/remoteAgentHostSessionType.js'; -/** The default agent provider name used by agent hosts when no explicit provider is specified. */ -const DEFAULT_AGENT_HOST_PROVIDER = 'copilot'; - -/** - * Maps well-known agent host provider names to the local platform session type - * they should be associated with. Agent providers not in this map keep the - * unique per-connection ID as their logical session type. - */ -const WELL_KNOWN_AGENT_SESSION_TYPES: ReadonlyMap = new Map([ - [DEFAULT_AGENT_HOST_PROVIDER, COPILOT_CLI_SESSION_TYPE], -]); - -function wellKnownSessionType(agentProvider: string): string | undefined { - return WELL_KNOWN_AGENT_SESSION_TYPES.get(agentProvider); -} - -function wellKnownAgentProvider(sessionType: string): string | undefined { - for (const [provider, type] of WELL_KNOWN_AGENT_SESSION_TYPES) { - if (type === sessionType) { - return provider; - } - } - return undefined; -} - /** Storage key prefix for cached session summaries, per remote address. */ const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.'; @@ -151,13 +126,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private _outputChannelId: string | undefined; get outputChannelId(): string | undefined { return this._outputChannelId; } - /** - * Maps logical session type id → unique per-connection resource scheme. - * Copilot agents map to `COPILOT_CLI_SESSION_TYPE` as the logical type - * but keep the unique per-connection id as the resource scheme. - */ - private readonly _sessionTypeToResourceScheme = new Map(); - private readonly _connectionStatus = observableValue('connectionStatus', RemoteAgentHostConnectionStatus.Disconnected); readonly connectionStatus: IObservable = this._connectionStatus; @@ -261,26 +229,21 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid protected get authenticationPending(): IObservable { return this._authenticationPending; } - protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { - const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_HOST_PROVIDER; - const resourceScheme = remoteAgentHostSessionTypeId(this._connectionAuthority, provider); - const logicalType = this._logicalSessionTypeForProvider(provider); + protected override createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { this._metaByRawId.set(AgentSession.id(meta.session), meta); - return new AgentHostSessionAdapter(meta, this.id, resourceScheme, logicalType, { - icon: this.icon, - description: new MarkdownString().appendText(this.label), - loading: this._authenticationPending, - buildWorkspace: (project, workingDirectory) => RemoteAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, this.label), - mapDiffUri: uri => toAgentHostUri(uri, this._connectionAuthority), - }); + return super.createAdapter(meta); } - protected resourceSchemeForSessionType(sessionTypeId: string): string { - return this._sessionTypeToResourceScheme.get(sessionTypeId) ?? sessionTypeId; + protected _adapterOptions() { + return { + description: new MarkdownString().appendText(this.label), + buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined) => + RemoteAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, this.label), + }; } - protected agentProviderFromSessionType(sessionType: string): string { - return wellKnownAgentProvider(sessionType) ?? sessionType.substring(`remote-${this._connectionAuthority}-`.length); + protected resourceSchemeForProvider(provider: string): string { + return remoteAgentHostSessionTypeId(this._connectionAuthority, provider); } override getSessions(): ISession[] { @@ -391,7 +354,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid if (this._sessionTypes.length > 0) { this._sessionTypes = []; - this._sessionTypeToResourceScheme.clear(); this._onDidChangeSessionTypes.fire(); } @@ -482,52 +444,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid // -- Session-type sync --------------------------------------------------- - /** - * Reconcile `_sessionTypes` against the agents advertised by the host's - * root state. Adds new types, removes types whose agents disappeared, and - * fires {@link onDidChangeSessionTypes} if anything actually changed. - * - * Each entry's label is formatted as ` []`. - */ - private _syncSessionTypesFromRootState(rootState: { agents: ReadonlyArray<{ provider: string; displayName?: string }> }): void { - const nextMap = new Map(); - const next = rootState.agents.map((agent): ISessionType => { - const resourceScheme = remoteAgentHostSessionTypeId(this._connectionAuthority, agent.provider); - const logicalType = this._logicalSessionTypeForProvider(agent.provider); - nextMap.set(logicalType, resourceScheme); - return { - id: logicalType, - label: this._formatSessionTypeLabel(agent.displayName?.trim() || agent.provider), - icon: Codicon.remote, - }; - }); - - const prev = this._sessionTypes; - if (prev.length === next.length && prev.every((t, i) => t.id === next[i].id && t.label === next[i].label)) { - return; - } - this._sessionTypes = next; - this._sessionTypeToResourceScheme.clear(); - for (const [key, value] of nextMap) { - this._sessionTypeToResourceScheme.set(key, value); - } - this._onDidChangeSessionTypes.fire(); - } - - private _formatSessionTypeLabel(agentLabel: string): string { + protected _formatSessionTypeLabel(agentLabel: string): string { return `${agentLabel} [${this.label}]`; } - /** - * Returns the logical session type for a given agent provider. - * Well-known providers (see {@link WELL_KNOWN_AGENT_SESSION_TYPES}) map - * to the corresponding platform session type. Other agents keep the - * unique per-connection ID. - */ - private _logicalSessionTypeForProvider(provider: string): string { - return wellKnownSessionType(provider) ?? remoteAgentHostSessionTypeId(this._connectionAuthority, provider); - } - // -- Workspaces ---------------------------------------------------------- static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string): ISessionWorkspace | undefined { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index 2bd9cf5652544..4b7379bd1227d 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -15,7 +15,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { AuthenticationSessionsChangeEvent, IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; import { logTunnelConnectAttempt, logTunnelConnectResolved, TunnelConnectErrorCategory, TunnelConnectFailureReason } from '../../../common/sessionsTelemetry.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; @@ -34,6 +34,9 @@ const RECONNECT_MAX_DELAY = 30_000; */ const RECONNECT_MAX_ATTEMPTS = 10; +/** Minimum gap between wake/visibility-triggered resumes. */ +const RESUME_RATE_LIMIT_MS = 10_000; + export class TunnelAgentHostContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.contrib.tunnelAgentHostContribution'; @@ -57,6 +60,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private readonly _reconnectAttempts = new Map(); /** Addresses whose auto-reconnect loop has paused after too many failures. */ private readonly _reconnectPaused = new Set(); + /** Addresses paused specifically because the remote host is offline. */ + private readonly _hostOfflinePaused = new Set(); /** Timestamp of the last wake-triggered resume, to rate-limit rapid tab toggles. */ private _lastResumeAt = 0; @@ -97,13 +102,14 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._pruneReconnectState(); })); - // Re-run discovery when a GitHub session becomes available - // (e.g. after the walkthrough completes sign-in). + // Re-run discovery when a GitHub session becomes available, + // and tear down tunnel state bound to that provider if its session + // is removed. this._register(this._authenticationService.onDidChangeSessions(e => { - if (e.providerId === 'github') { - this._logService.info('[TunnelAgentHost] GitHub sessions changed, retrying discovery...'); - this._silentStatusCheck(); + if (e.providerId !== 'github') { + return; } + this._handleSessionsChange(e); })); // Wake-triggered retry: when the browser regains connectivity or @@ -278,6 +284,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._finishConnectAttempt(address, { success: true, attemptNumber, attemptStart, session, isReconnect }); } catch (err) { this._logService.warn(`[TunnelAgentHost] Connect to ${cached.name} failed:`, err); + const errorCategory = this._categorizeError(err); this._finishConnectAttempt(address, { success: false, attemptNumber, attemptStart, session, isReconnect, error: err }); // Clear the pending-connect entry BEFORE deciding what to do // next; otherwise `_scheduleReconnect`'s in-flight guard @@ -285,6 +292,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc // we'd never re-arm the timer, leaving the tunnel stuck. this._pendingConnects.delete(address); + // Auth failures are not worth retrying — a fresh token must + // be acquired by the user or by a session-change event. Pause + // immediately and let `_handleSessionsChange` resume us when + // a new session appears. + if (errorCategory === 'authExpired' || errorCategory === 'auth') { + this._pauseReconnect(address, errorCategory); + throw err; + } + const hostOnline = await this._probeHostOnline(cached.tunnelId); if (hostOnline === false) { this._pauseReconnect(address, 'hostOffline'); @@ -467,6 +483,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private _clearReconnectBackoff(address: string): void { this._reconnectAttempts.delete(address); this._reconnectPaused.delete(address); + this._hostOfflinePaused.delete(address); } /** Drop all reconnect + telemetry state for an address (e.g. on removal). */ @@ -476,6 +493,39 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._connectSessions.delete(address); } + /** + * React to auth session add/remove. Additions re-run discovery (a fresh + * token may unblock a previously auth-paused tunnel). Removals drop any + * tunnel state that depended on that provider — otherwise we'd sit on a + * stale auth pause forever, or hammer a provider whose session is gone. + */ + private _handleSessionsChange(e: { providerId: string; label: string; event: AuthenticationSessionsChangeEvent }): void { + const added = (e.event.added?.length ?? 0) > 0; + const removed = (e.event.removed?.length ?? 0) > 0; + + if (removed) { + const cached = this._tunnelService.getCachedTunnels(); + for (const tunnel of cached) { + if (tunnel.authProvider !== e.providerId) { + continue; + } + const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`; + this._logService.info( + `[TunnelAgentHost] Auth session removed for ${e.providerId}; tearing down ${address}.` + ); + this._resetReconnectState(address); + // Best-effort disconnect — the transport may already be dead. + this._tunnelService.disconnect(address).catch(() => { /* ignore */ }); + } + } + + if (added) { + this._logService.info(`[TunnelAgentHost] ${e.providerId} session added; resuming reconnects and rediscovering.`); + this._resumeReconnects('sessionAdded'); + this._silentStatusCheck(); + } + } + /** * Stop auto-reconnecting for an address until a wake/online/visibility * event resumes us, and close out any active telemetry session. @@ -484,9 +534,14 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._cancelReconnect(address); this._reconnectAttempts.delete(address); this._reconnectPaused.add(address); + if (reason === 'hostOffline') { + this._hostOfflinePaused.add(address); + } else { + this._hostOfflinePaused.delete(address); + } this._logService.info( `[TunnelAgentHost] Pausing auto-reconnect for ${address} (${reason}); ` + - `will resume on network-online, tab-visible, or next status check.` + `will resume on network-online, tab-visible, session change, or next status check.` ); const session = this._connectSessions.get(address); if (session) { @@ -544,13 +599,20 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private _categorizeError(err: unknown): TunnelConnectErrorCategory { const message = err instanceof Error ? err.message : String(err); - if (/WebSocket relay connection failed/i.test(message)) { - return 'relayConnectionFailed'; - } - if (/authenticat|token|unauthor/i.test(message)) { + // Expired / invalid credential — callers short-circuit this category + // to avoid burning retry budget on a token the user has to refresh. + if (/\b(401|403)\b|token.*expired|expired.*token|invalid[_ -]?grant/i.test(message)) { + return 'authExpired'; + } + // Match authentication-specific language but NOT "connection token" + // or other protocol uses of the word "token". + if (/authenticat|unauthoriz|auth.*(fail|error|invalid)/i.test(message)) { return 'auth'; } - if (/network|fetch|offline/i.test(message)) { + if (/WebSocket relay connection failed|failed to connect to relay/i.test(message)) { + return 'relayConnectionFailed'; + } + if (/network|fetch|offline|ECONN|ENOTFOUND|ETIMEDOUT/i.test(message)) { return 'network'; } return 'other'; @@ -566,12 +628,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc * sequence (by clearing the pause flag) rather than zeroing the * attempt counter. */ - private _resumeReconnects(trigger: 'wake' | 'visible'): void { + private _resumeReconnects(trigger: 'wake' | 'visible' | 'sessionAdded'): void { if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { return; } - const RESUME_RATE_LIMIT_MS = 10_000; + // Rate-limit rapid wake/visibility events (e.g. alt-tab bursts or + // flaky Wi-Fi toggling online/offline) so we don't hammer the relay + // with immediate retries. This is an event-smoothing gate, not an + // error-backoff — that's handled by `_scheduleReconnect`. const now = Date.now(); if (now - this._lastResumeAt < RESUME_RATE_LIMIT_MS) { return; @@ -654,10 +719,12 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc // Auto-cache online tunnels that aren't cached yet so they // appear in the UI on first discovery (e.g. fresh web session). + // Pass 'github' as authProvider so _handleSessionsChange can + // match these tunnels for teardown on session removal. const cachedIds = new Set(cached.map(t => t.tunnelId)); for (const tunnel of onlineTunnels) { if (!cachedIds.has(tunnel.tunnelId) && tunnel.hostConnectionCount > 0) { - this._tunnelService.cacheTunnel(tunnel); + this._tunnelService.cacheTunnel(tunnel, 'github'); } } @@ -680,6 +747,19 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc const info = onlineTunnelMap.get(tunnelId); if (info && info.hostConnectionCount > 0) { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connected); + + // If we paused reconnects because the host had gone + // offline, the status check is our cue to resume — + // don't wait for a wake/visibility event. Covers the + // common "my laptop came back, the remote host came + // back first" scenario deterministically. + if (this._hostOfflinePaused.has(address)) { + this._logService.info( + `[TunnelAgentHost] Host came back online for ${address}; auto-resuming reconnect.` + ); + this._clearReconnectBackoff(address); + this._scheduleReconnect(address, /*immediate*/ true); + } } else { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected); // Host is not online — drop any cached sessions we were diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts index d6a25425e6914..5b4a1b8f4a8b1 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts @@ -9,6 +9,7 @@ import { RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/br import { RemoteAgentHostEntryType, IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import type { IProtocolTransport } from '../../../../platform/agentHost/common/state/sessionTransport.js'; import type { IProtocolMessage, IAhpServerNotification, IJsonRpcResponse } from '../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../../../platform/agentHost/common/transportConstants.js'; import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, @@ -130,7 +131,7 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen // Derive connection token from tunnel ID (same convention as CLI and desktop) const connectionToken = await deriveConnectionToken(tunnelId); - const transport = new TunnelConnectionTransport(connection); + const transport = new TunnelConnectionTransport(connection, this._logService); const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; const protocolClient = this._instantiationService.createInstance( RemoteAgentHostProtocolClient, address, transport, @@ -233,14 +234,35 @@ class TunnelConnectionTransport extends Disposable implements IProtocolTransport private readonly _onClose = this._register(new Emitter()); readonly onClose = this._onClose.event; - constructor(private readonly _connection: ITunnelConnection) { + private _malformedFrames = 0; + + constructor( + private readonly _connection: ITunnelConnection, + private readonly _logService: ILogService, + ) { super(); this._register(_connection.onMessage((data: string) => { + let message: IProtocolMessage; try { - this._onMessage.fire(JSON.parse(data) as IProtocolMessage); - } catch { - // Malformed message - drop. + message = JSON.parse(data) as IProtocolMessage; + } catch (err) { + this._malformedFrames++; + if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { + const preview = data.length > 80 ? data.slice(0, 80) + '…' : data; + this._logService.warn( + `[TunnelConnectionTransport] Malformed frame #${this._malformedFrames} (len=${data.length}): ${preview}`, + err instanceof Error ? err.message : String(err) + ); + } + if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { + this._logService.warn( + '[TunnelConnectionTransport] Malformed frame threshold exceeded; forcing tunnel close.' + ); + this._connection.close(); + } + return; } + this._onMessage.fire(message); })); this._register(_connection.onClose(() => { this._onClose.fire(); diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 04d2ee6d5d65f..d4c016225fa9b 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -29,7 +29,6 @@ import { IChatSessionsService } from '../../../../../workbench/contrib/chat/comm import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js'; import { SessionStatus, COPILOT_CLI_SESSION_TYPE } from '../../../../services/sessions/common/session.js'; -import { remoteAgentHostSessionTypeId } from '../../common/remoteAgentHostSessionType.js'; import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; // ---- Mock connection -------------------------------------------------------- @@ -43,7 +42,7 @@ class MockAgentConnection extends mock() { override readonly onDidNotification = this._onDidNotification.event; private readonly _onDidRootStateChange = new Emitter(); - private _rootStateValue: IRootState = { agents: [{ provider: 'copilot', displayName: 'Copilot', description: '', models: [] } as IAgentInfo] }; + private _rootStateValue: IRootState = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo] }; override readonly rootState: IAgentSubscription; override readonly clientId = 'test-client-1'; @@ -167,7 +166,7 @@ class MockAgentConnection extends mock() { function createSession(id: string, opts?: { provider?: string; summary?: string; model?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata { return { - session: AgentSession.uri(opts?.provider ?? 'copilot', id), + session: AgentSession.uri(opts?.provider ?? 'copilotcli', id), startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, @@ -226,7 +225,7 @@ async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, s } function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; model?: string; modelConfig?: Record; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { - const provider = opts?.provider ?? 'copilot'; + const provider = opts?.provider ?? 'copilotcli'; const sessionUri = AgentSession.uri(provider, rawId); connection.fireNotification({ type: NotificationType.SessionAdded, @@ -244,7 +243,7 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: }); } -function fireSessionRemoved(connection: MockAgentConnection, rawId: string, provider = 'copilot'): void { +function fireSessionRemoved(connection: MockAgentConnection, rawId: string, provider = 'copilotcli'): void { const sessionUri = AgentSession.uri(provider, rawId); connection.fireNotification({ type: NotificationType.SessionRemoved, @@ -289,14 +288,14 @@ suite('RemoteAgentHostSessionsProvider', () => { disposables.add(provider.onDidChangeSessionTypes!(() => changes++)); connection.setAgents([ - { provider: 'copilot', displayName: 'Copilot', description: '', models: [] } as IAgentInfo, + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo, { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as IAgentInfo, ]); assert.strictEqual(changes, 1); assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ { id: COPILOT_CLI_SESSION_TYPE, label: 'Copilot [My Host]' }, - { id: remoteAgentHostSessionTypeId('10.0.0.1__8080', 'openai'), label: 'OpenAI [My Host]' }, + { id: 'openai', label: 'OpenAI [My Host]' }, ]); }); @@ -345,12 +344,12 @@ suite('RemoteAgentHostSessionsProvider', () => { test('session added notifications ingest any advertised agent provider', () => runWithFakedTimers({ useFakeTimers: true }, async () => { connection.setAgents([ - { provider: 'copilot', displayName: 'Copilot', description: '', models: [] } as IAgentInfo, + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo, { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as IAgentInfo, ]); const provider = createProvider(disposables, connection); - fireSessionAdded(connection, 'cop-1', { provider: 'copilot', title: 'Copilot Session' }); + fireSessionAdded(connection, 'cop-1', { provider: 'copilotcli', title: 'Copilot Session' }); fireSessionAdded(connection, 'oai-1', { provider: 'openai', title: 'OpenAI Session' }); const sessions = provider.getSessions(); @@ -358,7 +357,7 @@ suite('RemoteAgentHostSessionsProvider', () => { sessions.map(s => ({ title: s.title.get(), sessionType: s.sessionType })).sort((a, b) => a.title.localeCompare(b.title)), [ { title: 'Copilot Session', sessionType: COPILOT_CLI_SESSION_TYPE }, - { title: 'OpenAI Session', sessionType: remoteAgentHostSessionTypeId('localhost__4321', 'openai') }, + { title: 'OpenAI Session', sessionType: 'openai' }, ], ); })); @@ -470,7 +469,7 @@ suite('RemoteAgentHostSessionsProvider', () => { await timeout(0); const session = provider.getSessions().find(s => s.title.get() === 'Model Session'); - assert.strictEqual(session?.modelId.get(), 'remote-localhost__4321-copilot:claude-sonnet-4.5'); + assert.strictEqual(session?.modelId.get(), 'remote-localhost__4321-copilotcli:claude-sonnet-4.5'); })); test('uses model metadata from session added notification', () => { @@ -478,7 +477,7 @@ suite('RemoteAgentHostSessionsProvider', () => { fireSessionAdded(connection, 'notif-model', { title: 'Notif Model Session', model: 'gpt-5' }); const session = provider.getSessions().find(s => s.title.get() === 'Notif Model Session'); - assert.strictEqual(session?.modelId.get(), 'remote-localhost__4321-copilot:gpt-5'); + assert.strictEqual(session?.modelId.get(), 'remote-localhost__4321-copilotcli:gpt-5'); }); test('setModel updates existing session model and dispatches raw model', () => { @@ -488,12 +487,12 @@ suite('RemoteAgentHostSessionsProvider', () => { const session = provider.getSessions().find(s => s.title.get() === 'Set Model Session'); assert.ok(session); - provider.setModel(session!.sessionId, 'remote-localhost__4321-copilot:new-model'); + provider.setModel(session!.sessionId, 'remote-localhost__4321-copilotcli:new-model'); - assert.strictEqual(session!.modelId.get(), 'remote-localhost__4321-copilot:new-model'); + assert.strictEqual(session!.modelId.get(), 'remote-localhost__4321-copilotcli:new-model'); assert.deepStrictEqual(connection.dispatchedActions.at(-1)?.action, { type: ActionType.SessionModelChanged, - session: AgentSession.uri('copilot', 'set-model').toString(), + session: AgentSession.uri('copilotcli', 'set-model').toString(), model: { id: 'new-model' }, }); }); @@ -505,11 +504,11 @@ suite('RemoteAgentHostSessionsProvider', () => { const session = provider.getSessions().find(s => s.title.get() === 'Set Model Config Session'); assert.ok(session); - provider.setModel(session!.sessionId, 'remote-localhost__4321-copilot:configured-model'); + provider.setModel(session!.sessionId, 'remote-localhost__4321-copilotcli:configured-model'); assert.deepStrictEqual(connection.dispatchedActions.at(-1)?.action, { type: ActionType.SessionModelChanged, - session: AgentSession.uri('copilot', 'set-model-config').toString(), + session: AgentSession.uri('copilotcli', 'set-model-config').toString(), model: { id: 'configured-model', config: { thinkingLevel: 'high' } }, }); }); @@ -578,7 +577,7 @@ suite('RemoteAgentHostSessionsProvider', () => { // The disposed URI must be a backend agent session URI (copilot://del-sess), // not the UI resource (remote-localhost_4321-copilot:///del-sess) const disposedUri = connection.disposedSessions[0]; - assert.strictEqual(AgentSession.provider(disposedUri), 'copilot'); + assert.strictEqual(AgentSession.provider(disposedUri), 'copilotcli'); assert.strictEqual(AgentSession.id(disposedUri), 'del-sess'); // Session should no longer appear in getSessions const remaining = provider.getSessions(); @@ -603,7 +602,7 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual((dispatched.action as { title: string }).title, 'New Title'); // The session URI in the action must be the backend agent session URI const actionSession = (dispatched.action as { session: string }).session; - assert.strictEqual(AgentSession.provider(actionSession), 'copilot'); + assert.strictEqual(AgentSession.provider(actionSession), 'copilotcli'); assert.strictEqual(AgentSession.id(actionSession), 'rename-sess'); assert.strictEqual(dispatched.clientId, 'test-client-1'); }); @@ -661,7 +660,7 @@ suite('RemoteAgentHostSessionsProvider', () => { connection.fireAction({ action: { type: ActionType.SessionTitleChanged, - session: AgentSession.uri('copilot', 'echo-sess').toString(), + session: AgentSession.uri('copilotcli', 'echo-sess').toString(), title: 'Server Title', }, serverSeq: 1, @@ -686,14 +685,14 @@ suite('RemoteAgentHostSessionsProvider', () => { connection.fireAction({ action: { type: ActionType.SessionModelChanged, - session: AgentSession.uri('copilot', 'model-change').toString(), + session: AgentSession.uri('copilotcli', 'model-change').toString(), model: { id: 'new-model' } satisfies IModelSelection, }, serverSeq: 1, origin: undefined, } as IActionEnvelope); - assert.strictEqual(target!.modelId.get(), 'remote-localhost__4321-copilot:new-model'); + assert.strictEqual(target!.modelId.get(), 'remote-localhost__4321-copilotcli:new-model'); assert.strictEqual(changes.length, 1); assert.strictEqual(changes[0].changed.length, 1); }); @@ -718,7 +717,7 @@ suite('RemoteAgentHostSessionsProvider', () => { connection.fireAction({ action: { type: 'session/turnComplete', - session: AgentSession.uri('copilot', 'persist-sess').toString(), + session: AgentSession.uri('copilotcli', 'persist-sess').toString(), }, serverSeq: 1, origin: undefined, @@ -909,7 +908,7 @@ suite('RemoteAgentHostSessionsProvider', () => { connection.fireAction({ action: { type: 'session/turnComplete', - session: AgentSession.uri('copilot', 'turn-sess').toString(), + session: AgentSession.uri('copilotcli', 'turn-sess').toString(), }, serverSeq: 1, origin: undefined, @@ -945,12 +944,12 @@ suite('RemoteAgentHostSessionsProvider', () => { values: { autoApprove: 'default', isolation: 'worktree' }, }; const fakeState: ISessionState = { - summary: { resource: AgentSession.uri('copilot', 'seed-1').toString(), provider: 'copilot', title: 'Seeded Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, + summary: { resource: AgentSession.uri('copilotcli', 'seed-1').toString(), provider: 'copilotcli', title: 'Seeded Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, lifecycle: SessionLifecycle.Ready, turns: [], config, }; - connection.setSessionState('seed-1', 'copilot', fakeState); + connection.setSessionState('seed-1', 'copilotcli', fakeState); await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default'); @@ -973,7 +972,7 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.ok(session); provider.getSessionConfig(session!.sessionId); - const sessionUriStr = AgentSession.uri('copilot', 'seed-2').toString(); + const sessionUriStr = AgentSession.uri('copilotcli', 'seed-2').toString(); assert.strictEqual(connection.sessionSubscribeCounts.get(sessionUriStr), 1); assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0); @@ -991,7 +990,7 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.ok(session); provider.getSessionConfig(session!.sessionId); - const sessionUriStr = AgentSession.uri('copilot', 'seed-3').toString(); + const sessionUriStr = AgentSession.uri('copilotcli', 'seed-3').toString(); assert.strictEqual(connection.sessionSubscribeCounts.get(sessionUriStr), 1); assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0); diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index 8efccdb6b731a..3ad2a355b1910 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -136,6 +136,7 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc // Auth is handled by the walkthrough's GitHub button via // IAuthenticationService. Discovery runs separately after auth. this._checkWebAuth(); + this._watchWebAuth(); return; } const isFirstLaunch = !this.storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false); @@ -165,6 +166,32 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc this.showWalkthrough(); } + /** + * Web-only: react to GitHub session loss. When the user's last GitHub + * session is removed (token expired, secret storage wiped, or explicit + * sign-out from the account menu), clear the welcome completion marker + * and show the sign-in walkthrough again. Without this, passive sign-out + * leaves the user on a seemingly-working workbench with a stale UI. + */ + private _watchWebAuth(): void { + this._register(this.authenticationService.onDidChangeSessions(async e => { + if (e.providerId !== 'github' || !e.event.removed?.length) { + return; + } + try { + const remaining = await this.authenticationService.getSessions('github'); + if (remaining.length > 0) { + return; + } + } catch { + // Provider became unavailable — treat as signed out + } + this.logService.info('[sessions welcome] GitHub session removed on web, re-showing walkthrough'); + this.storageService.remove(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION); + this.showWalkthrough(); + })); + } + private showWalkthroughIfNeeded(): void { if (this._needsChatSetup()) { this.showWalkthrough(); diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index 83652b1ea7ae9..a881628e6cee3 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -14,7 +14,6 @@ import { URI } from '../../../../base/common/uri.js'; import { autorun } from '../../../../base/common/observable.js'; import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { Queue } from '../../../../base/common/async.js'; -import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; import { ISession } from '../../../services/sessions/common/session.js'; export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { @@ -72,12 +71,6 @@ export class WorkspaceFolderManagementContribution extends Disposable implements const worktree = repo?.workingDirectory; const branchName = repo?.detail; - // Remote agent host sessions use a read-only FS provider that - // should not be added as a workspace folder. - if (worktree?.scheme === AGENT_HOST_SCHEME || repository?.scheme === AGENT_HOST_SCHEME) { - return undefined; - } - if (worktree) { return { uri: worktree, diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 41615e5a2fbce..cc00fbeccd1a2 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -262,13 +262,11 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } if (!sessionTypeId) { - const defaultType = provider.getSessionTypes(repositoryUri)[0]; - if (!defaultType) { + sessionTypeId = provider.getSessionTypes(repositoryUri)[0]?.id; + if (!sessionTypeId) { throw new Error(`No session types available for provider '${providerId}'`); } - sessionTypeId = defaultType.id; } - const session = provider.createNewSession(repositoryUri, sessionTypeId); this.setActiveSession(session); return session; diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 2d26f435abf04..8d97f27a7506c 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -40,6 +40,16 @@ export const CopilotCloudSessionType: ISessionType = { icon: Codicon.cloud, }; +/** Session type ID for Claude Code sessions. */ +export const CLAUDE_CODE_SESSION_TYPE = 'claude-code'; + +/** Claude Code session type — local agent powered by Claude. */ +export const ClaudeCodeSessionType: ISessionType = { + id: CLAUDE_CODE_SESSION_TYPE, + label: localize('claudeCode', "Claude"), + icon: Codicon.claude, +}; + export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; /** diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 901dcb40f6f08..fa93ea472e090 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -460,7 +460,7 @@ import './browser/layoutActions.js'; import './contrib/accountMenu/browser/account.contribution.js'; import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; -import './contrib/chat/browser/agentHostSessionConfigPicker.js'; +import './contrib/chat/browser/agentHost/agentHostSessionConfigPicker.js'; import './contrib/chat/browser/customizationsDebugLog.contribution.js'; import './contrib/copilotChatSessions/browser/copilotChatSessions.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 746f8bbb3df12..a79f531f9ef47 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -199,7 +199,6 @@ class AgentHostChatSession extends Disposable implements IChatSession { this.progressObs.set(initialProgress, undefined); } - this._register(toDisposable(() => this._onWillDispose.fire())); this._register(toDisposable(onDispose)); // Always provide an interrupt callback so the chat UI's stop button @@ -210,6 +209,16 @@ class AgentHostChatSession extends Disposable implements IChatSession { this.forkSession = this._forkSession; } + override dispose(): void { + // Fire `onWillDispose` BEFORE `super.dispose()` so listeners (notably + // `ContributedChatSessionData` in `ChatSessionsService`) can evict + // this session from their caches. + if (!this._store.isDisposed) { + this._onWillDispose.fire(); + } + super.dispose(); + } + /** * Registers a disposable to be cleaned up when this session is disposed. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatAgentHover.ts b/src/vs/workbench/contrib/chat/browser/widget/chatAgentHover.ts index a9d7948cf9890..0a282d4d84028 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatAgentHover.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatAgentHover.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { IManagedHoverOptions } from '../../../../../base/browser/ui/hover/hover.js'; +import { IHoverAction, IManagedHoverOptions } from '../../../../../base/browser/ui/hover/hover.js'; import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -121,18 +121,26 @@ export class ChatAgentHover extends Disposable { } export function getChatAgentHoverOptions(getAgent: () => IChatAgentData | undefined, commandService: ICommandService): IManagedHoverOptions { - return { - actions: [ - { - commandId: showExtensionsWithIdsCommandId, - label: localize('viewExtensionLabel', "View Extension"), - run: () => { - const agent = getAgent(); - if (agent) { - commandService.executeCommand(showExtensionsWithIdsCommandId, [agent.extensionId.value]); - } - }, + const viewExtensionAction: IHoverAction = { + commandId: showExtensionsWithIdsCommandId, + label: localize('viewExtensionLabel', "View Extension"), + run: () => { + const agent = getAgent(); + if (agent) { + commandService.executeCommand(showExtensionsWithIdsCommandId, [agent.extensionId.value]); } - ] + }, + }; + + // `actions` is a getter so the agent is only resolved at hover-show time. + // Some callers (e.g. chatListRenderer) construct these options before the + // surrounding template is initialized, so calling `getAgent()` eagerly here + // would hit a TDZ on the captured `template` variable. + // Core agents (e.g. agent host) have a placeholder extension id and no real + // extension to view, so we omit the action for them. + return { + get actions() { + return getAgent()?.isCore ? [] : [viewExtensionAction]; + } }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts index f6258db1a3fc7..d539df5cc1b02 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts @@ -53,14 +53,14 @@ export function lastBlockBoundary(text: string): number { export class ParagraphBuffer implements IIncrementalRenderingBuffer { readonly handlesFlush = false; - getRenderable(fullMarkdown: string, lastRendered: string): string { + getRenderable(fullMarkdown: string, _lastRendered: string): string { const lastBlock = lastBlockBoundary(fullMarkdown); let renderable = lastBlock === -1 - ? lastRendered // no complete block yet — keep current + ? fullMarkdown // no paragraph breaks — single block, render as-is : fullMarkdown.slice(0, lastBlock + 2); - // Escape hatch: if too much content has accumulated without a - // block boundary, render what we have. + // Escape hatch: if too much content has accumulated beyond the + // last block boundary, render what we have. if (fullMarkdown.length - renderable.length > MAX_BUFFERED_CHARS) { renderable = fullMarkdown; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts index 80ecb6679d800..ad3ca63fa8849 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts @@ -114,12 +114,23 @@ export class IncrementalDOMMorpher extends Disposable { /** * Forward the stream's word-rate estimate to the active buffer - * (word buffer or line buffer). + * (word buffer or line buffer). When the stream completes, + * also flushes any remaining buffered content for buffers + * that don't handle their own flushing (e.g. ParagraphBuffer). */ updateStreamRate(rate: number, isComplete: boolean): void { if (this._buffer instanceof WordBuffer) { this._buffer.setRate(rate, isComplete); } + + // For buffers that don't handle their own flushing (e.g. + // ParagraphBuffer), force-render any remaining buffered + // content when the stream completes. Without this, content + // after the last \n\n boundary is never rendered. + if (isComplete && !this._buffer.handlesFlush && this._lastMarkdown.length > this._renderedMarkdown.length) { + this._pendingMarkdown = this._lastMarkdown; + this._scheduleRender(); + } } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index ae60dfcfb93e3..e5056e5e59ee7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -553,6 +553,31 @@ suite('AgentHostChatContribution', () => { }); }); + // ---- Session disposal ----------------------------------------------- + + suite('disposal', () => { + + test('fires onWillDispose before session is disposed', async () => { + const { sessionHandler } = createContribution(disposables); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-dispose-test' }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + + // `onWillDispose` is consumed by `ContributedChatSessionData` in + // `ChatSessionsService` to evict disposed sessions from its cache. + // If this event does not fire (e.g. because the emitter was + // disposed before `.fire()` ran during teardown), the service + // would hand out the disposed `IChatSession` to subsequent + // `getOrCreateChatSession` callers. + let fired = 0; + disposables.add(chatSession.onWillDispose(() => { fired++; })); + + chatSession.dispose(); + + assert.strictEqual(fired, 1, 'onWillDispose should fire exactly once when the session is disposed'); + }); + }); + // ---- Session list (IChatSessionItemController) ---------------------- suite('session list', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatIncrementalRendering.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatIncrementalRendering.test.ts index 320ee9b6b9102..c8b320a04ccc8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatIncrementalRendering.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatIncrementalRendering.test.ts @@ -191,6 +191,30 @@ suite('IncrementalDOMMorpher', () => { // No \n\n — content is buffered assert.strictEqual(morpher.tryMorph('partial paragraph'), true); }); + + test('schedules render for content without any paragraph breaks', async () => { + configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingBuffering, 'paragraph'); + const morpher = createMorpher(); + const rendered: string[] = []; + morpher.setRenderCallback(md => rendered.push(md)); + morpher.seed(''); + + // Append content with no \n\n at all — previously this would + // never render because getRenderable returned lastRendered (empty seed). + morpher.tryMorph('single block no paragraph breaks'); + + // Flush the rAF — the full content should render since + // there are no paragraph boundaries to buffer at. + await new Promise(r => mainWindow.requestAnimationFrame(r)); + assert.strictEqual(rendered.length, 1); + assert.strictEqual(rendered[0], 'single block no paragraph breaks'); + + // Further appends should also render + morpher.tryMorph('single block no paragraph breaks — more words'); + await new Promise(r => mainWindow.requestAnimationFrame(r)); + assert.strictEqual(rendered.length, 2); + assert.strictEqual(rendered[1], 'single block no paragraph breaks — more words'); + }); }); suite('seed', () => { @@ -293,6 +317,37 @@ suite('IncrementalDOMMorpher', () => { // No error should occur — rAF is cancelled }); }); + + suite('updateStreamRate', () => { + + test('flushes remaining buffered content on completion for paragraph buffer', async () => { + // Use paragraph buffer (default) + configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingBuffering, 'paragraph'); + const morpher = createMorpher(); + const rendered: string[] = []; + morpher.setRenderCallback(md => rendered.push(md)); + morpher.seed(''); + + const fullContent = 'paragraph one\n\nparagraph two trailing'; + // Append content where the tail has no \n\n boundary + morpher.tryMorph(fullContent); + + // Flush the rAF so the paragraph-boundary render fires + await new Promise(r => mainWindow.requestAnimationFrame(r)); + // Only content up to the last \n\n should have rendered + assert.strictEqual(rendered.length, 1); + assert.strictEqual(rendered[0], 'paragraph one\n\n'); + + // Signal stream completion — should schedule a render of + // the full content including the unbounded tail. + morpher.updateStreamRate(100, true); + await new Promise(r => mainWindow.requestAnimationFrame(r)); + + // The render callback should now have the full content + assert.strictEqual(rendered.length, 2); + assert.strictEqual(rendered[1], fullContent); + }); + }); }); suite('BlockAnimation', () => { diff --git a/src/vs/workbench/contrib/debug/browser/repl.ts b/src/vs/workbench/contrib/debug/browser/repl.ts index 7533ef033b6fc..8ece6109fd6b1 100644 --- a/src/vs/workbench/contrib/debug/browser/repl.ts +++ b/src/vs/workbench/contrib/debug/browser/repl.ts @@ -33,7 +33,7 @@ import { Position } from '../../../../editor/common/core/position.js'; import { Range } from '../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../editor/common/editorCommon.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; -import { CompletionContext, CompletionItem, CompletionItemInsertTextRule, CompletionItemKind, CompletionItemKinds, CompletionList } from '../../../../editor/common/languages.js'; +import { CompletionContext, CompletionItem, CompletionItemInsertTextRule, CompletionItemKind, CompletionItemKinds, CompletionItemLabel, CompletionList } from '../../../../editor/common/languages.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { IModelService } from '../../../../editor/common/services/model.js'; @@ -288,10 +288,12 @@ export class Repl extends FilterViewPane implements IHistoryNavigationWidget { insertText = insertText.substring(0, item.selectionStart) + placeholder + insertText.substring(item.selectionStart + selectionLength); } + const label: string | CompletionItemLabel = item.detail + ? { label: item.label, description: item.detail } + : item.label; suggestions.push({ - label: item.label, + label, insertText, - detail: item.detail, kind: CompletionItemKinds.fromString(item.type || 'property'), filterText: (item.start && item.length) ? text.substring(item.start, item.start + item.length).concat(item.label) : undefined, range: computeRange(item.length || 0), diff --git a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts index bb37f7a15d61b..6e39e4850d7ad 100644 --- a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts @@ -31,7 +31,7 @@ interface IConfiguration extends IWindowsConfiguration { window: IWindowSettings; workbench?: { enableExperiments?: boolean }; telemetry?: { feedback?: { enabled?: boolean } }; - chat?: { extensionUnification?: { enabled?: boolean } }; + chat?: { extensionUnification?: { enabled?: boolean }; agentHost?: { enabled?: boolean } }; _extensionsGallery?: { enablePPE?: boolean }; accessibility?: { verbosity?: { debug?: boolean } }; } @@ -53,7 +53,8 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo 'security.restrictUNCAccess', 'accessibility.verbosity.debug', 'telemetry.feedback.enabled', - 'chat.extensionUnification.enabled' + 'chat.extensionUnification.enabled', + 'chat.agentHost.enabled' ]; private readonly titleBarStyle = new ChangeObserver('string'); @@ -71,6 +72,7 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo private readonly accessibilityVerbosityDebug = new ChangeObserver('boolean'); private readonly telemetryFeedbackEnabled = new ChangeObserver('boolean'); private readonly extensionUnificationEnabled = new ChangeObserver('boolean'); + private readonly agentHostEnabled = new ChangeObserver('boolean'); constructor( @IHostService private readonly hostService: IHostService, @@ -166,6 +168,9 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo // Extension Unification (only when turning on) processChanged(this.extensionUnificationEnabled.handleChange(config.chat?.extensionUnification?.enabled) && config.chat?.extensionUnification?.enabled === true); + // Agent Host + processChanged(this.agentHostEnabled.handleChange(config.chat?.agentHost?.enabled)); + if (askToRelaunch && changed && this.hostService.hasFocus) { this.doConfirm( isNative ? diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 20eb5b46b5c37..a7a981020976d 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -318,7 +318,10 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } private async init(): Promise { - if (isWeb && !this.environmentService.remoteAuthority) { + // Skip initialization for classic web-no-remote (vscode.dev editor), but + // still initialize for the agents web workbench (vscode.dev/agents) where + // account state drives the title bar and the welcome walkthrough. + if (isWeb && !this.environmentService.remoteAuthority && !this.environmentService.isSessionsWindow) { this.logService.debug('[DefaultAccount] Running in web without remote, skipping initialization'); return; } diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 54a061cec5a7c..f3fa03bbf6200 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -343,7 +343,7 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme ); this.sentimentObs = observableFromEvent(this.onDidChangeSentiment, () => this.sentiment); - if ((isWeb && !environmentService.remoteAuthority)) { + if ((isWeb && !environmentService.remoteAuthority && !environmentService.isSessionsWindow)) { ChatEntitlementContextKeys.Setup.hidden.bindTo(this.contextKeyService).set(true); // hide copilot UI on web if unsupported return; }