diff --git a/.github/commands.json b/.github/commands.json index c52e21eeb8dec8..c8afbde389730b 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -115,7 +115,7 @@ "type": "label", "name": "*duplicate", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Thanks for creating this issue! We figured it's covering the same as another one we already have. Thus, we closed this one as a duplicate. You can search for [similar existing issues](${duplicateQuery}). See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting).\n\nHappy Coding!" }, { @@ -544,7 +544,7 @@ "name": "~chat-rate-limiting", "removeLabel": "~chat-rate-limiting", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253124. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -552,7 +552,7 @@ "name": "~chat-request-failed", "removeLabel": "~chat-request-failed", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253136. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -560,7 +560,7 @@ "name": "~chat-rai-content-filters", "removeLabel": "~chat-rai-content-filters", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253130. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -568,7 +568,7 @@ "name": "~chat-public-code-blocking", "removeLabel": "~chat-public-code-blocking", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253129. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -576,7 +576,7 @@ "name": "~chat-lm-unavailable", "removeLabel": "~chat-lm-unavailable", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253137. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -584,7 +584,7 @@ "name": "~chat-authentication", "removeLabel": "~chat-authentication", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253132, if the bug you are experiencing is not there, please comment on this closed issue thread so we can re-open it.", "assign": [ "TylerLeonhardt" @@ -595,7 +595,7 @@ "name": "~chat-no-response-returned", "removeLabel": "~chat-no-response-returned", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253126. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -604,7 +604,7 @@ "removeLabel": "~chat-billing", "addLabel": "chat-billing", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/252230. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -613,7 +613,7 @@ "removeLabel": "~chat-infinite-response-loop", "addLabel": "chat-infinite-response-loop", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253134. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -670,5 +670,11 @@ "addLabel": "accessibility-sla", "removeLabel": "~accessibility-sla", "comment": "The Visual Studio and VS Code teams have an agreement with the Accessibility team that 3:1 contrast is enough for inside the editor." + }, + { + "type": "comment", + "name": "requires-eval-assessment", + "action": "updateLabels", + "addLabel": "~requires-eval-assessment" } ] diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index 4e49bcac12e400..49001f0eb02716 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.117.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" + "value": "$MILESTONE=milestone:\"1.119.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index 40cee05f835aaf..2ed063a13dcbcc 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.117.0\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.119.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index c282bb6a2d9f7e..b34b5777dc4338 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -375,6 +375,214 @@ DEALINGS IN THE SOFTWARE. --------------------------------------------------------- +codex +https://github.com/openai/codex + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 OpenAI + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--------------------------------------------------------- + +--------------------------------------------------------- + Colorsublime-Themes 0.1.0 https://github.com/Colorsublime/Colorsublime-Themes @@ -528,7 +736,7 @@ dompurify 3.2.7 - Apache 2.0 https://github.com/cure53/DOMPurify DOMPurify -Copyright 2025 Dr.-Ing. Mario Heiderich, Cure53 +Copyright 2025-2026 Dr.-Ing. Mario Heiderich, Cure53 DOMPurify is free software; you can redistribute it and/or modify it under the terms of either: @@ -726,7 +934,7 @@ b) the Mozilla Public License Version 2.0 same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] +Copyright 2025-2026 Dr.-Ing. Mario Heiderich, Cure53 Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1985,7 +2193,7 @@ THE SOFTWARE. marked 14.0.0 - MIT https://github.com/markedjs/marked -information +# License information ## Contribution License Agreement diff --git a/cli/ThirdPartyNotices.txt b/cli/ThirdPartyNotices.txt index 623e5993b76b99..63b09f420a9b3b 100644 --- a/cli/ThirdPartyNotices.txt +++ b/cli/ThirdPartyNotices.txt @@ -7753,6 +7753,7 @@ https://github.com/r-efi/r-efi rand 0.7.3 - MIT OR Apache-2.0 rand 0.8.5 - MIT OR Apache-2.0 +rand 0.9.3 - MIT OR Apache-2.0 https://github.com/rust-random/rand Copyright 2018 Developers of the Rand project @@ -7787,6 +7788,7 @@ DEALINGS IN THE SOFTWARE. rand_chacha 0.2.2 - MIT OR Apache-2.0 rand_chacha 0.3.1 - MIT OR Apache-2.0 +rand_chacha 0.9.0 - MIT OR Apache-2.0 https://github.com/rust-random/rand Copyright 2018 Developers of the Rand project @@ -7821,6 +7823,7 @@ DEALINGS IN THE SOFTWARE. rand_core 0.5.1 - MIT OR Apache-2.0 rand_core 0.6.4 - MIT OR Apache-2.0 +rand_core 0.9.5 - MIT OR Apache-2.0 https://github.com/rust-random/rand_core Copyright (c) 2018-2026 The Rand Project Developers diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 535451320340f3..e618a481369880 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -129,6 +129,8 @@ invoke_agent copilot [~15s] | `gen_ai.response.model` | Recommended | `gpt-4o-2024-08-06` | | `gen_ai.usage.input_tokens` | Recommended | `12500` | | `gen_ai.usage.output_tokens` | Recommended | `3200` | +| `gen_ai.usage.cache_read.input_tokens` | When available | `8000` | +| `gen_ai.usage.cache_creation.input_tokens` | When available | `4200` | | `copilot_chat.turn_count` | Always | `4` | | `error.type` | On error | `Error` | | `gen_ai.input.messages` | Opt-in (captureContent) | `[{"role":"user",...}]` | @@ -152,6 +154,8 @@ invoke_agent copilot [~15s] | `gen_ai.response.finish_reasons` | On response | `["stop"]` | | `gen_ai.usage.input_tokens` | On response | `1500` | | `gen_ai.usage.output_tokens` | On response | `250` | +| `gen_ai.usage.cache_read.input_tokens` | When available | `1200` | +| `gen_ai.usage.cache_creation.input_tokens` | When available | `300` | | `copilot_chat.time_to_first_token` | On response | `450` | | `server.address` | When available | `api.github.com` | | `copilot_chat.debug_name` | When available | `agentMode` | @@ -558,14 +562,66 @@ In your trace viewer, filter by `service.name` to see traces from specific agent | `service.name` | Source | |---|---| -| `copilot-chat` | Foreground agent + CLI wrapper spans | +| `copilot-chat` | Foreground agent, CLI wrapper, and Claude agent spans | | `github-copilot` | CLI SDK native spans + CLI terminal | +Within the `copilot-chat` service, distinguish agent types by `gen_ai.agent.name`: + +| `gen_ai.agent.name` | Agent Type | +|---|---| +| `GitHub Copilot Chat` | Foreground agent (agent mode) | +| `copilotcli` | CLI wrapper span | +| `claude` | Claude agent | + +--- + +## Claude Agent + +When OTel is enabled, Claude agent sessions produce extension-level spans (service `copilot-chat`) following GenAI semantic conventions. + +The extension creates spans by intercepting Claude SDK messages and proxying LLM calls through a local HTTP server to CAPI: + +``` +copilot-chat invoke_agent claude [~33s] + ├── chat claude-haiku-4.5 [~5s] (LLM call via CAPI proxy) + ├── execute_tool Agent [~11s] (subagent invocation) + │ ├── chat claude-haiku-4.5 [~4s] (subagent LLM call) + │ ├── execute_tool Grep [~20ms] (subagent tool) + │ └── chat claude-haiku-4.5 [~7s] (subagent LLM call) + ├── chat claude-haiku-4.5 [~3s] + ├── execute_tool Write [~40ms] + ├── chat claude-haiku-4.5 [~3s] + └── execute_hook Stop [~10ms] (hook execution) +``` + +**`invoke_agent claude`** — root span per user request. + +| Attribute | Example | +|---|---| +| `gen_ai.operation.name` | `invoke_agent` | +| `gen_ai.agent.name` | `claude` | +| `gen_ai.provider.name` | `github` | +| `gen_ai.request.model` | `claude-haiku-4.5` | +| `gen_ai.response.model` | `claude-haiku-4-5` | +| `gen_ai.usage.input_tokens` | `103739` (parent-only, excludes subagent tokens) | +| `gen_ai.usage.output_tokens` | `1100` | +| `gen_ai.usage.cache_read.input_tokens` | `64062` | +| `gen_ai.usage.cache_creation.input_tokens` | `39629` | +| `copilot_chat.turn_count` | `8` | +| `copilot_chat.total_cost_usd` | `0.067` (session-wide, includes subagents) | +| `copilot_chat.chat_session_id` | VS Code session ID | + +**`chat`** — one span per LLM API call, created by `chatMLFetcher` via the Claude language model proxy server. Same attributes as foreground agent `chat` spans (token usage, TTFT, response model, cache breakdown). + +**`execute_tool`** — one span per tool invocation. When the tool is `Agent` (subagent), child `chat` and `execute_tool` spans are nested underneath, giving full subagent visibility. + +**`execute_hook`** — one span per Claude hook execution (e.g., `Stop` hooks). + --- ## Interpreting the Data -**Traces** — Visualize the full agent execution in Jaeger or Grafana Tempo. Each `invoke_agent` span contains child `chat` and `execute_tool` spans, making it easy to identify bottlenecks and debug failures. Subagent invocations appear as nested `invoke_agent` spans under `execute_tool runSubagent`. +**Traces** — Visualize the full agent execution in Jaeger or Grafana Tempo. Each `invoke_agent` span contains child `chat` and `execute_tool` spans, making it easy to identify bottlenecks and debug failures. Subagent invocations appear as nested `invoke_agent` spans under `execute_tool runSubagent` (foreground agent) or under `execute_tool Agent` (Claude agent). **Metrics** — Track token usage trends by model and provider, monitor tool success rates via `copilot_chat.tool.call.count`, and watch perceived latency with `copilot_chat.time_to_first_token`. Agent activity metrics (`copilot_chat.edit.acceptance.count`, `copilot_chat.edit.survival.four_gram`, `copilot_chat.lines_of_code.count`) power accept rate and edit survival dashboards. All metrics carry the same resource attributes (`service.name`, `service.version`, `session.id`) for consistent filtering. diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 93c1fc5ed5f089..f09365db1654a8 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1570,6 +1570,11 @@ "description": "%copilot.chronicle.tips.description%", "when": "github.copilot.sessionSearch.enabled" }, + { + "name": "chronicle:reindex", + "description": "%copilot.chronicle.reindex.description%", + "when": "github.copilot.sessionSearch.enabled" + }, { "name": "explain", "description": "%copilot.workspace.explain.description%" diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 48024dc1e37ccd..36eb47115313a2 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -171,6 +171,7 @@ "copilot.chronicle.description": "Session history tools and insights", "copilot.chronicle.standup.description": "Generate a standup report from recent chat sessions", "copilot.chronicle.tips.description": "Get personalized tips based on your chat session usage patterns", + "copilot.chronicle.reindex.description": "Rebuild the local session index from stored session logs. Add 'force' to re-process already indexed sessions.", "github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.", "github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.", "github.copilot.config.localIndex.enabled": "Enable local session tracking. When enabled, session data is tracked locally for /chronicle commands.", @@ -420,7 +421,7 @@ "github.copilot.config.cli.thinkingEffort.enabled": "Enable thinking effort for Language Models in Copilot CLI.", "github.copilot.config.cli.sessionControllerForSessionsApp.enabled": "Enable the new session controller API for Sessions App. Requires VS Code reload.", "github.copilot.config.cli.terminalLinks.enabled": "Enable advanced clickable file links in Copilot CLI terminals. Resolves relative paths against session state directories. Requires VS Code reload.", - "github.copilot.config.cli.remote.enabled": "Enable the experimental /remote command for Copilot CLI sessions, allowing you to view and steer sessions from github.com (Mission Control).", + "github.copilot.config.cli.remote.enabled": "Enable the /remote command for Copilot CLI sessions, allowing you to view and steer from GitHub.com and the GitHub mobile app.", "github.copilot.config.backgroundAgent.enabled": "Enable the Copilot CLI. When disabled, the Copilot CLI will not be available in 'Continue In' context menus.", "github.copilot.config.cloudAgent.enabled": "Enable the Cloud Agent. When disabled, the Cloud Agent will not be available in 'Continue In' context menus.", "github.copilot.config.copilotMemory.enabled": "Enable agentic memory for GitHub Copilot. When enabled, Copilot can store repository-scoped facts about your codebase conventions, structure, and preferences remotely on GitHub, and recall them in future conversations to provide more contextually relevant assistance. [Learn more](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/copilot-memory).", @@ -447,7 +448,7 @@ "github.copilot.command.cli.compact.description": "Free up context by compacting the conversation history", "github.copilot.command.cli.plan.description": "Create an implementation plan before coding", "github.copilot.command.cli.fleet.description": "Enable fleet mode for parallel subagent execution", - "github.copilot.command.cli.remote.description": "Enable remote control for this session", + "github.copilot.command.cli.remote.description": "Show remote control status, or use /remote on and /remote off", "github.copilot.command.claude.sessions.rename": "Rename...", "github.copilot.command.claude.sessions.commit": "Commit", "github.copilot.command.claude.sessions.commitAndSync": "Commit and Sync", diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts index a9931c1ab9d258..a7e93dd1c841f4 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts @@ -10,7 +10,7 @@ import * as l10n from '@vscode/l10n'; import type * as vscode from 'vscode'; import { vBoolean, vLiteral, vObj, vString, type ValidatorType } from '../../../../platform/configuration/common/validator'; import { ILogService } from '../../../../platform/log/common/logService'; -import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel, type ISpanHandle } from '../../../../platform/otel/common/index'; +import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel, type ISpanHandle, type TraceContext } from '../../../../platform/otel/common/index'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger'; import { ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation'; @@ -37,6 +37,10 @@ export interface MessageHandlerState { readonly unprocessedToolCalls: Map; readonly otelToolSpans: Map; readonly otelHookSpans: Map; + readonly parentTraceContext?: TraceContext; + /** Trace contexts for subagent tool spans, keyed by tool_use_id. Used to parent + * child spans (chat, tool) from subagent messages under the Agent tool span. */ + readonly subagentTraceContexts: Map; } export interface MessageHandlerResult { @@ -148,6 +152,13 @@ export function handleAssistantMessage( const { stream } = request; const { otelToolSpans, unprocessedToolCalls } = state; + // Resolve the OTel parent context for spans in this message. + // If the message is from a subagent (parent_tool_use_id is set), parent spans + // under the Agent tool's execute_tool span. Otherwise, use the root invoke_agent context. + const spanParentContext = (message.parent_tool_use_id + ? state.subagentTraceContexts.get(message.parent_tool_use_id) + : undefined) ?? state.parentTraceContext; + for (const item of message.message.content) { if (item.type === 'text') { stream.markdown(item.text); @@ -164,6 +175,7 @@ export function handleAssistantMessage( [GenAiAttr.TOOL_CALL_ID]: item.id, [CopilotChatAttr.CHAT_SESSION_ID]: sessionId, }, + parentTraceContext: spanParentContext, }); if (item.input !== undefined) { try { @@ -176,6 +188,15 @@ export function handleAssistantMessage( } otelToolSpans.set(item.id, toolSpan); + // For Agent/Task (subagent) tool calls, store the span's trace context so that + // child messages (with parent_tool_use_id = this tool's id) are parented here. + if (item.name === ClaudeToolNames.Task || item.name === 'Agent') { + const toolSpanCtx = toolSpan.getSpanContext(); + if (toolSpanCtx) { + state.subagentTraceContexts.set(item.id, toolSpanCtx); + } + } + if (request.editTracker && claudeEditTools.includes(item.name)) { try { const uris = getAffectedUrisForEditTool(item.name, item.input); @@ -359,7 +380,7 @@ export function handleHookStarted( state: MessageHandlerState, ): void { const otelService = accessor.get(IOTelService); - const span = otelService.startSpan(`user_hook ${message.hook_event}:${message.hook_name}`, { + const span = otelService.startSpan(`${GenAiOperationName.EXECUTE_HOOK} ${message.hook_name}`, { kind: SpanKind.INTERNAL, attributes: { [GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK, @@ -368,6 +389,7 @@ export function handleHookStarted( 'copilot_chat.hook_id': message.hook_id, [CopilotChatAttr.CHAT_SESSION_ID]: sessionId, }, + parentTraceContext: state.parentTraceContext, }); state.otelHookSpans.set(message.hook_id, span); } diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts index 5b93a75029cf47..43c9ecdd0c5440 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts @@ -6,6 +6,7 @@ import { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import type * as vscode from 'vscode'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; +import type { TraceContext } from '../../../../platform/otel/common/otelService'; import { createServiceIdentifier } from '../../../../util/common/services'; import { Event } from '../../../../util/vs/base/common/event'; import type { ClaudeFolderInfo } from './claudeFolderInfo'; @@ -23,6 +24,7 @@ export interface SessionState { folderInfo: ClaudeFolderInfo | undefined; usageHandler: UsageHandler | undefined; reasoningEffort: EffortLevel | undefined; + traceContext: TraceContext | undefined; } /** @@ -102,6 +104,16 @@ export interface IClaudeSessionStateService { * Sets the reasoning effort for a session. */ setReasoningEffortForSession(sessionId: string, effort: EffortLevel | undefined): void; + + /** + * Gets the OTel trace context for a session (used to parent chat spans to invoke_agent). + */ + getTraceContextForSession(sessionId: string): TraceContext | undefined; + + /** + * Sets the OTel trace context for a session. + */ + setTraceContextForSession(sessionId: string, traceContext: TraceContext | undefined): void; } export const IClaudeSessionStateService = createServiceIdentifier('IClaudeSessionStateService'); diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts index 02ea03fa380464..7677977e724626 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts @@ -98,6 +98,7 @@ function createState(): MessageHandlerState { unprocessedToolCalls: new Map(), otelToolSpans: new Map(), otelHookSpans: new Map(), + subagentTraceContexts: new Map(), }; } @@ -635,7 +636,7 @@ describe('handleHookStarted', () => { handleHookStarted(makeHookStarted('hook-42', 'lint-check', 'PreToolUse'), accessor, TEST_SESSION_ID, state); expect(startSpanSpy).toHaveBeenCalledWith( - 'user_hook PreToolUse:lint-check', + 'execute_hook lint-check', expect.objectContaining({ attributes: expect.any(Object) }), ); expect(state.otelHookSpans.has('hook-42')).toBe(true); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index e2b82b4beeee0b..c341926fa12567 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -11,7 +11,8 @@ import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/ch import { INativeEnvService } from '../../../../platform/env/common/envService'; import { ILogService } from '../../../../platform/log/common/logService'; import { IMcpService } from '../../../../platform/mcp/common/mcpService'; -import { CopilotChatAttr, GenAiAttr, IOTelService, type ISpanHandle, SpanKind, SpanStatusCode, truncateForOTel } from '../../../../platform/otel/common/index'; +import { IOTelService, type ISpanHandle, SpanStatusCode, type TraceContext } from '../../../../platform/otel/common/index'; +import { deriveClaudeOTelEnv } from '../../../../platform/otel/common/agentOTelEnv'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { DeferredPromise } from '../../../../util/vs/base/common/async'; @@ -33,6 +34,7 @@ import { resolvePromptToContentBlocks } from './claudePromptResolver'; import { ClaudeSettingsChangeTracker } from './claudeSettingsChangeTracker'; import { ParsedClaudeModelId } from '../common/claudeModelId'; import { IClaudeSessionStateService } from '../common/claudeSessionStateService'; +import { ClaudeOTelTracker } from './claudeOTelTracker'; // Manages Claude Code agent interactions and language model server lifecycle export class ClaudeAgentManager extends Disposable { @@ -169,6 +171,7 @@ export class ClaudeCodeSession extends Disposable { private _currentToolNames: ReadonlySet | undefined; private _gateway: vscode.McpGateway | undefined; private _gatewayIdleTimeout: ReturnType | undefined; + private _otelTracker: ClaudeOTelTracker | undefined; /** * Sets the model on the active SDK session, or stores it for the next session start. @@ -223,6 +226,7 @@ export class ClaudeCodeSession extends Disposable { this._currentModelId = initialModelId; this._currentPermissionMode = initialPermissionMode; this._isResumed = !isNewSession; + this._otelTracker = new ClaudeOTelTracker(this.sessionId, this._otelService, this.sessionStateService); this._debugFileLogger.startSession(this.sessionId).catch(err => { this.logService.error('[ClaudeCodeSession] Failed to start debug log session', err); }); @@ -476,7 +480,9 @@ export class ClaudeCodeSession extends Disposable { ANTHROPIC_AUTH_TOKEN: `${this.serverConfig.nonce}.${this.sessionId}`, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', USE_BUILTIN_RIPGREP: '0', - PATH: `${this.envService.appRoot}/node_modules/@vscode/ripgrep/bin${pathSep}${process.env.PATH}` + PATH: `${this.envService.appRoot}/node_modules/@vscode/ripgrep/bin${pathSep}${process.env.PATH}`, + // Forward OTel configuration to the Claude SDK subprocess + ...deriveClaudeOTelEnv(this._otelService.config), }, attribution: { commit: '', @@ -546,20 +552,12 @@ export class ClaudeCodeSession extends Disposable { new CapturingToken(promptLabel, 'claude', undefined, undefined, this.sessionId) ); - // Emit a user_message span event for the debug panel - // Use a non-standard operation name so completedSpanToDebugEvent ignores this span - // (avoids a "Model Turn · 0 tokens" entry); only the user_message event is rendered. - const userMsgSpan = this._otelService.startSpan('user_message', { - kind: SpanKind.INTERNAL, - attributes: { - [GenAiAttr.OPERATION_NAME]: 'user_message', - [CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId, - }, - }); - const userContent = truncateForOTel(promptLabel); - userMsgSpan.setAttribute(CopilotChatAttr.USER_REQUEST, userContent); - userMsgSpan.addEvent('user_message', { content: userContent, [CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId }); - userMsgSpan.end(); + // Start OTel tracking for this request + const modelId = this._currentModelId.toEndpointModelId(); + this._otelTracker!.startRequest(modelId); + + // Emit user_message span event for the debug panel + this._otelTracker!.emitUserMessage(promptLabel); yield { type: 'user', @@ -600,6 +598,7 @@ export class ClaudeCodeSession extends Disposable { private async _processMessages(): Promise { const otelToolSpans = new Map(); const otelHookSpans = new Map(); + const subagentTraceContexts = new Map(); try { const unprocessedToolCalls = new Map(); for await (const message of this._queryGenerator!) { @@ -625,7 +624,11 @@ export class ClaudeCodeSession extends Disposable { continue; } + // Track OTel metrics from SDK messages + this._otelTracker!.onMessage(message, subagentTraceContexts); + this.logService.trace(`claude-agent-sdk Message: ${JSON.stringify(message, null, 2)}`); + const result = this.instantiationService.invokeFunction(dispatchMessage, message, this.sessionId, { stream: this._currentRequest.stream, toolInvocationToken: this._currentRequest.toolInvocationToken, @@ -635,9 +638,13 @@ export class ClaudeCodeSession extends Disposable { unprocessedToolCalls, otelToolSpans, otelHookSpans, + parentTraceContext: this._otelTracker!.traceContext, + subagentTraceContexts, }); if (result?.requestComplete) { + // End the invoke_agent span for this request + this._otelTracker!.endRequest(); // Clear the capturing token so subsequent requests get their own this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined); // Resolve and remove the completed request @@ -647,6 +654,7 @@ export class ClaudeCodeSession extends Disposable { } this._currentRequest = undefined; this._startGatewayIdleTimer(); + subagentTraceContexts.clear(); } } // Generator ended normally - clean up so next invoke starts fresh @@ -665,12 +673,16 @@ export class ClaudeCodeSession extends Disposable { span.end(); } otelHookSpans.clear(); + // End any lingering invoke_agent span + this._otelTracker!.endRequestWithError('session ended'); } } private _cleanup(error: Error): void { // Clear the capturing token so it doesn't leak across sessions or error boundaries this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined); + // End invoke_agent span with error if still open + this._otelTracker!.endRequestWithError(error.message); this._resetSessionState(); const wasYielding = this._yieldInProgress; diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts index 27c4e851f9010f..d9ac0c66ed0c53 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts @@ -12,6 +12,7 @@ import { ChatLocation, ChatResponse } from '../../../../platform/chat/common/com import { CustomModel, EndpointEditToolName } from '../../../../platform/endpoint/common/endpointProvider'; import { AnthropicMessagesProcessor } from '../../../../platform/endpoint/node/messagesApi'; import { ILogService } from '../../../../platform/log/common/logService'; +import { IOTelService } from '../../../../platform/otel/common/otelService'; import { FinishedCallback, getRequestId, OptionalChatRequestParams } from '../../../../platform/networking/common/fetch'; import { Response } from '../../../../platform/networking/common/fetcherService'; import { IChatEndpoint, ICreateEndpointBodyOptions, IEndpointBody, IEndpointFetchOptions, IMakeChatRequestOptions } from '../../../../platform/networking/common/networking'; @@ -80,6 +81,7 @@ export class ClaudeLanguageModelServer extends Disposable { @IRequestLogger private readonly requestLogger: IRequestLogger, @IInstantiationService private readonly instantiationService: IInstantiationService, @IClaudeCodeModels private readonly claudeCodeModels: IClaudeCodeModels, + @IOTelService private readonly _otelService: IOTelService, ) { super(); this.config = { @@ -229,10 +231,16 @@ export class ClaudeLanguageModelServer extends Disposable { userInitiatedRequest: isUserInitiatedMessage }, tokenSource.token); + // Wrap in trace context so chat spans are parented to the invoke_agent span + const traceContext = sessionId ? this.sessionStateService.getTraceContextForSession(sessionId) : undefined; + const doRequestInContext = traceContext + ? () => this._otelService.runWithTraceContext(traceContext, doRequest) + : doRequest; + if (capturingToken) { - await this.requestLogger.captureInvocation(capturingToken, doRequest); + await this.requestLogger.captureInvocation(capturingToken, doRequestInContext); } else { - await doRequest(); + await doRequestInContext(); } requestComplete = true; diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts new file mode 100644 index 00000000000000..f5690a1a215160 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import { CopilotChatAttr, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, IOTelService, type ISpanHandle, SpanKind, SpanStatusCode, type TraceContext, truncateForOTel } from '../../../../platform/otel/common/index'; +import { IClaudeSessionStateService } from '../common/claudeSessionStateService'; + +/** + * Manages OTel span lifecycle for a Claude agent session. + * + * Extracted from ClaudeCodeSession to keep tracing concerns separate from + * session orchestration. Tracks the invoke_agent root span, accumulates + * parent-only token usage, and manages trace context for subagent nesting. + */ +export class ClaudeOTelTracker { + private _currentSpan: ISpanHandle | undefined; + private _currentTraceContext: TraceContext | undefined; + private _startTime: number | undefined; + private _isFirstRequest = true; + private _turnCount = 0; + private _parentInputTokens = 0; + private _parentOutputTokens = 0; + private _parentCacheReadTokens = 0; + private _parentCacheCreationTokens = 0; + + constructor( + private readonly _sessionId: string, + private readonly _otelService: IOTelService, + private readonly _sessionStateService: IClaudeSessionStateService, + ) { } + + /** The trace context of the current invoke_agent span, used to parent child spans. */ + get traceContext(): TraceContext | undefined { + return this._currentTraceContext; + } + + /** + * Starts a new invoke_agent span for a user request. + * Ends any previous span and resets accumulators. + */ + startRequest(modelId: string): void { + this.endRequest(); + + this._currentSpan = this._otelService.startSpan('invoke_agent claude', { + kind: SpanKind.INTERNAL, + attributes: { + [GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT, + [GenAiAttr.AGENT_NAME]: 'claude', + [GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB, + [GenAiAttr.CONVERSATION_ID]: this._sessionId, + [CopilotChatAttr.SESSION_ID]: this._sessionId, + [CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId, + [GenAiAttr.REQUEST_MODEL]: modelId, + }, + }); + this._currentTraceContext = this._currentSpan.getSpanContext(); + this._startTime = Date.now(); + this._turnCount = 0; + this._parentInputTokens = 0; + this._parentOutputTokens = 0; + this._parentCacheReadTokens = 0; + this._parentCacheCreationTokens = 0; + + // Store trace context so the language model server can parent chat spans + this._sessionStateService.setTraceContextForSession(this._sessionId, this._currentTraceContext); + + // Emit session start event and metric for the first request + if (this._isFirstRequest) { + this._isFirstRequest = false; + GenAiMetrics.incrementSessionCount(this._otelService); + emitSessionStartEvent(this._otelService, this._sessionId, modelId, 'claude'); + } + } + + /** + * Emits a user_message span event for the debug panel. + */ + emitUserMessage(promptLabel: string): void { + const userMsgSpan = this._otelService.startSpan('user_message', { + kind: SpanKind.INTERNAL, + attributes: { + [GenAiAttr.OPERATION_NAME]: 'user_message', + [CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId, + }, + parentTraceContext: this._currentTraceContext, + }); + const userContent = truncateForOTel(promptLabel); + userMsgSpan.setAttribute(CopilotChatAttr.USER_REQUEST, userContent); + userMsgSpan.addEvent('user_message', { content: userContent, [CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId }); + userMsgSpan.end(); + } + + /** + * Processes an SDK message for OTel tracking. + * Call this for every message in the processing loop. + */ + onMessage(message: SDKMessage, subagentTraceContexts: Map): void { + if (message.type === 'assistant') { + this._turnCount++; + this._accumulateParentTokenUsage(message); + } + + if (message.type === 'result' && this._currentSpan) { + this._setResultAttributes(message); + } + + this._updateTraceContextForMessage(message, subagentTraceContexts); + } + + /** + * Ends the current invoke_agent span with OK status and records metrics. + */ + endRequest(): void { + this._endSpan(); + } + + /** + * Ends the current invoke_agent span with ERROR status. + */ + endRequestWithError(message: string): void { + this._endSpan(SpanStatusCode.ERROR, message); + } + + // ── Private ────────────────────────────────────────────────────────────── + + private _endSpan(statusCode?: SpanStatusCode, statusMessage?: string): void { + if (!this._currentSpan) { + return; + } + const span = this._currentSpan; + span.setAttribute(CopilotChatAttr.TURN_COUNT, this._turnCount); + + // Set parent-only token usage (comparable with foreground agent). + span.setAttributes({ + [GenAiAttr.USAGE_INPUT_TOKENS]: this._parentInputTokens, + [GenAiAttr.USAGE_OUTPUT_TOKENS]: this._parentOutputTokens, + ...(this._parentCacheReadTokens ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: this._parentCacheReadTokens } : {}), + ...(this._parentCacheCreationTokens ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: this._parentCacheCreationTokens } : {}), + }); + + if (statusCode !== undefined) { + span.setStatus(statusCode, statusMessage); + } else { + span.setStatus(SpanStatusCode.OK); + } + span.end(); + + // Record agent-level metrics + if (this._startTime) { + const durationSec = (Date.now() - this._startTime) / 1000; + GenAiMetrics.recordAgentDuration(this._otelService, 'claude', durationSec); + } + GenAiMetrics.recordAgentTurnCount(this._otelService, 'claude', this._turnCount); + + this._currentSpan = undefined; + this._currentTraceContext = undefined; + this._startTime = undefined; + this._sessionStateService.setTraceContextForSession(this._sessionId, undefined); + } + + /** + * Accumulates parent-only token usage from an assistant message. + * Excludes subagent turns so gen_ai.usage.* on the root span is comparable + * with the foreground agent. + */ + private _accumulateParentTokenUsage(message: SDKMessage & { type: 'assistant' }): void { + if (message.parent_tool_use_id) { + return; + } + const msgUsage = message.message?.usage; + if (msgUsage) { + this._parentInputTokens += (msgUsage.input_tokens ?? 0) + + (msgUsage.cache_creation_input_tokens ?? 0) + + (msgUsage.cache_read_input_tokens ?? 0); + this._parentOutputTokens += (msgUsage.output_tokens ?? 0); + this._parentCacheReadTokens += (msgUsage.cache_read_input_tokens ?? 0); + this._parentCacheCreationTokens += (msgUsage.cache_creation_input_tokens ?? 0); + } + } + + /** + * Sets cost, turn count, and response model on the invoke_agent span from a result message. + */ + private _setResultAttributes(message: SDKMessage & { type: 'result' }): void { + if (!this._currentSpan) { + return; + } + if (message.num_turns !== undefined) { + this._currentSpan.setAttribute(CopilotChatAttr.TURN_COUNT, message.num_turns); + } + if (message.total_cost_usd !== undefined) { + this._currentSpan.setAttribute('copilot_chat.total_cost_usd', message.total_cost_usd); + } + const responseModel = message.modelUsage ? Object.keys(message.modelUsage)[0] : undefined; + if (responseModel) { + this._currentSpan.setAttribute(GenAiAttr.RESPONSE_MODEL, responseModel); + } + } + + /** + * Updates the session trace context based on whether a message is from a subagent. + * Ensures chat spans created by chatMLFetcher are parented under the correct + * Agent tool span during subagent execution. + */ + private _updateTraceContextForMessage(message: SDKMessage, subagentTraceContexts: Map): void { + if (!('parent_tool_use_id' in message)) { + return; + } + if (message.parent_tool_use_id) { + const subagentCtx = subagentTraceContexts.get(message.parent_tool_use_id); + if (subagentCtx) { + this._sessionStateService.setTraceContextForSession(this._sessionId, subagentCtx); + } + } else { + this._sessionStateService.setTraceContextForSession(this._sessionId, this._currentTraceContext); + } + } +} diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts index 2dcf78c549b20b..f54975c4ff79f3 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts @@ -5,6 +5,7 @@ import { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; +import type { TraceContext } from '../../../../platform/otel/common/otelService'; import { arrayEquals } from '../../../../util/vs/base/common/equals'; import { Emitter } from '../../../../util/vs/base/common/event'; import { Disposable } from '../../../../util/vs/base/common/lifecycle'; @@ -46,6 +47,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); this._onDidChangeSessionState.fire({ sessionId, modelId }); } @@ -66,6 +68,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); this._onDidChangeSessionState.fire({ sessionId, permissionMode: mode }); } @@ -83,6 +86,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); } @@ -102,6 +106,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); this._onDidChangeSessionState.fire({ sessionId, folderInfo }); } @@ -119,6 +124,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: handler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); } @@ -138,6 +144,24 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: effort, + traceContext: existing?.traceContext, + }); + } + + getTraceContextForSession(sessionId: string): TraceContext | undefined { + return this._sessionState.get(sessionId)?.traceContext; + } + + setTraceContextForSession(sessionId: string, traceContext: TraceContext | undefined): void { + const existing = this._sessionState.get(sessionId); + this._sessionState.set(sessionId, { + modelId: existing?.modelId, + permissionMode: existing?.permissionMode ?? 'acceptEdits', + capturingToken: existing?.capturingToken, + folderInfo: existing?.folderInfo, + usageHandler: existing?.usageHandler, + reasoningEffort: existing?.reasoningEffort, + traceContext, }); } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts index f2240df3f365b4..816e13cd9b2d91 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts @@ -475,10 +475,10 @@ export function stripReminders(text: string): string { .replace(/[\s\S]*?<\/reminder>\s*/g, '') .replace(/[\s\S]*?<\/attachments>\s*/g, '') .replace(/[\s\S]*?<\/userRequest>\s*/g, '') + .replace(/[\s\S]*?<\/user_query>\s*/g, '') .replace(/[\s\S]*?<\/context>\s*/g, '') .replace(/[\s\S]*?<\/current_datetime>\s*/g, '') .replace(/]*\/?>\s*/g, '') - .replace(/]*\/?>\s*/g, '') .trim(); } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts index 0c39b7e1c35187..1d358f035bf73d 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts @@ -74,6 +74,10 @@ describe('CopilotCLITools', () => { const input = ' Body'; expect(stripReminders(input)).toBe('Body'); }); + it('removes user_query blocks', () => { + const input = 'Hidden prompt Visible'; + expect(stripReminders(input)).toBe('Visible'); + }); it('removes multiple constructs mixed', () => { const input = 'xOney Two'; // Current behavior compacts content without guaranteeing spacing @@ -1299,4 +1303,3 @@ describe('CopilotCLITools', () => { }); }); }); - diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index b7decb3b93fed4..7fbb1fbea9fd36 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -33,7 +33,7 @@ import { IToolsService } from '../../../tools/common/toolsService'; import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore'; import { ExternalEditTracker } from '../../common/externalEditTracker'; import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo'; -import { enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, ToolCall, updateTodoListFromSqlItems, clearTodoList } from '../common/copilotCLITools'; +import { clearTodoList, enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, stripReminders, ToolCall, updateTodoListFromSqlItems } from '../common/copilotCLITools'; import { clearPendingCopilotCLIRequestContext, setPendingCopilotCLIRequestContext } from '../common/pendingRequestContext'; import { getCopilotCLISessionDir } from './cliHelpers'; import { SessionIdForCLI } from '../common/utils'; @@ -43,7 +43,7 @@ import { handleExitPlanMode } from './exitPlanModeHandler'; import { type McCommand, type McEvent, type McSessionCreateResult, MissionControlApiClient } from './missionControlApiClient'; import { handleMcpPermission, handleReadPermission, handleShellPermission, handleWritePermission, type PermissionRequest, type PermissionRequestResult, showInteractivePermissionPrompt } from './permissionHelpers'; import { TodoSqlQuery } from './todoSqlQuery'; -import { IQuestion, IUserQuestionHandler } from './userInputHelpers'; +import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from './userInputHelpers'; /** * Known commands that can be sent to a CopilotCLI session instead of a free-form prompt. @@ -63,9 +63,11 @@ export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact', 'pla */ interface McSharedState { mcSessionId: string; + mcFrontendUrl?: string; mcEventBuffer: McEvent[]; mcCompletedCommandIds: string[]; mcPendingPermissionRequests: Map; + mcPendingUserInputRequests?: Set; mcFlushInterval: ReturnType | undefined; mcPollInterval: ReturnType | undefined; mcLastEventId: string | null; @@ -89,6 +91,35 @@ interface McPermissionResponseCommandData { readonly scope?: 'once' | 'session'; } +interface UserInputResponse { + readonly answer: string; + readonly wasFreeform: boolean; +} + +interface McPendingUserInputRequest { + readonly requestId: string; + readonly toolCallId?: string; + resolve(result: UserInputResponse | undefined): void; +} + +interface McAskUserResponsePayload { + readonly requestId?: string; + readonly promptId?: string; + readonly toolCallId?: string; + readonly answer?: string; + readonly wasFreeform?: boolean; + readonly freeText?: string | null; + readonly selected?: readonly string[]; + readonly skipped?: boolean; + readonly response?: { + readonly answer?: string; + readonly wasFreeform?: boolean; + readonly freeText?: string | null; + readonly selected?: readonly string[]; + readonly skipped?: boolean; + }; +} + const skippedMissionControlEventTypes = new Set([ 'assistant.message_delta', 'assistant.streaming_delta', @@ -145,11 +176,106 @@ function getMissionControlSessionTitleFromEvent(event: { type?: string; data?: u return typeof title === 'string' && title.trim().length > 0 ? title : undefined; } +function getMissionControlEventData(event: { type?: string; data?: unknown }): Record { + if (!event.data || typeof event.data !== 'object') { + return {}; + } + + const data = event.data as Record; + if (event.type === 'user.message') { + const content = data.content; + if (typeof content !== 'string') { + return data; + } + + const sanitizedContent = stripReminders(content); + return sanitizedContent === content ? data : { ...data, content: sanitizedContent }; + } + + if (event.type !== 'tool.execution_start') { + return data; + } + + const toolName = data.toolName; + if (toolName !== 'bash' && toolName !== 'powershell' && toolName !== 'task') { + return data; + } + + const args = data.arguments; + if (!args || typeof args !== 'object' || !('description' in args)) { + return data; + } + + const { description: _description, ...sanitizedArgs } = args as Record; + return { ...data, arguments: sanitizedArgs }; +} + function getMissionControlPendingCommandCompletionIds(state: McSharedState): Set { state.mcPendingCommandCompletionIds ??= new Set(); return state.mcPendingCommandCompletionIds; } +function getMissionControlPendingUserInputRequests(state: McSharedState): Set { + state.mcPendingUserInputRequests ??= new Set(); + return state.mcPendingUserInputRequests; +} + +function getMissionControlPendingUserInputRequest(state: McSharedState, payload: McAskUserResponsePayload | undefined): McPendingUserInputRequest | undefined { + const pendingRequests = [...getMissionControlPendingUserInputRequests(state)]; + const identifiers = [ + payload?.requestId, + payload?.promptId, + payload?.toolCallId, + ].filter((value): value is string => typeof value === 'string' && value.length > 0); + + if (identifiers.length > 0) { + return pendingRequests.find(request => + identifiers.includes(request.requestId) || + (typeof request.toolCallId === 'string' && identifiers.includes(request.toolCallId)) + ); + } + + return pendingRequests.length === 1 ? pendingRequests[0] : undefined; +} + +function toSdkUserInputResponse(answer: IQuestionAnswer | undefined): UserInputResponse { + if (!answer) { + return { answer: '', wasFreeform: false }; + } + + if (answer.freeText) { + return { answer: answer.freeText, wasFreeform: true }; + } + + return { answer: answer.selected.join(', '), wasFreeform: false }; +} + +function getMcAskUserResponse(payload: McAskUserResponsePayload | undefined, rawContent: string): UserInputResponse | undefined { + const response = payload?.response ?? payload; + const answer = typeof response?.answer === 'string' + ? response.answer + : typeof response?.freeText === 'string' + ? response.freeText + : Array.isArray(response?.selected) + ? response.selected.filter((value): value is string => typeof value === 'string').join(', ') + : response?.skipped + ? '' + : payload === undefined + ? rawContent + : undefined; + + if (answer === undefined) { + return undefined; + } + + return { + answer, + wasFreeform: typeof response?.wasFreeform === 'boolean' + ? response.wasFreeform + : typeof response?.freeText === 'string', + }; +} + function maybeAcknowledgeMissionControlCommandFromEvent(state: McSharedState, event: { type?: string; data?: unknown }): void { const commandId = getMissionControlCommandIdFromEvent(event); if (!commandId) { @@ -673,17 +799,28 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes allowFreeformInput: event.data.allowFreeform, header: event.data.question, }; - const answer = await this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, token); - flushPendingInvocationMessages(); - if (!answer) { - this._sdkSession.respondToUserInput(event.data.requestId, { answer: '', wasFreeform: false }); - return; - } - if (answer.freeText) { - this._sdkSession.respondToUserInput(event.data.requestId, { answer: answer.freeText, wasFreeform: true }); + let response: UserInputResponse; + if (this._mcState) { + const userInputResolutionTokenSource = new CancellationTokenSource(token); + const localQuestionPromise = this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, userInputResolutionTokenSource.token, event.data.toolCallId); + const remoteQuestionPromise = this._waitForMcUserInputResponse(this._mcState, event.data.requestId, event.data.toolCallId, userInputResolutionTokenSource.token); + try { + const result = await Promise.race([ + localQuestionPromise.then(answer => ({ source: 'local' as const, response: toSdkUserInputResponse(answer) })), + remoteQuestionPromise.then(result => ({ source: 'remote' as const, response: result })), + ]); + if (result.source === 'remote' && result.response && event.data.toolCallId) { + await this._userQuestionHandler.notifyQuestionCarouselAnswer?.(event.data.toolCallId, userInputRequest, result.response); + } + response = result.response ?? { answer: '', wasFreeform: false }; + } finally { + userInputResolutionTokenSource.dispose(true); + } } else { - this._sdkSession.respondToUserInput(event.data.requestId, { answer: answer.selected.join(', '), wasFreeform: false }); + response = toSdkUserInputResponse(await this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, token, event.data.toolCallId)); } + flushPendingInvocationMessages(); + this._sdkSession.respondToUserInput(event.data.requestId, response); }))); disposables.add(toDisposable(this._sdkSession.on('session.title_changed', (event) => { this._title = event.data.title; @@ -1060,8 +1197,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } /** - * Handle `/remote` command — enables or disables Mission Control remote - * control for this session by calling the Copilot API directly. + * Handle `/remote` command — prints status or enables/disables Mission + * Control remote control for this session by calling the Copilot API directly. */ private async _handleRemoteControl(input: CopilotCLISessionInput): Promise { if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIRemoteEnabled)) { @@ -1071,10 +1208,25 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const args = ('prompt' in input ? input.prompt : '')?.trim().toLowerCase(); const isCurrentlyActive = !!this._mcState; - const enable = args === 'off' ? false : (args === 'on' ? true : !isCurrentlyActive); + if (!args) { + this._showRemoteControlStatus(); + return; + } + if (args !== 'on' && args !== 'off') { + this._stream?.markdown(l10n.t('Usage: /remote, /remote on, /remote off')); + return; + } + if (args === 'on' && isCurrentlyActive) { + this._showRemoteControlStatus(); + return; + } + if (args === 'off' && !isCurrentlyActive) { + this._showRemoteControlStatus(); + return; + } try { - if (!enable) { + if (args === 'off') { await this._teardownRemoteControl(); this._stream?.markdown(l10n.t('Remote control disabled.')); return; @@ -1134,6 +1286,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // so it persists across CopilotCLISession instances. const sharedState: McSharedState = { mcSessionId: mcData.id, + mcFrontendUrl: undefined, mcEventBuffer: [], mcCompletedCommandIds: [], mcPendingPermissionRequests: new Map(), @@ -1229,7 +1382,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes parentId: e.parentId ?? state.mcLastEventId ?? null, ephemeral: e.ephemeral, type: eventType, - data: (e.data ?? {}) as Record, + data: getMissionControlEventData(e), }); state.mcLastEventId = e.id; } else { @@ -1239,7 +1392,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes timestamp: new Date().toISOString(), parentId: state.mcLastEventId ?? null, type: eventType, - data: (e.data ?? {}) as Record, + data: getMissionControlEventData(e), }); state.mcLastEventId = id; } @@ -1247,6 +1400,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // Step 8: Construct and display the frontend URL const frontendUrl = `https://github.com/${nwo.owner}/${nwo.repo}/tasks/${taskId}`; + sharedState.mcFrontendUrl = frontendUrl; this.logService.info(`[CopilotCLISession] MC session created, URL: ${frontendUrl}`); // Render a persistent inline info banner using the proposed @@ -1274,6 +1428,26 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } } + private _showRemoteControlStatus(): void { + const state = this._mcState; + if (!state) { + this._stream?.markdown(l10n.t('Remote control is disabled. Use /remote on to enable it.')); + return; + } + + const message = state.mcFrontendUrl + ? l10n.t('Remote control is enabled. Use /remote off to disable it. Session URL: {0}', state.mcFrontendUrl) + : l10n.t('Remote control is enabled. Use /remote off to disable it.'); + this._stream?.markdown(message); + if (state.mcFrontendUrl) { + this._stream?.button({ + command: 'vscode.open', + arguments: [Uri.parse(state.mcFrontendUrl)], + title: l10n.t('Open on GitHub'), + }); + } + } + /** * Disable remote control for an active Mission Control session. */ @@ -1298,6 +1472,10 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes pendingRequest.resolve({ kind: 'denied-interactively-by-user' }); } state.mcPendingPermissionRequests.clear(); + for (const pendingRequest of getMissionControlPendingUserInputRequests(state)) { + pendingRequest.resolve(undefined); + } + getMissionControlPendingUserInputRequests(state).clear(); state.mcEventBuffer.push(this._createMcEvent('session.remote_steerable_changed', { remoteSteerable: false, @@ -1393,12 +1571,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes parentId: event.parentId ?? state.mcLastEventId ?? null, ephemeral: event.ephemeral, type: eventType, - data: (event.data ?? {}) as Record, + data: getMissionControlEventData(event), }; state.mcLastEventId = event.id; state.mcEventBuffer.push(mcEvent); } else { - state.mcEventBuffer.push(this._createMcEvent(eventType, (event.data ?? {}) as Record)); + state.mcEventBuffer.push(this._createMcEvent(eventType, getMissionControlEventData(event))); } } @@ -1446,8 +1624,11 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const content = typeof event.data === 'object' && event.data !== null && 'content' in event.data ? event.data.content : undefined; - if (typeof content === 'string' && content.trim().length > 0) { - return content.trim(); + if (typeof content === 'string') { + const sanitizedContent = stripReminders(content).trim(); + if (sanitizedContent.length > 0) { + return sanitizedContent; + } } } @@ -1481,6 +1662,36 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes }); } + private _waitForMcUserInputResponse( + state: McSharedState, + requestId: string, + toolCallId: string | undefined, + token: CancellationToken, + ): Promise { + return new Promise(resolve => { + let settled = false; + const complete = (result: UserInputResponse | undefined) => { + if (settled) { + return; + } + settled = true; + getMissionControlPendingUserInputRequests(state).delete(pendingRequest); + cancellationListener?.dispose(); + resolve(result); + }; + const pendingRequest: McPendingUserInputRequest = { + requestId, + toolCallId, + resolve: complete, + }; + const cancellationListener = token.onCancellationRequested(() => { + complete(undefined); + }); + + getMissionControlPendingUserInputRequests(state).add(pendingRequest); + }); + } + /** * Flush buffered events to the Mission Control API. */ @@ -1584,8 +1795,45 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes switch (cmd.type) { case 'abort': + for (const pendingRequest of state.mcPendingPermissionRequests.values()) { + pendingRequest.resolve({ kind: 'denied-interactively-by-user' }); + } + state.mcPendingPermissionRequests.clear(); + for (const pendingRequest of getMissionControlPendingUserInputRequests(state)) { + pendingRequest.resolve(undefined); + } + getMissionControlPendingUserInputRequests(state).clear(); state.mcSdkSession.abort(); break; + case 'ask_user_response': { + let responsePayload: McAskUserResponsePayload | undefined; + const trimmedContent = cmd.content.trim(); + if (trimmedContent.startsWith('{')) { + try { + const parsed = JSON.parse(trimmedContent) as unknown; + if (parsed && typeof parsed === 'object') { + responsePayload = parsed as McAskUserResponsePayload; + } + } catch (error) { + logService.warn(`[CopilotCLISession] Failed to parse MC ask_user_response payload (${cmd.id}): ${error}`); + } + } + + const pendingRequest = getMissionControlPendingUserInputRequest(state, responsePayload); + if (!pendingRequest) { + logService.warn(`[CopilotCLISession] No pending MC ask_user request found for command ${cmd.id}`); + break; + } + + const response = getMcAskUserResponse(responsePayload, trimmedContent); + if (!response) { + logService.warn(`[CopilotCLISession] MC ask_user response missing answer payload (${cmd.id})`); + break; + } + + pendingRequest.resolve(response); + break; + } case 'permission_response': { const responseData = CopilotCLISession._parseMcJsonCommand(cmd, logService); const promptId = responseData?.promptId; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index cae2cbb89e22c7..3fdbcc3a8971c4 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -27,7 +27,7 @@ import { IWorkspaceInfo } from '../../../common/workspaceInfo'; import { FakeToolsService, ToolCall } from '../../common/copilotCLITools'; import { CopilotCLISession } from '../copilotcliSession'; import { PermissionRequest } from '../permissionHelpers'; -import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers'; +import { IQuestion, IQuestionAnswer, IUserQuestionHandler, UserInputResponse } from '../userInputHelpers'; import { NullICopilotCLIImageSupport } from './testHelpers'; import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; @@ -47,8 +47,11 @@ class MockSdkSession { public authInfo: unknown; private _pendingPermissions = new Map void }>(); private _permissionCounter = 0; + private _pendingUserInputs = new Map void }>(); + private _userInputCounter = 0; private _pendingExitPlanMode = new Map void }>(); private _exitPlanModeCounter = 0; + public aborted = false; on(event: string, handler: MockSdkEventHandler) { if (!this.onHandlers.has(event)) { @@ -82,6 +85,14 @@ class MockSdkSession { } } + async emitUserInputRequest(request: { question: string; choices?: string[]; allowFreeform?: boolean; toolCallId?: string }): Promise { + const requestId = `user-input-${++this._userInputCounter}`; + return new Promise(resolve => { + this._pendingUserInputs.set(requestId, { resolve }); + this.emit('user_input.requested', { requestId, ...request }); + }); + } + /** * Simulate the SDK emitting an exit_plan_mode.requested event and await the response. * The session's event handler will call respondToExitPlanMode() which resolves the returned promise. @@ -102,8 +113,12 @@ class MockSdkSession { } } - respondToUserInput(_requestId: string, _response: unknown) { - // placeholder for user input responses + respondToUserInput(requestId: string, response: unknown) { + const pending = this._pendingUserInputs.get(requestId); + if (pending) { + pending.resolve(response); + this._pendingUserInputs.delete(requestId); + } } public lastSendOptions: { prompt: string; mode?: string; source?: string } | undefined; @@ -120,7 +135,9 @@ class MockSdkSession { async compactHistory() { return { success: true }; } - async abort() { } + async abort() { + this.aborted = true; + } isAbortable(): boolean { return true; } @@ -228,7 +245,7 @@ describe('CopilotCLISession', () => { async function createSession(): Promise { class FakeUserQuestionHandler implements IUserQuestionHandler { _serviceBrand: undefined; - async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise { + async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken, toolCallId?: string): Promise { return userQuestionAnswer; } } @@ -790,6 +807,236 @@ describe('CopilotCLISession', () => { expect(remoteState.mcPendingPermissionRequests.size).toBe(0); }); + it('uses remote ask user responses when Mission Control is active', async () => { + let userInputResult: unknown; + const notifiedAnswers: Array<{ toolCallId: string; question: IQuestion; response: UserInputResponse }> = []; + sdkSession.send = async () => { + userInputResult = await sdkSession.emitUserInputRequest({ + question: 'What is your favorite VS Code feature or extension?', + allowFreeform: true, + toolCallId: 'ask-user-tool', + }); + }; + const session = await createSession(); + let localPromptToken: CancellationToken | undefined; + Object.defineProperty(session, '_userQuestionHandler', { + value: { + _serviceBrand: undefined, + async askUserQuestion(_question: IQuestion, _toolInvocationToken: ChatParticipantToolToken, token: CancellationToken, _toolCallId?: string): Promise { + localPromptToken = token; + return await new Promise(resolve => { + token.onCancellationRequested(() => resolve(undefined)); + }); + }, + async notifyQuestionCarouselAnswer(toolCallId: string, question: IQuestion, response: UserInputResponse): Promise { + notifiedAnswers.push({ toolCallId, question, response }); + }, + } satisfies IUserQuestionHandler, + configurable: true, + }); + const remoteState = { + mcSessionId: 'mc-session', + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcPendingPermissionRequests: new Map(), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose: undefined, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + + const requestPromise = session.handleRequest( + { id: '', toolInvocationToken: {} as never }, + { prompt: 'Ask me about VS Code' }, + [], + undefined, + authInfo, + CancellationToken.None + ); + await new Promise(r => setTimeout(r, 0)); + + await (CopilotCLISession as any)._pollMcCommandsStatic( + session.sessionId, + remoteState, + { + getPendingCommands: async () => [{ + id: 'mc-command-ask-user', + content: JSON.stringify({ requestId: 'user-input-1', answer: 'none', wasFreeform: true }), + state: 'in_progress', + type: 'ask_user_response', + }], + }, + logger, + ); + + await requestPromise; + + expect(userInputResult).toEqual({ answer: 'none', wasFreeform: true }); + expect(notifiedAnswers).toEqual([{ + toolCallId: 'ask-user-tool', + question: { + question: 'What is your favorite VS Code feature or extension?', + options: [], + allowFreeformInput: true, + header: 'What is your favorite VS Code feature or extension?', + }, + response: { answer: 'none', wasFreeform: true }, + }]); + expect(localPromptToken?.isCancellationRequested).toBe(true); + expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-ask-user']); + }); + + it('aborts pending remote ask user requests when Mission Control stop is requested', async () => { + let userInputResult: unknown; + sdkSession.send = async () => { + userInputResult = await sdkSession.emitUserInputRequest({ + question: 'What is your favorite VS Code feature or extension?', + allowFreeform: true, + toolCallId: 'ask-user-tool', + }); + if (sdkSession.aborted) { + return; + } + sdkSession.emit('assistant.turn_start', {}); + sdkSession.emit('assistant.turn_end', {}); + }; + const session = await createSession(); + let localPromptToken: CancellationToken | undefined; + Object.defineProperty(session, '_userQuestionHandler', { + value: { + _serviceBrand: undefined, + async askUserQuestion(_question: IQuestion, _toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise { + localPromptToken = token; + return await new Promise(resolve => { + token.onCancellationRequested(() => resolve(undefined)); + }); + }, + } satisfies IUserQuestionHandler, + configurable: true, + }); + const remoteState = { + mcSessionId: 'mc-session', + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcPendingPermissionRequests: new Map(), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose: undefined, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + + const requestPromise = session.handleRequest( + { id: '', toolInvocationToken: {} as never }, + { prompt: 'Ask me about VS Code' }, + [], + undefined, + authInfo, + CancellationToken.None + ); + await new Promise(r => setTimeout(r, 0)); + + await (CopilotCLISession as any)._pollMcCommandsStatic( + session.sessionId, + remoteState, + { + getPendingCommands: async () => [{ + id: 'mc-command-abort', + content: '', + state: 'in_progress', + type: 'abort', + }], + }, + logger, + ); + + await requestPromise; + + expect(sdkSession.aborted).toBe(true); + expect(userInputResult).toEqual({ answer: '', wasFreeform: false }); + expect(localPromptToken?.isCancellationRequested).toBe(true); + expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-abort']); + }); + + it('reports remote control status when /remote is invoked without arguments', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIRemoteEnabled, true); + const session = await createSession(); + const stream = new MockChatResponseStream(); + session.attachStream(stream); + + await session.handleRequest( + { id: '', toolInvocationToken: undefined as never }, + { command: 'remote', prompt: '' }, + [], + undefined, + authInfo, + CancellationToken.None + ); + + expect(stream.output.join('\n')).toContain('Remote control is disabled. Use /remote on to enable it.'); + }); + + it('reports enabled remote control status when /remote is invoked without arguments', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIRemoteEnabled, true); + const session = await createSession(); + const stream = new MockChatResponseStream(); + session.attachStream(stream); + const remoteState = { + mcSessionId: 'mc-session', + mcFrontendUrl: 'https://github.com/microsoft/vscode/tasks/123', + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcPendingPermissionRequests: new Map(), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose: undefined, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + + await session.handleRequest( + { id: '', toolInvocationToken: undefined as never }, + { command: 'remote', prompt: '' }, + [], + undefined, + authInfo, + CancellationToken.None + ); + + expect(stream.output.join('\n')).toContain('Remote control is enabled. Use /remote off to disable it. Session URL: https://github.com/microsoft/vscode/tasks/123'); + }); + + it('shows /remote usage for unsupported arguments', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIRemoteEnabled, true); + const session = await createSession(); + const stream = new MockChatResponseStream(); + session.attachStream(stream); + + await session.handleRequest( + { id: '', toolInvocationToken: undefined as never }, + { command: 'remote', prompt: 'wat' }, + [], + undefined, + authInfo, + CancellationToken.None + ); + + expect(stream.output.join('\n')).toContain('Usage: /remote, /remote on, /remote off'); + }); + it('forwards session.idle to Mission Control so remote running state clears', async () => { const session = await createSession(); const remoteState = { @@ -857,6 +1104,117 @@ describe('CopilotCLISession', () => { await expect((session as any)._getMissionControlSessionTitle()).resolves.toBe('hey'); }); + it('sanitizes hidden prompt markup when deriving the Mission Control title', async () => { + const session = await createSession(); + vi.spyOn(sdkSession, 'getEvents').mockReturnValue([ + { + type: 'user.message', + data: { + content: '/remote IMPORTANT: hidden contextrepo', + } + }, + ] as any); + + await expect((session as any)._getMissionControlSessionTitle()).resolves.toBe('/remote'); + }); + + it('sanitizes hidden prompt markup before forwarding user messages to Mission Control', async () => { + const session = await createSession(); + const remoteState = { + mcSessionId: 'mc-session', + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcPendingPermissionRequests: new Map(), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose: undefined, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + + (session as any)._bufferMcEvent({ + type: 'user.message', + id: 'remote-command-message', + timestamp: '2026-01-01T00:00:00.000Z', + data: { + content: '/remote IMPORTANT: hidden contextrepo', + }, + }); + + expect(remoteState.mcEventBuffer).toHaveLength(1); + expect((remoteState.mcEventBuffer[0] as { data: { content: string } }).data.content).toBe('/remote'); + }); + + it('strips shell tool descriptions before forwarding tool starts to Mission Control', async () => { + const session = await createSession(); + const remoteState = { + mcSessionId: 'mc-session', + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcPendingPermissionRequests: new Map(), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose: undefined, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + + (session as any)._bufferMcEvent({ + type: 'tool.execution_start', + data: { + toolCallId: 'bash-1', + toolName: 'bash', + arguments: { command: 'echo hello', description: 'Simple echo command.' }, + }, + }); + + expect(remoteState.mcEventBuffer).toHaveLength(1); + expect((remoteState.mcEventBuffer[0] as { + data: { arguments: { command: string; description?: string } }; + }).data.arguments).toEqual({ command: 'echo hello' }); + }); + + it('strips task descriptions before forwarding tool starts to Mission Control', async () => { + const session = await createSession(); + const remoteState = { + mcSessionId: 'mc-session', + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcPendingPermissionRequests: new Map(), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose: undefined, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + + (session as any)._bufferMcEvent({ + type: 'tool.execution_start', + data: { + toolCallId: 'task-1', + toolName: 'task', + arguments: { description: 'Simple task.', prompt: 'Run echo', agent_type: 'task' }, + }, + }); + + expect(remoteState.mcEventBuffer).toHaveLength(1); + expect((remoteState.mcEventBuffer[0] as { + data: { arguments: { prompt: string; agent_type: string; description?: string } }; + }).data.arguments).toEqual({ prompt: 'Run echo', agent_type: 'task' }); + }); + it('does not forward report_intent tool events to Mission Control', async () => { const session = await createSession(); const remoteState = { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/userInputHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/userInputHelpers.ts index 77bd7501f03d6c..0bff726fe09b9d 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/userInputHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/userInputHelpers.ts @@ -36,5 +36,6 @@ export interface IQuestion { export interface IUserQuestionHandler { _serviceBrand: undefined; - askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise; + askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken, toolCallId?: string): Promise; + notifyQuestionCarouselAnswer?(toolCallId: string, question: IQuestion, response: UserInputResponse): Promise; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/askUserQuestionHandler.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/askUserQuestionHandler.ts index ac8e1d3fd3c32f..e43c79277008f9 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/askUserQuestionHandler.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/askUserQuestionHandler.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ChatParticipantToolToken, LanguageModelTextPart } from 'vscode'; +import { ChatParticipantToolToken, commands, LanguageModelTextPart } from 'vscode'; import { ILogService } from '../../../../platform/log/common/logService'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { ToolName } from '../../../tools/common/toolNames'; import { IToolsService } from '../../../tools/common/toolsService'; -import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../../copilotcli/node/userInputHelpers'; +import { IQuestion, IQuestionAnswer, IUserQuestionHandler, UserInputResponse } from '../../copilotcli/node/userInputHelpers'; export interface IAskQuestionsParams { @@ -19,6 +19,30 @@ export interface IAnswerResult { readonly answers: Record; } +const NotifyQuestionCarouselAnswerCommandId = '_chat.notifyQuestionCarouselAnswer'; + +function toCarouselAnswerValue(question: IQuestion, response: UserInputResponse): string | { selectedValue?: string; freeformValue?: string } | { selectedValues: string[]; freeformValue?: string } | undefined { + if (!response.answer) { + return undefined; + } + + if (!question.options || question.options.length === 0) { + return response.answer; + } + + if (question.multiSelect) { + const selectedValues = question.options.some(option => option.label === response.answer) + ? [response.answer] + : response.answer.split(',').map(value => value.trim()).filter(Boolean); + return response.wasFreeform + ? { selectedValues, freeformValue: response.answer } + : { selectedValues }; + } + + return response.wasFreeform + ? { freeformValue: response.answer } + : { selectedValue: response.answer }; +} export class UserQuestionHandler implements IUserQuestionHandler { declare _serviceBrand: undefined; @@ -27,11 +51,12 @@ export class UserQuestionHandler implements IUserQuestionHandler { @IToolsService private readonly _toolsService: IToolsService, ) { } - async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise { + async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken, toolCallId?: string): Promise { const input: IAskQuestionsParams = { questions: [question] }; const result = await this._toolsService.invokeTool(ToolName.CoreAskQuestions, { input, toolInvocationToken, + chatStreamToolCallId: toolCallId, }, token); @@ -56,4 +81,11 @@ export class UserQuestionHandler implements IUserQuestionHandler { } return undefined; } + + async notifyQuestionCarouselAnswer(toolCallId: string, question: IQuestion, response: UserInputResponse): Promise { + const answerValue = toCarouselAnswerValue(question, response); + await commands.executeCommand(NotifyQuestionCarouselAnswerCommandId, toolCallId, answerValue === undefined ? undefined : { + [`${toolCallId}:0`]: answerValue, + }); + } } diff --git a/extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts b/extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts index 7ad681f532bfbf..7b0f2d61dcb35c 100644 --- a/extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts +++ b/extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts @@ -3,9 +3,65 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { GenAiAttr } from '../../../platform/otel/common/genAiAttributes'; +import type { ICompletedSpanData } from '../../../platform/otel/common/otelService'; + +/** + * Helpers for extracting file paths and refs from tool calls, + * plus shared constants for session store truncation limits. + */ + +// ── Truncation limits (shared by sessionStoreTracker and sessionReindexer) ── + +/** Maximum characters stored for user_message. */ +export const MAX_USER_MESSAGE_LENGTH = 100; + +/** Maximum characters stored for assistant_response. */ +export const MAX_ASSISTANT_RESPONSE_LENGTH = 1000; + +/** Maximum characters stored for session summary. */ +export const MAX_SUMMARY_LENGTH = 100; + +/** + * Truncate a string to at most `maxLength` stored characters, appending '...' if truncated. + * The returned value, including the truncation suffix, never exceeds `maxLength`. + * Returns `undefined` for falsy input. + */ +export function truncateForStore(value: string | undefined, maxLength: number): string | undefined { + if (!value) { + return undefined; + } + if (value.length <= maxLength) { + return value; + } + const ellipsis = '...'; + if (maxLength <= ellipsis.length) { + return ellipsis.slice(0, maxLength); + } + return value.slice(0, maxLength - ellipsis.length).trimEnd() + ellipsis; +} + +/** Terminal/shell tool names that may produce refs. */ +export function isTerminalTool(toolName: string): boolean { + return toolName === 'runInTerminal' || toolName === 'run_in_terminal'; +} + /** - * Helpers for extracting file paths and refs from tool calls. + * Extract tool arguments from an OTel span. + * Parses the serialized JSON from gen_ai.tool.call.arguments attribute. + * @internal Exported for testing. */ +export function extractToolArgs(span: ICompletedSpanData): Record { + const serialized = span.attributes[GenAiAttr.TOOL_CALL_ARGUMENTS]; + if (typeof serialized === 'string') { + try { + return JSON.parse(serialized) as Record; + } catch { + // ignore parse errors + } + } + return {}; +} /** Tools whose arguments contain a file path being modified or read. */ const FILE_TRACKING_TOOLS = new Set([ diff --git a/extensions/copilot/src/extension/chronicle/node/sessionReindexer.ts b/extensions/copilot/src/extension/chronicle/node/sessionReindexer.ts new file mode 100644 index 00000000000000..9f61de6a74dbca --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/node/sessionReindexer.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import type { IChatDebugFileLoggerService, IDebugLogEntry } from '../../../platform/chat/common/chatDebugFileLoggerService'; +import type { ISessionStore, SessionRow, TurnRow, FileRow, RefRow } from '../../../platform/chronicle/common/sessionStore'; +import type { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import { + MAX_ASSISTANT_RESPONSE_LENGTH, + MAX_SUMMARY_LENGTH, + MAX_USER_MESSAGE_LENGTH, + extractAssistantResponse, + extractFilePath, + extractRefsFromMcpTool, + extractRefsFromTerminal, + extractRepoFromMcpTool, + isGitHubMcpTool, + isTerminalTool, + truncateForStore, +} from '../common/sessionStoreTracking'; + +/** + * Result of a reindex operation. + */ +export interface ReindexResult { + /** Number of sessions successfully processed. */ + processed: number; + /** Number of sessions skipped (already indexed or errors). */ + skipped: number; + /** Whether the operation was cancelled. */ + cancelled: boolean; +} + +/** + * Per-session write buffer. Allocated per-session, freed after the transaction commits. + * Bounded by the number of events in a single session. + */ +interface PerSessionWriteBuffer { + session: SessionRow | undefined; + turns: TurnRow[]; + files: FileRow[]; + refs: RefRow[]; +} + +/** + * Safely parse JSON from a string attribute. Returns undefined on failure. + */ +function tryParseArgs(raw: string | number | boolean | undefined): unknown { + if (typeof raw !== 'string') { + return undefined; + } + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +/** + * Rebuild the local Chronicle session store by re-reading JSONL debug logs from disk. + */ +export async function reindexSessions( + store: ISessionStore, + debugLogService: IChatDebugFileLoggerService, + reportProgress: (message: string) => void, + token: CancellationToken, + force: boolean = false, +): Promise { + const sessionIds = await debugLogService.listSessionIds(); + + let processed = 0; + let skipped = 0; + + for (let i = 0; i < sessionIds.length; i++) { + if (token.isCancellationRequested) { + return { processed, skipped, cancelled: true }; + } + + const sessionId = sessionIds[i]; + + // Fast-path: skip sessions already in the store unless force mode + if (!force && store.getSession(sessionId)) { + skipped++; + continue; + } + + reportProgress(l10n.t('Reindexing session {0} of {1}...', i + 1, sessionIds.length)); + + try { + await reindexOneSession(store, debugLogService, sessionId); + processed++; + } catch { + // Non-fatal — skip corrupt/unreadable sessions + skipped++; + } + + // Yield to event loop between sessions to avoid blocking the extension host + await new Promise(resolve => setTimeout(resolve, 0)); + } + + return { processed, skipped, cancelled: false }; +} + +/** + * Reindex a single session from its JSONL debug log. + * Streams events, builds a bounded per-session buffer, and flushes atomically. + */ +async function reindexOneSession( + store: ISessionStore, + debugLogService: IChatDebugFileLoggerService, + sessionId: string, +): Promise { + const buffer: PerSessionWriteBuffer = { + session: undefined, + turns: [], + files: [], + refs: [], + }; + + // State for turn pairing — tracks the pending user message to pair with next assistant response. + let pendingUserMessage: string | undefined; + let pendingUserTimestamp: string | undefined; + let turnIndex = 0; + + await debugLogService.streamEntries(sessionId, (entry: IDebugLogEntry) => { + processEntry(entry, sessionId, buffer, { + get pendingUserMessage() { return pendingUserMessage; }, + set pendingUserMessage(v) { pendingUserMessage = v; }, + get pendingUserTimestamp() { return pendingUserTimestamp; }, + set pendingUserTimestamp(v) { pendingUserTimestamp = v; }, + get turnIndex() { return turnIndex; }, + set turnIndex(v) { turnIndex = v; }, + }); + }); + + // If there's a trailing user message without a paired assistant response, flush it + if (pendingUserMessage) { + buffer.turns.push({ + session_id: sessionId, + turn_index: turnIndex, + user_message: truncateForStore(pendingUserMessage, MAX_USER_MESSAGE_LENGTH), + timestamp: pendingUserTimestamp, + }); + } + + // Ensure we always have a session row (even if no session_start event was found) + if (!buffer.session) { + buffer.session = { id: sessionId, host_type: 'vscode' }; + } + + // Flush all buffered data in a single transaction + store.runInTransaction(() => { + store.upsertSession(buffer.session!); + + for (const turn of buffer.turns) { + store.insertTurn(turn); + } + for (const file of buffer.files) { + store.insertFile(file); + } + for (const ref of buffer.refs) { + store.insertRef(ref); + } + }); + + // Help GC by clearing references — buffer is a local variable so this + // is defensive; it becomes unreachable when the function returns. + buffer.turns.length = 0; + buffer.files.length = 0; + buffer.refs.length = 0; +} + +interface TurnPairingState { + pendingUserMessage: string | undefined; + pendingUserTimestamp: string | undefined; + turnIndex: number; +} + +/** + * Process a single JSONL entry and update the per-session buffer. + * This is the streaming callback — called once per line, no accumulation. + */ +function processEntry( + entry: IDebugLogEntry, + sessionId: string, + buffer: PerSessionWriteBuffer, + state: TurnPairingState, +): void { + switch (entry.type) { + case 'session_start': + processSessionStart(entry, sessionId, buffer); + break; + case 'user_message': + case 'turn_start': + processUserMessage(entry, state); + break; + case 'agent_response': + processAssistantResponse(entry, sessionId, buffer, state); + break; + case 'tool_call': + processToolCall(entry, sessionId, buffer, state); + break; + } +} + +function processSessionStart( + entry: IDebugLogEntry, + sessionId: string, + buffer: PerSessionWriteBuffer, +): void { + const attrs = entry.attrs; + buffer.session = { + id: sessionId, + host_type: 'vscode', + cwd: typeof attrs.cwd === 'string' ? attrs.cwd : undefined, + repository: typeof attrs.repository === 'string' ? attrs.repository : undefined, + branch: typeof attrs.branch === 'string' ? attrs.branch : undefined, + created_at: new Date(entry.ts).toISOString(), + }; +} + +function processUserMessage( + entry: IDebugLogEntry, + state: TurnPairingState, +): void { + const content = typeof entry.attrs.content === 'string' + ? entry.attrs.content + : typeof entry.attrs.userRequest === 'string' + ? entry.attrs.userRequest + : undefined; + if (content) { + state.pendingUserMessage = content; + state.pendingUserTimestamp = new Date(entry.ts).toISOString(); + } +} + +function processAssistantResponse( + entry: IDebugLogEntry, + sessionId: string, + buffer: PerSessionWriteBuffer, + state: TurnPairingState, +): void { + // Extract assistant response from the 'response' attribute (as written by chatDebugFileLoggerService) + const responseRaw = entry.attrs.response as string | undefined; + const assistantResponse = extractAssistantResponse(responseRaw); + + // Only create a turn if we have at least a user message or assistant response + if (!state.pendingUserMessage && !assistantResponse) { + return; + } + + buffer.turns.push({ + session_id: sessionId, + turn_index: state.turnIndex, + user_message: truncateForStore(state.pendingUserMessage, MAX_USER_MESSAGE_LENGTH), + assistant_response: truncateForStore(assistantResponse, MAX_ASSISTANT_RESPONSE_LENGTH), + timestamp: state.pendingUserTimestamp ?? new Date(entry.ts).toISOString(), + }); + + // Use first user message as summary if not yet set + if (!buffer.session?.summary && state.pendingUserMessage) { + const summary = truncateForStore(state.pendingUserMessage, MAX_SUMMARY_LENGTH); + if (!buffer.session) { + buffer.session = { id: sessionId, host_type: 'vscode' }; + } + buffer.session.summary = summary; + } + + state.turnIndex++; + state.pendingUserMessage = undefined; + state.pendingUserTimestamp = undefined; +} + +function processToolCall( + entry: IDebugLogEntry, + sessionId: string, + buffer: PerSessionWriteBuffer, + state: TurnPairingState, +): void { + const toolName = entry.name; + const toolArgs = tryParseArgs(entry.attrs.args); + const resultText = typeof entry.attrs.result === 'string' ? entry.attrs.result : undefined; + + // Extract file path + const filePath = extractFilePath(toolName, toolArgs); + if (filePath) { + buffer.files.push({ + session_id: sessionId, + file_path: filePath, + tool_name: toolName, + turn_index: state.turnIndex, + }); + } + + // Extract refs from GitHub MCP tools + if (isGitHubMcpTool(toolName)) { + const refs = extractRefsFromMcpTool(toolName, toolArgs); + for (const ref of refs) { + buffer.refs.push({ session_id: sessionId, ...ref, turn_index: state.turnIndex }); + } + + const repo = extractRepoFromMcpTool(toolArgs); + if (repo) { + if (!buffer.session) { + buffer.session = { id: sessionId, host_type: 'vscode' }; + } + buffer.session.repository = repo; + } + } + + // Extract refs from terminal/shell tools + if (isTerminalTool(toolName)) { + const refs = extractRefsFromTerminal(toolArgs, resultText); + for (const ref of refs) { + buffer.refs.push({ session_id: sessionId, ...ref, turn_index: state.turnIndex }); + } + } +} diff --git a/extensions/copilot/src/extension/chronicle/node/test/sessionReindexer.spec.ts b/extensions/copilot/src/extension/chronicle/node/test/sessionReindexer.spec.ts new file mode 100644 index 00000000000000..fcacbd95d9f740 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/node/test/sessionReindexer.spec.ts @@ -0,0 +1,351 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, vi } from 'vitest'; +import type { IChatDebugFileLoggerService, IDebugLogEntry } from '../../../../platform/chat/common/chatDebugFileLoggerService'; +import type { ISessionStore, SessionRow, TurnRow, FileRow, RefRow } from '../../../../platform/chronicle/common/sessionStore'; +import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation'; +import { reindexSessions } from '../sessionReindexer'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeEntry(overrides: Partial): IDebugLogEntry { + return { + ts: Date.now(), + dur: 0, + sid: 'session-1', + type: 'generic', + name: '', + spanId: 'span-1', + status: 'ok', + attrs: {}, + ...overrides, + }; +} + +interface MockSessionStore extends ISessionStore { + upsertedSessions: SessionRow[]; + insertedTurns: TurnRow[]; + insertedFiles: FileRow[]; + insertedRefs: RefRow[]; + existingSessions: Set; +} + +function createMockStore(): MockSessionStore { + const mock: MockSessionStore = { + _serviceBrand: undefined as any, + upsertedSessions: [] as SessionRow[], + insertedTurns: [] as TurnRow[], + insertedFiles: [] as FileRow[], + insertedRefs: [] as RefRow[], + existingSessions: new Set(), + + getPath: () => '/tmp/test.db', + upsertSession: (s: SessionRow) => mock.upsertedSessions.push(s), + insertTurn: (t: TurnRow) => mock.insertedTurns.push(t), + insertCheckpoint: () => { }, + insertFile: (f: FileRow) => mock.insertedFiles.push(f), + insertRef: (r: RefRow) => mock.insertedRefs.push(r), + indexWorkspaceArtifact: () => { }, + search: () => [], + getSession: (id: string) => mock.existingSessions.has(id) ? { id } as SessionRow : undefined, + getTurns: () => [], + getFiles: () => [], + getRefs: () => [], + getMaxTurnIndex: () => -1, + getStats: () => ({ sessions: 0, turns: 0, checkpoints: 0, files: 0, refs: 0 }), + executeReadOnly: () => [], + executeReadOnlyFallback: () => [], + runInTransaction: (fn: () => void) => fn(), + close: () => { }, + }; + return mock; +} + +function createMockDebugLogService( + sessionIds: string[], + entriesMap: Map, +): IChatDebugFileLoggerService { + return { + _serviceBrand: undefined as any, + listSessionIds: async () => sessionIds, + streamEntries: async (sessionId: string, onEntry: (entry: IDebugLogEntry) => void) => { + const entries = entriesMap.get(sessionId) ?? []; + for (const entry of entries) { + onEntry(entry); + } + }, + // Stubs for unused methods + startSession: async () => { }, + startChildSession: () => { }, + registerSpanSession: () => { }, + endSession: async () => { }, + flush: async () => { }, + getLogPath: () => undefined, + getSessionDir: () => undefined, + getActiveSessionIds: () => [], + isDebugLogUri: () => false, + getSessionDirForResource: () => undefined, + setModelSnapshot: () => { }, + debugLogsDir: undefined, + onDidEmitEntry: undefined as any, + readEntries: async () => [], + readTailEntries: async () => [], + } as any; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('reindexSessions', () => { + it('processes a session with user + assistant turns', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'session_start', name: 'session_start', sid: 'session-1', attrs: { cwd: '/workspace' } }), + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'Fix the bug' } }), + makeEntry({ type: 'agent_response', name: 'agent_response', sid: 'session-1', attrs: { response: JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'I fixed the bug by changing X' }] }]) } }), + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'Now add tests' } }), + makeEntry({ type: 'agent_response', name: 'agent_response', sid: 'session-1', attrs: { response: JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'Added tests for X' }] }]) } }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cts = new CancellationTokenSource(); + const progress = vi.fn(); + + const result = await reindexSessions(store, debugLog, progress, cts.token); + + expect(result).toEqual({ processed: 1, skipped: 0, cancelled: false }); + expect(store.upsertedSessions).toHaveLength(1); + expect(store.upsertedSessions[0].cwd).toBe('/workspace'); + expect(store.insertedTurns).toHaveLength(2); + expect(store.insertedTurns[0].user_message).toBe('Fix the bug'); + expect(store.insertedTurns[0].assistant_response).toBe('I fixed the bug by changing X'); + expect(store.insertedTurns[1].user_message).toBe('Now add tests'); + expect(store.insertedTurns[1].assistant_response).toBe('Added tests for X'); + }); + + it('extracts file paths from tool_call events', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'tool_call', name: 'read_file', sid: 'session-1', attrs: { args: JSON.stringify({ filePath: '/src/foo.ts', startLine: 1, endLine: 10 }) } }), + makeEntry({ type: 'tool_call', name: 'create_file', sid: 'session-1', attrs: { args: JSON.stringify({ filePath: '/src/bar.ts', content: '// new' }) } }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cts = new CancellationTokenSource(); + + await reindexSessions(store, debugLog, vi.fn(), cts.token); + + expect(store.insertedFiles).toHaveLength(2); + expect(store.insertedFiles[0].file_path).toBe('/src/foo.ts'); + expect(store.insertedFiles[1].file_path).toBe('/src/bar.ts'); + }); + + it('extracts refs from GitHub MCP tool calls', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ + type: 'tool_call', + name: 'mcp_github_pull_request_read', + sid: 'session-1', + attrs: { args: JSON.stringify({ owner: 'microsoft', repo: 'vscode', pullNumber: 42 }) }, + }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cts = new CancellationTokenSource(); + + await reindexSessions(store, debugLog, vi.fn(), cts.token); + + expect(store.insertedRefs).toHaveLength(1); + expect(store.insertedRefs[0]).toEqual(expect.objectContaining({ ref_type: 'pr', ref_value: '42' })); + expect(store.upsertedSessions[0].repository).toBe('microsoft/vscode'); + }); + + it('extracts refs from terminal tool calls', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ + type: 'tool_call', + name: 'run_in_terminal', + sid: 'session-1', + attrs: { + args: JSON.stringify({ command: 'gh pr create --title "Fix" --body "desc"' }), + result: 'https://github.com/microsoft/vscode/pull/123', + }, + }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cts = new CancellationTokenSource(); + + await reindexSessions(store, debugLog, vi.fn(), cts.token); + + expect(store.insertedRefs).toHaveLength(1); + expect(store.insertedRefs[0]).toEqual(expect.objectContaining({ ref_type: 'pr', ref_value: '123' })); + }); + + it('skips already-indexed sessions unless force=true', async () => { + const store = createMockStore(); + store.existingSessions.add('session-1'); + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'hello' } }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cts = new CancellationTokenSource(); + + // Default: skip + const result = await reindexSessions(store, debugLog, vi.fn(), cts.token); + expect(result).toEqual({ processed: 0, skipped: 1, cancelled: false }); + expect(store.insertedTurns).toHaveLength(0); + + // Force: process + const result2 = await reindexSessions(store, debugLog, vi.fn(), cts.token, true); + expect(result2.processed).toBe(1); + }); + + it('respects cancellation token', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('session-1', [makeEntry({ type: 'session_start', name: 'session_start', sid: 'session-1' })]); + entries.set('session-2', [makeEntry({ type: 'session_start', name: 'session_start', sid: 'session-2' })]); + + const debugLog = createMockDebugLogService(['session-1', 'session-2'], entries); + const cts = new CancellationTokenSource(); + + // Cancel immediately + cts.cancel(); + + const result = await reindexSessions(store, debugLog, vi.fn(), cts.token); + expect(result.cancelled).toBe(true); + expect(result.processed).toBe(0); + }); + + it('skips corrupt sessions and continues', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('session-good', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-good', attrs: { content: 'hello' } }), + makeEntry({ type: 'agent_response', name: 'agent_response', sid: 'session-good', attrs: { response: JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'hi' }] }]) } }), + ]); + + // Create a debug log service where session-bad throws + const debugLog = createMockDebugLogService(['session-bad', 'session-good'], entries); + const originalStream = debugLog.streamEntries.bind(debugLog); + (debugLog as any).streamEntries = async (sessionId: string, onEntry: any) => { + if (sessionId === 'session-bad') { + throw new Error('corrupt file'); + } + return originalStream(sessionId, onEntry); + }; + + const cts = new CancellationTokenSource(); + const result = await reindexSessions(store, debugLog, vi.fn(), cts.token); + + expect(result.processed).toBe(1); + expect(result.skipped).toBe(1); + expect(store.insertedTurns).toHaveLength(1); + }); + + it('truncates long user messages and assistant responses', async () => { + const store = createMockStore(); + const longUserMsg = 'a'.repeat(200); + const longAssistantMsg = 'b'.repeat(2000); + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: longUserMsg } }), + makeEntry({ type: 'agent_response', name: 'agent_response', sid: 'session-1', attrs: { response: JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: longAssistantMsg }] }]) } }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cts = new CancellationTokenSource(); + + await reindexSessions(store, debugLog, vi.fn(), cts.token); + + expect(store.insertedTurns[0].user_message!.length).toBeLessThanOrEqual(100); + expect(store.insertedTurns[0].assistant_response!.length).toBeLessThanOrEqual(1000); + }); + + it('handles sessions with no session_start event', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'hello' } }), + makeEntry({ type: 'agent_response', name: 'agent_response', sid: 'session-1', attrs: { response: JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'hi' }] }]) } }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cts = new CancellationTokenSource(); + + await reindexSessions(store, debugLog, vi.fn(), cts.token); + + expect(store.upsertedSessions).toHaveLength(1); + expect(store.upsertedSessions[0].id).toBe('session-1'); + expect(store.upsertedSessions[0].host_type).toBe('vscode'); + }); + + it('handles trailing user message without assistant response', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'hello' } }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cts = new CancellationTokenSource(); + + await reindexSessions(store, debugLog, vi.fn(), cts.token); + + expect(store.insertedTurns).toHaveLength(1); + expect(store.insertedTurns[0].user_message).toBe('hello'); + expect(store.insertedTurns[0].assistant_response).toBeUndefined(); + }); + + it('reports progress for each session', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('s1', [makeEntry({ type: 'session_start', name: 'session_start', sid: 's1' })]); + entries.set('s2', [makeEntry({ type: 'session_start', name: 'session_start', sid: 's2' })]); + + const debugLog = createMockDebugLogService(['s1', 's2'], entries); + const cts = new CancellationTokenSource(); + const progress = vi.fn(); + + await reindexSessions(store, debugLog, progress, cts.token); + + expect(progress).toHaveBeenCalledTimes(2); + }); + + it('sets summary from first user message', async () => { + const store = createMockStore(); + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'Implement a login page' } }), + makeEntry({ type: 'agent_response', name: 'agent_response', sid: 'session-1', attrs: { response: JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'Done' }] }]) } }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cts = new CancellationTokenSource(); + + await reindexSessions(store, debugLog, vi.fn(), cts.token); + + expect(store.upsertedSessions[0].summary).toBe('Implement a login page'); + }); + + it('returns empty result for no sessions', async () => { + const store = createMockStore(); + const debugLog = createMockDebugLogService([], new Map()); + const cts = new CancellationTokenSource(); + + const result = await reindexSessions(store, debugLog, vi.fn(), cts.token); + + expect(result).toEqual({ processed: 0, skipped: 0, cancelled: false }); + }); +}); diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts index 02ea1213fa56b7..67689a90546dc0 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts @@ -15,12 +15,17 @@ import { autorun } from '../../../util/vs/base/common/observableInternal'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { IExtensionContribution } from '../../common/contributions'; import { + MAX_ASSISTANT_RESPONSE_LENGTH, + MAX_SUMMARY_LENGTH, extractAssistantResponse, extractFilePath, extractRefsFromMcpTool, extractRefsFromTerminal, extractRepoFromMcpTool, + extractToolArgs, isGitHubMcpTool, + isTerminalTool, + truncateForStore, } from '../common/sessionStoreTracking'; /** How often to flush buffered writes to SQLite (ms). */ @@ -70,6 +75,9 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib /** Per-session turn counter to avoid collisions between buffered writes and DB state. */ private readonly _turnCounters = new Map(); + /** Tool spans received before session was initialized, keyed by session ID. */ + private readonly _pendingToolSpans = new Map(); + constructor( @ISessionStore private readonly _sessionStore: ISessionStore, @IOTelService private readonly _otelService: IOTelService, @@ -102,7 +110,10 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib "sessionSource": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The agent name/source for the session, or unknown if unavailable." }, "success": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the operation succeeded." }, "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Truncated error message if failed." }, -"opsCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of buffered operations in a failed flush." } +"opsCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of buffered operations in a failed flush." }, +"filesCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of files tracked in first write." }, +"refsCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of refs tracked in first write." }, +"pendingSpansProcessed": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of pending tool spans processed on session init." } } */ this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle.localStore', { @@ -126,6 +137,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib this._initializedSessions.delete(sessionId); this._lastSessionTimestamp.delete(sessionId); this._turnCounters.delete(sessionId); + this._pendingToolSpans.delete(sessionId); })); })); } @@ -153,6 +165,17 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib // Only track sessions that have an invoke_agent span (real user interactions). // Skip internal LLM calls (title generation, progress messages, etc.) if (!this._initializedSessions.has(sessionId)) { + // Queue tool spans to process after session initialization + // (tool spans complete before their parent invoke_agent span) + if (operationName === GenAiOperationName.EXECUTE_TOOL) { + let pending = this._pendingToolSpans.get(sessionId); + if (!pending) { + pending = []; + this._pendingToolSpans.set(sessionId, pending); + } + pending.push(span); + return; + } if (operationName !== GenAiOperationName.INVOKE_AGENT) { return; } @@ -197,10 +220,22 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib this._firstWriteSessionSource = sessionSource; } + // Process any tool spans that arrived before session was initialized + const pendingSpans = this._pendingToolSpans.get(sessionId); + const pendingCount = pendingSpans?.length ?? 0; + if (pendingSpans) { + this._pendingToolSpans.delete(sessionId); + for (const toolSpan of pendingSpans) { + this._handleToolSpan(sessionId, toolSpan); + } + } + this._telemetryService.sendMSFTTelemetryEvent('chronicle.localStore', { operation: 'sessionInit', sessionSource, - }, {}); + }, { + pendingSpansProcessed: pendingCount, + }); } private _backfillFromSpanAttributes(sessionId: string, span: ICompletedSpanData): void { @@ -209,9 +244,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib const userRequest = span.attributes[CopilotChatAttr.USER_REQUEST] as string | undefined; if (branch || remoteUrl || userRequest) { - const summary = userRequest - ? (userRequest.length > 100 ? userRequest.slice(0, 100).trim() + '...' : userRequest) - : undefined; + const summary = truncateForStore(userRequest, MAX_SUMMARY_LENGTH); this._bufferSessionUpsert({ id: sessionId, @@ -229,7 +262,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib } const turnIndex = span.attributes[CopilotChatAttr.TURN_INDEX] as number | undefined; - const toolArgs = this._extractToolArgs(span); + const toolArgs = extractToolArgs(span); // Extract file path const filePath = extractFilePath(toolName, toolArgs); @@ -256,7 +289,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib } // Track refs from terminal/shell tool - if (toolName === 'runInTerminal' || toolName === 'run_in_terminal') { + if (isTerminalTool(toolName)) { const resultText = span.attributes['gen_ai.tool.result'] as string | undefined; const refs = extractRefsFromTerminal(toolArgs, resultText); for (const ref of refs) { @@ -290,17 +323,15 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib const existingSession = this._buffer.sessions.get(sessionId); if (!existingSession?.summary) { const firstMessage = userMessages[0]?.content ?? userRequest; - if (firstMessage) { - const summary = firstMessage.length > 100 ? firstMessage.slice(0, 100).trim() + '...' : firstMessage; + const summary = truncateForStore(firstMessage, MAX_SUMMARY_LENGTH); + if (summary) { this._bufferSessionUpsert({ id: sessionId, summary }); } } // Extract assistant response from OUTPUT_MESSAGES attribute, truncated for storage const fullResponse = extractAssistantResponse(span.attributes[GenAiAttr.OUTPUT_MESSAGES] as string | undefined); - const assistantResponse = fullResponse - ? (fullResponse.length > 1000 ? fullResponse.slice(0, 1000).trim() + '...' : fullResponse) - : undefined; + const assistantResponse = truncateForStore(fullResponse, MAX_ASSISTANT_RESPONSE_LENGTH); // Use in-memory turn counter to avoid collisions with buffered-but-unflushed turns. // Initialize from DB on first use, then increment in memory. @@ -407,7 +438,10 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib this._telemetryService.sendMSFTTelemetryEvent('chronicle.localStore', { operation: 'firstWrite', sessionSource: this._firstWriteSessionSource ?? 'unknown', - }, {}); + }, { + filesCount: filesToFlush.length, + refsCount: refsToFlush.length, + }); } } catch (err) { @@ -418,24 +452,4 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib }, { opsCount: totalOps }); } } - - // ── Utilities ──────────────────────────────────────────────────────── - - private _extractToolArgs(span: ICompletedSpanData): Record { - const args: Record = {}; - for (const [key, value] of Object.entries(span.attributes)) { - if (key.startsWith('gen_ai.tool.input.')) { - args[key.slice('gen_ai.tool.input.'.length)] = value; - } - } - const serialized = span.attributes['gen_ai.tool.input']; - if (typeof serialized === 'string') { - try { - return JSON.parse(serialized); - } catch { - // ignore parse errors - } - } - return args; - } } diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/test/sessionStoreTracker.spec.ts b/extensions/copilot/src/extension/chronicle/vscode-node/test/sessionStoreTracker.spec.ts new file mode 100644 index 00000000000000..6d4d93d671c774 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/vscode-node/test/sessionStoreTracker.spec.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { GenAiAttr } from '../../../../platform/otel/common/genAiAttributes'; +import type { ICompletedSpanData } from '../../../../platform/otel/common/otelService'; +import { extractFilePath, extractToolArgs } from '../../common/sessionStoreTracking'; + +/** + * These tests verify the span data processing logic used by SessionStoreTracker. + * + * The tests focus on: + * 1. Tool argument extraction from OTel span attributes using the real extractToolArgs helper + * 2. File path extraction using the real extractFilePath helper + * + * Note: Full integration tests of SessionStoreTracker require mocking multiple + * services (ISessionStore, IOTelService, IChatSessionService, etc.) and are + * covered by manual testing and telemetry validation. + */ + +// Create a minimal mock span for testing +function makeSpan(overrides: Partial = {}): ICompletedSpanData { + return { + name: 'test', + traceId: 'trace-1', + spanId: 'span-1', + startTime: 0, + endTime: 1, + attributes: {}, + events: [], + status: { code: 0 }, + ...overrides, + }; +} + +describe('SessionStoreTracker span processing', () => { + describe('tool argument extraction from OTel attributes', () => { + it('uses gen_ai.tool.call.arguments attribute (not gen_ai.tool.input)', () => { + // This test documents the fix for using the correct OTel attribute + const span = makeSpan({ + attributes: { + // The correct attribute that OTel uses + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ + filePath: '/src/file.ts', + content: 'test', + }), + // This was incorrectly used before - should be ignored + 'gen_ai.tool.input': JSON.stringify({ wrong: 'data' }), + }, + }); + + const args = extractToolArgs(span); + + expect(args).toEqual({ + filePath: '/src/file.ts', + content: 'test', + }); + // Verify we're not reading from the wrong attribute + expect(args).not.toHaveProperty('wrong'); + }); + + it('returns empty object when attribute is missing', () => { + const span = makeSpan({ attributes: {} }); + expect(extractToolArgs(span)).toEqual({}); + }); + + it('returns empty object for malformed JSON', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: 'not valid json {', + }, + }); + expect(extractToolArgs(span)).toEqual({}); + }); + + it('returns empty object for non-string attribute', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: 12345 as unknown as string, + }, + }); + expect(extractToolArgs(span)).toEqual({}); + }); + }); + + describe('file path extraction pipeline', () => { + // These tests verify the full pipeline: span -> extractToolArgs -> extractFilePath + + it('extracts file from replace_string_in_file span', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ + filePath: '/workspace/src/utils.ts', + oldString: 'old', + newString: 'new', + }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('replace_string_in_file', args); + + expect(filePath).toBe('/workspace/src/utils.ts'); + }); + + it('extracts file from create_file span', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ + filePath: '/new/module.ts', + content: 'export {}', + }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('create_file', args); + + expect(filePath).toBe('/new/module.ts'); + }); + + it('extracts file from apply_patch span using input field', () => { + const patchInput = '*** Begin Patch\n*** Update File: /lib/helpers.ts\n@@export\n-old\n+new\n*** End Patch'; + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ input: patchInput }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('apply_patch', args); + + expect(filePath).toBe('/lib/helpers.ts'); + }); + + it('extracts file from multi_replace_string_in_file span', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ + explanation: 'fix imports', + replacements: [ + { filePath: '/src/a.ts', oldString: 'x', newString: 'y' }, + { filePath: '/src/b.ts', oldString: 'x', newString: 'y' }, + ], + }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('multi_replace_string_in_file', args); + + // extractFilePath returns first file from replacements array + expect(filePath).toBe('/src/a.ts'); + }); + + it('returns undefined for non-file tools', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ command: 'ls -la' }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('run_in_terminal', args); + + expect(filePath).toBeUndefined(); + }); + + it('returns undefined when args are missing filePath', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ content: 'no path' }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('create_file', args); + + expect(filePath).toBeUndefined(); + }); + }); +}); diff --git a/extensions/copilot/src/extension/common/constants.ts b/extensions/copilot/src/extension/common/constants.ts index 268acfd1b05daa..0251d33c8b8e25 100644 --- a/extensions/copilot/src/extension/common/constants.ts +++ b/extensions/copilot/src/extension/common/constants.ts @@ -50,6 +50,7 @@ export const agentsToCommands: Partial>> = 'chronicle': Intent.Chronicle, 'chronicle:standup': Intent.Chronicle, 'chronicle:tips': Intent.Chronicle, + 'chronicle:reindex': Intent.Chronicle, }, [Intent.VSCode]: { 'search': Intent.Search, diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 96f947cfb99386..1533bc779a16b6 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -630,11 +630,20 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I this.logService.debug(`[ConversationHistorySummarizer] background compaction applied after budget exceeded (roundId=${bgResult.toolCallRoundId})`); this._applySummaryToRounds(bgResult, promptContext); this._persistSummaryOnTurn(bgResult, promptContext, contextLengthBefore); - this._sendBackgroundCompactionTelemetry(budgetExceededTrigger, 'applied', contextRatio, promptContext); didSummarizeThisIteration = true; - // Re-render with the compacted history - const renderer = PromptRenderer.create(this.instantiationService, endpoint, this.prompt, { ...props, promptContext }); - result = await renderer.render(progress, token); + try { + const reRenderer = PromptRenderer.create(this.instantiationService, endpoint, this.prompt, { ...props, promptContext }); + result = await reRenderer.render(progress, token); + this._sendBackgroundCompactionTelemetry(budgetExceededTrigger, 'applied', contextRatio, promptContext); + } catch (reRenderError) { + if (reRenderError instanceof BudgetExceededError) { + this.logService.debug(`[ConversationHistorySummarizer] re-render after background compaction still exceeded budget — falling back`); + this._sendBackgroundCompactionTelemetry(budgetExceededTrigger, 'appliedButReRenderFailed', contextRatio, promptContext); + result = await renderWithoutSummarization('budget exceeded after background compaction applied', { ...props, promptContext }); + } else { + throw reRenderError; + } + } } else { this.logService.debug(`[ConversationHistorySummarizer] background compaction produced no usable result after budget exceeded — falling back to synchronous summarization`); this._sendBackgroundCompactionTelemetry(budgetExceededTrigger, 'noResult', contextRatio, promptContext); @@ -1135,7 +1144,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I "owner": "bhavyau", "comment": "Tracks background compaction orchestration decisions and outcomes in the agent loop.", "trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The code path that triggered background compaction consumption." }, - "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the background compaction result was applied or produced no usable result." }, + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Outcome of the background compaction consumption. One of: 'applied' (result applied and re-render succeeded), 'appliedButReRenderFailed' (result applied but the subsequent re-render still exceeded budget and required a fallback), 'noResult' (no usable result was produced)." }, "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Id for the current chat conversation." }, "chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat request ID that this background compaction was consumed during." }, "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model ID used." }, diff --git a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts index 632a376df5e099..307315fc8f0160 100644 --- a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts +++ b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts @@ -9,6 +9,7 @@ import { IAuthenticationService } from '../../../platform/authentication/common/ import { ICopilotTokenManager } from '../../../platform/authentication/common/copilotTokenManager'; import { ChatLocation } from '../../../platform/chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; import { type SessionRow, type RefRow, ISessionStore } from '../../../platform/chronicle/common/sessionStore'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; @@ -32,6 +33,7 @@ import { DefaultIntentRequestHandler } from '../../prompt/node/defaultIntentRequ import { IIntent, IIntentInvocation, IIntentInvocationContext, IIntentSlashCommandInfo, IntentLinkificationOptions } from '../../prompt/node/intents'; import { PromptRenderer, RendererIntentInvocation } from '../../prompts/node/base/promptRenderer'; import { ChroniclePrompt } from '../../prompts/node/panel/chroniclePrompt'; +import { reindexSessions } from '../../chronicle/node/sessionReindexer'; /** Cloud SQL dialect sessions query. */ const SESSIONS_QUERY_CLOUD = `SELECT * @@ -40,7 +42,7 @@ const SESSIONS_QUERY_CLOUD = `SELECT * ORDER BY updated_at DESC LIMIT 100`; -const SUBCOMMANDS = ['standup', 'tips', 'improve'] as const; +const SUBCOMMANDS = ['standup', 'tips', 'improve', 'reindex'] as const; type ChronicleSubcommand = typeof SUBCOMMANDS[number]; export class ChronicleIntent implements IIntent { @@ -67,6 +69,7 @@ export class ChronicleIntent implements IIntent { @ITelemetryService private readonly _telemetryService: ITelemetryService, @IExperimentationService private readonly _expService: IExperimentationService, @IFetcherService private readonly _fetcherService: IFetcherService, + @IChatDebugFileLoggerService private readonly _debugLogService: IChatDebugFileLoggerService, ) { this._indexingPreference = new SessionIndexingPreference(this._configService); } @@ -99,6 +102,8 @@ export class ChronicleIntent implements IIntent { return this._handleStandup(rest, stream, request, token); case 'tips': return this._handleTips(rest, stream, request, token, conversation, documentContext, location, chatTelemetry); + case 'reindex': + return this._handleReindex(rest, stream, token); case 'improve': stream.markdown(l10n.t('`/chronicle {0}` is not yet implemented. Try `/chronicle:standup` or `/chronicle:tips`.', subcommand)); return {}; @@ -138,6 +143,62 @@ export class ChronicleIntent implements IIntent { }; } + private async _handleReindex( + rest: string | undefined, + stream: vscode.ChatResponseStream, + token: CancellationToken, + ): Promise { + const force = rest?.toLowerCase().includes('force') ?? false; + const statsBefore = this._sessionStore.getStats(); + const startTime = Date.now(); + + stream.progress(l10n.t('Discovering sessions...')); + + const result = await reindexSessions( + this._sessionStore, + this._debugLogService, + (message: string) => stream.progress(message), + token, + force, + ); + + const statsAfter = this._sessionStore.getStats(); + + const lines: string[] = []; + if (result.cancelled) { + lines.push(l10n.t('Reindex cancelled.')); + } else { + lines.push(l10n.t('Reindex complete.')); + } + + lines.push(''); + lines.push(`| | ${l10n.t('Before')} | ${l10n.t('After')} | ${l10n.t('Delta')} |`); + lines.push('|---|---|---|---|'); + lines.push(`| ${l10n.t('Sessions')} | ${statsBefore.sessions} | ${statsAfter.sessions} | +${statsAfter.sessions - statsBefore.sessions} |`); + lines.push(`| ${l10n.t('Turns')} | ${statsBefore.turns} | ${statsAfter.turns} | +${statsAfter.turns - statsBefore.turns} |`); + lines.push(`| ${l10n.t('Files')} | ${statsBefore.files} | ${statsAfter.files} | +${statsAfter.files - statsBefore.files} |`); + lines.push(`| ${l10n.t('Refs')} | ${statsBefore.refs} | ${statsAfter.refs} | +${statsAfter.refs - statsBefore.refs} |`); + lines.push(''); + lines.push(l10n.t('{0} session(s) processed, {1} skipped.', result.processed, result.skipped)); + + stream.markdown(lines.join('\n')); + + this._telemetryService.sendMSFTTelemetryEvent('chronicle', { + subcommand: 'reindex', + querySource: 'local', + force: String(force), + cancelled: String(result.cancelled), + }, { + localSessionCount: result.processed, + cloudSessionCount: 0, + totalSessionCount: result.processed + result.skipped, + skippedCount: result.skipped, + durationMs: Date.now() - startTime, + }); + + return {}; + } + private async _handleStandup( extra: string | undefined, stream: vscode.ChatResponseStream, @@ -378,12 +439,16 @@ Use the session_store_sql tool to run queries. Start with a broad query, then dr "chronicle" : { "owner": "vijayu", "comment": "Tracks chronicle subcommand usage, data sources, and query failures", -"subcommand": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chronicle subcommand: standup, tips, or freeform." }, +"subcommand": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chronicle subcommand: standup, tips, freeform, or reindex." }, "querySource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The data source used: local, cloud, both, or cloudRefs." }, "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Truncated error message." }, +"force": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether force mode was used (reindex only)." }, +"cancelled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the operation was cancelled (reindex only)." }, "localSessionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of local sessions used." }, "cloudSessionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of cloud sessions used." }, -"totalSessionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total sessions used." } +"totalSessionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total sessions used." }, +"skippedCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of sessions skipped during reindex." }, +"durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Duration of the reindex operation in milliseconds." } } */ this._telemetryService.sendMSFTTelemetryEvent('chronicle', { diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 403a3ae61c145b..57e1b1ae66c373 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -839,6 +839,8 @@ export abstract class ToolCallingLoop { @@ -847,6 +849,8 @@ export abstract class ToolCallingLoop message.role === ChatRole.User); + if (userMessages.length > 0) { + const textParts = userMessages.flatMap(message => message.content); + if (textParts.every(part => part.type === ChatCompletionContentPartKind.Text)) { + return textParts.map(part => part.text).join(''); + } } throw new Error(`[CopilotCLISession] Unexpected generated prompt structure.`); diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/copilotCLIPrompt.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/copilotCLIPrompt.spec.ts new file mode 100644 index 00000000000000..1870423ce1199a --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/agent/test/copilotCLIPrompt.spec.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChatCompletionContentPartKind, ChatRole } from '@vscode/prompt-tsx/dist/base/output/rawTypes'; +import { expect, suite, test, vi } from 'vitest'; +import type { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; +import type { ChatRequest } from '../../../../../vscodeTypes'; +import { ChatVariablesCollection } from '../../../../prompt/common/chatVariablesCollection'; +import { renderPromptElement } from '../../base/promptRenderer'; +import { generateUserPrompt } from '../copilotCLIPrompt'; + +vi.mock('../../base/promptRenderer', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + renderPromptElement: vi.fn(), + }; +}); + +suite('generateUserPrompt', () => { + const renderPromptElementMock = vi.mocked(renderPromptElement); + const request = { prompt: 'Implement this.' } as ChatRequest; + const chatVariables = new ChatVariablesCollection(); + const instantiationService = { + invokeFunction(fn: (accessor: { get: (service: unknown) => { getChatEndpoint: (request: ChatRequest) => { family: string } } }) => T): T { + return fn({ + get: () => ({ + getChatEndpoint: () => ({ family: 'gpt-4.1' }), + }), + }); + }, + } as unknown as IInstantiationService; + + test('joins multiple text parts from a generated user prompt', async () => { + renderPromptElementMock.mockResolvedValue({ + messages: [{ + role: ChatRole.User, + content: [ + { type: ChatCompletionContentPartKind.Text, text: '2026-04-27T12:17:47.949-06:00\n\n' }, + { type: ChatCompletionContentPartKind.Text, text: '[CopilotCLISession] Unexpected generated prompt structure.\n\n' }, + { type: ChatCompletionContentPartKind.Text, text: '\nAvailable tables: todos, todo_deps, inbox_entries\n' }, + ], + }], + } as Awaited>); + + await expect(generateUserPrompt(request, undefined, chatVariables, instantiationService)).resolves.toBe( + '2026-04-27T12:17:47.949-06:00\n\n' + + '[CopilotCLISession] Unexpected generated prompt structure.\n\n' + + '\nAvailable tables: todos, todo_deps, inbox_entries\n' + ); + }); + + test('joins text parts across multiple generated user messages', async () => { + renderPromptElementMock.mockResolvedValue({ + messages: [ + { + role: ChatRole.User, + content: [ + { type: ChatCompletionContentPartKind.Text, text: '2026-04-27T13:29:45.461-06:00\n\n' }, + ], + }, + { + role: ChatRole.User, + content: [ + { type: ChatCompletionContentPartKind.Text, text: '[CopilotCLISession] Unexpected generated prompt structure.\n\n' }, + { type: ChatCompletionContentPartKind.Text, text: '\nAvailable tables: todos, todo_deps, inbox_entries\n' }, + ], + }, + ], + } as Awaited>); + + await expect(generateUserPrompt(request, undefined, chatVariables, instantiationService)).resolves.toBe( + '2026-04-27T13:29:45.461-06:00\n\n' + + '[CopilotCLISession] Unexpected generated prompt structure.\n\n' + + '\nAvailable tables: todos, todo_deps, inbox_entries\n' + ); + }); + + test('rejects non-text generated user prompt content', async () => { + renderPromptElementMock.mockResolvedValue({ + messages: [{ + role: ChatRole.User, + content: [ + { type: ChatCompletionContentPartKind.Text, text: 'Implement this.' }, + { type: 'image_url' }, + ], + }], + } as Awaited>); + + await expect(generateUserPrompt(request, undefined, chatVariables, instantiationService)).rejects.toThrow('[CopilotCLISession] Unexpected generated prompt structure.'); + }); +}); diff --git a/extensions/copilot/src/extension/tools/node/toolSearchTool.ts b/extensions/copilot/src/extension/tools/node/toolSearchTool.ts index 90a19a56ec0e96..fb35e4a0f2ae25 100644 --- a/extensions/copilot/src/extension/tools/node/toolSearchTool.ts +++ b/extensions/copilot/src/extension/tools/node/toolSearchTool.ts @@ -79,7 +79,9 @@ ToolRegistry.registerModelSpecificTool( { family: 'claude-sonnet-4.6' }, { family: 'claude-opus-4.5' }, { family: 'claude-opus-4.6' }, + { family: 'claude-opus-4.6-1m' }, { family: 'claude-opus-4.7' }, + { family: 'claude-opus-4.7-1m' }, ], }, ToolSearchTool, diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index c029e9981b50e5..5c8b0be0542b94 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -993,6 +993,7 @@ export class AnthropicMessagesProcessor { total_tokens: computedPromptTokens + this.outputTokens, prompt_tokens_details: { cached_tokens: this.cacheReadTokens, + cache_creation_input_tokens: this.cacheCreationTokens, }, completion_tokens_details: { reasoning_tokens: 0, diff --git a/extensions/copilot/src/platform/networking/common/openai.ts b/extensions/copilot/src/platform/networking/common/openai.ts index 22f404c0288e8b..66abbd60f6bf7a 100644 --- a/extensions/copilot/src/platform/networking/common/openai.ts +++ b/extensions/copilot/src/platform/networking/common/openai.ts @@ -42,6 +42,7 @@ export interface APIUsage { */ prompt_tokens_details?: { cached_tokens: number; + cache_creation_input_tokens?: number; }; /** * Breakdown of tokens used in a completion. diff --git a/extensions/git/package.json b/extensions/git/package.json index 17ddbced72d93a..d56de56d47675e 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -3740,7 +3740,7 @@ "%config.addAICoAuthor.all%" ], "scope": "resource", - "default": "all", + "default": "chatAndAgent", "description": "%config.addAICoAuthor%" }, "git.ignoreSubmodules": { diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 21b98bfd8399b4..2d385b7b98ee02 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -1498,7 +1498,7 @@ export class Repository implements Disposable { } const config = workspace.getConfiguration('git', Uri.file(this.root)); - const addAICoAuthor = config.get<'off' | 'chatAndAgent' | 'all'>('addAICoAuthor', 'all'); + const addAICoAuthor = config.get<'off' | 'chatAndAgent' | 'all'>('addAICoAuthor', 'chatAndAgent'); if (addAICoAuthor === 'off') { return message; diff --git a/package.json b/package.json index 7cfef4e28f2753..a4b58b1f6d3333 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.119.0", - "distro": "44a02d0d07de5cbb9ab43fd345dd5c8f3cfd08f8", + "distro": "6de3b8c2129ba0b524b19a9151f1d7f8e2fa0baa", "author": { "name": "Microsoft Corporation" }, diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index a62f326e013304..c0111057f94725 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -432,15 +432,15 @@ Each agent session part uses separate storage keys to avoid conflicts with regul ### 9.5 Part Borders and Card Appearance -Parts manage their own border and background styling via the `updateStyles()` method. In the default light theme, the sessions workbench surface uses the off-white workbench/sidebar background while the card-like chat, auxiliary bar, and panel surfaces use the brighter editor background. Light themes also override the chat, auxiliary bar, and panel card border color in CSS to use `editorWidget.border`, giving those cards a darker outline. Dark and high-contrast mappings continue to use the existing part border tokens. The optional shell gradient treatment is gated behind the application setting `sessions.experimental.shellGradientBackground`. When that setting is disabled, the sessions shell uses the same solid sidebar/grid backgrounds and sidebar view styling as the upstream default experience. When enabled, the sessions shell adds a single root-level background layer in `browser/media/style.css` (`.agent-sessions-workbench.experimental-shell-gradient-background::before`) that sits behind the workbench parts and falls back to the normal solid shell background when `color-mix(...)` is unavailable. When supported, the layer derives its tint from the theme's primary accent signal in `button.background`. The gradient runs from the base shell color at the top-left toward a gentle, deliberately low-contrast accent tint in the bottom-right; light themes use a transparentized accent overlay to preserve a bit more of the original accent hue without letting it dominate the shell, dark themes use shallower direct mixes into the shell background, and high-contrast themes disable the gradient entirely for accessibility. Titlebar/sidebar wrappers are made transparent so that one shared layer reads continuously across the whole window chrome without clipping at part boundaries. These surfaces use a **card appearance** with CSS variables for background and border: +Parts manage their own border and background styling via the `updateStyles()` method. In the default light theme, the sessions workbench surface uses the off-white workbench/sidebar background while the card-like chat, auxiliary bar, and panel surfaces use the brighter editor background. Light themes also override the chat, auxiliary bar, and panel card border color in CSS to use `editorWidget.border`, giving those cards a darker outline. Dark and high-contrast mappings continue to use the existing part border tokens. The sessions shell now applies its accent-tinted gradient treatment by default via a single root-level background layer in `browser/media/style.css` (`.agent-sessions-workbench.shell-gradient-background::before`) that sits behind the workbench parts and falls back to the normal solid shell background when `color-mix(...)` is unavailable. When supported, the layer derives its tint from the theme's primary accent signal in `button.background`. The gradient runs from the base shell color at the top-left toward a gentle, deliberately low-contrast accent tint in the bottom-right; light themes use a transparentized accent overlay to preserve a bit more of the original accent hue without letting it dominate the shell, dark themes use shallower direct mixes into the shell background, and high-contrast themes disable the gradient entirely for accessibility. Titlebar/sidebar wrappers are made transparent so that one shared layer reads continuously across the whole window chrome without clipping at part boundaries. These surfaces use a **card appearance** with CSS variables for background and border: | Part | Styling | Notes | |------|---------|-------| -| Sidebar | Right border via `SIDE_BAR_BORDER` / `contrastBorder` | Flush appearance; when `sessions.experimental.shellGradientBackground` is enabled, the sidebar wrappers are transparent so the shared root shell gradient reads through continuously | +| Sidebar | Right border via `SIDE_BAR_BORDER` / `contrastBorder` | Flush appearance; the sidebar wrappers are transparent so the shared root shell gradient reads through continuously | | Chat Bar | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsChatBarBackground`; remains a solid view surface so chat content is unaffected | | Auxiliary Bar | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsAuxiliaryBarBackground` / `PANEL_BORDER`; remains a solid view surface so files/changes content is unaffected | | Panel | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsPanelBackground` / `PANEL_BORDER`; remains a solid view surface so terminal/debug content is unaffected | -| Titlebar | Simplified sessions titlebar part | When `sessions.experimental.shellGradientBackground` is enabled, its wrappers are transparent so the shared root shell gradient reads through the top chrome as one continuous surface | +| Titlebar | Simplified sessions titlebar part | Its wrappers are transparent so the shared root shell gradient reads through the top chrome as one continuous surface | The sessions workbench also scopes its resize sash styling in `browser/media/style.css`, rounding the sash hover indicator and orthogonal drag handles so the layout chrome matches the card surfaces. Both sessions chat input surfaces keep the unfocused `editorWidget.border` outline in light themes, but switch to `focusBorder` while focused so the new-chat view and the active chat input match the core workbench chat widget focus treatment. @@ -664,6 +664,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-27 | Made the sessions shell gradient background the default treatment by removing the `sessions.experimental.shellGradientBackground` opt-in, always applying the root shell gradient layer, and renaming the workbench CSS hook to `shell-gradient-background`. | | 2026-04-23 | Updated mobile layout policy platform detection to use shared `platform.isMobile`, and reduced phone-layout CSS `!important` usage where selector specificity already provides stable overrides. | | 2026-04-22 | Increased the sessions titlebar account widget's GitHub profile image from `16px × 16px` to `18px × 18px` while keeping the existing `22px × 22px` control footprint and avatar border treatment. | | 2026-04-22 | Added sessions-only toast offset overrides so notification toasts now use `right: 15px` in the default bottom-right placement and `left: 15px` in the bottom-left placement, matching the notification center spacing. | diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md index 7b3181e22bf38f..6f01038256d4b0 100644 --- a/src/vs/sessions/MOBILE.md +++ b/src/vs/sessions/MOBILE.md @@ -51,7 +51,7 @@ On phone-sized viewports (`< 640px` width): ``` ┌──────────────────────────────────┐ -│ [☰] Session Title [+] │ ← MobileTitlebarPart (prepended before grid) +│ [☰] Session Title [+|👤] │ ← MobileTitlebarPart (prepended before grid) ├──────────────────────────────────┤ │ │ │ Chat (edge-to-edge) │ ← Grid: ChatBarPart fills 100% @@ -64,7 +64,7 @@ On phone-sized viewports (`< 640px` width): └──────────────────────────────────┘ ``` -- **MobileTitlebarPart** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and new session (+) button. +- **MobileTitlebarPart** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and a contextual right slot that swaps between the new session (+) button (when in a chat) and the account indicator 👤 (on the welcome / new session screen). - **Sidebar** is hidden by default and opens as an **85% width drawer overlay** with a backdrop when the hamburger is tapped. CSS makes its `split-view-view` absolutely positioned with `z-index: 250`. The workbench manually calls `sidebarPart.layout()` with drawer dimensions after opening. Closing the drawer clears the navigation stack. - **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTitlebarPart. - **SessionCompositeBar** (chat tabs) is hidden via CSS. @@ -90,13 +90,14 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des | Desktop Component | Mobile Equivalent | How Accessed | |---|---|---| -| **Titlebar** (3-section toolbar) | **MobileTitlebarPart** (☰ / title / +) | Always visible at top | +| **Titlebar** (3-section toolbar) | **MobileTitlebarPart** (☰ / title / +|👤) | Always visible at top | | **Sidebar** (sessions list) | Drawer overlay (85% width) | Hamburger button (☰) | | **ChatBar** (chat widget) | Same Part, edge-to-edge, no card chrome | Default view (always visible) | | **AuxiliaryBar** (files, changes) | Gated — not shown on mobile | Planned: mobile-specific view | | **Panel** (terminal, output) | Gated — not shown on mobile | Planned: mobile-specific view | | **SessionCompositeBar** (chat tabs) | Hidden on phone | — | -| **New Session** (sidebar button) | + button in MobileTitlebarPart | Always visible in top bar | +| **New Session** (sidebar button) | + button in MobileTitlebarPart | Visible in top bar when in a chat | +| **Account indicator** (titlebar) | Account button in MobileTitlebarPart | Visible in top bar on welcome/new session | ## File Map @@ -113,7 +114,7 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des | File | Purpose | |------|---------| -| `browser/parts/mobile/mobileTitlebarPart.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. | +| `browser/parts/mobile/mobileTitlebarPart.ts` | Phone top bar: hamburger (☰), session title, contextual right slot (+ for in-chat, account indicator for welcome). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. Includes account state tracking, avatar loading, and account panel with copilot dashboard. | | `browser/parts/mobile/mobileChatShell.css` | **Single source of truth** for all phone-layout CSS: flex column layout, split-view-view absolute positioning, card chrome removal, part/content width overrides, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, mobile pickers. | ### Layout & Navigation diff --git a/src/vs/sessions/contrib/accountMenu/browser/accountTitleBarState.ts b/src/vs/sessions/browser/accountTitleBarState.ts similarity index 77% rename from src/vs/sessions/contrib/accountMenu/browser/accountTitleBarState.ts rename to src/vs/sessions/browser/accountTitleBarState.ts index a75224a99b6992..2ba44ecf1143d5 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/accountTitleBarState.ts +++ b/src/vs/sessions/browser/accountTitleBarState.ts @@ -3,10 +3,53 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize } from '../../../../nls.js'; -import { ChatEntitlement, IChatSentiment, IQuotaSnapshot } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { Codicon } from '../../base/common/codicons.js'; +import { ThemeIcon } from '../../base/common/themables.js'; +import { localize } from '../../nls.js'; +import { ChatEntitlement, IChatSentiment, IQuotaSnapshot } from '../../workbench/services/chat/common/chatEntitlementService.js'; +import { IDefaultAccountService } from '../../platform/defaultAccount/common/defaultAccount.js'; +import { IAuthenticationService } from '../../workbench/services/authentication/common/authentication.js'; + +export interface IResolvedAccountInfo { + readonly accountName: string; + readonly accountProviderId: string; + readonly accountProviderLabel: string; +} + +/** + * Resolves the current account info by trying the default account service + * first, then falling back to raw GitHub sessions from the authentication + * service. The fallback covers the window between session creation and + * {@link IDefaultAccountService} initialization. + */ +export async function resolveAccountInfo( + defaultAccountService: IDefaultAccountService, + authenticationService: IAuthenticationService, +): Promise { + const account = await defaultAccountService.getDefaultAccount(); + if (account) { + return { + accountName: account.accountName, + accountProviderId: account.authenticationProvider.id, + accountProviderLabel: account.authenticationProvider.name, + }; + } + + try { + const sessions = await authenticationService.getSessions('github'); + if (sessions.length > 0) { + return { + accountName: sessions[0].account.label, + accountProviderId: 'github', + accountProviderLabel: 'GitHub', + }; + } + } catch { + // Provider not available yet + } + + return undefined; +} export type AccountTitleBarStateSource = 'account' | 'copilot'; export type AccountTitleBarStateKind = 'default' | 'accent' | 'warning' | 'prominent'; @@ -16,7 +59,7 @@ export interface IAccountTitleBarStateContext { readonly accountName?: string; readonly accountProviderLabel?: string; readonly entitlement: ChatEntitlement; - readonly sentiment: Pick; + readonly sentiment: IChatSentiment; readonly quotas: { readonly chat?: IQuotaSnapshot; readonly completions?: IQuotaSnapshot; @@ -91,7 +134,7 @@ export function getAccountTitleBarState(context: IAccountTitleBarStateContext): function getCopilotPresentation( entitlement: ChatEntitlement, - sentiment: Pick, + sentiment: IChatSentiment, quotas: { readonly chat?: IQuotaSnapshot; readonly completions?: IQuotaSnapshot } ): IAccountTitleBarState | undefined { if (sentiment.hidden) { diff --git a/src/vs/sessions/browser/chatDashboardService.ts b/src/vs/sessions/browser/chatDashboardService.ts new file mode 100644 index 00000000000000..e27353740de6df --- /dev/null +++ b/src/vs/sessions/browser/chatDashboardService.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../platform/instantiation/common/instantiation.js'; + +export const IChatDashboardService = createDecorator('chatDashboardService'); + +export interface IChatDashboardService { + readonly _serviceBrand: undefined; + + /** + * Creates a chat status dashboard element embedded in a container div. + * Returns `undefined` if the dashboard is not available. + */ + createDashboardElement(store: DisposableStore): HTMLElement | undefined; +} + +class NullChatDashboardService implements IChatDashboardService { + readonly _serviceBrand: undefined; + createDashboardElement(): HTMLElement | undefined { return undefined; } +} + +registerSingleton(IChatDashboardService, NullChatDashboardService, InstantiationType.Delayed); diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index a199957995a87a..1dddca7ec3411f 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -13,13 +13,13 @@ background-color: var(--vscode-agents-background); } -.agent-sessions-workbench.experimental-shell-gradient-background { +.agent-sessions-workbench.shell-gradient-background { position: relative; isolation: isolate; background: var(--vscode-agents-background); } -.agent-sessions-workbench.experimental-shell-gradient-background::before { +.agent-sessions-workbench.shell-gradient-background::before { content: ''; position: absolute; inset: 0; @@ -29,7 +29,7 @@ } @supports (background: color-mix(in srgb, black 0%, white)) { - .agent-sessions-workbench.experimental-shell-gradient-background::before { + .agent-sessions-workbench.shell-gradient-background::before { background: linear-gradient( to bottom right, @@ -41,7 +41,7 @@ var(--vscode-agents-background); } - .monaco-workbench.vs.agent-sessions-workbench.experimental-shell-gradient-background::before { + .monaco-workbench.vs.agent-sessions-workbench.shell-gradient-background::before { background: linear-gradient( to bottom right, @@ -53,7 +53,7 @@ var(--vscode-agents-background); } - .monaco-workbench.vs-dark.agent-sessions-workbench.experimental-shell-gradient-background::before { + .monaco-workbench.vs-dark.agent-sessions-workbench.shell-gradient-background::before { background: linear-gradient( to bottom right, var(--vscode-agents-background) 0%, @@ -64,8 +64,8 @@ } } -.monaco-workbench.hc-black.agent-sessions-workbench.experimental-shell-gradient-background::before, -.monaco-workbench.hc-light.agent-sessions-workbench.experimental-shell-gradient-background::before { +.monaco-workbench.hc-black.agent-sessions-workbench.shell-gradient-background::before, +.monaco-workbench.hc-light.agent-sessions-workbench.shell-gradient-background::before { display: none; } @@ -105,7 +105,7 @@ bottom: auto; } -.agent-sessions-workbench.experimental-shell-gradient-background > .monaco-grid-view { +.agent-sessions-workbench.shell-gradient-background > .monaco-grid-view { position: relative; z-index: 1; background: transparent; @@ -125,14 +125,14 @@ * Panel: bottom=18, left=16, right=16 */ -.agent-sessions-workbench.experimental-shell-gradient-background .part.titlebar, -.agent-sessions-workbench.experimental-shell-gradient-background .part.sidebar, -.agent-sessions-workbench.experimental-shell-gradient-background .part.titlebar > .content, -.agent-sessions-workbench.experimental-shell-gradient-background .part.titlebar > .titlebar-container, -.agent-sessions-workbench.experimental-shell-gradient-background .part.titlebar > .sessions-titlebar-container, -.agent-sessions-workbench.experimental-shell-gradient-background .part.sidebar > .composite.title, -.agent-sessions-workbench.experimental-shell-gradient-background .part.sidebar > .content, -.agent-sessions-workbench.experimental-shell-gradient-background .part.sidebar > .sidebar-footer { +.agent-sessions-workbench.shell-gradient-background .part.titlebar, +.agent-sessions-workbench.shell-gradient-background .part.sidebar, +.agent-sessions-workbench.shell-gradient-background .part.titlebar > .content, +.agent-sessions-workbench.shell-gradient-background .part.titlebar > .titlebar-container, +.agent-sessions-workbench.shell-gradient-background .part.titlebar > .sessions-titlebar-container, +.agent-sessions-workbench.shell-gradient-background .part.sidebar > .composite.title, +.agent-sessions-workbench.shell-gradient-background .part.sidebar > .content, +.agent-sessions-workbench.shell-gradient-background .part.sidebar > .sidebar-footer { background: transparent !important; } diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 3e449ca83e173b..f6f3929a725a66 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -24,6 +24,7 @@ export const Menus = { AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), SidebarFooter: new MenuId('SessionsSidebarFooter'), SidebarCustomizations: new MenuId('SessionsSidebarCustomizations'), + AccountMenu: new MenuId('SessionsAccountMenu'), AgentFeedbackEditorContent: new MenuId('AgentFeedbackEditorContent'), NewSessionConfig: new MenuId('NewSessions.SessionConfigMenu'), diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index 2f79a84a73e258..57e2bcea194091 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -392,3 +392,240 @@ .agent-sessions-workbench.phone-layout .part.panel { transition: none !important; } + +/* ---- Mobile Account Indicator ---- */ + +.mobile-top-bar .mobile-account-indicator { + position: relative; +} + +.mobile-top-bar .mobile-account-avatar { + display: none; + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--vscode-commandCenter-border, transparent); + box-sizing: border-box; +} + +.mobile-top-bar .mobile-account-avatar.visible { + display: block; +} + +/* Hide the codicon when the avatar is loaded */ +.mobile-top-bar .mobile-account-indicator .codicon.hidden { + display: none; +} + +.mobile-top-bar .mobile-account-badge { + position: absolute; + top: 8px; + right: 8px; + width: 8px; + height: 8px; + border-radius: 50%; + border: 1.5px solid var(--vscode-editor-background); + background: var(--vscode-editorWarning-foreground); + pointer-events: none; +} + +.mobile-top-bar .mobile-account-badge.dot-badge-warning { + background: var(--vscode-editorWarning-foreground); +} + +.mobile-top-bar .mobile-account-badge.dot-badge-error { + background: var(--vscode-editorError-foreground); +} + +/* ---- Mobile Account Sheet (full-screen bottom sheet) ---- */ + +.mobile-account-sheet { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + flex-direction: column; + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + touch-action: manipulation; + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; +} + +.mobile-account-sheet-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; + min-height: 56px; + padding: 0 16px; + padding-top: env(safe-area-inset-top); + border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); + flex-shrink: 0; +} + +.mobile-account-sheet-title { + font-size: 18px; + font-weight: 600; + margin: 0; + color: var(--vscode-foreground); +} + +.mobile-account-sheet-close { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: none; + background: none; + color: var(--vscode-foreground); + cursor: pointer; + border-radius: 50%; + font-size: 18px; + padding: 0; + touch-action: manipulation; +} + +.mobile-account-sheet-close:active { + background: var(--vscode-toolbar-hoverBackground); +} + +.mobile-account-sheet-content { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + padding: 0 16px; + padding-bottom: calc(16px + env(safe-area-inset-bottom)); +} + +/* Profile card */ + +.mobile-account-sheet-profile { + display: flex; + align-items: center; + gap: 16px; + padding: 24px 0 20px; +} + +.mobile-account-sheet-avatar { + width: 56px; + height: 56px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.mobile-account-sheet-avatar-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 28px; + flex-shrink: 0; +} + +.mobile-account-sheet-profile-info { + min-width: 0; + flex: 1; +} + +.mobile-account-sheet-name { + font-size: 18px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-account-sheet-provider { + font-size: 14px; + color: var(--vscode-descriptionForeground); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Dashboard section */ + +.mobile-account-sheet-section { + padding: 8px 0 16px; + border-top: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); +} + +/* Action rows */ + +.mobile-account-sheet-actions { + display: flex; + flex-direction: column; + border-top: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); + padding: 8px 0; +} + +.mobile-account-sheet-action { + display: flex; + align-items: center; + gap: 16px; + height: 52px; + min-height: 52px; + padding: 0 4px; + border: none; + border-radius: 12px; + background: none; + color: var(--vscode-foreground); + font-size: 16px; + text-align: left; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; +} + +.mobile-account-sheet-action:active { + background: var(--vscode-list-hoverBackground); +} + +.mobile-account-sheet-action:disabled { + opacity: 0.5; + cursor: default; +} + +.mobile-account-sheet-action-icon { + font-size: 20px; + width: 24px; + text-align: center; + flex-shrink: 0; + color: var(--vscode-descriptionForeground); +} + +.mobile-account-sheet-action-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-account-sheet-separator { + height: 1px; + margin: 4px 0; + background: var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); +} + +.agent-sessions-workbench.phone-layout .sessions-account-titlebar-panel-action { + min-height: 44px; + touch-action: manipulation; +} + +.agent-sessions-workbench.phone-layout .sessions-account-titlebar-panel-header-action { + min-width: 44px; + min-height: 44px; + touch-action: manipulation; +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts index 38c4cb598138f9..e1101175cdd60e 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts @@ -4,28 +4,37 @@ *--------------------------------------------------------------------------------------------*/ import './mobileChatShell.css'; -import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { IAction, Separator } from '../../../../base/common/actions.js'; import { localize } from '../../../../nls.js'; import { autorun } from '../../../../base/common/observable.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IMenuService } from '../../../../platform/actions/common/actions.js'; +import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { IsNewChatSessionContext } from '../../../common/contextkeys.js'; import { SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { Menus } from '../../menus.js'; +import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { getAccountTitleBarState, getAccountProfileImageUrl, getAccountTitleBarBadgeKey, resolveAccountInfo } from '../../accountTitleBarState.js'; +import { IChatDashboardService } from '../../chatDashboardService.js'; /** * Mobile titlebar — prepended above the workbench grid on phone viewports * in place of the desktop titlebar. * - * Layout: + * Layout (contextual right slot): * - * `[menu] [session title | host widget] [+]` + * - **In a chat session** → `[toggle sidebar] [session title] [+]` + * - **Welcome / new session** → `[toggle sidebar] [host widget | title] [account]` * * The center slot switches content based on whether the sessions welcome * (home/empty) screen is visible: @@ -39,6 +48,12 @@ import { Menus } from '../../menus.js'; * The switch is driven entirely by the menu: when the toolbar has no * items the title is shown; as soon as it has items the title is hidden * and the toolbar fills the slot. + * + * The right slot swaps between the new-session (+) button (in a chat) + * and the account indicator (on welcome / new session). The account + * indicator shows the user's avatar or a person icon with an optional + * dot badge for quota/status warnings. Tapping it opens a panel with + * account info, copilot status dashboard, and sign-in/sign-out actions. */ export class MobileTitlebarPart extends Disposable { @@ -56,11 +71,36 @@ export class MobileTitlebarPart extends Disposable { private readonly _onDidClickTitle = this._register(new Emitter()); readonly onDidClickTitle: Event = this._onDidClickTitle.event; + // Account indicator state + private readonly accountButton: HTMLElement; + private readonly accountAvatarElement: HTMLImageElement; + private readonly accountIconElement: HTMLElement; + private readonly accountBadgeElement: HTMLElement; + private accountName: string | undefined; + private accountProviderId: string | undefined; + private accountProviderLabel: string | undefined; + private isAccountLoading = true; + private accountRequestCounter = 0; + private avatarRequestCounter = 0; + private currentAvatarUrl: string | undefined; + private loadedAvatarUrl: string | undefined; + private isAccountMenuVisible = false; + private lastBadgeKey: string | undefined; + private dismissedBadgeKey: string | undefined; + private readonly accountPanelDisposable = this._register(new MutableDisposable()); + private readonly avatarLoadDisposable = this._register(new MutableDisposable()); + private readonly copilotDashboardStore = this._register(new MutableDisposable()); + constructor( parent: HTMLElement, @IInstantiationService instantiationService: IInstantiationService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, - @IContextKeyService contextKeyService: IContextKeyService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @IMenuService private readonly menuService: IMenuService, + @IChatDashboardService private readonly chatDashboardService: IChatDashboardService, ) { super(); @@ -105,12 +145,32 @@ export class MobileTitlebarPart extends Disposable { this.actionsContainer = append(center, $('div.mobile-top-bar-actions')); - // New session button (+) - const newSession = append(this.element, $('button.mobile-top-bar-button')); - newSession.setAttribute('aria-label', localize('mobileTopBar.newSessionAria', "New session")); - const newSessionIcon = append(newSession, $('span')); + // New session button (+) — shown when in a chat, hidden on welcome + const newSessionButton = append(this.element, $('button.mobile-top-bar-button.mobile-new-session-button')); + newSessionButton.setAttribute('aria-label', localize('mobileTopBar.newSessionAria', "New session")); + const newSessionIcon = append(newSessionButton, $('span')); newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus)); - this._register(addDisposableListener(newSession, EventType.CLICK, () => this._onDidClickNewSession.fire())); + this._register(addDisposableListener(newSessionButton, EventType.CLICK, () => this._onDidClickNewSession.fire())); + + // Account indicator — shown on welcome/new session, hidden in a chat + this.accountButton = append(this.element, $('button.mobile-top-bar-button.mobile-account-indicator')); + this.accountButton.setAttribute('aria-label', localize('mobileTopBar.account', "Account")); + this.accountAvatarElement = append(this.accountButton, $('img.mobile-account-avatar', { alt: '', draggable: 'false' })) as HTMLImageElement; + this.accountAvatarElement.decoding = 'async'; + this.accountAvatarElement.referrerPolicy = 'no-referrer'; + this.accountIconElement = append(this.accountButton, $('span')); + this.accountBadgeElement = append(this.accountButton, $('span.mobile-account-badge')); + this._register(addDisposableListener(this.accountButton, EventType.CLICK, () => this.showAccountPanel())); + + // Track account state — listen to multiple sources to catch + // updates regardless of service initialization ordering. + this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.refreshAccount())); + this._register(this.authenticationService.onDidChangeSessions(() => this.refreshAccount())); + this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.renderAccountState())); + this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.renderAccountState())); + this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.renderAccountState())); + this._register(this.chatEntitlementService.onDidChangeQuotaRemaining(() => this.renderAccountState())); + this.refreshAccount(); // Keep the title in sync with the active session this._register(autorun(reader => { @@ -136,6 +196,10 @@ export class MobileTitlebarPart extends Disposable { const isNewChat = !!IsNewChatSessionContext.getValue(contextKeyService); const hasActions = toolbar.getItemsLength() > 0; this.element.classList.toggle('show-actions', isNewChat && hasActions); + + // Right slot: swap between [+] (in-chat) and [account] (welcome) + newSessionButton.style.display = isNewChat ? 'none' : ''; + this.accountButton.style.display = isNewChat ? '' : 'none'; }; updateCenterMode(); this._register(contextKeyService.onDidChangeContext(e => { @@ -157,4 +221,252 @@ export class MobileTitlebarPart extends Disposable { setTitle(title: string): void { this.sessionTitleElement.textContent = title; } + + // --- Account Indicator --- // + + private async refreshAccount(): Promise { + const requestId = ++this.accountRequestCounter; + this.isAccountLoading = true; + this.renderAccountState(); + + const info = await resolveAccountInfo(this.defaultAccountService, this.authenticationService); + if (requestId !== this.accountRequestCounter) { + return; + } + + this.accountName = info?.accountName; + this.accountProviderId = info?.accountProviderId; + this.accountProviderLabel = info?.accountProviderLabel; + this.isAccountLoading = false; + this.refreshAvatar(); + this.renderAccountState(); + } + + private renderAccountState(): void { + // When we have a session from the auth service but the entitlement + // service hasn't resolved yet (still Unknown), treat it as the + // account being available rather than signed out. This avoids + // showing "Sign In" right after the walkthrough completes. + const entitlement = this.accountName && this.chatEntitlementService.entitlement === ChatEntitlement.Unknown + ? ChatEntitlement.Unresolved + : this.chatEntitlementService.entitlement; + + const state = getAccountTitleBarState({ + isAccountLoading: this.isAccountLoading, + accountName: this.accountName, + accountProviderLabel: this.accountProviderLabel, + entitlement, + sentiment: this.chatEntitlementService.sentiment, + quotas: this.chatEntitlementService.quotas, + }); + + // Avatar + const hasAvatar = !!this.loadedAvatarUrl && !this.isAccountLoading; + this.accountAvatarElement.classList.toggle('visible', hasAvatar); + if (hasAvatar && this.accountAvatarElement.src !== this.loadedAvatarUrl) { + this.accountAvatarElement.src = this.loadedAvatarUrl!; + } else if (!hasAvatar) { + this.accountAvatarElement.removeAttribute('src'); + } + + // Codicon fallback + const titleBarIcon = state.dotBadge ? Codicon.account : state.icon; + this.accountIconElement.className = ThemeIcon.asClassName(titleBarIcon); + this.accountIconElement.classList.toggle('hidden', hasAvatar); + + // Dot badge + const badgeKey = getAccountTitleBarBadgeKey(state); + if (badgeKey !== this.lastBadgeKey) { + this.lastBadgeKey = badgeKey; + this.dismissedBadgeKey = undefined; + } + const showBadge = !!badgeKey && badgeKey !== this.dismissedBadgeKey; + this.accountBadgeElement.style.display = showBadge ? '' : 'none'; + this.accountBadgeElement.classList.toggle('dot-badge-warning', showBadge && state.dotBadge === 'warning'); + this.accountBadgeElement.classList.toggle('dot-badge-error', showBadge && state.dotBadge === 'error'); + + // ARIA + this.accountButton.setAttribute('aria-label', state.ariaLabel); + } + + private refreshAvatar(): void { + const avatarUrl = getAccountProfileImageUrl(this.accountProviderId, this.accountName); + if (avatarUrl === this.currentAvatarUrl) { + return; + } + + this.currentAvatarUrl = avatarUrl; + this.loadedAvatarUrl = undefined; + this.avatarLoadDisposable.clear(); + const requestId = ++this.avatarRequestCounter; + + if (!avatarUrl) { + this.renderAccountState(); + return; + } + + const image = new Image(); + image.referrerPolicy = 'no-referrer'; + const clearHandlers = () => { image.onload = null; image.onerror = null; }; + image.onload = () => { + if (requestId !== this.avatarRequestCounter) { return; } + this.loadedAvatarUrl = avatarUrl; + this.renderAccountState(); + clearHandlers(); + }; + image.onerror = () => { + if (requestId !== this.avatarRequestCounter) { return; } + this.loadedAvatarUrl = undefined; + this.renderAccountState(); + clearHandlers(); + }; + this.avatarLoadDisposable.value = toDisposable(() => { clearHandlers(); image.src = ''; }); + image.src = avatarUrl; + } + + // --- Account Sheet --- // + + private showAccountPanel(): void { + if (this.isAccountMenuVisible) { + this.accountPanelDisposable.clear(); + return; + } + + this.accountPanelDisposable.clear(); + + const panelStore = new DisposableStore(); + this.accountPanelDisposable.value = panelStore; + + const badgeKey = getAccountTitleBarBadgeKey(getAccountTitleBarState({ + isAccountLoading: this.isAccountLoading, + accountName: this.accountName, + accountProviderLabel: this.accountProviderLabel, + entitlement: this.chatEntitlementService.entitlement, + sentiment: this.chatEntitlementService.sentiment, + quotas: this.chatEntitlementService.quotas, + })); + if (badgeKey) { + this.dismissedBadgeKey = badgeKey; + } + + this.isAccountMenuVisible = true; + this.renderAccountState(); + panelStore.add({ + dispose: () => { + this.isAccountMenuVisible = false; + this.copilotDashboardStore.clear(); + this.renderAccountState(); + } + }); + + const closeSheet = () => this.accountPanelDisposable.clear(); + + // Full-screen sheet inside the workbench container + const workbenchContainer = this.element.parentElement!; + const sheet = append(workbenchContainer, $('div.mobile-account-sheet')); + panelStore.add(toDisposable(() => sheet.remove())); + + // Header: title + close button + const header = append(sheet, $('div.mobile-account-sheet-header')); + const headerTitle = append(header, $('h2.mobile-account-sheet-title')); + headerTitle.textContent = localize('mobileAccount.title', "Account"); + const closeButton = append(header, $('button.mobile-account-sheet-close', { type: 'button' })) as HTMLButtonElement; + closeButton.setAttribute('aria-label', localize('mobileAccount.close', "Close")); + append(closeButton, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.close)); + panelStore.add(addDisposableListener(closeButton, EventType.CLICK, closeSheet)); + + // Scrollable content + const content = append(sheet, $('div.mobile-account-sheet-content')); + + // Profile section + const profile = append(content, $('div.mobile-account-sheet-profile')); + if (this.loadedAvatarUrl) { + const avatar = append(profile, $('img.mobile-account-sheet-avatar', { alt: '', draggable: 'false' })) as HTMLImageElement; + avatar.src = this.loadedAvatarUrl; + avatar.referrerPolicy = 'no-referrer'; + avatar.decoding = 'async'; + } else { + const avatarPlaceholder = append(profile, $('div.mobile-account-sheet-avatar-placeholder')); + append(avatarPlaceholder, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.account)); + } + const profileInfo = append(profile, $('div.mobile-account-sheet-profile-info')); + if (this.isAccountLoading) { + append(profileInfo, $('div.mobile-account-sheet-name')).textContent = localize('mobileAccount.loading', "Loading..."); + } else if (this.accountName) { + append(profileInfo, $('div.mobile-account-sheet-name')).textContent = this.accountName; + if (this.accountProviderLabel) { + append(profileInfo, $('div.mobile-account-sheet-provider')).textContent = this.accountProviderLabel; + } + } else { + append(profileInfo, $('div.mobile-account-sheet-name')).textContent = localize('mobileAccount.signedOut', "Not signed in"); + } + + // Copilot status dashboard — only when signed in AND entitlements + // have resolved. When entitlement is Unknown or Available (setup + // pending), the dashboard shows a "Set up Copilot" prompt that + // doesn't apply in the agents app. + const entitlement = this.chatEntitlementService.entitlement; + const showDashboard = !this.chatEntitlementService.sentiment.hidden + && !!this.accountName + && entitlement !== ChatEntitlement.Unknown + && entitlement !== ChatEntitlement.Available; + if (showDashboard) { + const dashboardSection = append(content, $('div.mobile-account-sheet-section')); + const store = new DisposableStore(); + this.copilotDashboardStore.value = store; + const dashboardElement = this.chatDashboardService.createDashboardElement(store); + if (dashboardElement) { + append(dashboardSection, dashboardElement); + } + } + + // Actions list + const actionsSection = append(content, $('div.mobile-account-sheet-actions')); + const allActions = this.getSheetActions(); + for (const action of allActions) { + if (action instanceof Separator) { + append(actionsSection, $('div.mobile-account-sheet-separator')); + continue; + } + const row = append(actionsSection, $('button.mobile-account-sheet-action', { type: 'button' })) as HTMLButtonElement; + row.disabled = !action.enabled; + row.setAttribute('aria-label', action.tooltip || action.label); + const icon = this.getActionIcon(action); + if (icon) { + append(row, $('span.mobile-account-sheet-action-icon')).classList.add(...ThemeIcon.asClassNameArray(icon)); + } + append(row, $('span.mobile-account-sheet-action-label')).textContent = action.label; + panelStore.add(addDisposableListener(row, EventType.CLICK, async event => { + event.preventDefault(); + event.stopPropagation(); + closeSheet(); + await Promise.resolve(action.run()); + })); + } + } + + private getSheetActions(): IAction[] { + const menu = this.menuService.createMenu(Menus.AccountMenu, this.contextKeyService); + const rawActions: IAction[] = []; + fillInActionBarActions(menu.getActions(), rawActions); + menu.dispose(); + return rawActions.filter(action => { + if (action instanceof Separator) { + return true; + } + if (this.isAccountLoading && action.id === 'workbench.action.agenticSignIn') { + return false; + } + return !action.id.startsWith('update.'); + }); + } + + private getActionIcon(action: IAction): ThemeIcon | undefined { + switch (action.id) { + case 'workbench.action.openSettings': return Codicon.settingsGear; + case 'workbench.action.agenticSignOut': return Codicon.signOut; + case 'workbench.action.agenticSignIn': return Codicon.signIn; + default: return undefined; + } + } } diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 01bd31f30a8919..4298334831b16c 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -61,7 +61,6 @@ import { IMarkdownRendererService } from '../../platform/markdown/browser/markdo import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; import { TitleService } from './parts/titlebarPart.js'; -import { SessionsExperimentalShellGradientBackgroundSettingId } from '../common/configuration.js'; import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; import { EditorMaximizedContext, IsPhoneLayoutContext, KeyboardVisibleContext } from '../common/contextkeys.js'; import { @@ -95,7 +94,7 @@ enum LayoutClasses { AUXILIARYBAR_HIDDEN = 'noauxiliarybar', CHATBAR_HIDDEN = 'nochatbar', STATUSBAR_HIDDEN = 'nostatusbar', - EXPERIMENTAL_SHELL_GRADIENT_BACKGROUND = 'experimental-shell-gradient-background', + SHELL_GRADIENT_BACKGROUND = 'shell-gradient-background', FULLSCREEN = 'fullscreen', MAXIMIZED = 'maximized', PHONE_LAYOUT = 'phone-layout' @@ -521,7 +520,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private registerListeners(lifecycleService: ILifecycleService, storageService: IStorageService, configurationService: IConfigurationService, hostService: IHostService, dialogService: IDialogService): void { // Configuration changes this._register(configurationService.onDidChangeConfiguration(e => this.updateFontAliasing(e, configurationService))); - this._register(configurationService.onDidChangeConfiguration(e => this.updateShellGradientBackground(e, configurationService))); // Font Info if (isNative) { @@ -605,17 +603,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } } - private updateShellGradientBackground(e: IConfigurationChangeEvent | undefined, configurationService: IConfigurationService): void { - if (e && !e.affectsConfiguration(SessionsExperimentalShellGradientBackgroundSettingId)) { - return; - } - - this.mainContainer.classList.toggle( - LayoutClasses.EXPERIMENTAL_SHELL_GRADIENT_BACKGROUND, - configurationService.getValue(SessionsExperimentalShellGradientBackgroundSettingId) - ); - } - //#endregion private renderWorkbench(instantiationService: IInstantiationService, notificationService: NotificationService, storageService: IStorageService, configurationService: IConfigurationService): void { @@ -640,6 +627,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const workbenchClasses = coalesce([ 'monaco-workbench', 'agent-sessions-workbench', + LayoutClasses.SHELL_GRADIENT_BACKGROUND, platformClass, isWeb ? 'web' : undefined, isChrome ? 'chromium' : isFirefox ? 'firefox' : isSafari ? 'safari' : undefined, @@ -651,7 +639,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Apply font aliasing this.updateFontAliasing(undefined, configurationService); - this.updateShellGradientBackground(undefined, configurationService); // Warm up font cache information before building up too many dom elements this.restoreFontInfo(storageService, configurationService); diff --git a/src/vs/sessions/common/configuration.ts b/src/vs/sessions/common/configuration.ts deleted file mode 100644 index 16949ea8c9c0d0..00000000000000 --- a/src/vs/sessions/common/configuration.ts +++ /dev/null @@ -1,6 +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 const SessionsExperimentalShellGradientBackgroundSettingId = 'sessions.experimental.shellGradientBackground'; diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index ce31e92612301e..217acc988f9416 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -10,7 +10,7 @@ import '../../../../workbench/contrib/chat/browser/chatStatus/media/chatStatus.c import Severity from '../../../../base/common/severity.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../nls.js'; -import { Action2, MenuRegistry, registerAction2, IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { Action2, MenuRegistry, registerAction2, IMenuService } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -34,19 +34,21 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { URI } from '../../../../base/common/uri.js'; import { isWindows, isMacintosh } from '../../../../base/common/platform.js'; import { UpdateHoverWidget } from './updateHoverWidget.js'; -import { ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { ChatStatusDashboard } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { getAccountProfileImageUrl, getAccountTitleBarBadgeKey, getAccountTitleBarState } from './accountTitleBarState.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { getAccountProfileImageUrl, getAccountTitleBarBadgeKey, getAccountTitleBarState, resolveAccountInfo } from '../../../browser/accountTitleBarState.js'; +import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IAuthenticationAccessService } from '../../../../workbench/services/authentication/browser/authenticationAccessService.js'; import { IAuthenticationUsageService } from '../../../../workbench/services/authentication/browser/authenticationUsageService.js'; import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { IChatDashboardService } from '../../../browser/chatDashboardService.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; // --- Account Menu Items --- // -const AccountMenu = new MenuId('SessionsAccountMenu'); +const AccountMenu = Menus.AccountMenu; const SessionsTitleBarAccountWidgetAction = 'sessions.action.titleBarAccountWidget'; const SessionsTitleBarUpdateWidgetAction = 'sessions.action.titleBarUpdateWidget'; const SESSIONS_ACCOUNT_TITLEBAR_PANEL_WIDTH = 280; @@ -218,12 +220,13 @@ registerAction2(class extends Action2 { } }); -// Settings +// Settings (hidden on phone — no settings UI on mobile) MenuRegistry.appendMenuItem(AccountMenu, { command: { id: 'workbench.action.openSettings', title: localize('settings', "Settings"), }, + when: IsPhoneLayoutContext.negate(), group: '2_settings', order: 1, }); @@ -258,6 +261,7 @@ class TitleBarAccountWidget extends BaseActionViewItem { action: IAction, options: IBaseActionViewItemOptions | undefined, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IHoverService private readonly hoverService: IHoverService, @@ -273,6 +277,7 @@ class TitleBarAccountWidget extends BaseActionViewItem { }); this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.refreshAccount())); + this._register(this.authenticationService.onDidChangeSessions(() => this.refreshAccount())); this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.renderState())); this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.renderState())); this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.renderState())); @@ -316,14 +321,14 @@ class TitleBarAccountWidget extends BaseActionViewItem { this.isAccountLoading = true; this.renderState(); - const account = await this.defaultAccountService.getDefaultAccount(); + const info = await resolveAccountInfo(this.defaultAccountService, this.authenticationService); if (requestId !== this.accountRequestCounter) { return; } - this.accountName = account?.accountName; - this.accountProviderId = account?.authenticationProvider.id; - this.accountProviderLabel = account?.authenticationProvider.name; + this.accountName = info?.accountName; + this.accountProviderId = info?.accountProviderId; + this.accountProviderLabel = info?.accountProviderLabel; this.isAccountLoading = false; this.refreshAvatar(); this.renderState(); @@ -334,11 +339,17 @@ class TitleBarAccountWidget extends BaseActionViewItem { return; } + // When we have a session but entitlement hasn't resolved yet, + // treat as Unresolved to avoid showing "Agents Signed Out". + const entitlement = this.accountName && this.chatEntitlementService.entitlement === ChatEntitlement.Unknown + ? ChatEntitlement.Unresolved + : this.chatEntitlementService.entitlement; + const state = getAccountTitleBarState({ isAccountLoading: this.isAccountLoading, accountName: this.accountName, accountProviderLabel: this.accountProviderLabel, - entitlement: this.chatEntitlementService.entitlement, + entitlement, sentiment: this.chatEntitlementService.sentiment, quotas: this.chatEntitlementService.quotas, }); @@ -785,3 +796,32 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu } registerWorkbenchContribution2(AccountWidgetContribution.ID, AccountWidgetContribution, WorkbenchPhase.BlockRestore); + +// --- Chat Dashboard Service (real implementation for mobile account sheet) --- // + +class ChatDashboardServiceImpl implements IChatDashboardService { + readonly _serviceBrand: undefined; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + createDashboardElement(store: DisposableStore): HTMLElement | undefined { + const dashboardElement = ChatStatusDashboard.instantiateInContents(this.instantiationService, store, { + disableInlineSuggestionsSettings: true, + disableModelSelection: true, + disableProviderOptions: true, + disableCompletionsSnooze: true, + }); + + store.add(disposableWindowInterval(mainWindow, () => { + if (!dashboardElement.isConnected) { + store.dispose(); + } + }, 2000)); + + return dashboardElement; + } +} + +registerSingleton(IChatDashboardService, ChatDashboardServiceImpl, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/accountTitleBarState.test.ts b/src/vs/sessions/contrib/accountMenu/test/browser/accountTitleBarState.test.ts index bfc22888348dec..364c19a6de081a 100644 --- a/src/vs/sessions/contrib/accountMenu/test/browser/accountTitleBarState.test.ts +++ b/src/vs/sessions/contrib/accountMenu/test/browser/accountTitleBarState.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ChatEntitlement } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; -import { getAccountProfileImageUrl, getAccountTitleBarBadgeKey, getAccountTitleBarState, IAccountTitleBarStateContext } from '../../browser/accountTitleBarState.js'; +import { getAccountProfileImageUrl, getAccountTitleBarBadgeKey, getAccountTitleBarState, IAccountTitleBarStateContext } from '../../../../browser/accountTitleBarState.js'; suite('Sessions - Account Title Bar State', () => { diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index af75eb0144c435..9a55814e64d229 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -3,25 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { localize } from '../../../../nls.js'; +import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { SessionsExperimentalShellGradientBackgroundSettingId } from '../../../common/configuration.js'; import { ThemeSettingDefaults } from '../../../../workbench/services/themes/common/workbenchThemeService.js'; -Registry.as(Extensions.Configuration).registerConfiguration({ - id: 'sessions', - properties: { - [SessionsExperimentalShellGradientBackgroundSettingId]: { - type: 'boolean', - default: false, - scope: ConfigurationScope.APPLICATION, - tags: ['experimental'], - description: localize('sessions.experimental.shellGradientBackground', "Whether to enable the experimental accent-tinted shell background in the Sessions window."), - }, - }, -}); - Registry.as(Extensions.Configuration).registerDefaultConfigurations([{ overrides: { 'breadcrumbs.enabled': false, diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubChangesFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubChangesFetcher.ts index bc53ff1b4eb7a9..c991541331f27b 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubChangesFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubChangesFetcher.ts @@ -26,19 +26,19 @@ export class GitHubChangesFetcher { ) { } async getChangedFiles(owner: string, repo: string, base: string, head: string): Promise { - const data = await this._apiClient.request( + const response = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/compare/${e(base)}...${e(head)}`, 'githubApi.getChangedFiles' ); - return data.files.map(file => ({ + return response.data?.files.map(file => ({ filename: file.filename, previous_filename: file.previous_filename, status: file.status, additions: file.additions, deletions: file.deletions, - })); + })) ?? []; } } diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts index 3b46c17564ae0c..81b172be84509a 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../common/types.js'; -import { GitHubApiClient } from '../githubApiClient.js'; +import { GitHubApiClient, IGitHubApiResponse } from '../githubApiClient.js'; //#region GitHub API response types @@ -59,20 +59,28 @@ export class GitHubPRCIFetcher { private readonly _apiClient: GitHubApiClient, ) { } - async getCheckRuns(owner: string, repo: string, ref: string): Promise { - const data = await this._apiClient.request( + async getCheckRuns(owner: string, repo: string, ref: string, etag?: string): Promise> { + const response = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/commits/${e(ref)}/check-runs`, - 'githubApi.getCheckRuns' + 'githubApi.getCheckRuns', + undefined, + etag ); - return data.check_runs.map(mapCheckRun); + + return { + ...response, + data: response.data + ? response.data.check_runs.map(mapCheckRun) + : undefined + }; } /** * Rerun failed jobs in a GitHub Actions workflow run. */ async rerunFailedJobs(owner: string, repo: string, runId: number): Promise { - await this._apiClient.request( + await this._apiClient.request2( 'POST', `/repos/${e(owner)}/${e(repo)}/actions/runs/${runId}/rerun-failed-jobs`, 'githubApi.rerunFailedJobs' @@ -90,23 +98,22 @@ export class GitHubPRCIFetcher { */ async getCheckRunAnnotations(owner: string, repo: string, checkRunId: number): Promise { const sections: string[] = []; - let detail: IGitHubCheckRunDetailResponse | undefined; // 1. Fetch check run detail for output fields try { - detail = await this._apiClient.request( + const detailResponse = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/check-runs/${checkRunId}`, 'githubApi.getCheckRunAnnotations' ); - const output = detail.output; - if (output.title) { + const output = detailResponse.data?.output; + if (output?.title) { sections.push(`# ${output.title}`); } - if (output.summary) { + if (output?.summary) { sections.push(output.summary); } - if (output.text) { + if (output?.text) { sections.push(output.text); } } catch { @@ -115,12 +122,13 @@ export class GitHubPRCIFetcher { // 2. Fetch annotations try { - const annotations = await this._apiClient.request( + const annotationsResponse = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/check-runs/${checkRunId}/annotations`, 'githubApi.getCheckRunAnnotations.annotations' ); - if (annotations.length > 0) { + const annotations = annotationsResponse.data; + if (annotations && annotations.length > 0) { sections.push( annotations.map(a => `[${a.annotation_level}] ${a.path}:${a.start_line}${a.end_line !== a.start_line ? `-${a.end_line}` : ''} ${a.title ? `(${a.title}) ` : ''}${a.message}` diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts index 9197d31a274d45..37fdfc8849ece4 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts @@ -14,7 +14,7 @@ import { MergeBlockerKind, IGitHubPullRequestReviewThread, } from '../../common/types.js'; -import { GitHubApiClient } from '../githubApiClient.js'; +import { GitHubApiClient, IGitHubApiResponse } from '../githubApiClient.js'; //#region GitHub API response types @@ -159,22 +159,38 @@ export class GitHubPRFetcher { private readonly _apiClient: GitHubApiClient, ) { } - async getPullRequest(owner: string, repo: string, prNumber: number): Promise { - const data = await this._apiClient.request( + async getPullRequest(owner: string, repo: string, prNumber: number, etag?: string): Promise> { + const response = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}`, - 'githubApi.getPullRequest' + 'githubApi.getPullRequest', + undefined, + etag ); - return mapPullRequest(data); + + return { + ...response, + data: response.data + ? mapPullRequest(response.data) + : undefined + }; } - async getReviews(owner: string, repo: string, prNumber: number): Promise { - const data = await this._apiClient.request( + async getReviews(owner: string, repo: string, prNumber: number, etag?: string): Promise> { + const response = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}/reviews`, 'githubApi.getReviews', + undefined, + etag ); - return data.map(mapReview); + + return { + ...response, + data: response.data + ? response.data.map(mapReview) + : undefined + }; } async getReviewThreads(owner: string, repo: string, prNumber: number): Promise { @@ -199,13 +215,16 @@ export class GitHubPRFetcher { body: string, inReplyTo: number, ): Promise { - const data = await this._apiClient.request( + const response = await this._apiClient.request2( 'POST', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}/comments`, 'githubApi.postReviewComment', { body, in_reply_to: inReplyTo }, ); - return mapReviewComment(data); + if (!response.data) { + throw new Error(`Failed to post review comment to ${owner}/${repo}#${prNumber}`); + } + return mapReviewComment(response.data); } async postIssueComment( @@ -214,12 +233,16 @@ export class GitHubPRFetcher { prNumber: number, body: string, ): Promise { - const data = await this._apiClient.request( + const response = await this._apiClient.request2( 'POST', `/repos/${e(owner)}/${e(repo)}/issues/${prNumber}/comments`, 'githubApi.postIssueComment', { body }, ); + const data = response.data; + if (!data) { + throw new Error(`Failed to post issue comment to ${owner}/${repo}#${prNumber}`); + } return { id: data.id, body: data.body ?? '', diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts index 5e4a90dfa90d18..2f4cb1bbfef73b 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IGitHubRepository } from '../../common/types.js'; -import { GitHubApiClient } from '../githubApiClient.js'; +import { GitHubApiClient, IGitHubApiResponse } from '../githubApiClient.js'; interface IGitHubRepoResponse { readonly name: string; @@ -25,19 +25,27 @@ export class GitHubRepositoryFetcher { private readonly _apiClient: GitHubApiClient, ) { } - async getRepository(owner: string, repo: string): Promise { - const data = await this._apiClient.request( + async getRepository(owner: string, repo: string, etag?: string): Promise> { + const response = await this._apiClient.request2( 'GET', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, - 'githubApi.getRepository' + 'githubApi.getRepository', + undefined, + etag ); + return { - owner: data.owner.login, - name: data.name, - fullName: data.full_name, - defaultBranch: data.default_branch, - isPrivate: data.private, - description: data.description ?? '', + ...response, + data: response.data + ? { + owner: response.data.owner.login, + name: response.data.name, + fullName: response.data.full_name, + defaultBranch: response.data.default_branch, + isPrivate: response.data.private, + description: response.data.description ?? '', + } + : undefined }; } } diff --git a/src/vs/sessions/contrib/github/browser/githubApiClient.ts b/src/vs/sessions/contrib/github/browser/githubApiClient.ts index 9b02e854ffc8e6..7fcf6ae5d665c4 100644 --- a/src/vs/sessions/contrib/github/browser/githubApiClient.ts +++ b/src/vs/sessions/contrib/github/browser/githubApiClient.ts @@ -13,6 +13,12 @@ const LOG_PREFIX = '[GitHubApiClient]'; const GITHUB_API_BASE = 'https://api.github.com'; const GITHUB_GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`; +export interface IGitHubApiResponse { + readonly data: T | undefined; + readonly statusCode: number; + readonly etag?: string; +} + interface IGitHubGraphQLError { readonly message: string; } @@ -54,6 +60,10 @@ export class GitHubApiClient extends Disposable { return this._request(method, `${GITHUB_API_BASE}${path}`, path, 'application/vnd.github.v3+json', callSite, body); } + async request2(method: string, path: string, callSite: string, body?: unknown, etag?: string): Promise> { + return this._request2(method, `${GITHUB_API_BASE}${path}`, path, 'application/vnd.github.v3+json', callSite, body, etag); + } + async graphql(query: string, callSite: string, variables?: Record): Promise { const response = await this._request>( 'POST', @@ -128,6 +138,61 @@ export class GitHubApiClient extends Disposable { return data; } + private async _request2(method: string, url: string, pathForLogging: string, accept: string, callSite: string, body?: unknown, etag?: string): Promise> { + const token = await this._getAuthToken(); + + this._logService.trace(`${LOG_PREFIX} ${method} ${pathForLogging}`); + + const response = await this._requestService.request({ + type: method, + url, + headers: { + 'Authorization': `token ${token}`, + 'Accept': accept, + 'User-Agent': 'VSCode-Sessions-GitHub', + ...(etag !== undefined ? { 'If-None-Match': etag } : {}), + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + data: body !== undefined ? JSON.stringify(body) : undefined, + callSite + }, CancellationToken.None); + + const rateLimitRemaining = parseRateLimitHeader(response.res.headers?.['x-ratelimit-remaining']); + if (rateLimitRemaining !== undefined && rateLimitRemaining < 100) { + this._logService.warn(`${LOG_PREFIX} GitHub API rate limit low: ${rateLimitRemaining} remaining`); + } + + const statusCode = response.res.statusCode ?? 0; + const responseETag = response.res.headers?.['etag']; + + if ( + statusCode === 204 /* No Content */ || + statusCode === 304 /* Not Modified */ + ) { + return { data: undefined, statusCode, etag: responseETag }; + } + + if (statusCode < 200 || statusCode >= 300) { + const errorBody = await asJson<{ message?: string }>(response).catch(() => undefined); + throw new GitHubApiError( + errorBody?.message ?? `GitHub API request failed: ${method} ${pathForLogging} (${statusCode})`, + statusCode, + rateLimitRemaining, + ); + } + + const data = await asJson(response); + if (!data) { + throw new GitHubApiError( + `Failed to parse response for ${method} ${pathForLogging}`, + statusCode, + rateLimitRemaining, + ); + } + + return { data, statusCode, etag: responseETag }; + } + private async _getAuthToken(): Promise { let sessions = await this._authenticationService.getSessions('github', [], { silent: true }); if (!sessions || sessions.length === 0) { diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts index 3f0cec668525eb..ba12e62e611113 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts @@ -19,6 +19,7 @@ const DEFAULT_POLL_INTERVAL_MS = 60_000; */ export class GitHubPullRequestCIModel extends Disposable { + private _checksEtag: string | undefined = undefined; private readonly _checks = observableValue(this, []); readonly checks: IObservable = this._checks; @@ -45,9 +46,12 @@ export class GitHubPullRequestCIModel extends Disposable { */ async refresh(): Promise { try { - const checks = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headRef); - this._checks.set(checks, undefined); - this._overallStatus.set(computeOverallCIStatus(checks), undefined); + const response = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headRef, this._checksEtag); + if (response.statusCode === 200 && response.data) { + this._checksEtag = response.etag; + this._checks.set(response.data, undefined); + this._overallStatus.set(computeOverallCIStatus(response.data), undefined); + } } catch (err) { this._logService.error(`${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}@${this.headRef}:`, err); } diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts index 415b0f60a6328c..21c77de511bbfb 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts @@ -7,7 +7,7 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IGitHubPRComment, IGitHubPullRequest, IGitHubPullRequestMergeability, IGitHubPullRequestReviewThread } from '../../common/types.js'; +import { IGitHubPRComment, IGitHubPullRequest, IGitHubPullRequestMergeability, IGitHubPullRequestReview, IGitHubPullRequestReviewThread } from '../../common/types.js'; import { computeMergeability, GitHubPRFetcher } from '../fetchers/githubPRFetcher.js'; const LOG_PREFIX = '[GitHubPullRequestModel]'; @@ -19,9 +19,14 @@ const DEFAULT_POLL_INTERVAL_MS = 60_000; */ export class GitHubPullRequestModel extends Disposable { + private _pullRequestEtag: string | undefined = undefined; private readonly _pullRequest = observableValue(this, undefined); readonly pullRequest: IObservable = this._pullRequest; + private _reviewsEtag: string | undefined = undefined; + private readonly _reviews = observableValue(this, undefined); + readonly reviews: IObservable = this._reviews; + private readonly _mergeability = observableValue(this, undefined); readonly mergeability: IObservable = this._mergeability; @@ -112,15 +117,33 @@ export class GitHubPullRequestModel extends Disposable { private async _refreshPullRequestAndMergeability(): Promise { try { const [pr, reviews] = await Promise.all([ - this._fetcher.getPullRequest(this.owner, this.repo, this.prNumber), - this._fetcher.getReviews(this.owner, this.repo, this.prNumber), + this._fetcher.getPullRequest(this.owner, this.repo, this.prNumber, this._pullRequestEtag), + this._fetcher.getReviews(this.owner, this.repo, this.prNumber, this._reviewsEtag), ]); - const mergeability = computeMergeability(pr, reviews); - transaction(tx => { - this._pullRequest.set(pr, tx); - this._mergeability.set(mergeability, tx); + if (pr.statusCode === 200 && pr.data) { + this._pullRequestEtag = pr.etag; + this._pullRequest.set(pr.data, tx); + } + + if (reviews.statusCode === 200 && reviews.data) { + this._reviewsEtag = reviews.etag; + this._reviews.set(reviews.data, tx); + } + + // Recompute mergeability if either the pull request or reviews changed. Both + // are needed to compute mergeability, so we wait until both requests complete + // before updating. + if (pr.statusCode === 200 || reviews.statusCode === 200) { + const prData = pr.data ?? this._pullRequest.get(); + const reviewsData = reviews.data ?? this._reviews.get(); + + if (prData && reviewsData) { + const mergeability = computeMergeability(prData, reviewsData); + this._mergeability.set(mergeability, tx); + } + } }); } catch (err) { this._logService.error(`${LOG_PREFIX} Failed to refresh PR #${this.prNumber}:`, err); diff --git a/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts b/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts index 9e2c368a329ab3..e34399eff78a15 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts @@ -17,6 +17,7 @@ const LOG_PREFIX = '[GitHubRepositoryModel]'; */ export class GitHubRepositoryModel extends Disposable { + private _repositoryEtag: string | undefined = undefined; private readonly _repository = observableValue(this, undefined); readonly repository: IObservable = this._repository; @@ -31,8 +32,11 @@ export class GitHubRepositoryModel extends Disposable { async refresh(): Promise { try { - const data = await this._fetcher.getRepository(this.owner, this.repo); - this._repository.set(data, undefined); + const response = await this._fetcher.getRepository(this.owner, this.repo, this._repositoryEtag); + if (response.statusCode === 200 && response.data) { + this._repositoryEtag = response.etag; + this._repository.set(response.data, undefined); + } } catch (err) { this._logService.error(`${LOG_PREFIX} Failed to refresh repository ${this.owner}/${this.repo}:`, err); } diff --git a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts index e3eddbc105157e..c9c6f7d54253e0 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts @@ -37,6 +37,14 @@ class MockApiClient { return this._nextResponse as T; } + async request2(_method: string, _path: string, _callSite: string, _body?: unknown, _etag?: string): Promise<{ data: T | undefined; statusCode: number; etag?: string }> { + this.requestCalls.push({ method: _method, path: _path, body: _body }); + if (this._nextError) { + throw this._nextError; + } + return { data: this._nextResponse as T, statusCode: 200 }; + } + async graphql(query: string, _callSite: string, variables?: Record): Promise { this.graphqlCalls.push({ query, variables }); if (this._nextError) { @@ -72,7 +80,7 @@ suite('GitHubRepositoryFetcher', () => { }); const repo = await fetcher.getRepository('microsoft', 'vscode'); - assert.deepStrictEqual(repo, { + assert.deepStrictEqual(repo.data, { owner: 'microsoft', name: 'vscode', fullName: 'microsoft/vscode', @@ -94,7 +102,7 @@ suite('GitHubRepositoryFetcher', () => { }); const repo = await fetcher.getRepository('owner', 'test'); - assert.strictEqual(repo.description, ''); + assert.strictEqual(repo.data?.description, ''); }); test('getRepository propagates API errors', async () => { @@ -125,25 +133,25 @@ suite('GitHubPRFetcher', () => { mockApi.setNextResponse(makePRResponse({ state: 'open', merged: false, draft: false })); const pr = await fetcher.getPullRequest('owner', 'repo', 1); - assert.strictEqual(pr.state, GitHubPullRequestState.Open); - assert.strictEqual(pr.isDraft, false); - assert.strictEqual(pr.number, 1); - assert.strictEqual(pr.title, 'Test PR'); + assert.strictEqual(pr.data?.state, GitHubPullRequestState.Open); + assert.strictEqual(pr.data?.isDraft, false); + assert.strictEqual(pr.data?.number, 1); + assert.strictEqual(pr.data?.title, 'Test PR'); }); test('getPullRequest maps merged PR', async () => { mockApi.setNextResponse(makePRResponse({ state: 'closed', merged: true, draft: false })); const pr = await fetcher.getPullRequest('owner', 'repo', 1); - assert.strictEqual(pr.state, GitHubPullRequestState.Merged); - assert.ok(pr.mergedAt); + assert.strictEqual(pr.data?.state, GitHubPullRequestState.Merged); + assert.ok(pr.data?.mergedAt); }); test('getPullRequest maps closed PR', async () => { mockApi.setNextResponse(makePRResponse({ state: 'closed', merged: false, draft: false })); const pr = await fetcher.getPullRequest('owner', 'repo', 1); - assert.strictEqual(pr.state, GitHubPullRequestState.Closed); + assert.strictEqual(pr.data?.state, GitHubPullRequestState.Closed); }); test('getReviewThreads returns GraphQL thread metadata', async () => { @@ -205,7 +213,7 @@ suite('GitHubPRFetcher', () => { ]); const reviews = await fetcher.getReviews('owner', 'repo', 1); - assert.deepStrictEqual(reviews, [ + assert.deepStrictEqual(reviews.data, [ { id: 1, author: { login: 'reviewer', avatarUrl: '' }, state: 'APPROVED', submittedAt: '2024-01-01T00:00:00Z' }, { id: 2, author: { login: 'other', avatarUrl: '' }, state: 'CHANGES_REQUESTED', submittedAt: '2024-01-02T00:00:00Z' }, ]); @@ -270,8 +278,8 @@ suite('GitHubPRCIFetcher', () => { }); const checks = await fetcher.getCheckRuns('owner', 'repo', 'abc123'); - assert.strictEqual(checks.length, 2); - assert.deepStrictEqual(checks[0], { + assert.strictEqual(checks.data?.length, 2); + assert.deepStrictEqual(checks.data?.[0], { id: 1, name: 'build', status: GitHubCheckStatus.Completed, @@ -280,7 +288,7 @@ suite('GitHubPRCIFetcher', () => { completedAt: '2024-01-01T00:10:00Z', detailsUrl: 'https://example.com/1', }); - assert.strictEqual(checks[1].conclusion, undefined); + assert.strictEqual(checks.data?.[1].conclusion, undefined); }); test('getCheckRunAnnotations returns formatted annotations', async () => { diff --git a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts index 7cd1239391c342..a8618eefec6c0b 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts @@ -20,11 +20,11 @@ import { GitHubCIOverallStatus, GitHubCheckConclusion, GitHubCheckStatus, GitHub class MockRepositoryFetcher { nextResult: IGitHubRepository | undefined; - async getRepository(_owner: string, _repo: string): Promise { + async getRepository(_owner: string, _repo: string, _etag?: string): Promise<{ data: IGitHubRepository | undefined; statusCode: number; etag?: string }> { if (!this.nextResult) { throw new Error('No mock result'); } - return this.nextResult; + return { data: this.nextResult, statusCode: 200 }; } } @@ -35,15 +35,15 @@ class MockPRFetcher { postReviewCommentCalls: { body: string; inReplyTo: number }[] = []; postIssueCommentCalls: { body: string }[] = []; - async getPullRequest(_owner: string, _repo: string, _prNumber: number): Promise { + async getPullRequest(_owner: string, _repo: string, _prNumber: number, _etag?: string): Promise<{ data: IGitHubPullRequest | undefined; statusCode: number; etag?: string }> { if (!this.nextPR) { throw new Error('No mock PR'); } - return this.nextPR; + return { data: this.nextPR, statusCode: 200 }; } - async getReviews(_owner: string, _repo: string, _prNumber: number): Promise { - return this.nextReviews; + async getReviews(_owner: string, _repo: string, _prNumber: number, _etag?: string): Promise<{ data: readonly IGitHubPullRequestReview[] | undefined; statusCode: number; etag?: string }> { + return { data: this.nextReviews, statusCode: 200 }; } async getReviewThreads(_owner: string, _repo: string, _prNumber: number): Promise { @@ -68,8 +68,8 @@ class MockPRFetcher { class MockCIFetcher { nextChecks: IGitHubCICheck[] = []; - async getCheckRuns(_owner: string, _repo: string, _ref: string): Promise { - return this.nextChecks; + async getCheckRuns(_owner: string, _repo: string, _ref: string, _etag?: string): Promise<{ data: readonly IGitHubCICheck[] | undefined; statusCode: number; etag?: string }> { + return { data: this.nextChecks, statusCode: 200 }; } async getCheckRunAnnotations(_owner: string, _repo: string, _checkRunId: number): Promise { diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index a7ace353613ee8..9edc816237ecc6 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -229,14 +229,14 @@ } } -.agent-sessions-workbench.experimental-shell-gradient-background .agent-sessions-viewpane { +.agent-sessions-workbench.shell-gradient-background .agent-sessions-viewpane { background: transparent !important; } -.agent-sessions-workbench.experimental-shell-gradient-background .part.sidebar > .content .pane-body, -.agent-sessions-workbench.experimental-shell-gradient-background .part.sidebar > .content .monaco-scrollable-element, -.agent-sessions-workbench.experimental-shell-gradient-background .part.sidebar > .content .monaco-list, -.agent-sessions-workbench.experimental-shell-gradient-background .part.sidebar > .content .monaco-list-rows { +.agent-sessions-workbench.shell-gradient-background .part.sidebar > .content .pane-body, +.agent-sessions-workbench.shell-gradient-background .part.sidebar > .content .monaco-scrollable-element, +.agent-sessions-workbench.shell-gradient-background .part.sidebar > .content .monaco-list, +.agent-sessions-workbench.shell-gradient-background .part.sidebar > .content .monaco-list-rows { background: transparent !important; } diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 87032a4efda170..4fdb3346c19570 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -64,7 +64,9 @@ function getSessionTerminalInfo(session: ISession | undefined): ISessionTerminal * Manages terminal instances in the sessions window, ensuring: * - A terminal exists for the active session's worktree (or repository if no worktree). * - Terminals are shown/hidden based on their initial cwd matching the active path. - * - All terminals for a worktree are closed when the session is archived. + * - Terminals for an archived/removed session are closed only when no other + * live session still owns the same cwd (terminals are reused across sessions + * at the same worktree). */ export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution { @@ -152,11 +154,32 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } })); - // When a session is archived or removed, close all terminals for its cwd + // Close terminals for archived/removed sessions, but only when no other + // live session still owns that cwd. Terminals are reused across sessions + // at the same cwd, so a plain cwd match would kill a terminal still in use + // (e.g. the committed session from `onDidReplaceSession`). + // TODO: Consider removing the logic for trying to "delete/clean-up" terminal. + // Or consider tag terminals by sessionId + refcount instead of guarding here. + this._register(this._sessionsManagementService.onDidChangeSessions(e => { - for (const session of [...e.removed, ...e.changed.filter(s => s.isArchived.get())]) { + const archivedChanged = e.changed.filter(s => s.isArchived.get()); + if (e.removed.length === 0 && archivedChanged.length === 0) { + return; + } + const removedIds = new Set(e.removed.map(s => s.sessionId)); + const liveCwdKeys = new Set(); + for (const session of this._sessionsManagementService.getSessions()) { + if (removedIds.has(session.sessionId) || session.isArchived.get()) { + continue; + } const info = getSessionTerminalInfo(session); if (info) { + liveCwdKeys.add(info.cwd.fsPath.toLowerCase()); + } + } + for (const session of [...e.removed, ...archivedChanged]) { + const info = getSessionTerminalInfo(session); + if (info && !liveCwdKeys.has(info.cwd.fsPath.toLowerCase())) { this._closeTerminalsForPath(info.cwd.fsPath); } } diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 9e2ec230e58d79..f725fe4177e2d3 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -50,6 +50,7 @@ function makeAgentSession(opts: { worktree?: URI; providerType?: string; isArchived?: boolean; + sessionId?: string; }): IActiveSession { const repo = opts.repository || opts.worktree ? { uri: opts.repository ?? opts.worktree!, @@ -72,7 +73,7 @@ function makeAgentSession(opts: { description: observableValue('test.description', undefined), }; const session: IActiveSession = { - sessionId: 'test:session', + sessionId: opts.sessionId ?? 'test:session', resource: chat.resource, providerId: 'test', sessionType: opts.providerType ?? AgentSessionProviders.Local, @@ -198,6 +199,7 @@ suite('SessionsTerminalContribution', () => { let showBackgroundCalls: number[]; let disposeOnCreatePaths: Set; let logService: TestLogService; + let allSessions: ISession[]; setup(() => { createdTerminals = []; @@ -211,6 +213,7 @@ suite('SessionsTerminalContribution', () => { showBackgroundCalls = []; disposeOnCreatePaths = new Set(); logService = new TestLogService(); + allSessions = []; const instantiationService = store.add(new TestInstantiationService()); @@ -223,6 +226,7 @@ suite('SessionsTerminalContribution', () => { instantiationService.stub(ISessionsManagementService, new class extends mock() { override activeSession = activeSessionObs; override readonly onDidChangeSessions = onDidChangeSessions.event; + override getSessions(): ISession[] { return [...allSessions]; } }); instantiationService.stub(ITerminalService, new class extends mock() { @@ -553,6 +557,50 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(disposedInstances.length, 1); }); + test('does not close terminal when another live session still owns the cwd (replace case)', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + // Simulate the onDidReplaceSession flow: `from` (untitled) is reported as + // removed while `to` (committed) is still live at the same cwd. + const fromSession = makeAgentSession({ sessionId: 'test:untitled', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + const toSession = makeAgentSession({ sessionId: 'test:committed', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + allSessions = [toSession]; + + onDidChangeSessions.fire({ added: [], removed: [fromSession], changed: [toSession] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 0, 'terminal should be kept alive for the surviving session'); + }); + + test('does not close terminal when archiving one of two sessions sharing a cwd', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + const liveSession = makeAgentSession({ sessionId: 'test:live', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + const archivedSession = makeAgentSession({ sessionId: 'test:archived', worktree: worktreeUri, providerType: AgentSessionProviders.Background, isArchived: true }); + allSessions = [liveSession, archivedSession]; + + onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 0, 'terminal should be kept for the still-live session'); + }); + + test('closes terminal when the only session at a cwd is removed even if other live sessions exist elsewhere', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + const otherLive = makeAgentSession({ sessionId: 'test:other', worktree: URI.file('/other'), providerType: AgentSessionProviders.Background }); + const removedSession = makeAgentSession({ sessionId: 'test:gone', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + allSessions = [otherLive]; + + onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 1, 'no live session owns this cwd, terminal should be closed'); + }); + // --- switching back to previously used path reuses terminal --- test('switching back to a previously used background path reuses the existing terminal', async () => { diff --git a/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts index 246f89eb07208b..4c134230f4b58c 100644 --- a/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts +++ b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts @@ -195,7 +195,7 @@ export class SessionsWalkthroughOverlay extends Disposable { // Always show the welcome title/subtitle with sign-in buttons, // whether it's the first launch or a returning user who is signed out. const titleEl = append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName))); - const subtitleEl = append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered coding agent that builds, tests, and iterates for you."))); + const subtitleEl = append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered application where agents explore, build, and iterate with you."))); append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!"))); this._renderSignInButtons(stepDisposables, right, titleEl, subtitleEl); @@ -245,7 +245,7 @@ export class SessionsWalkthroughOverlay extends Disposable { this.currentFocusableElements = [...providerButtons, ...this.disclaimerLinks]; if (isWeb) { - // Web: GitHub button uses IAuthenticationService directly + // Web: GitHub button uses IAuthenticationService with product scopes stepDisposables.add(addDisposableListener(githubBtn, EventType.CLICK, () => this._runSignInWeb( providerButtons, errorContainer, @@ -283,7 +283,7 @@ export class SessionsWalkthroughOverlay extends Disposable { this.disclaimerElement.classList.toggle('hidden', this.disclaimerLinks.length === 0); append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName))); - append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered coding agent that builds, tests, and iterates for you."))); + append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered application where agents explore, build, and iterate with you."))); append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!"))); const actions = append(right, $('.sessions-walkthrough-welcome-actions')); @@ -350,10 +350,10 @@ export class SessionsWalkthroughOverlay extends Disposable { } /** - * Web sign-in: uses IAuthenticationService to create a GitHub session. - * On production vscode.dev this triggers an OAuth popup. On localhost - * the embedder's env-contributed auth provider handles the flow - * (e.g. device code). + * Web sign-in: uses IAuthenticationService to create a GitHub session + * with the scopes defined in product.json. On production vscode.dev + * this triggers an OAuth popup. On localhost the embedder's + * env-contributed auth provider handles the flow (e.g. device code). */ private async _runSignInWeb(providerButtons: HTMLButtonElement[], error: HTMLElement, titleEl: HTMLElement, subtitleEl: HTMLElement, signInActions: HTMLElement): Promise { await this._fadeToProgress(providerButtons, error, titleEl, subtitleEl, signInActions); @@ -362,7 +362,9 @@ export class SessionsWalkthroughOverlay extends Disposable { } try { - await this.authenticationService.createSession('github', ['repo', 'user:email', 'read:user'], { activateImmediate: true }); + const scopes = this.productService.defaultChatAgent?.providerScopes?.[0] + ?? ['read:user', 'user:email', 'repo', 'workflow']; + await this.authenticationService.createSession('github', scopes, { activateImmediate: true }); this.complete(); } catch (err) { this.logService.error('[sessions walkthrough] Web sign-in failed:', err); diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index 476c4a59a83c7b..0d0d9b2b5bc934 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -160,6 +160,8 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc * 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. + * + * Also watches for passive token expiry on web. */ private _watchWebAuth(): void { this._register(this.authenticationService.onDidChangeSessions(async e => { diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 7f7211ae2af4b5..10b6d60ecf4719 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -357,9 +357,16 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA chatSessionContext, }, token); - // Suppress expected operational errors (rate limiting, quota exceeded) from error telemetry - // to avoid noise in error reporting. See https://github.com/microsoft/vscode/issues/311582 - if (rpcResult?.errorCallstack && !rpcResult.errorDetails?.isRateLimited && !rpcResult.errorDetails?.isQuotaExceeded) { + // Suppress expected operational errors (rate limiting, quota exceeded, and other + // user-actionable conditions flagged via `isExpectedError`) from error telemetry + // to avoid noise in error reporting. + // See https://github.com/microsoft/vscode/issues/311582 (rate-limited precedent), + // https://github.com/microsoft/vscode/issues/311583 (spawn git ENOENT), + // https://github.com/microsoft/vscode/issues/311584 (network connectivity), + // https://github.com/microsoft/vscode/issues/311585 (EPERM/permission errors), + // https://github.com/microsoft/vscode/issues/311586 (UNC host access), + // https://github.com/microsoft/vscode/issues/311587 (cloud agent not enabled). + if (rpcResult?.errorCallstack && !rpcResult.errorDetails?.isRateLimited && !rpcResult.errorDetails?.isQuotaExceeded && !rpcResult.errorDetails?.isExpectedError) { type ChatAgentErrorEvent = { callstack: string; msg: string; errorName: string; agent: string; agentExtensionId: string }; type ChatAgentErrorClassification = { owner: 'bryanchen-d'; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 0b58d688ac7e42..08e75067940b4b 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -1012,7 +1012,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS responseIsIncomplete: true }; } - if (errorDetails?.responseIsRedacted || errorDetails?.isQuotaExceeded || errorDetails?.isRateLimited || errorDetails?.confirmationButtons || errorDetails?.code) { + if (errorDetails?.responseIsRedacted || errorDetails?.isQuotaExceeded || errorDetails?.isRateLimited || errorDetails?.isExpectedError || errorDetails?.confirmationButtons || errorDetails?.code) { checkProposedApiEnabled(agent.extension, 'chatParticipantPrivate'); } @@ -1027,9 +1027,10 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const isQuotaExceeded = e instanceof Error && e.name === 'ChatQuotaExceeded'; const isRateLimited = e instanceof Error && e.name === 'ChatRateLimited'; + const isExpectedError = e instanceof Error && e.name === 'ChatExpectedError'; const { callstack: errorCallstack } = packErrorForTelemetry(e); const errorName = e instanceof Error ? e.name : undefined; - return { errorDetails: { message: toErrorMessage(e), responseIsIncomplete: true, isQuotaExceeded, isRateLimited }, errorCallstack, errorName }; + return { errorDetails: { message: toErrorMessage(e), responseIsIncomplete: true, isQuotaExceeded, isRateLimited, isExpectedError }, errorCallstack, errorName }; } finally { if (inFlightRequest) { 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 3c6dd0200bebe0..8f207dac6fb81e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -20,7 +20,7 @@ import { SessionConfigKey } from '../../../../../../platform/agentHost/common/se import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ActionType, SessionTurnStartedAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, SessionTurnStartedAction, type ClientSessionAction, type SessionAction, type SessionInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type MessageAttachment, type ModelSelection, type ResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -32,7 +32,8 @@ import { IProductService } from '../../../../../../platform/product/common/produ import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; -import { ChatRequestQueueKind, ConfirmedReason, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMultiSelectAnswer, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; +import { IChatWidgetService } from '../../chat.js'; +import { ChatRequestQueueKind, ConfirmedReason, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMultiSelectAnswer, type IChatQuestionAnswerValue, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; @@ -99,6 +100,12 @@ interface IClientToolCallEntry { approvedDispatched: boolean; } +interface IActiveInputRequestEntry { + readonly carousel: ChatQuestionCarouselData; + protocolAnswers: Record | undefined; + completedFromState: boolean; +} + /** * Map a local {@link ConfirmedReason} (how the {@link ChatToolInvocation} * resolved its confirmation gate) to the protocol's @@ -166,6 +173,43 @@ export function convertCarouselAnswers(raw: IChatQuestionAnswers): Record | undefined): IChatQuestionAnswers | undefined { + if (!raw) { + return undefined; + } + const answers: IChatQuestionAnswers = {}; + for (const [questionId, answer] of Object.entries(raw)) { + const converted = convertProtocolAnswer(answer); + if (converted !== undefined) { + answers[questionId] = converted; + } + } + return Object.keys(answers).length > 0 ? answers : undefined; +} + // ============================================================================= // Chat session // ============================================================================= @@ -355,6 +399,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, ) { super(); this._config = config; @@ -851,7 +896,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const sessionStr = backendSession.toString(); const activeToolInvocations = new Map(); const lastEmittedLengths = new Map(); - const activeInputRequests = new Map(); + const activeInputRequests = new Map(); const observedSubagentToolIds = new Set(); const throttler = new Throttler(); turnDisposables.add(throttler); @@ -891,7 +936,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return; } const isActive = this._processSessionState(sessionState, ctx); - this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, CancellationToken.None, progress); + this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, chatSession.sessionResource, CancellationToken.None, progress); // Observe subagent sessions for subagent tool calls this._observeSubagentToolCalls(sessionState, turnId, activeToolInvocations, observedSubagentToolIds, backendSession, progress, turnDisposables); @@ -902,6 +947,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }; const trackSub = this._ensureSessionSubscription(sessionStr); + turnDisposables.add(trackSub.onWillApplyAction(envelope => this._applyCompletedInputRequest(activeInputRequests, envelope.action as SessionAction))); turnDisposables.add(trackSub.onDidChange(state => { throttler.queue(async () => processState(state)); })); @@ -999,7 +1045,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const activeToolInvocations = new Map(); // Track live input request carousels to cancel if they disappear from state - const activeInputRequests = new Map(); + const activeInputRequests = new Map(); // Track last-emitted content lengths per response part to compute deltas const lastEmittedLengths = new Map(); @@ -1035,6 +1081,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Listen to state changes and translate to IChatProgress[] const handleTurnSub = this._ensureSessionSubscription(session.toString()); + turnDisposables.add(handleTurnSub.onWillApplyAction(envelope => this._applyCompletedInputRequest(activeInputRequests, envelope.action as SessionAction))); const ctx: ITurnProcessingContext = { turnId, backendSession: session, @@ -1059,7 +1106,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const isActive = this._processSessionState(rawSessionState, ctx); // Process input requests (ask_user tool elicitations) - this._syncInputRequests(activeInputRequests, rawSessionState.inputRequests, session, cancellationToken, progress); + this._syncInputRequests(activeInputRequests, rawSessionState.inputRequests, session, request.sessionResource, cancellationToken, progress); // Observe subagent sessions for subagent tool calls this._observeSubagentToolCalls(rawSessionState, turnId, activeToolInvocations, observedSubagentToolIds, session, progress, turnDisposables); @@ -1656,28 +1703,75 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * new carousels for newly appeared requests. */ private _syncInputRequests( - active: Map, + active: Map, inputRequests: readonly SessionInputRequest[] | undefined, session: URI, + sessionResource: URI, token: CancellationToken, progress: (items: IChatProgress[]) => void, ): void { const currentIds = new Set(inputRequests?.map(r => r.id)); - for (const [id, carousel] of active) { + for (const [id, entry] of active) { if (!currentIds.has(id)) { - carousel.completion.complete({ answers: undefined }); + if (!entry.carousel.isUsed) { + entry.completedFromState = true; + entry.carousel.data = {}; + entry.carousel.isUsed = true; + entry.carousel.draftAnswers = undefined; + entry.carousel.draftCurrentIndex = undefined; + entry.carousel.draftCollapsed = undefined; + entry.carousel.completion.complete({ answers: undefined }); + } + if (entry.completedFromState) { + this._chatWidgetService.getWidgetBySessionResource(sessionResource)?.input.clearQuestionCarousel(undefined, id); + } active.delete(id); } } if (inputRequests) { for (const inputReq of inputRequests) { - if (!active.has(inputReq.id)) { + const entry = active.get(inputReq.id); + if (!entry) { active.set(inputReq.id, this._handleInputRequest(inputReq, session, token, progress)); + } else { + entry.protocolAnswers = inputReq.answers; } } } } + /** + * Called from `onWillApplyAction` — **before** the reducer runs — to + * capture the answers from a `SessionInputCompleted` action and stash + * them on the carousel. This must happen pre-reduction because the + * reducer removes the input request from `state.inputRequests` + * entirely; by the time `onDidChange` fires the answers only exist on + * the action payload, which is no longer accessible. + */ + private _applyCompletedInputRequest(active: Map, action: SessionAction): void { + if (action.type !== ActionType.SessionInputCompleted) { + return; + } + const entry = active.get(action.requestId); + if (!entry) { + return; + } + const completedAnswers = action.response === SessionInputResponseKind.Accept + ? (action as SessionInputCompletedAction).answers ?? entry.protocolAnswers + : undefined; + const carouselAnswers = convertProtocolAnswers(completedAnswers); + entry.carousel.data = carouselAnswers ?? {}; + entry.carousel.draftAnswers = undefined; + entry.carousel.draftCurrentIndex = undefined; + entry.carousel.draftCollapsed = undefined; + if (entry.carousel.isUsed) { + return; + } + entry.completedFromState = true; + entry.carousel.isUsed = true; + entry.carousel.completion.complete({ answers: carouselAnswers }); + } + /** * Creates a question carousel for a session input request and dispatches * the `SessionInputCompleted` action when the user answers or cancels. @@ -1687,7 +1781,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC session: URI, cancellationToken: CancellationToken, progress: (items: IChatProgress[]) => void, - ): ChatQuestionCarouselData { + ): IActiveInputRequestEntry { const questions: IChatQuestion[] = (inputReq.questions ?? []).map((q): IChatQuestion => { switch (q.kind) { case SessionInputQuestionKind.SingleSelect: @@ -1744,11 +1838,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const carousel = new ChatQuestionCarouselData( questions, /* allowSkip */ true, - /* resolveId */ undefined, + inputReq.id, /* data */ undefined, /* isUsed */ undefined, /* message */ inputReq.message ? rawMarkdownToString(inputReq.message, this._config.connectionAuthority) : undefined, ); + const entry: IActiveInputRequestEntry = { carousel, protocolAnswers: inputReq.answers, completedFromState: false }; progress([carousel]); @@ -1762,6 +1857,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } carousel.completion.p.then(result => { + if (entry.completedFromState) { + return; + } if (!result.answers) { this._config.connection.dispatch({ type: ActionType.SessionInputCompleted, @@ -1781,7 +1879,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } }); - return carousel; + return entry; } // ---- Subagent child session observation --------------------------------- @@ -2063,11 +2161,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } // Track live input request carousels for reconnection - const activeInputRequests = new Map(); + const activeInputRequests = new Map(); const appendProgress = (parts: IChatProgress[]) => chatSession.appendProgress(parts); // Restore any pending input requests from the initial state - this._syncInputRequests(activeInputRequests, currentState?.inputRequests, backendSession, cts.token, appendProgress); + this._syncInputRequests(activeInputRequests, currentState?.inputRequests, backendSession, chatSession.sessionResource, cts.token, appendProgress); // Process state changes from the protocol layer. const ctx: ITurnProcessingContext = { @@ -2081,7 +2179,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }; const processStateChange = (sessionState: SessionState) => { const isActive = this._processSessionState(sessionState, ctx); - this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, cts.token, appendProgress); + this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, chatSession.sessionResource, cts.token, appendProgress); // Observe subagent sessions for subagent tool calls this._observeSubagentToolCalls(sessionState, turnId, activeToolInvocations, observedSubagentToolIds, backendSession, (parts: IChatProgress[]) => chatSession.appendProgress(parts), reconnectDisposables); @@ -2094,6 +2192,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Attach the ongoing state listener const reconnectSub = this._ensureSessionSubscription(sessionKey); + reconnectDisposables.add(reconnectSub.onWillApplyAction(envelope => this._applyCompletedInputRequest(activeInputRequests, envelope.action as SessionAction))); reconnectDisposables.add(reconnectSub.onDidChange(state => { throttler.queue(async () => processStateChange(state)); })); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 2fb6b9783a63b3..241a0072b995ad 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -20,7 +20,7 @@ import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../. import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; -import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, sectionToPromptType } from './aiCustomizationManagement.js'; +import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, sectionToPromptType } from './aiCustomizationManagement.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -235,7 +235,6 @@ class AICustomizationItemRenderer implements IListRenderer { - const commandService = accessor.get(ICommandService); - const editorService = accessor.get(IEditorService); - const rawName = extractName(context); - const displayName = rawName?.replace(/\.md$/i, ''); - const query = displayName - ? `/troubleshoot ${displayName}` - : '/troubleshoot'; - - // Close any open Agent Customizations editors before sending the chat. - const customizationEditors = editorService.getEditors(EditorsOrder.SEQUENTIAL) - .filter(({ editor }) => editor instanceof AICustomizationManagementEditorInput); - if (customizationEditors.length) { - await editorService.closeEditors(customizationEditors); - } - - await commandService.executeCommand('workbench.action.chat.open', { - query, - isPartialQuery: false, - }); - } -}); - // Reveal in Finder/Explorer action const REVEAL_IN_OS_LABEL = isWindows ? localize2('revealInWindows', "Reveal in File Explorer") @@ -492,13 +449,6 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: WHEN_ITEM_IS_DELETABLE, }); -MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { - command: { id: TROUBLESHOOT_AI_CUSTOMIZATION_ID, title: localize('troubleshootInline', "Troubleshoot"), icon: Codicon.bug }, - group: 'inline', - order: 2, - when: ContextKeyExpr.equals(AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, true), -}); - // Context menu items (shown on right-click) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID, title: localize('open', "Open") }, @@ -513,13 +463,6 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.prompt), }); -MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { - command: { id: TROUBLESHOOT_AI_CUSTOMIZATION_ID, title: localize('troubleshootItem', "Troubleshoot") }, - group: '2_run', - order: 2, - when: ContextKeyExpr.equals(AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, true), -}); - MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: REVEAL_AI_CUSTOMIZATION_IN_OS_ID, title: REVEAL_IN_OS_LABEL.value }, group: '3_file', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index f2a30634514fcf..cea174c6d5835d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -123,10 +123,6 @@ export const AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY = 'aiCustomizationManagementIt */ export const AI_CUSTOMIZATION_ITEM_DISABLED_KEY = 'aiCustomizationManagementItemDisabled'; -/** - * Context key indicating whether the active harness supports troubleshooting. - */ -export const AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY = 'aiCustomizationManagementSupportsTroubleshoot'; /** * Storage key for persisting the selected section. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index d5765d0d7490c6..7cc082fcb6de60 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -215,7 +215,10 @@ background: transparent; cursor: pointer; font-size: 12px; + font-family: inherit; text-align: left; + -webkit-appearance: none; + appearance: none; } .ai-customization-management-editor .harness-dropdown-button:hover { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index ed276d53fb8609..883cd676a93852 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -16,10 +16,11 @@ import { registerEditorFeature } from '../../../../editor/common/editorFeatures. import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationNode, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { McpAccessValue, McpAutoStartValue, mcpAccessConfig, mcpAutoStartConfig, mcpGalleryServiceEnablementConfig, mcpGalleryServiceUrlConfig, mcpAppsEnabledConfig } from '../../../../platform/mcp/common/mcpManagement.js'; import product from '../../../../platform/product/common/product.js'; @@ -187,6 +188,10 @@ import { ExploreAgentDefaultModel } from './exploreAgentDefaultModel.js'; import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; import { ChatImageCarouselService, IChatImageCarouselService } from './chatImageCarouselService.js'; +CommandsRegistry.registerCommand('_chat.notifyQuestionCarouselAnswer', (accessor: ServicesAccessor, resolveId: string, answers?: import('../common/chatService/chatService.js').IChatQuestionAnswers) => { + accessor.get(IChatService).notifyQuestionCarouselAnswer('', resolveId, answers); +}); + const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index e8c12f7c29e8a9..0b5969073e2dc7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2748,6 +2748,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined, part: ChatQuestionCarouselPart) => { // Mark the carousel as used and store the answers @@ -2769,7 +2770,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + const supportsFork = this.chatSessionsService.sessionSupportsFork(model.sessionResource); + this._chatSessionSupportsForkContextKey.set(supportsFork); + this.listWidget?.updateRendererOptions({ supportsFork }); + }; + updateSupportsFork(); + this.viewModelDisposables.add(this.chatSessionsService.onDidChangeAvailability(() => updateSupportsFork())); this._sessionHasDebugDataContextKey.set(this.chatDebugService.getEvents(model.sessionResource).length > 0); let lastSteeringCount = 0; const updatePendingRequestKeys = (announceSteering: boolean) => { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 96c7c595b2abe8..46396908560402 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -59,6 +59,12 @@ export interface IChatResponseErrorDetails { responseIsRedacted?: boolean; isQuotaExceeded?: boolean; isRateLimited?: boolean; + /** + * If true, the error is an expected operational condition (e.g. user-actionable + * configuration, network connectivity, missing dependency) and should not be + * logged as a `chatAgentError` telemetry event. + */ + isExpectedError?: boolean; level?: ChatErrorLevel; confirmationButtons?: IChatResponseErrorDetailsConfirmationButton[]; code?: string; diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts index b0b55f34700672..b6efa0a30815f1 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -206,10 +206,11 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { // In autopilot mode or when auto-reply is enabled, the user is not available — // auto-respond instead of blocking. Still append a completed carousel so the // user can see what was skipped. + const resolveId = invocation.chatStreamToolCallId ?? invocation.callId; if (request.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot || this.configService.getValue(ChatConfiguration.AutoReply)) { const reason = request.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot ? 'Autopilot mode' : 'Auto-reply enabled'; this.logService.info(`[AskQuestionsTool] ${reason}: auto-responding to questions`); - const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); + const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions, resolveId); carousel.terminalId = this.extractTerminalId(request); carousel.data = this.buildAutopilotCarouselAnswers(questions, carousel, idToHeaderMap); carousel.isUsed = true; @@ -217,12 +218,50 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { return this.createAutopilotResult(questions); } - const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); + const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions, resolveId); carousel.terminalId = this.extractTerminalId(request); this.logService.trace(`[AskQuestionsTool] request=${request.id} terminalExecutionId=${request.terminalExecutionId ?? 'undefined'} carousel.terminalId=${carousel.terminalId ?? 'undefined'}`); this.chatService.appendProgress(request, carousel); + const externalAnswerListener = this.chatService.onDidReceiveQuestionCarouselAnswer(event => { + if (event.resolveId !== carousel.resolveId || carousel.isUsed) { + return; + } - const answerResult = await raceCancellation(carousel.completion.p, token); + carousel.data = event.answers ?? {}; + carousel.isUsed = true; + carousel.draftAnswers = undefined; + carousel.draftCurrentIndex = undefined; + carousel.draftCollapsed = undefined; + void carousel.completion.complete({ answers: event.answers }); + }); + + let answerResult: { answers: IChatQuestionAnswers | undefined } | undefined; + try { + answerResult = await raceCancellation(carousel.completion.p, token); + } catch (error) { + if (error instanceof CancellationError && !carousel.isUsed) { + carousel.data = {}; + carousel.isUsed = true; + carousel.draftAnswers = undefined; + carousel.draftCurrentIndex = undefined; + carousel.draftCollapsed = undefined; + await carousel.completion.complete({ answers: undefined }); + } + throw error; + } finally { + externalAnswerListener.dispose(); + } + if (!answerResult) { + if (!carousel.isUsed) { + carousel.data = {}; + carousel.isUsed = true; + carousel.draftAnswers = undefined; + carousel.draftCurrentIndex = undefined; + carousel.draftCollapsed = undefined; + await carousel.completion.complete({ answers: undefined }); + } + throw new CancellationError(); + } if (token.isCancellationRequested) { throw new CancellationError(); } @@ -356,16 +395,17 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { return undefined; } - private toQuestionCarousel(questions: IQuestion[]): { carousel: ChatQuestionCarouselData; idToHeaderMap: Map } { + private toQuestionCarousel(questions: IQuestion[], resolveId?: string): { carousel: ChatQuestionCarouselData; idToHeaderMap: Map } { const idToHeaderMap = new Map(); - const mappedQuestions = questions.map(question => this.toChatQuestion(question, idToHeaderMap)); + const carouselResolveId = resolveId ?? generateUuid(); + const mappedQuestions = questions.map((question, index) => this.toChatQuestion(question, idToHeaderMap, carouselResolveId, index)); return { - carousel: new ChatQuestionCarouselData(mappedQuestions, true, generateUuid()), + carousel: new ChatQuestionCarouselData(mappedQuestions, true, carouselResolveId), idToHeaderMap }; } - private toChatQuestion(question: IQuestion, idToHeaderMap: Map): IChatQuestion { + private toChatQuestion(question: IQuestion, idToHeaderMap: Map, resolveId: string, index: number): IChatQuestion { let type: IChatQuestion['type']; if (!question.options || question.options.length === 0) { type = 'text'; @@ -385,7 +425,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { // Use a stable UUID as the internal ID to avoid collisions when truncating headers // The original header is preserved in idToHeaderMap for answer correlation - const internalId = generateUuid(); + const internalId = `${resolveId}:${index}`; idToHeaderMap.set(internalId, question.header); // Truncate header for display only 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 0e9f2a42684a68..d1c75912a670a1 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 @@ -16,10 +16,10 @@ import { timeout } from '../../../../../../base/common/async.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { CustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -52,6 +52,8 @@ import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agen import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { IChatWidgetService } from '../../../browser/chat.js'; +import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; // ---- Mock agent host service ------------------------------------------------ @@ -77,7 +79,7 @@ class MockAgentHostService extends mock() { } // Track live subscriptions so fireAction can route to them - private readonly _liveSubscriptions = new Map }>(); + private readonly _liveSubscriptions = new Map; onWillApply: Emitter; onDidApply: Emitter }>(); private _nextId = 1; private readonly _sessions = new Map(); @@ -232,7 +234,7 @@ class MockAgentHostService extends mock() { } // Register in live subscriptions so fireAction can route to it - const entry = { state: initialState, emitter: emitter as unknown as Emitter }; + const entry = { state: initialState, emitter: emitter as unknown as Emitter, onWillApply, onDidApply }; this._liveSubscriptions.set(resourceStr, entry); const self = this; @@ -240,8 +242,8 @@ class MockAgentHostService extends mock() { get value() { return self._liveSubscriptions.get(resourceStr)?.state as unknown as T; }, get verifiedValue() { return self._liveSubscriptions.get(resourceStr)?.state as unknown as T; }, onDidChange: emitter.event, - onWillApplyAction: onWillApply.event, - onDidApplyAction: onDidApply.event, + onWillApplyAction: entry.onWillApply.event, + onDidApplyAction: entry.onDidApply.event, }; return { object: sub, @@ -263,8 +265,8 @@ class MockAgentHostService extends mock() { get value() { return self._liveSubscriptions.get(resource.toString())?.state as unknown as T; }, get verifiedValue() { return self._liveSubscriptions.get(resource.toString())?.state as unknown as T; }, onDidChange: entry.emitter.event as unknown as Event, - onWillApplyAction: Event.None, - onDidApplyAction: Event.None, + onWillApplyAction: entry.onWillApply.event, + onDidApplyAction: entry.onDidApply.event, } satisfies IAgentSubscription; } override dispatch(action: SessionAction | TerminalAction | IRootConfigChangedAction): void { @@ -292,8 +294,10 @@ class MockAgentHostService extends mock() { const entry = this._liveSubscriptions.get(sessionUri); if (entry) { const noop = () => { }; + entry.onWillApply.fire(envelope); entry.state = sessionReducer(entry.state, envelope.action as Parameters[1], noop); entry.emitter.fire(entry.state); + entry.onDidApply.fire(envelope); } } } @@ -322,6 +326,28 @@ class MockChatAgentService extends mock() { } } +class MockChatWidgetService extends mock() { + declare readonly _serviceBrand: undefined; + + readonly clearQuestionCarouselCalls: { sessionResource: URI; responseId: string | undefined; resolveId: string | undefined }[] = []; + private readonly _widgets = new Map>(); + + setWidgetForSession(sessionResource: URI): void { + // eslint-disable-next-line local/code-no-any-casts + this._widgets.set(sessionResource.toString(), { + input: { + clearQuestionCarousel: (responseId?: string, resolveId?: string) => { + this.clearQuestionCarouselCalls.push({ sessionResource, responseId, resolveId }); + }, + }, + } as any); + } + + override getWidgetBySessionResource(sessionResource: URI): ReturnType { + return this._widgets.get(sessionResource.toString()); + } +} + // ---- Helpers ---------------------------------------------------------------- function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined }, authServiceOverride?: Partial) { @@ -331,11 +357,13 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv disposables.add(toDisposable(() => agentHostService.dispose())); const chatAgentService = new MockChatAgentService(); + const chatWidgetService = new MockChatWidgetService(); instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IProductService, { quality: 'insider' }); instantiationService.stub(IChatAgentService, chatAgentService); + instantiationService.stub(IChatWidgetService, chatWidgetService); instantiationService.stub(IFileService, TestFileService); instantiationService.stub(ILabelService, MockLabelService); instantiationService.stub(IChatSessionsService, { @@ -408,11 +436,11 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }); instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow: false } as Partial); - return { instantiationService, agentHostService, chatAgentService }; + return { instantiationService, agentHostService, chatAgentService, chatWidgetService }; } function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined } }) { - const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride); + const { instantiationService, agentHostService, chatAgentService, chatWidgetService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride); const listController = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot', agentHostService, undefined, 'local')); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -426,7 +454,7 @@ function createContribution(disposables: DisposableStore, opts?: { authServiceOv })); const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); - return { contribution, listController, sessionHandler, agentHostService, chatAgentService }; + return { contribution, listController, sessionHandler, agentHostService, chatAgentService, chatWidgetService }; } function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record; agentHostSessionConfig: Record; agentId: string }> = {}): IChatAgentRequest { @@ -1028,6 +1056,186 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(collected.length, 1); assert.strictEqual((collected[0][0] as IChatMarkdownContent).content.value, 'right'); })); + + test('input request completion from another client clears local question carousel', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-input-request-test' }); + chatWidgetService.setWidgetForSession(sessionResource); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + + fire({ + type: ActionType.SessionInputRequested, + session, + request: { + id: 'input-1', + message: 'Need more information', + questions: [{ + kind: SessionInputQuestionKind.Text, + id: 'question-1', + message: 'What should I use?', + required: true, + }, { + kind: SessionInputQuestionKind.SingleSelect, + id: 'question-2', + message: 'Which color?', + options: [{ id: 'blue', label: 'Blue' }], + }], + }, + }); + await timeout(10); + + const carousel = collected.flat().find(part => part.kind === 'questionCarousel'); + assert.ok(carousel, 'input request should render a question carousel'); + assert.strictEqual(carousel.resolveId, 'input-1'); + + agentHostService.dispatchedActions.length = 0; + fire({ + type: ActionType.SessionInputCompleted, + session, + requestId: 'input-1', + response: SessionInputResponseKind.Accept, + answers: { + 'question-1': { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Text, value: 'from another client' }, + }, + 'question-2': { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Selected, value: 'blue', freeformValues: ['cerulean'] }, + }, + }, + }); + await timeout(10); + + assert.deepStrictEqual(chatWidgetService.clearQuestionCarouselCalls.map(call => ({ responseId: call.responseId, resolveId: call.resolveId })), [ + { responseId: undefined, resolveId: 'input-1' }, + ]); + assert.strictEqual(carousel.isUsed, true); + assert.deepStrictEqual(carousel.data, { + 'question-1': 'from another client', + 'question-2': { selectedValue: 'blue', freeformValue: 'cerulean' }, + }); + assert.ok(carousel instanceof ChatQuestionCarouselData, 'AgentHost input request should use runtime carousel data'); + assert.deepStrictEqual((await carousel.completion.p).answers, { + 'question-1': 'from another client', + 'question-2': { selectedValue: 'blue', freeformValue: 'cerulean' }, + }); + assert.strictEqual(agentHostService.dispatchedActions.some(dispatched => dispatched.action.type === ActionType.SessionInputCompleted), false); + + fire({ type: ActionType.SessionTurnComplete, session, turnId }); + await turnPromise; + })); + + test('input request completion echo applies authoritative answers after local submit', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-local-input-request-test' }); + chatWidgetService.setWidgetForSession(sessionResource); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + + fire({ + type: ActionType.SessionInputRequested, + session, + request: { + id: 'input-1', + message: 'Need more information', + questions: [{ + kind: SessionInputQuestionKind.Text, + id: 'question-1', + message: 'What should I use?', + required: true, + }], + }, + }); + await timeout(10); + + const carousel = collected.flat().find(part => part.kind === 'questionCarousel'); + assert.ok(carousel, 'input request should render a question carousel'); + assert.ok(carousel instanceof ChatQuestionCarouselData, 'AgentHost input request should use runtime carousel data'); + + const submittedAnswers = { 'question-1': 'local answer' }; + carousel.data = submittedAnswers; + carousel.isUsed = true; + carousel.completion.complete({ answers: submittedAnswers }); + await timeout(10); + + agentHostService.dispatchedActions.length = 0; + fire({ + type: ActionType.SessionInputCompleted, + session, + requestId: 'input-1', + response: SessionInputResponseKind.Accept, + answers: { + 'question-1': { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Text, value: 'accepted answer' }, + }, + }, + }); + await timeout(10); + + assert.deepStrictEqual(carousel.data, { 'question-1': 'accepted answer' }); + assert.deepStrictEqual(chatWidgetService.clearQuestionCarouselCalls, []); + assert.strictEqual(agentHostService.dispatchedActions.some(dispatched => dispatched.action.type === ActionType.SessionInputCompleted), false); + + fire({ type: ActionType.SessionTurnComplete, session, turnId }); + await turnPromise; + })); + + test('input request cancellation does not show draft answers as submitted', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-cancelled-input-request-test' }); + chatWidgetService.setWidgetForSession(sessionResource); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + + fire({ + type: ActionType.SessionInputRequested, + session, + request: { + id: 'input-1', + message: 'Need more information', + questions: [{ + kind: SessionInputQuestionKind.Text, + id: 'question-1', + message: 'What should I use?', + required: true, + }], + answers: { + 'question-1': { + state: SessionInputAnswerState.Draft, + value: { kind: SessionInputAnswerValueKind.Text, value: 'draft answer' }, + }, + }, + }, + }); + await timeout(10); + + const carousel = collected.flat().find(part => part.kind === 'questionCarousel'); + assert.ok(carousel, 'input request should render a question carousel'); + + agentHostService.dispatchedActions.length = 0; + fire({ + type: ActionType.SessionInputCompleted, + session, + requestId: 'input-1', + response: SessionInputResponseKind.Cancel, + }); + await timeout(10); + + assert.strictEqual(carousel.isUsed, true); + assert.deepStrictEqual(carousel.data, {}); + assert.ok(carousel instanceof ChatQuestionCarouselData, 'AgentHost input request should use runtime carousel data'); + assert.strictEqual((await carousel.completion.p).answers, undefined); + assert.deepStrictEqual(chatWidgetService.clearQuestionCarouselCalls.map(call => ({ responseId: call.responseId, resolveId: call.resolveId })), [ + { responseId: undefined, resolveId: 'input-1' }, + ]); + assert.strictEqual(agentHostService.dispatchedActions.some(dispatched => dispatched.action.type === ActionType.SessionInputCompleted), false); + + fire({ type: ActionType.SessionTurnComplete, session, turnId }); + await turnPromise; + })); }); // ---- Cancellation ----------------------------------------------------- diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts index 015fde819ab209..525a1b0ff5395d 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts @@ -4,12 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../../../base/common/event.js'; +import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../../../platform/log/common/log.js'; import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IChatQuestionAnswers, IChatService } from '../../../../common/chatService/chatService.js'; import { AskQuestionsTool, IAnswerResult, IQuestion, IQuestionAnswer } from '../../../../common/tools/builtinTools/askQuestionsTool.js'; +import { ChatQuestionCarouselData } from '../../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; class TestableAskQuestionsTool extends AskQuestionsTool { public testConvertCarouselAnswers(questions: IQuestion[], carouselAnswers: IChatQuestionAnswers | undefined): IAnswerResult { @@ -152,3 +157,112 @@ suite('AskQuestionsTool - convertCarouselAnswers', () => { assert.deepStrictEqual(result.answers['Case'], { selected: [], freeText: 'yes', skipped: false }); }); }); + +suite('AskQuestionsTool - invoke', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('marks the carousel used when invocation is cancelled after it is shown', async () => { + let appendedCarousel: ChatQuestionCarouselData | undefined; + const request = { + id: 'request-1', + message: { text: '' }, + modeInfo: undefined, + response: undefined, + terminalExecutionId: undefined, + }; + const chatService = { + getSession: () => ({ + getRequests: () => [request], + }), + appendProgress: (_request: unknown, progress: ChatQuestionCarouselData) => { + appendedCarousel = progress; + }, + onDidReceiveQuestionCarouselAnswer: Event.None, + } as unknown as IChatService; + const tool = store.add(new AskQuestionsTool( + chatService, + NullTelemetryService, + new NullLogService(), + new TestConfigurationService() + )); + const tokenSource = new CancellationTokenSource(); + + const invokePromise = tool.invoke({ + parameters: { + questions: [{ header: 'Theme', question: 'What is your favorite theme in VS Code?' }], + }, + context: { sessionResource: URI.parse('test://session') }, + chatRequestId: 'request-1', + } as never, undefined as never, { report: () => { } }, tokenSource.token); + + assert.ok(appendedCarousel, 'expected question carousel to be appended before cancellation'); + tokenSource.cancel(); + + await assert.rejects(invokePromise, error => error instanceof CancellationError); + assert.ok(appendedCarousel, 'expected appended carousel to remain available after cancellation'); + assert.strictEqual(appendedCarousel.isUsed, true); + assert.deepStrictEqual(appendedCarousel.data, {}); + assert.strictEqual(appendedCarousel.completion.isResolved, true); + assert.deepStrictEqual(appendedCarousel.completion.value, { answers: undefined }); + assert.strictEqual(appendedCarousel.draftAnswers, undefined); + assert.strictEqual(appendedCarousel.draftCurrentIndex, undefined); + assert.strictEqual(appendedCarousel.draftCollapsed, undefined); + }); + + test('uses externally notified answers instead of showing skipped', async () => { + let appendedCarousel: ChatQuestionCarouselData | undefined; + const onDidReceiveQuestionCarouselAnswer = new Emitter<{ requestId: string; resolveId: string; answers: IChatQuestionAnswers | undefined }>(); + const request = { + id: 'request-1', + message: { text: '' }, + modeInfo: undefined, + response: undefined, + terminalExecutionId: undefined, + }; + const chatService = { + getSession: () => ({ + getRequests: () => [request], + }), + appendProgress: (_request: unknown, progress: ChatQuestionCarouselData) => { + appendedCarousel = progress; + }, + onDidReceiveQuestionCarouselAnswer: onDidReceiveQuestionCarouselAnswer.event, + } as unknown as IChatService; + const tool = store.add(new AskQuestionsTool( + chatService, + NullTelemetryService, + new NullLogService(), + new TestConfigurationService() + )); + const invokePromise = tool.invoke({ + callId: 'tool-call', + chatStreamToolCallId: 'remote-tool-call', + parameters: { + questions: [{ header: 'Color', question: 'What is your favorite color?', options: [{ label: 'Blue' }, { label: 'Red' }] }], + }, + context: { sessionResource: URI.parse('test://session') }, + chatRequestId: 'request-1', + toolId: 'vscode_askQuestions', + } as never, undefined as never, { report: () => { } }, CancellationToken.None); + + assert.ok(appendedCarousel, 'expected question carousel to be appended before external answer'); + onDidReceiveQuestionCarouselAnswer.fire({ + requestId: 'ignored', + resolveId: 'remote-tool-call', + answers: { + 'remote-tool-call:0': { selectedValue: 'Blue' }, + }, + }); + + const result = await invokePromise; + assert.deepStrictEqual(JSON.parse(String(result.content[0].value)), { + answers: { + Color: { selected: ['Blue'], freeText: null, skipped: false }, + }, + }); + assert.strictEqual(appendedCarousel.isUsed, true); + assert.deepStrictEqual(appendedCarousel.data, { + 'remote-tool-call:0': { selectedValue: 'Blue' }, + }); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts index ef5f463b2a4b58..8f7950e28d38c9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts @@ -197,6 +197,21 @@ export async function trackIdleOnPrompt( scheduler.schedule(); }, 10_000)); initialFallbackScheduler.schedule(); + // Fallback for when shell integration breaks mid-command: data arrives and + // C/D sequences transition us to Executing, but no A (prompt) sequence ever + // follows. Both initialFallbackScheduler and promptFallbackScheduler get + // cancelled in that state, causing a permanent hang. This scheduler is + // rescheduled on every data event while in the Executing state, so it only + // fires after 30s of data-idle — long enough that actively-outputting + // commands won't be cut off, but short enough to prevent indefinite hangs + // when shell integration breaks. When shell integration is working, + // onCommandFinished in the rich strategy's race wins before this fires. + const executingFallbackScheduler = store.add(new RunOnceScheduler(() => { + if (state === TerminalState.Executing) { + state = TerminalState.PromptAfterExecuting; + scheduler.schedule(); + } + }, 30_000)); // Only schedule when a prompt sequence (A) is seen after an execute sequence (C). This prevents // cases where the command is executed before the prompt is written. While not perfect, sitting // on an A without a C following shortly after is a very good indicator that the command is done @@ -222,14 +237,17 @@ export async function trackIdleOnPrompt( state = TerminalState.Prompt; } else if (state === TerminalState.Executing) { state = TerminalState.PromptAfterExecuting; + executingFallbackScheduler.cancel(); } } else if (match.groups?.type === 'C' || match.groups?.type === 'D') { state = TerminalState.Executing; + executingFallbackScheduler.schedule(); } } // Re-schedule on every data event as we're tracking data idle if (state === TerminalState.PromptAfterExecuting) { promptFallbackScheduler.cancel(); + executingFallbackScheduler.cancel(); scheduler.schedule(); } else { scheduler.cancel(); @@ -237,6 +255,9 @@ export async function trackIdleOnPrompt( promptFallbackScheduler.schedule(); } else { promptFallbackScheduler.cancel(); + // Re-schedule on every data event so it only fires after 30s + // of data-idle while in the Executing state. + executingFallbackScheduler.schedule(); } } })); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index c3020a95f7bb69..5b417b85289b42 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -473,6 +473,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { * This ensures we catch any input that happens between idle detection and prompt creation. */ private _setupIdleInputListener(): void { + if (this._store.isDisposed) { + return; + } this._userInputtedSinceIdleDetected = false; this._logService.trace('OutputMonitor: Setting up idle input listener'); diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index d13453552f1b29..1d831a025a592f 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -48,13 +48,34 @@ import { IRemoteAgentService } from '../../../services/remote/common/remoteAgent import { securityConfigurationNodeBase } from '../../../common/configuration.js'; import { basename, dirname as uriDirname } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../platform/files/common/files.js'; const BANNER_RESTRICTED_MODE = 'workbench.banner.restrictedMode'; const STARTUP_PROMPT_SHOWN_KEY = 'workspace.trust.startupPrompt.shown'; const BANNER_RESTRICTED_MODE_DISMISSED_KEY = 'workbench.banner.restrictedMode.dismissed'; +/** + * Returns a trust note string for the sessions window explaining that trusting + * a folder/workspace also persists trust to the parent VS Code install. + * Returns `undefined` when not running in the sessions window. + */ +function getSessionsWindowTrustNote(environmentService: IWorkbenchEnvironmentService, productService: IProductService, isWorkspace: boolean): string | undefined { + if (!environmentService.isSessionsWindow) { + return undefined; + } + const parentAppName = productService.quality === 'stable' + ? 'Visual Studio Code' + : productService.quality === 'insider' + ? 'Visual Studio Code Insiders' + : productService.quality === 'exploration' + ? 'Visual Studio Code Exploration' + : productService.nameLong; + if (isWorkspace) { + return localize('sessionsWindowWorkspaceTrustNote', "Trusting this workspace will also mark it as trusted in {0}.", parentAppName); + } + return localize('sessionsWindowFolderTrustNote', "Trusting this folder will also mark it as trusted in {0}.", parentAppName); +} + export class WorkspaceTrustContextKeys extends Disposable implements IWorkbenchContribution { private readonly _ctxWorkspaceTrustEnabled: IContextKey; @@ -94,7 +115,9 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben @ILabelService private readonly labelService: ILabelService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, - @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService) { + @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IProductService private readonly productService: IProductService) { super(); this.registerListeners(); @@ -159,6 +182,11 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben `\`${this.labelService.getUriLabel(options.uri)}\`` ]; + const sessionsTrustNote = getSessionsWindowTrustNote(this.environmentService, this.productService, false); + if (sessionsTrustNote) { + markdownDetails.push(sessionsTrustNote); + } + // Dialog await this.dialogService.prompt({ type: Severity.Info, @@ -204,15 +232,20 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben } // Dialog + const markdownDetails = [ + { markdown: new MarkdownString(details) }, + { markdown: new MarkdownString(localize('immediateTrustRequestLearnMore', "If you don't trust the authors of these files, we do not recommend continuing as the files may be malicious. See [our docs](https://aka.ms/vscode-workspace-trust) to learn more.")) } + ]; + const sessionsTrustNote = getSessionsWindowTrustNote(this.environmentService, this.productService, this.useWorkspaceLanguage); + if (sessionsTrustNote) { + markdownDetails.push({ markdown: new MarkdownString(sessionsTrustNote) }); + } const { result } = await this.dialogService.prompt({ type: Severity.Info, message, custom: { icon: Codicon.shield, - markdownDetails: [ - { markdown: new MarkdownString(details) }, - { markdown: new MarkdownString(localize('immediateTrustRequestLearnMore', "If you don't trust the authors of these files, we do not recommend continuing as the files may be malicious. See [our docs](https://aka.ms/vscode-workspace-trust) to learn more.")) } - ] + markdownDetails }, buttons: buttons.filter(b => b.type !== 'Cancel').map(button => { return { @@ -278,7 +311,7 @@ export class WorkspaceTrustUXHandler extends Disposable implements IWorkbenchCon @IHostService private readonly hostService: IHostService, @IProductService private readonly productService: IProductService, @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService, - @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IFileService private readonly fileService: IFileService, ) { super(); @@ -325,10 +358,15 @@ export class WorkspaceTrustUXHandler extends Disposable implements IWorkbenchCon const addedFoldersTrustInfo = await Promise.all(e.changes.added.map(folder => this.workspaceTrustManagementService.getUriTrustInfo(folder.uri))); if (!addedFoldersTrustInfo.map(info => info.trusted).every(trusted => trusted)) { + let detail = localize('addWorkspaceFolderDetail', "You are adding files that are not currently trusted to a trusted workspace. Do you trust the authors of these new files?"); + const sessionsTrustNote = getSessionsWindowTrustNote(this.environmentService, this.productService, false); + if (sessionsTrustNote) { + detail += '\n\n' + sessionsTrustNote; + } const { confirmed } = await this.dialogService.confirm({ type: Severity.Info, message: localize('addWorkspaceFolderMessage', "Do you trust the authors of the files in this folder?"), - detail: localize('addWorkspaceFolderDetail', "You are adding files that are not currently trusted to a trusted workspace. Do you trust the authors of these new files?"), + detail, cancelButton: localize('no', 'No'), custom: { icon: Codicon.shield } }); @@ -376,18 +414,23 @@ export class WorkspaceTrustUXHandler extends Disposable implements IWorkbenchCon } // Show Workspace Trust Start Dialog + const markdownStrings = [ + !isSingleFolderWorkspace ? + localize('workspaceStartupTrustDetails', "{0} provides features that may automatically execute files in this workspace.", this.productService.nameShort) : + localize('folderStartupTrustDetails', "{0} provides features that may automatically execute files in this folder.", this.productService.nameShort), + learnMoreString ?? localize('startupTrustRequestLearnMore', "If you don't trust the authors of these files, we recommend to continue in restricted mode as the files may be malicious. See [our docs](https://aka.ms/vscode-workspace-trust) to learn more."), + !isEmptyWindow ? + `\`${this.labelService.getWorkspaceLabel(workspaceIdentifier, { verbose: Verbosity.LONG })}\`` : '', + ]; + const sessionsTrustNote = getSessionsWindowTrustNote(this.environmentService, this.productService, !isSingleFolderWorkspace); + if (sessionsTrustNote) { + markdownStrings.push(sessionsTrustNote); + } this.doShowModal( title, { label: trustOption ?? localize({ key: 'trustOption', comment: ['&& denotes a mnemonic'] }, "&&Yes, I trust the authors"), sublabel: isSingleFolderWorkspace ? localize('trustFolderOptionDescription', "Trust folder and enable all features") : localize('trustWorkspaceOptionDescription', "Trust workspace and enable all features") }, { label: dontTrustOption ?? localize({ key: 'dontTrustOption', comment: ['&& denotes a mnemonic'] }, "&&No, I don't trust the authors"), sublabel: isSingleFolderWorkspace ? localize('dontTrustFolderOptionDescription', "Open folder in restricted mode") : localize('dontTrustWorkspaceOptionDescription', "Open workspace in restricted mode") }, - [ - !isSingleFolderWorkspace ? - localize('workspaceStartupTrustDetails', "{0} provides features that may automatically execute files in this workspace.", this.productService.nameShort) : - localize('folderStartupTrustDetails', "{0} provides features that may automatically execute files in this folder.", this.productService.nameShort), - learnMoreString ?? localize('startupTrustRequestLearnMore', "If you don't trust the authors of these files, we recommend to continue in restricted mode as the files may be malicious. See [our docs](https://aka.ms/vscode-workspace-trust) to learn more."), - !isEmptyWindow ? - `\`${this.labelService.getWorkspaceLabel(workspaceIdentifier, { verbose: Verbosity.LONG })}\`` : '', - ], + markdownStrings, checkboxText ); })); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index f817a62e9a2153..7deab625bbfb33 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -524,7 +524,6 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid this.logService.debug('[DefaultAccount] No matching session found for provider:', authenticationProvider.id); return null; } - return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, options); } catch (error) { this.logService.error('[DefaultAccount] Failed to get default account for provider:', authenticationProvider.id, getErrorMessage(error)); diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 5e354883f77856..2276e22cba05c1 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -256,6 +256,15 @@ declare module 'vscode' { isRateLimited?: boolean; + /** + * If true, the error is an expected operational condition (e.g. user-actionable + * configuration, network connectivity, missing dependency) and should not be + * logged as a `chatAgentError` telemetry event. The error is still surfaced to + * the user. Throwing an `Error` whose `name` is `'ChatExpectedError'` from a + * chat participant handler will set this flag automatically. + */ + isExpectedError?: boolean; + level?: ChatErrorLevel; code?: string;