diff --git a/.github/instructions/language-model-tool-descriptions.instructions.md b/.github/instructions/language-model-tool-descriptions.instructions.md new file mode 100644 index 00000000000000..68e280e4da2b40 --- /dev/null +++ b/.github/instructions/language-model-tool-descriptions.instructions.md @@ -0,0 +1,21 @@ +--- +description: Use when writing or reviewing model-facing language model tool descriptions. +applyTo: "src/vs/**/*Tool.ts,src/vs/**/*Tools.ts" +--- + +# Language model tool descriptions + +Apply these rules only when adding or changing a model-facing tool description, such as `modelDescription`. They do not govern display names, user-facing messages, confirmation text, or routine schema property descriptions. + +- Begin with a direct imperative statement of the tool's capability. +- When tool selection could be ambiguous, state the positive invocation criteria explicitly. +- Place exclusions for likely near-neighbor requests immediately after the positive criteria. +- Describe persistent, external, costly, or otherwise consequential side effects. +- State meaningful defaults, cross-field requirements, and prerequisite tool calls that are not obvious from the input schema. +- Put approval, cancellation, retry, repeat-call, and polling guidance last. +- Do not repeat the complete input schema in prose. +- Keep descriptions concise and scale their detail with the ambiguity and cost of incorrect selection. +- Preserve terminology and formatting conventions used by related tools. +- Reserve `MUST`, `ONLY`, `CRITICAL`, and direct "Do not" language for likely, consequential misuse. + +When correctness depends on specific model-facing guidance, add a focused test for the required semantic clauses. Prefer checking a small set of meaningful phrases or conditions over snapshotting the entire description. Do not test routine descriptive prose. diff --git a/.github/instructions/source-code-organization.instructions.md b/.github/instructions/source-code-organization.instructions.md index 430bb9794fecc1..e80818cb0e9d27 100644 --- a/.github/instructions/source-code-organization.instructions.md +++ b/.github/instructions/source-code-organization.instructions.md @@ -64,4 +64,6 @@ class MyComponent { } ``` +Decorated service parameters must use their canonical service interface. Do not invent local subset interfaces or `Pick<...>` aliases in production to simplify tests; keep partial stubs, adapters, and other test-only conveniences in test code. + Services are provided via `registerSingleton(IMyService, MyServiceImpl, InstantiationType.Delayed)`. diff --git a/.github/skills/agent-host-chat-contributions/SKILL.md b/.github/skills/agent-host-chat-contributions/SKILL.md index 86ca6abbc3e73f..11952a683d9479 100644 --- a/.github/skills/agent-host-chat-contributions/SKILL.md +++ b/.github/skills/agent-host-chat-contributions/SKILL.md @@ -18,10 +18,10 @@ agent-host chat lifecycle boundary. Read these files before changing the model: > **New cross-cutting features go in contributions, not in `AgentSideEffects` or > `AgentService`.** -`AgentSideEffects` shed roughly 500 lines by extracting ten behaviors. Do not -put another lifecycle side effect, outgoing-prompt addition, restored-turn -enrichment, or client-action observer back into either large orchestration -class. `AgentSideEffects` dispatches the lifecycle and retains routing and send +`AgentSideEffects` has shed well over 700 lines by extracting seventeen behaviors. +Do not put another lifecycle side effect, outgoing-prompt addition, restored-turn +enrichment, admission check, or action observer back into either large +orchestration class. `AgentSideEffects` dispatches the lifecycle and retains routing and send mechanics; `AgentService` owns session lifecycle and invokes hydration and memento eviction. See `agentSideEffects.ts` and `agentService.ts`. @@ -33,17 +33,19 @@ dispatcher call site. "It was convenient to add here" is not a reason. Write a contribution when the behavior answers yes to this test: -1. Does it react to a completed turn, a reduced client action, a turn about to - be sent, or a complete restored turn list? +1. Does it react to a lifecycle moment the hooks already name — a turn request + asking to proceed, a completed turn, a dispatched action, a turn about to be + sent, a restored turn list, or a chat being restored? 2. Can an explicitly registered, dependency-injected unit own it without changing protocol routing or provider mapping? 3. Does it compose with other behavior in a defined order? -Examples are checkpoint and changeset work, queue draining, GitHub-reference -attachment, title handling, persisted usage, worktree announcements, unread -state, rich-link guidance, artifact-tool guidance, and chat-surface guidance. -Their directories under `node/chatContributions/` are the reference -implementations. +Examples are admission gating, local-command interception, checkpoint and +changeset work, queue draining, GitHub-reference attachment, title handling, +persisted usage, session input-needed aggregation, session flag persistence, +chat drafts, worktree announcements, unread state, rich-link guidance, +artifact-tool guidance, and chat-surface guidance. Their directories under +`node/chatContributions/` are the reference implementations. Do **not** use a contribution for the following: @@ -52,18 +54,21 @@ Do **not** use a contribution for the following: - Turn-id remapping in `node/agentSideEffects.ts` is also routing fabric. - The three SDK event mappers and three attachment serializers are provider-shaped. +- Provider-shaped overlays applied during restore, such as seeding a draft's + model from `IAgent`-supplied session metadata in `_doRestoreSession`. Moving + one would push provider metadata into `IHydrationContext` for one consumer. If the answer is "this must route every signal correctly before state changes" -or "this differs by SDK," it is not a contribution. The explicit non-goals and -future boundaries are in `node/chatContributions/TODO.md`. +or "this differs by SDK," it is not a contribution. Open work and standing +caveats are tracked in `node/chatContributions/TODO.md`. ## Keep the hook surface small -`IAgentHostChatContribution` intentionally has four hooks in +`IAgentHostChatContribution` has seven hooks in `common/agentHostChatContributionsService.ts`. Only add another method when no existing payload can express the behavior. -The set was reduced from six to four: +The set was once reduced from six to four: - `onTurnConsumable` became `onTurnEnd` with `TurnEndReason.kind === 'localCommand'`. @@ -74,6 +79,23 @@ Prefer adding a discriminant or payload to an existing type, such as a new with one caller and one implementer is the smell that produced both removals. Do not create an abstraction merely to move one direct call. +The three hooks added since clear a specific bar: **no existing payload could +express them because they fire at a different moment or from a different +source, not merely with different data.** + +- `onHydrateChat` restores host-owned chat state *before* a chat enters the + catalog. `onHydrateTurns` runs lazily, after a provider returns turns, so + reusing it would leave a restored peer chat untitled until it was opened. +- `onIncomingRequest` gates a turn before it reaches a provider, and can reject + it. No observer hook can refuse. +- `onDidDispatchAction` observes server-dispatched and rejected actions, which + `onDidApplyClientAction` structurally cannot see. + +Adding a `{ kind: ... }` discriminant to an existing hook was considered and +rejected for `onHydrateChat`: the discriminant guidance covers payload variants +*within* one moment, the way `TurnEndReason` discriminates outcomes of a single +turn ending. + ## Anatomy and registration Give every contribution its own directory under @@ -117,18 +139,20 @@ activated by `activateAgentHostContributions` in The dispatcher constructs each contribution through `IInstantiationService`, rejects duplicate ids, and disposes it on unregistration. -## The four hooks +## The seven hooks | Hook | Fires when | Must not assume | Existing example | |---|---|---|---| -| `onTurnEnd` | An agent-signal terminal path ends, or a host-handled local command completes. | It does not see every terminal outcome. It does not fire for client-dispatched cancellation, missing provider, read-only or archived chat, or a send that throws. Discriminate `success`, `cancelled`, `error`, and `localCommand`; do not throw. | `checkpointAndChangeset`, `queueDrain`, `githubReferences`, `sessionTitle`, `markUnread` | -| `onAction` | A client action has been reduced into host state. | It is not an envelope observer and does not see pending-message server envelopes. Filter the action type and read post-reduction state. | `queueDrain` tracks queued senders; `sessionTitle` persists user title changes. | -| `onOutgoingTurn` | Awaited after the read-only or archived guard and provider lookup, before the turn is sent. | Rejected messages and `noAgent` failures never reach it. `IOutgoingTurn` carries the full `Message`; contributions can replace its text in order and add instructions, but cannot replace its already-committed attachments, model, agent, origin, or metadata. Prefer the message-text channel for context injection: changing instructions invalidates the provider prompt cache and increases user cost. Do not bypass the send path. | `markdownPlanRichLinks`, `artifactTools`, `chatSurface`, `githubReferences`, `sessionTitle` | -| `onHydrateTurns` | A provider has returned the complete restored turn list for a chat. | It is not limited to default chats and must return a list for the next stage. Do not assume its input is pristine provider output because earlier contributions may have transformed it. | `persistedTurnUsage`, `worktreeAnnouncement` | +| `onIncomingRequest` | A turn request asks to proceed to a provider, before any side effect. **Synchronous, and fails closed.** | It decides admission, so a throw rejects rather than being skipped. Return `accept`, `handled`, or `reject`; the first non-accept wins and later contributions are not consulted. | `localCommand` 50, `turnAdmission` 100 | +| `onTurnEnd` | Any terminal outcome a started turn can reach, plus a request refused before its turn started. | Discriminate `rejected` from `error`: the former has no started turn to finalize and no checkpoint to capture. Do not throw. It does not fire for an agent-emitted terminal action arriving with no active turn, because the reducer no-ops for those. | `checkpointAndChangeset`, `queueDrain`, `githubReferences`, `sessionTitle`, `markUnread` | +| `onDidApplyClientAction` | A client-dispatched action was applied to host state. | It never sees server-dispatched or rejected actions. Anything it sees definitely reduced. | `queueDrain` tracks queued senders; `sessionTitle` persists user title changes. | +| `onDidDispatchAction` | Any action was dispatched and its outcome is known, from any origin. | It also delivers **rejected** actions that never reduced — check `rejectionReason`. Implementing this and `onDidApplyClientAction` together sees every client action twice. | `sessionInputNeeded`, `persistedTurnUsage`, `sessionFlags` | +| `onOutgoingTurn` | Awaited after admission and provider lookup, before the turn is sent. | Rejected turns and `noAgent` failures never reach it. `IOutgoingTurn` carries the full `Message`; contributions can replace its text in order and add instructions, but cannot replace its already-committed attachments, model, agent, origin, or metadata. Prefer the message-text channel for context injection: changing instructions invalidates the provider prompt cache and increases user cost. Do not bypass the send path. | `markdownPlanRichLinks`, `artifactTools`, `chatSurface`, `sessionTitle`, `sideChat` | +| `onHydrateTurns` | A provider has returned the complete restored turn list for a chat. | It is not limited to default chats and must return a list for the next stage. Do not assume its input is pristine provider output because earlier contributions may have transformed it. | `turnDelegation`, `persistedTurnUsage`, `worktreeAnnouncement`, `sideChat` | +| `onHydrateChat` | A chat is being restored, eagerly, before it enters the session catalog. | It runs with no provider — it is a metadata-only read — and before the chat exists, so a chat memento taken here would outlive a failed registration. | `sessionTitle`, `chatDraft` | -The call sites are in `node/agentSideEffects.ts` and `node/agentService.ts`. -The coverage caveat for `onTurnEnd` is deliberate and documented in -`node/chatContributions/TODO.md`; do not claim it is a universal finally hook. +The call sites are in `node/agentSideEffects.ts`, `node/agentHostTurnStarter.ts`, +and `node/agentService.ts`. ## Ordering is behavior @@ -139,12 +163,22 @@ for hydration independently of outgoing turns. See Use explicit orders to preserve a required sequence: +- `LocalCommandContribution` is 50 and `TurnAdmissionContribution` is 100 on + `onIncomingRequest`. That order is load-bearing: local commands were always + intercepted before the read-only guard, so `/rename` still works in a + read-only or archived chat. - `CheckpointAndChangesetContribution` runs at 100 so it captures a checkpoint before later changeset-related work. -- `QueueDrainContribution` is 200. +- `QueueDrainContribution` is 200 and `SessionInputNeededContribution` is 200. - `GitHubReferencesContribution` and `ChatSurfaceContribution` are 300 where applicable. -- `SessionTitleContribution` is 400 and `MarkUnreadContribution` is 500. +- `SessionTitleContribution` is 400, `MarkUnreadContribution` and + `SideChatContribution` are 500, `ChatDraftContribution` is 600, and + `SessionFlagsContribution` is 700. + +`markUnread` and `sideChat` both declare 500 and both implement `onTurnEnd`, so +their relative sequence rests on registration order. Give a new contribution a +distinct order rather than joining that tie. A silent reordering regression already happened. The built-in-sequence regression tests in `test/node/chatContributions.test.ts` protect the original @@ -163,9 +197,10 @@ to seven members before being cut back to two by promoting collaborators to real services: - `IAgentHostSessionTitleController` -- `IAgentHostProviderLocator` +- `IAgentHostProviderService` - `IAgentHostTelemetryReporter` - `IAgentHostTurnTracker` +- `IAgentHostToolCallTracker` - `IAgentHostLocalCommands` `IAgentHostWorktreeIsolation` is injectable too. "It isn't injectable" was a @@ -176,6 +211,19 @@ Trace registration from `node/agentHostServices.ts` through instantiation path constructs contributions; do not smuggle it through the context or host bridge. +Adding a service is not free: roughly six hand-built test `ServiceCollection`s +construct `AgentSideEffects` or call `registerBuiltInChatContributions`, and each +needs the new registration or fails with `UNKNOWN service `. Two related +traps, both hit for real: + +- Only wrap a test registration in `disposables.add(...)` when the class extends + `Disposable`; wrapping a plain object throws `d.dispose is not a function` at + teardown. +- If the collaborator is stateless, prefer a plain function taking + `ServicesAccessor`, invoked through `IInstantiationService.invokeFunction`, and + skip DI entirely. `node/agentHostTurnStarter.ts` is the reference: making it a + service bought nothing and cost a registration in every graph. + ## Mementos have a lifecycle Create keys with `createChatMementoKey` or `createSessionMementoKey` in @@ -191,23 +239,40 @@ so identical debug names in two contributions do not collide. Setting its observable to `undefined` leaves its map entry alive until chat or session disposal, so a long-lived chat otherwise accumulates one entry per segment value. +- `onHydrateChat` runs *before* its chat is registered, so a chat memento taken + there would outlive a registration that then failed. No current contribution + does this, but do not be the first without handling it. `queueDrain/queueDrainContribution.ts` demonstrates the required delete on queued-message removal and consumption. Central eviction lives in `node/agentHostChatContributionsService.ts` and is called by `node/agentService.ts`. -## Failure isolation +## Failure isolation, and the one exception -The dispatcher logs and isolates failures independently for every hook: +The dispatcher logs and isolates failures independently for every observer hook: -- A throwing `onTurnEnd` or `onAction` contribution does not stop later - contributions. +- A throwing `onTurnEnd`, `onDidApplyClientAction`, or `onDidDispatchAction` + contribution does not stop later contributions. - A failed `onOutgoingTurn` contribution adds no instructions and does not block the send or later contributions. -- A failed `onHydrateTurns` contribution deliberately passes the previous - turns to the next stage. Losing chat history is worse than losing one - enrichment. +- A failed `onHydrateTurns` or `onHydrateChat` contribution deliberately passes + the previous value to the next stage. Losing chat history, a title, or a draft + is worse than losing one enrichment. + +**`onIncomingRequest` inverts this and fails closed.** A contribution that +throws there *rejects* the request with `internalError` at stage `validation`. +That gate is the enforcement behind the UI hiding the composer, so failing open +would let a buggy or remote client run work in a session that may no longer have +its isolated worktree on disk. Losing one enrichment is survivable; letting a +request past a guard is not. + +It is also **deliberately synchronous**, for the same reason: a gate has to +decide before the send path performs any await, so the state it reads cannot +change between the decision and its effect. Every admission check the host makes +today is already synchronous. An async version was tried first and broke three +`agentSideEffects` tests by deferring the read-only guard one microtask past the +assertions — which was the design saying the gate belongs before the first await. Implement normal error handling where it improves the feature, but never depend on a thrown error to control lifecycle flow. The exact dispatch behavior @@ -225,3 +290,10 @@ When extracting existing behavior from `AgentSideEffects`, leave the existing the proof that the extraction preserved semantics; test new dispatcher mechanics separately rather than rewriting the old assertions to fit the new structure. + +One caveat that has bitten twice: some hand-built test graphs construct +`AgentSideEffects` **without** calling `registerBuiltInChatContributions`, so +they silently stop exercising behavior the moment it moves into a contribution. +When an extraction breaks such a suite, add the missing registration so the graph +mirrors production wiring — do not weaken the assertion. The failure means the +test was verifying an incomplete graph, not that the extraction was wrong. diff --git a/.github/skills/policy-and-managed-settings/local-testing.md b/.github/skills/policy-and-managed-settings/local-testing.md index 844bebc2a08b71..8733fd9f9e7b4e 100644 --- a/.github/skills/policy-and-managed-settings/local-testing.md +++ b/.github/skills/policy-and-managed-settings/local-testing.md @@ -28,6 +28,11 @@ Choose the client setup in the GUI: proxy mapping and enable Proxyman's platform proxy toggle (**Tools > macOS Proxy** or **Tools > Override Windows Proxy**). VS Code clients must also add the displayed `http.proxy` property to `settings.json`. +- **File-based settings (no proxy):** expand **Deploy as a file** under the + Managed Settings response body and run the copied per-platform command to write + the current body to `managed-settings.json` on the device. Restart the client to + load it. Use it to skip proxying or to test precedence against a server-managed + response. See [Deploying file-based settings](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings#deploying-file-based-settings). Use **Clear SDK Policy Cache**, expand the macOS or Windows section, and run the copied command when the runtime's fresh managed-settings cache prevents a network diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index a6c18ed36f5b34..015e6be556186d 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -6,26 +6,26 @@ "": { "name": "agent-sdk-claude", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.220" + "@anthropic-ai/claude-agent-sdk": "0.3.239" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz", - "integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.239.tgz", + "integrity": "sha512-cIuZhK4u76S5Otq78U890GSA6BFT4SLqOuMqzU/bP/tWRWKhHhNp/3/pvgLwoVGlkdhD7luXWduqXKyLC+VNBQ==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.239", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.239", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.239" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -34,9 +34,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz", - "integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.239.tgz", + "integrity": "sha512-GGVGuCwFEUm6cMlnBX0LTC9JX5NdGzxddbuqWtRxEgo9EetS70SO3FW+reitALlotHghPTfnICQILbBDIRyX+Q==", "cpu": [ "arm64" ], @@ -47,9 +47,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz", - "integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.239.tgz", + "integrity": "sha512-QNbBXz3Pb3pQ7a+Kcbets6t9IrQhStKsfl5D518nYiGFoRMioO7efkZ6zHUcrGDqDC0LIzrs7tY2KNzH4RfwZA==", "cpu": [ "x64" ], @@ -60,9 +60,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz", - "integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.239.tgz", + "integrity": "sha512-RE6tDtzU0xj58tsuxnlXMJO8ckJ4tx/1nUgR+D/fPEQVt84oOyXeVspKt1ffvycogh3Sr3MkCGwZrPmxu/V1nA==", "cpu": [ "arm64" ], @@ -76,9 +76,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz", - "integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.239.tgz", + "integrity": "sha512-Ajc3cuszVdOwfMZVsGdxrCTmgWOeJpQWAIqu8jNEvERIeNnBxvWfGrjmLPxYT3/LJ9Uj/tFTpp52J4IcmrJE5w==", "cpu": [ "arm64" ], @@ -92,9 +92,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz", - "integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.239.tgz", + "integrity": "sha512-q4YaDoPgqh0XM23RM1/Zje7OSKccuCTQE89KoppDFOsyGdRsUj5xr01LTtr5hnYQuZD7dfwAbz9zl1g0MF/7TA==", "cpu": [ "x64" ], @@ -108,9 +108,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz", - "integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.239.tgz", + "integrity": "sha512-zIUHiG4Romm/t6m/S9n8x4BKluyRCPk0147hPVo4xxkHkp4Di/7TnDMRKO3Wau4x361wRqlE2i5EpYT+CyJjHg==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz", - "integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.239.tgz", + "integrity": "sha512-RxA29NdX9g3ZbpcXvSeWxpbg/Eoo3wXfO2eA1Vc7qa5JyAYjrl9xu6dIGAWMiyk/gnFxNh1WoFER77J9P6LTiQ==", "cpu": [ "arm64" ], @@ -137,9 +137,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz", - "integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.239.tgz", + "integrity": "sha512-ylKIX0DfaK1EgWYbVEvBMYATVFdKjFcWvvypTIv2sJhM3KxJT/0lTzvqsai8jYeZN1FBHWlRNEUQJvJMjO/diA==", "cpu": [ "x64" ], diff --git a/build/agent-sdk/agents/claude/package.json b/build/agent-sdk/agents/claude/package.json index 1702b450b196b4..e3f17dc6f5752c 100644 --- a/build/agent-sdk/agents/claude/package.json +++ b/build/agent-sdk/agents/claude/package.json @@ -3,6 +3,6 @@ "private": true, "comment": "Pinned dependency set for the build/agent-sdk claude tarball. The package-lock.json alongside is the source of truth for transitive deps — produce.ts runs `npm ci` against this directory to get byte-deterministic output across pipeline runs.", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.220" + "@anthropic-ai/claude-agent-sdk": "0.3.239" } } diff --git a/build/lib/preLaunch.ts b/build/lib/preLaunch.ts index acba96db70d6c1..46a20a8625cd07 100644 --- a/build/lib/preLaunch.ts +++ b/build/lib/preLaunch.ts @@ -66,6 +66,9 @@ async function ensureCompiled() { async function main() { await ensureNodeModules(); await getElectron(); + if (process.argv.includes('--only-electron')) { + return; + } await ensureCompiled(); // Can't require this until after dependencies are installed diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 589adf58ddf5e0..5e2444864fd447 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4024,6 +4024,15 @@ ], "description": "%github.copilot.config.gemini35FlashReducedToolUsePrompt.enabled%" }, + "github.copilot.chat.geminiFlashPromptAdditions.enabled": { + "type": "boolean", + "default": false, + "tags": [ + "experimental", + "onExp" + ], + "description": "%github.copilot.config.geminiFlashPromptAdditions.enabled%" + }, "github.copilot.chat.anthropic.contextEditing.mode": { "type": "string", "default": "off", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index c6c794aeb48ef2..a6d7eac6a176d4 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -163,6 +163,7 @@ "github.copilot.config.alternateGptPrompt.enabled": "Enables an experimental alternate prompt for GPT models instead of the default prompt.", "github.copilot.config.alternateGeminiModelFPrompt.enabled": "Enables an experimental alternate prompt for Gemini Model F instead of the default prompt.", "github.copilot.config.gemini35FlashReducedToolUsePrompt.enabled": "Enables an experimental prompt for Gemini 3.5 Flash that instructs the model to minimize tool calls to reduce token usage.", + "github.copilot.config.geminiFlashPromptAdditions.enabled": "Enables experimental additional prompt guidance for Gemini Flash 3.6 and 3.7 models.", "github.copilot.config.gpt5CodexAlternatePrompt": "Specifies an experimental alternate prompt to use for the GPT-5-Codex model.", "github.copilot.command.fixTestFailure": "Fix Test Failure", "copilot.description": "Ask or edit in context", diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index 4fdf0a71ffdc09..0ba90c6564105f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -228,6 +228,25 @@ const OPEN_PULL_REQUEST_COMMAND_ID = 'github.copilot.chat.cloudSessions.openPull const CLEAR_CACHES_COMMAND_ID = 'github.copilot.chat.cloudSessions.clearCaches'; const CREATE_PULL_REQUEST_FOR_TASK_COMMAND_ID = 'github.copilot.chat.cloudSessions.createPullRequestForTask'; const OPEN_PULL_REQUEST_FOR_TASK_COMMAND_ID = 'github.copilot.chat.cloudSessions.openPullRequestForTask'; + +export function parseGitHubContextUrl(value: string, kind: 'issue' | 'pullRequest'): { readonly repoId: string; readonly url: string; readonly label: string } | undefined { + const match = /^https:\/\/(?:www\.)?github\.com\/(?[^/?#]+)\/(?[^/?#]+)\/(?issues|pull)\/(?[1-9]\d*)\/?(?:[?#].*)?$/i.exec(value.trim()); + if (!match?.groups) { + return undefined; + } + const resource = match.groups.resource.toLowerCase(); + if ((resource === 'issues') !== (kind === 'issue')) { + return undefined; + } + + const repoId = `${match.groups.owner}/${match.groups.repository}`; + return { + repoId, + url: `https://github.com/${repoId}/${resource}/${match.groups.number}`, + label: `${repoId}#${match.groups.number}`, + }; +} + /** Context key gating the chat-input "Create pull request" toolbar action: true while the viewed cloud task is settled and has no PR yet. */ const CAN_CREATE_PULL_REQUEST_CONTEXT_KEY = 'github.copilot.chat.cloudTaskCanCreatePullRequest'; /** Context key gating the chat-input "Open pull request" toolbar action: true once the viewed cloud task has a pull request. */ @@ -706,6 +725,18 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C clearTimeout(searchTimeout); } const query = value.trim(); + const pastedSelection = parseGitHubContextUrl(query, kind); + if (pastedSelection) { + searchGeneration++; + quickPick.busy = false; + quickPick.items = [{ + label: pastedSelection.label, + description: pastedSelection.repoId, + alwaysShow: true, + selection: pastedSelection, + }]; + return; + } if (query.length < 2) { if (query.length === 0) { void search('', ++searchGeneration); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts index d52c0c981a75db..5a463b596895cb 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts @@ -12,7 +12,7 @@ import { mock } from '../../../../util/common/test/simpleMock'; import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes'; import { ITaskApiClient, ListTaskEventsOptions, ListTasksOptions } from '../../common/taskApiTypes'; import { ChatSessionContentBuilder, extractTaskErrorDetail, formatTaskStoppedMessage } from '../copilotCloudSessionContentBuilder'; -import { formatNewSessionContextReference, getCloudSessionItemMetadata, getCloudSessionResources, normalizeInitialSessionOptions, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider'; +import { formatNewSessionContextReference, getCloudSessionItemMetadata, getCloudSessionResources, normalizeInitialSessionOptions, parseGitHubContextUrl, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider'; import { TaskApiBackend, parseRepoFromTaskUrl, isCloudCodingAgentTask } from '../taskApiBackend'; import { isActiveTaskState, isFailedTaskState } from '../../vscode/copilotCodingAgentUtils'; import { NullCloudBackendInstrumentation } from '../cloudBackendTelemetry'; @@ -60,6 +60,28 @@ describe('copilotCloudSessionsProvider helpers', () => { ]); }); + it('parses pasted GitHub issue and pull request URLs for the matching picker', () => { + expect({ + issue: parseGitHubContextUrl(' https://github.com/microsoft/vscode/ISSUES/333149#issuecomment-1 ', 'issue'), + pullRequest: parseGitHubContextUrl('https://www.github.com/microsoft/vscode/pull/333149/', 'pullRequest'), + wrongPicker: parseGitHubContextUrl('https://github.com/microsoft/vscode/pull/333149', 'issue'), + unrelated: parseGitHubContextUrl('https://example.com/microsoft/vscode/issues/333149', 'issue'), + }).toEqual({ + issue: { + repoId: 'microsoft/vscode', + url: 'https://github.com/microsoft/vscode/issues/333149', + label: 'microsoft/vscode#333149', + }, + pullRequest: { + repoId: 'microsoft/vscode', + url: 'https://github.com/microsoft/vscode/pull/333149', + label: 'microsoft/vscode#333149', + }, + wrongPicker: undefined, + unrelated: undefined, + }); + }); + it('coerces object-shaped initialSessionOptions into option entries', () => { const logService = new RecordingLogService(); const sessionResource = vscode.Uri.parse('copilot-cloud-agent:/1'); diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineEditProviderFeature.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineEditProviderFeature.ts index 7f9c6de7701022..73c04d30515e75 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineEditProviderFeature.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineEditProviderFeature.ts @@ -9,6 +9,7 @@ import { ConfigKey, IConfigurationService } from '../../../platform/configuratio import { IEnvService } from '../../../platform/env/common/envService'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { InlineEditRequestLogContext } from '../../../platform/inlineEdits/common/inlineEditLogContext'; +import { IInlineEditsModelService } from '../../../platform/inlineEdits/common/inlineEditsModelService'; import { ObservableGit } from '../../../platform/inlineEdits/common/observableGit'; import { NesHistoryContextProvider } from '../../../platform/inlineEdits/common/workspaceEditTracker/nesHistoryContextProvider'; import { ILogService } from '../../../platform/log/common/logService'; @@ -63,6 +64,9 @@ export class InlineEditProviderFeature { private readonly _yieldToCopilot = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsYieldToCopilot, this._expService); private readonly _excludedProviders = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsExcludedProviders, this._expService).map(v => v ? v.split(',').map(v => v.trim()).filter(v => v !== '') : []); private readonly _copilotToken = observableFromEvent(this, this._authenticationService.onDidCopilotTokenChange, () => this._authenticationService.copilotToken); + // Read reactively: on a fetched `/models` deployment this resolves async, so a brief cold-start + // window can emit completions until `onModelListUpdated` fires and re-registers with the excludes. + private readonly _supportsUnifiedCompletions = observableFromEvent(this, this._modelService.onModelListUpdated, () => this._modelService.selectedModelConfiguration().supportsUnifiedCompletions ?? false); public readonly inlineEditsEnabled = derived(this, (reader) => { const copilotToken = this._copilotToken.read(reader); @@ -92,6 +96,7 @@ export class InlineEditProviderFeature { @IExperimentationService private readonly _expService: IExperimentationService, @IEnvService private readonly _envService: IEnvService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IInlineEditsModelService private readonly _modelService: IInlineEditsModelService, ) { } @@ -158,8 +163,11 @@ export class InlineEditProviderFeature { const provider = this._instantiationService.createInstance(InlineCompletionProviderImpl, model, logger, logContextRecorder, inlineEditDebugComponent, telemetrySender, expectedEditCaptureController); const unificationStateValue = unificationState.read(reader); + // Unify when the selected model's strategy bakes in `supportsUnifiedCompletions`, or when the + // core deployment/ExP `modelUnification` toggle is set. + const modelUnification = this._supportsUnifiedCompletions.read(reader) || (unificationStateValue?.modelUnification ?? false); let excludes = this._excludedProviders.read(reader); - if (unificationStateValue?.modelUnification) { + if (modelUnification) { excludes = excludes.slice(0); if (!excludes.includes('completions')) { excludes.push('completions'); diff --git a/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx index aab977cc04b096..2b3f7c763e4a40 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx @@ -44,6 +44,14 @@ export class DefaultGeminiAgentPrompt extends PromptElement You are a highly sophisticated automated coding agent with expert-level knowledge across many different programming languages and frameworks.
@@ -55,6 +63,7 @@ export class DefaultGeminiAgentPrompt extends PromptElementTool calls to read files or search are expensive. Minimize their use by solving tasks in the fewest steps and tool calls possible.
: <>If you aren't sure which tool is relevant, you can call multiple tools. You can call tools repeatedly to take actions or gather as much context as needed until you have completed the task fully. Don't give up unless you are sure the request cannot be fulfilled with the tools you have. It's YOUR RESPONSIBILITY to make sure that you have done all you can to collect necessary context.
} When reading files, prefer reading large meaningful chunks rather than consecutive small sections to minimize tool calls and gain better context.
+ {enableFlashPromptAdditions && <>**Read ast/definitions first**: When inspecting a new codebase, prioritize reading configuration files (e.g. package.json, tsconfig.json) or directory listings before searching for source files blindly.
} Don't make assumptions about the situation- gather context first, then perform the task or answer the question.
{!this.props.codesearchMode && <>Think creatively and explore the workspace in order to make a complete fix.
} Don't repeat yourself after a tool call, pick up where you left off.
@@ -75,6 +84,8 @@ export class DefaultGeminiAgentPrompt extends PromptElementYou can use the {ToolName.FindTextInFiles} to get an overview of a file by searching for a string within that one file, instead of using {ToolName.ReadFile} many times.
} {tools[ToolName.Codebase] && <>If you don't know exactly the string or filename pattern you're looking for, use {ToolName.Codebase} to do a semantic search across the workspace.
} {tools[ToolName.CoreRunInTerminal] && <>Don't call the {ToolName.CoreRunInTerminal} tool multiple times in parallel. Instead, run one command and wait for the output before running the next command.
} + {enableFlashPromptAdditions && tools[ToolName.CoreRunInTerminal] && <>**Tool Batching**: Combine terminal execution steps. If you need to verify multiple files or run multiple tests, chain them into a single terminal command (e.g., pytest tests/a.py tests/b.py).
} + {enableFlashPromptAdditions && tools[ToolName.FindTextInFiles] && <>**Search Precision**: Refine {ToolName.FindTextInFiles} patterns to target exact definitions rather than running broad, high-volume keyword searches that clutter the context window.
} When invoking a tool that takes a file path, always use the absolute file path. If the file has a scheme like untitled: or vscode-userdata:, then use a URI with the scheme.
{tools[ToolName.CoreRunInTerminal] && <>NEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} {!tools.hasSomeEditTool && <>You don't currently have any tools available for editing files. If the user asks you to edit a file, you can ask the user to enable editing tools or print a codeblock with the suggested changes.
} diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx b/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx index 4256aa6b70e2e3..b0d12ee8de63ac 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx @@ -18,6 +18,7 @@ import { createTextDocumentData } from '../../../../../util/common/test/shims/te import { URI } from '../../../../../util/vs/base/common/uri'; import { SyncDescriptor } from '../../../../../util/vs/platform/instantiation/common/descriptors'; import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; +import type { LanguageModelToolInformation } from 'vscode'; import { ChatRequestEditedFileEventKind, LanguageModelTextPart, LanguageModelToolResult } from '../../../../../vscodeTypes'; import { addCacheBreakpoints } from '../../../../intents/node/cacheBreakpoints'; import { ChatVariablesCollection } from '../../../../prompt/common/chatVariablesCollection'; @@ -385,3 +386,90 @@ testFamilies.forEach(family => { }); }); }); + +suite('AgentPrompt - Gemini Flash prompt additions experiment', () => { + let accessor: ITestingServicesAccessor; + + beforeAll(() => { + const services = createExtensionUnitTestingServices(); + services.define(IWorkspaceService, new SyncDescriptor(TestWorkspaceService, [[URI.file('/workspace')]])); + services.define(IChatMLFetcher, new StaticChatMLFetcher([])); + accessor = services.createTestingAccessor(); + }); + + afterAll(() => { + accessor.dispose(); + }); + + async function renderForFamily(family: string, includeToolSpecificTools = true): Promise { + const instaService = accessor.get(IInstantiationService); + const endpoint = instaService.createInstance(MockEndpoint, family); + const toolsService = accessor.get(IToolsService); + const turn = new Turn('turnId', { type: 'user', message: 'hello' }); + const conversation = new Conversation('sessionId', [turn]); + const customizations = await PromptRegistry.resolveAllCustomizations(instaService, endpoint); + const syntheticTool = (name: ToolName): LanguageModelToolInformation => ({ + name, + description: '', + inputSchema: undefined, + tags: [], + source: undefined, + }); + const availableTools = includeToolSpecificTools + ? [...toolsService.tools, syntheticTool(ToolName.CoreRunInTerminal), syntheticTool(ToolName.FindTextInFiles)] + : toolsService.tools.filter(t => t.name !== ToolName.CoreRunInTerminal && t.name !== ToolName.FindTextInFiles); + const props: AgentPromptProps = { + priority: 1, + endpoint, + location: ChatLocation.Panel, + promptContext: { + chatVariables: new ChatVariablesCollection(), + history: [], + query: 'hello', + conversation, + tools: { + availableTools, + toolInvocationToken: null as never, + toolReferences: [], + }, + }, + customizations, + }; + const renderer = PromptRenderer.create(instaService, endpoint, AgentPrompt, props); + const r = await renderer.render(); + return r.messages.map(m => messageToMarkdown(m)).join('\n\n'); + } + + function assertAdditions(rendered: string, expected: { readAst: boolean; toolBatching: boolean; searchPrecision: boolean }) { + expect({ + readAst: rendered.includes('Read ast/definitions first'), + toolBatching: rendered.includes('**Tool Batching**'), + searchPrecision: rendered.includes('**Search Precision**'), + }).toEqual(expected); + } + + test('additions appear for Gemini Flash 3.6 when experiment is enabled', async () => { + accessor.get(IConfigurationService).setConfig(ConfigKey.EnableGeminiFlashPromptAdditions, true); + assertAdditions(await renderForFamily('gemini-3.6-flash'), { readAst: true, toolBatching: true, searchPrecision: true }); + }); + + test('additions appear for Gemini Flash 3.7 when experiment is enabled', async () => { + accessor.get(IConfigurationService).setConfig(ConfigKey.EnableGeminiFlashPromptAdditions, true); + assertAdditions(await renderForFamily('gemini-3.7-flash'), { readAst: true, toolBatching: true, searchPrecision: true }); + }); + + test('tool-specific additions are gated on tool availability', async () => { + accessor.get(IConfigurationService).setConfig(ConfigKey.EnableGeminiFlashPromptAdditions, true); + assertAdditions(await renderForFamily('gemini-3.6-flash', /* includeToolSpecificTools */ false), { readAst: true, toolBatching: false, searchPrecision: false }); + }); + + test('additions are omitted for Gemini Flash 3.6 when experiment is disabled', async () => { + accessor.get(IConfigurationService).setConfig(ConfigKey.EnableGeminiFlashPromptAdditions, false); + assertAdditions(await renderForFamily('gemini-3.6-flash'), { readAst: false, toolBatching: false, searchPrecision: false }); + }); + + test('additions are omitted for other Gemini families even when experiment is enabled', async () => { + accessor.get(IConfigurationService).setConfig(ConfigKey.EnableGeminiFlashPromptAdditions, true); + assertAdditions(await renderForFamily('gemini-2.0-flash'), { readAst: false, toolBatching: false, searchPrecision: false }); + }); +}); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 1643ec07ba7370..2150a44da82f5c 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -1072,6 +1072,7 @@ export namespace ConfigKey { export const EnableAlternateGptPrompt = defineSetting('chat.alternateGptPrompt.enabled', ConfigType.ExperimentBased, false); export const EnableAlternateGeminiModelFPrompt = defineSetting('chat.alternateGeminiModelFPrompt.enabled', ConfigType.ExperimentBased, false); export const EnableGemini3ReducedToolUsePrompt = defineSetting('chat.gemini35FlashReducedToolUsePrompt.enabled', ConfigType.ExperimentBased, true); + export const EnableGeminiFlashPromptAdditions = defineSetting('chat.geminiFlashPromptAdditions.enabled', ConfigType.ExperimentBased, false); export const EnableOrganizationCustomAgents = defineSetting('chat.organizationCustomAgents.enabled', ConfigType.Simple, true); export const EnableOrganizationInstructions = defineSetting('chat.organizationInstructions.enabled', ConfigType.Simple, true); diff --git a/extensions/copilot/src/platform/networking/node/fetcherFallback.ts b/extensions/copilot/src/platform/networking/node/fetcherFallback.ts index 421a37eb291fb3..745dac6427909a 100644 --- a/extensions/copilot/src/platform/networking/node/fetcherFallback.ts +++ b/extensions/copilot/src/platform/networking/node/fetcherFallback.ts @@ -3,9 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - import { Config, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; -import { collectSingleLineErrorMessage, ILogService } from '../../log/common/logService'; +import { collectSingleLineErrorMessage, ILogService, sanitizeNetworkErrorForTelemetry } from '../../log/common/logService'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { FetcherId, FetchOptions, Response } from '../common/fetcherService'; @@ -18,11 +17,113 @@ const fetcherConfigKeys: Partial>> = { 'node-http': ConfigKey.Shared.DebugUseNodeFetcher, }; +const terminalResponseStatusCodes = new Set([429, 502, 503]); +const allFetchersFailedTelemetryIntervalMs = 15 * 60 * 1000; +const maxAggregatedErrorLength = 1024; +const maxAggregatedErrorsSerializedLength = 8192; +const aggregatedErrorsOverflowKey = ''; +const allFetchersFailedErrors = new Map(); +let lastAllFetchersFailedTelemetryTime = Date.now(); +const terminalResponseErrors = new Map(); +let lastTerminalResponseTelemetryTime = Date.now(); + +function reportAllFetchersFailed(telemetryService: ITelemetryService | undefined): void { + const now = Date.now(); + if (!telemetryService || now - lastAllFetchersFailedTelemetryTime <= allFetchersFailedTelemetryIntervalMs || allFetchersFailedErrors.size === 0) { + return; + } + + /* __GDPR__ + "fetcherAllFailed" : { + "owner": "chrmarti", + "comment": "Aggregates errors from requests for which every fallback fetcher failed during a 15-minute reporting interval", + "errors": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "JSON object mapping sanitized fetcher failure messages to their counts, bounded to the telemetry property limit with omitted messages counted under " } + } + */ + telemetryService.sendTelemetryEvent('fetcherAllFailed', { github: true, microsoft: true }, { + errors: serializeErrors(allFetchersFailedErrors), + }); + + allFetchersFailedErrors.clear(); + lastAllFetchersFailedTelemetryTime = now; +} + +function reportTerminalResponses(telemetryService: ITelemetryService | undefined): void { + const now = Date.now(); + if (!telemetryService || now - lastTerminalResponseTelemetryTime <= allFetchersFailedTelemetryIntervalMs || terminalResponseErrors.size === 0) { + return; + } + + /* __GDPR__ + "fetcherTerminalResponse" : { + "owner": "chrmarti", + "comment": "Aggregates errors from fetcher fallback sweeps stopped by a terminal response during a 15-minute reporting interval", + "errors": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "JSON object mapping sanitized fetcher failure messages from stopped sweeps to their counts, bounded to the telemetry property limit with omitted messages counted under " } + } + */ + telemetryService.sendTelemetryEvent('fetcherTerminalResponse', { github: true, microsoft: true }, { + errors: serializeErrors(terminalResponseErrors), + }); + + terminalResponseErrors.clear(); + lastTerminalResponseTelemetryTime = now; +} + +function recordAllFetchersFailed(errors: readonly string[]): void { + recordErrors(allFetchersFailedErrors, errors); +} + +function recordTerminalResponseErrors(errors: readonly string[]): void { + recordErrors(terminalResponseErrors, errors); +} + +function recordErrors(target: Map, errors: readonly string[]): void { + for (const error of errors) { + const truncatedError = error.slice(0, maxAggregatedErrorLength); + const count = target.get(truncatedError); + if (count !== undefined) { + target.set(truncatedError, Math.min(count + 1, Number.MAX_SAFE_INTEGER)); + continue; + } + target.set(truncatedError, 1); + } +} + +function serializeErrors(errors: ReadonlyMap): string { + const entries: string[] = []; + const maximumOverflowEntry = `${JSON.stringify(aggregatedErrorsOverflowKey)}:${Number.MAX_SAFE_INTEGER}`; + let serializedLength = 2; + let overflowCount = 0; + for (const [error, count] of errors) { + const entry = `${JSON.stringify(error)}:${count}`; + const separatorLength = entries.length === 0 ? 0 : 1; + if (overflowCount === 0 && serializedLength + separatorLength + entry.length + 1 + maximumOverflowEntry.length <= maxAggregatedErrorsSerializedLength) { + entries.push(entry); + serializedLength += separatorLength + entry.length; + } else { + overflowCount = Math.min(overflowCount + count, Number.MAX_SAFE_INTEGER); + } + } + if (overflowCount > 0) { + entries.push(`${JSON.stringify(aggregatedErrorsOverflowKey)}:${overflowCount}`); + } + return `{${entries.join(',')}}`; +} + export async function fetchWithFallbacks(availableFetchers: readonly IFetcher[], url: string, options: FetchOptions, knownBadFetchers: Set, configurationService: IConfigurationService, logService: ILogService, telemetryService: ITelemetryService | undefined, experimentationService: IExperimentationService | undefined): Promise<{ response: Response; updatedFetchers?: IFetcher[]; updatedKnownBadFetchers?: Set }> { if (options.retryFallbacks && availableFetchers.length > 1) { + reportAllFetchersFailed(telemetryService); + reportTerminalResponses(telemetryService); let firstResult: { ok: boolean; response: Response } | { ok: false; err: any } | undefined; const updatedKnownBadFetchers = new Set(); - let lastError: string | undefined; + const errors: string[] = []; + const promoteFallbackFetcher = (fetcher: IFetcher, response: Response) => { + logService.info(`FetcherService: using ${fetcher.getUserAgentLibrary()} from now on`); + const updatedFetchers = availableFetchers.slice(); + updatedFetchers.splice(updatedFetchers.indexOf(fetcher), 1); + updatedFetchers.unshift(fetcher); + return { response, updatedFetchers, updatedKnownBadFetchers }; + }; for (const fetcher of availableFetchers) { const result = await tryFetch(fetcher, url, options, logService); if (fetcher === availableFetchers[0]) { @@ -31,9 +132,19 @@ export async function fetchWithFallbacks(availableFetchers: readonly IFetcher[], if (!result.ok) { const fetcherId = fetcher.getUserAgentLibrary(); if ('response' in result) { - lastError = `${fetcherId}: ${result.response.status} ${result.response.statusText}`; + errors.push(result.response.ok + ? `${fetcherId}: invalid-json` + : `${fetcherId}: ${result.response.status} ${result.response.statusText}`); } else { - lastError = `${fetcherId}: ${collectSingleLineErrorMessage(result.err, true)}`; + const error = sanitizeNetworkErrorForTelemetry(collectSingleLineErrorMessage(result.err, true)); + errors.push(`${fetcherId}: ${error}`); + } + if ('response' in result && terminalResponseStatusCodes.has(result.response.status)) { + recordTerminalResponseErrors(errors); + if (fetcher === availableFetchers[0]) { + return { response: result.response }; + } + return promoteFallbackFetcher(fetcher, result.response); } updatedKnownBadFetchers.add(fetcherId); continue; @@ -43,7 +154,6 @@ export async function fetchWithFallbacks(availableFetchers: readonly IFetcher[], if (retry.ok) { return { response: retry.response }; } - logService.info(`FetcherService: using ${fetcher.getUserAgentLibrary()} from now on`); /* __GDPR__ "fetcherFallback" : { "owner": "chrmarti", @@ -57,17 +167,15 @@ export async function fetchWithFallbacks(availableFetchers: readonly IFetcher[], telemetryService?.sendTelemetryEvent('fetcherFallback', { github: true, microsoft: true }, { newFetcher: fetcher.getUserAgentLibrary(), knownBadFetchers: Array.from(updatedKnownBadFetchers).join(','), - lastError, + lastError: errors[errors.length - 1], }, { knownBadFetchersCount: updatedKnownBadFetchers.size, }); - const updatedFetchers = availableFetchers.slice(); - updatedFetchers.splice(updatedFetchers.indexOf(fetcher), 1); - updatedFetchers.unshift(fetcher); - return { response: result.response, updatedFetchers, updatedKnownBadFetchers }; + return promoteFallbackFetcher(fetcher, result.response); } return { response: result.response }; } + recordAllFetchersFailed(errors); if ('response' in firstResult!) { return { response: firstResult.response }; } diff --git a/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts b/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts index c3fbd770203e96..50d94fba8ffeba 100644 --- a/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts +++ b/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { suite, test } from 'vitest'; +import { afterEach, suite, test, vi } from 'vitest'; import { ConfigKey } from '../../../configuration/common/configurationService'; import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService'; import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; import { NullExperimentationService } from '../../../telemetry/common/nullExperimentationService'; import { NullTelemetryService } from '../../../telemetry/common/nullTelemetryService'; +import { SpyingTelemetryService } from '../../../telemetry/node/spyingTelemetryService'; import { FakeHeaders } from '../../../test/node/fetcher'; import { TestLogService } from '../../../testing/common/testLogService'; import { FetcherId, FetchOptions, PaginationOptions, Response } from '../../common/fetcherService'; @@ -25,6 +26,22 @@ suite('FetcherFallback Test Suite', function () { const configurationService = new DefaultsOnlyConfigurationService(); const someHTML = '...'; const someJSON = '{"key": "value"}'; + let cleanupTime = Date.now(); + + afterEach(async () => { + cleanupTime += 24 * 60 * 60 * 1000; + vi.useFakeTimers(); + vi.setSystemTime(cleanupTime); + try { + const testFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(200, someJSON) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(testFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, new SpyingTelemetryService(), experimentationService); + } finally { + vi.useRealTimers(); + } + }); test('first fetcher succeeds', async function () { const fetcherSpec = [ @@ -41,6 +58,124 @@ suite('FetcherFallback Test Suite', function () { assert.deepStrictEqual(json, JSON.parse(someJSON)); }); + test('terminal responses stop fallback, preserve fallback state, and aggregate telemetry', async function () { + vi.useFakeTimers(); + cleanupTime += 24 * 60 * 60 * 1000; + vi.setSystemTime(cleanupTime); + const primingTelemetryService = new SpyingTelemetryService(); + const primingFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(429, someJSON) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(primingFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, primingTelemetryService, experimentationService); + vi.advanceTimersByTime(16 * 60 * 1000); + const createSuccessfulFetchers = () => createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(200, someJSON) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(createSuccessfulFetchers().fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, primingTelemetryService, experimentationService); + const spyingTelemetryService = new SpyingTelemetryService(); + try { + const primaryResults = []; + for (const { status, fetchers } of [ + { status: 429, fetchers: ['electron-fetch', 'node-fetch', 'node-http'] }, + { status: 502, fetchers: ['node-fetch', 'electron-fetch', 'node-http'] }, + { status: 503, fetchers: ['node-http', 'electron-fetch', 'node-fetch'] }, + ]) { + const serverResponse = createFakeResponse(status, someHTML); + const testFetchers = createTestFetchers([ + { name: fetchers[0], response: serverResponse }, + { name: fetchers[1], response: createFakeResponse(200, someJSON) }, + { name: fetchers[2], response: createFakeResponse(200, someJSON) }, + ]); + const { response, updatedFetchers, updatedKnownBadFetchers } = await fetchWithFallbacks(testFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + primaryResults.push({ + calls: testFetchers.calls.map(c => c.name), + responseIsUnchanged: response === serverResponse, + updatedFetchers, + updatedKnownBadFetchers, + }); + } + + const fallbackResponse = createFakeResponse(503, someJSON); + const fallbackTestFetchers = createTestFetchers([ + { name: 'electron-fetch', response: createFakeResponse(200, someHTML) }, + { name: 'node-fetch', response: fallbackResponse }, + { name: 'node-http', response: createFakeResponse(200, someJSON) }, + ]); + const fallbackResult = await fetchWithFallbacks(fallbackTestFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + for (let i = 0; i < 16; i++) { + const testFetchers = createTestFetchers([ + { name: 'electron-fetch', response: new Error(`terminal error ${i} ${'x'.repeat(1024)}`) }, + { name: 'node-fetch', response: createFakeResponse(503, someJSON) }, + ]); + await fetchWithFallbacks(testFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + } + const eventsBeforeInterval = [...spyingTelemetryService.getEvents().telemetryServiceEvents]; + + vi.advanceTimersByTime(15 * 60 * 1000 + 1); + const successfulFetchers = createSuccessfulFetchers(); + await fetchWithFallbacks(successfulFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + const telemetryEvents = spyingTelemetryService.getEvents().telemetryServiceEvents; + const properties = telemetryEvents[0].properties; + if (!properties || !('errors' in properties) || typeof properties.errors !== 'string') { + assert.fail('Expected an errors telemetry property'); + } + const terminalErrorsProperty = properties.errors; + const terminalErrors: Record = JSON.parse(terminalErrorsProperty); + + assert.deepStrictEqual({ + primaryResults, + fallbackResult: { + calls: fallbackTestFetchers.calls.map(c => c.name), + responseIsUnchanged: fallbackResult.response === fallbackResponse, + updatedFetchers: fallbackResult.updatedFetchers?.map(fetcher => fetcher.getUserAgentLibrary()), + updatedKnownBadFetchers: Array.from(fallbackResult.updatedKnownBadFetchers ?? []), + }, + eventsBeforeInterval, + eventCount: telemetryEvents.length, + eventName: telemetryEvents[0].eventName, + knownTerminalErrors: { + 'electron-fetch: 429 status text': terminalErrors['electron-fetch: 429 status text'], + 'node-fetch: 502 status text': terminalErrors['node-fetch: 502 status text'], + 'node-http: 503 status text': terminalErrors['node-http: 503 status text'], + 'electron-fetch: invalid-json': terminalErrors['electron-fetch: invalid-json'], + 'node-fetch: 503 status text': terminalErrors['node-fetch: 503 status text'], + }, + hasOverflow: terminalErrors[''] > 0, + allFailuresAccountedFor: Object.values(terminalErrors).reduce((total, count) => total + count, 0), + propertyWithinTelemetryLimit: terminalErrorsProperty.length <= 8192, + }, { + primaryResults: [ + { calls: ['electron-fetch'], responseIsUnchanged: true, updatedFetchers: undefined, updatedKnownBadFetchers: undefined }, + { calls: ['node-fetch'], responseIsUnchanged: true, updatedFetchers: undefined, updatedKnownBadFetchers: undefined }, + { calls: ['node-http'], responseIsUnchanged: true, updatedFetchers: undefined, updatedKnownBadFetchers: undefined }, + ], + fallbackResult: { + calls: ['electron-fetch', 'node-fetch'], + responseIsUnchanged: true, + updatedFetchers: ['node-fetch', 'electron-fetch', 'node-http'], + updatedKnownBadFetchers: ['electron-fetch'], + }, + eventsBeforeInterval: [], + eventCount: 1, + eventName: 'fetcherTerminalResponse', + knownTerminalErrors: { + 'electron-fetch: 429 status text': 1, + 'node-fetch: 502 status text': 1, + 'node-http: 503 status text': 1, + 'electron-fetch: invalid-json': 1, + 'node-fetch: 503 status text': 17, + }, + hasOverflow: true, + allFailuresAccountedFor: 37, + propertyWithinTelemetryLimit: true, + }); + } finally { + vi.useRealTimers(); + } + }); + test('first fetcher is retried to confirm failure', async function () { const fetcherSpec = [ { name: 'fetcher1', response: createFakeResponse(200, someHTML) }, @@ -61,6 +196,106 @@ suite('FetcherFallback Test Suite', function () { assert.deepStrictEqual(json, JSON.parse(someJSON)); }); + test('aggregates all-failed errors and reports them after 15 minutes', async function () { + vi.useFakeTimers(); + cleanupTime += 24 * 60 * 60 * 1000; + vi.setSystemTime(cleanupTime); + const primingTelemetryService = new SpyingTelemetryService(); + const primingFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(200, someHTML) }, + { name: 'fetcher2', response: new Error('priming error') }, + ]); + await fetchWithFallbacks(primingFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, primingTelemetryService, experimentationService); + vi.advanceTimersByTime(16 * 60 * 1000); + const createSuccessfulFetchers = () => createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(200, someJSON) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(createSuccessfulFetchers().fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, primingTelemetryService, experimentationService); + const spyingTelemetryService = new SpyingTelemetryService(); + const failingFetcherSpec = [ + { name: 'fetcher1', response: createFakeResponse(200, someHTML) }, + { name: 'fetcher2', response: new Error('connect ETIMEDOUT proxy.fictional.example.com:443') }, + ]; + try { + for (let i = 0; i < 2; i++) { + const testFetchers = createTestFetchers(failingFetcherSpec); + await fetchWithFallbacks(testFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + } + const escapedErrorFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(200, someHTML) }, + { name: 'fetcher2', response: new Error('quoted "error" with \\ slash') }, + ]); + await fetchWithFallbacks(escapedErrorFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + for (let i = 0; i < 52; i++) { + const testFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(200, someHTML) }, + { name: 'fetcher2', response: new Error(`unique error ${i} ${'x'.repeat(1024)}`) }, + ]); + await fetchWithFallbacks(testFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + } + const eventsBeforeInterval = [...spyingTelemetryService.getEvents().telemetryServiceEvents]; + + vi.advanceTimersByTime(15 * 60 * 1000); + await fetchWithFallbacks(createSuccessfulFetchers().fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + const eventsAtInterval = [...spyingTelemetryService.getEvents().telemetryServiceEvents]; + vi.advanceTimersByTime(1); + await fetchWithFallbacks(createSuccessfulFetchers().fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + const eventsAfterInterval = spyingTelemetryService.getEvents().telemetryServiceEvents; + assert.deepStrictEqual({ + primingEventCount: primingTelemetryService.getEvents().telemetryServiceEvents.length, + eventsBeforeInterval, + eventsAtInterval, + eventCountAfterInterval: eventsAfterInterval.length, + }, { + primingEventCount: 1, + eventsBeforeInterval: [], + eventsAtInterval: [], + eventCountAfterInterval: 1, + }); + const properties = eventsAfterInterval[0].properties; + if (!properties || !('errors' in properties) || typeof properties.errors !== 'string') { + assert.fail('Expected an errors telemetry property'); + } + const errorsProperty = properties.errors; + const errors: Record = JSON.parse(errorsProperty); + const truncatedError = Object.keys(errors).find(error => error.startsWith('fetcher2: unique error 0 ')); + + vi.advanceTimersByTime(16 * 60 * 1000); + await fetchWithFallbacks(createSuccessfulFetchers().fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + + assert.deepStrictEqual({ + eventsBeforeInterval, + eventsAtInterval, + eventName: eventsAfterInterval[0].eventName, + errorKeyCount: Object.keys(errors).length, + invalidJSONCount: errors['fetcher1: invalid-json'], + sanitizedNetworkErrorCount: errors['fetcher2: connect ETIMEDOUT :443'], + escapedErrorCount: errors['fetcher2: quoted "error" with \\ slash'], + truncatedErrorLength: truncatedError?.length, + hasOverflow: errors[''] > 0, + allFailuresAccountedFor: Object.values(errors).reduce((total: number, count) => total + count, 0), + propertyWithinTelemetryLimit: errorsProperty.length <= 8192, + eventCountAfterEmptyInterval: spyingTelemetryService.getEvents().telemetryServiceEvents.length, + }, { + eventsBeforeInterval: [], + eventsAtInterval: [], + eventName: 'fetcherAllFailed', + errorKeyCount: 11, + invalidJSONCount: 55, + sanitizedNetworkErrorCount: 2, + escapedErrorCount: 1, + truncatedErrorLength: 1024, + hasOverflow: true, + allFailuresAccountedFor: 110, + propertyWithinTelemetryLimit: true, + eventCountAfterEmptyInterval: 1, + }); + } finally { + vi.useRealTimers(); + } + }); + test('no fetcher succeeds', async function () { const fetcherSpec = [ { name: 'fetcher1', response: createFakeResponse(407, someHTML) }, diff --git a/extensions/copilot/src/platform/telemetry/node/baseExperimentationService.ts b/extensions/copilot/src/platform/telemetry/node/baseExperimentationService.ts index e2ea58919cb3b8..9805d3c0761712 100644 --- a/extensions/copilot/src/platform/telemetry/node/baseExperimentationService.ts +++ b/extensions/copilot/src/platform/telemetry/node/baseExperimentationService.ts @@ -15,6 +15,14 @@ import { IVSCodeExtensionContext } from '../../extContext/common/extensionContex import { ILogService } from '../../log/common/logService'; import { IExperimentationService, TreatmentsChangeEvent } from '../common/nullExperimentationService'; +/** + * Scope prefix that the new TAS assignments endpoint (`/api/v1/assignments`) prepends to the + * feature variable keys it returns (e.g. `/vscode/config.chat...`). The legacy endpoint and + * callers query treatments by the bare name, so this prefix must be accounted for when a bare + * lookup misses. This is an interim workaround until vscode-tas-client strips the scope itself. + */ +const ASSIGNMENTS_SCOPE_PREFIX = '/vscode/'; + export class UserInfoStore extends Disposable { private _internalOrg: string | undefined; private _sku: string | undefined; @@ -244,7 +252,7 @@ export class BaseExperimentationService extends Disposable implements IExperimen private _signalTreatmentsChangeEvent = () => { const affectedTreatmentVariables: string[] = []; for (const [key, previousValue] of this._previouslyReadTreatments) { - const currentValue = this._delegate.getTreatmentVariable('vscode', key); + const currentValue = this._readTreatmentVariable(key); if (currentValue !== previousValue) { this._logService.trace(`[BaseExperimentationService] Treatment changed: ${key} from ${previousValue} to ${currentValue}`); this._previouslyReadTreatments.set(key, currentValue); @@ -267,11 +275,27 @@ export class BaseExperimentationService extends Disposable implements IExperimen } getTreatmentVariable(name: string): T | undefined { - const result = this._delegate.getTreatmentVariable('vscode', name) as T; + const result = this._readTreatmentVariable(name); this._previouslyReadTreatments.set(name, result); return result; } + /** + * Reads a treatment, preferring the `/vscode/`-scoped key over the bare key. Interim workaround + * till its fixed upstream: the new TAS assignments endpoint namespaces its returned feature + * variable keys with a `/vscode/` scope that vscode-tas-client does not strip. Reading the + * scoped key first makes the new endpoint win over the legacy (bare) key when both assign a + * treatment - matching the behavior once the scope is stripped upstream - while still resolving + * legacy-only treatments from the bare key. + */ + private _readTreatmentVariable(name: string): T | undefined { + let result = this._delegate.getTreatmentVariable('vscode', `${ASSIGNMENTS_SCOPE_PREFIX}${name}`) as T | undefined; + if (result === undefined) { + result = this._delegate.getTreatmentVariable('vscode', name) as T | undefined; + } + return result; + } + // Note: This is only temporarily until we have fully migrated to the new completions implementation. // At that point, we can remove this method and the related code. private _completionsFilters: Map = new Map(); diff --git a/extensions/copilot/src/platform/telemetry/test/node/experimentation.spec.ts b/extensions/copilot/src/platform/telemetry/test/node/experimentation.spec.ts index 7ad939b60f7b8a..a00317856fa833 100644 --- a/extensions/copilot/src/platform/telemetry/test/node/experimentation.spec.ts +++ b/extensions/copilot/src/platform/telemetry/test/node/experimentation.spec.ts @@ -135,6 +135,13 @@ class MockTASExperimentationService implements ITASExperimentationService { return undefined; } + // This suite models treatments served by the legacy endpoint (bare keys). The new + // assignments endpoint does not assign these, so scoped lookups resolve to undefined, + // exercising the service's bare-key fallback. + if (name.startsWith('/vscode/')) { + return undefined; + } + const org = this.userInfoStore.internalOrg; const sku = this.userInfoStore.sku; @@ -826,6 +833,41 @@ describe('ExP Service delegate recreation', () => { }); }); +describe('ExP Service scoped treatment resolution', () => { + let accessor: ITestingServicesAccessor; + + beforeAll(() => { + const testingServiceCollection = createPlatformServices(); + accessor = testingServiceCollection.createTestingAccessor(); + }); + + const create = () => accessor.get(IInstantiationService).createInstance(RecreatableExperimentationService); + + it('resolves a treatment served only under the /vscode/ assignments scope prefix', () => { + const service = create(); + const delegate = service.delegates[0]; + + // The new assignments endpoint returns the key with a `/vscode/` scope prefix only. + delegate.setTreatment('/vscode/config.chat.copilot.subagentModelGuidance.enabled', true); + + expect(service.getTreatmentVariable('config.chat.copilot.subagentModelGuidance.enabled')).toBe(true); + + service.dispose(); + }); + + it('prefers the /vscode/ scoped key (new endpoint) over the bare key on collision', () => { + const service = create(); + const delegate = service.delegates[0]; + + delegate.setTreatment('config.foo', 'bare'); + delegate.setTreatment('/vscode/config.foo', 'scoped'); + + expect(service.getTreatmentVariable('config.foo')).toBe('scoped'); + + service.dispose(); + }); +}); + /** * Records every request routed through the fetcher service so a test can assert that both TAS * endpoints go through it (proxy-aware transport) with the expected method and call site. diff --git a/extensions/terminal-suggest/src/completions/azd.ts b/extensions/terminal-suggest/src/completions/azd.ts index b75b8d72504d9c..d52af72864d29d 100644 --- a/extensions/terminal-suggest/src/completions/azd.ts +++ b/extensions/terminal-suggest/src/completions/azd.ts @@ -227,7 +227,7 @@ const completionSpec: Fig.Spec = { subcommands: [ { name: ['agent'], - description: 'Ship agents with Microsoft Foundry from your terminal. (Preview)', + description: 'Ship agents with Microsoft Foundry from your terminal. (Beta)', subcommands: [ { name: ['code'], @@ -350,7 +350,7 @@ const completionSpec: Fig.Spec = { options: [ { name: ['--agent'], - description: 'Target agent name', + description: 'Agent service name from azure.yaml, or Foundry agent name outside a project', args: [ { name: 'agent', @@ -576,15 +576,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, { name: ['--file', '-f'], description: 'Remote file or directory path to delete', @@ -608,11 +599,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -631,15 +622,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, { name: ['--file', '-f'], description: 'Remote file path to download', @@ -668,11 +650,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -691,15 +673,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, { name: ['--output', '-o'], description: 'The output format', @@ -720,11 +693,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -743,15 +716,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, { name: ['--dir', '-d'], description: 'Remote directory path to create', @@ -771,11 +735,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -794,15 +758,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, { name: ['--output', '-o'], description: 'The output format', @@ -823,11 +778,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -846,15 +801,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, { name: ['--file', '-f'], description: 'Local file path to upload', @@ -883,11 +829,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -919,7 +865,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--deploy-mode'], - description: 'Deployment mode: \'container\' (Docker image) or \'code\' (ZIP upload). Defaults to \'container\' in --no-prompt.', + description: 'Deployment mode: \'container\' (Docker image) or \'code\' (ZIP upload). Defaults to \'code\' for Python/.NET projects in --no-prompt.', args: [ { name: 'deploy-mode', @@ -940,9 +886,28 @@ const completionSpec: Fig.Spec = { description: 'Overwrite an input manifest that already lives inside the generated src tree without prompting. Required together with --no-prompt when init would otherwise need confirmation.', isDangerous: true, }, + { + name: ['--image'], + description: 'Pre-built container image URL (e.g., \'myacr.azurecr.io/agent:v1\'). When set without --manifest, skips template/language selection, code scaffolding, Dockerfile generation, and ACR setup, and requires --agent-name. Incompatible with --deploy-mode code.', + args: [ + { + name: 'image', + }, + ], + }, + { + name: ['--infra'], + description: 'Eject infrastructure-as-code from azure.yaml. Existing infrastructure is preserved and Foundry files are generated as a separate infra/foundry layer. A bare --infra ejects Bicep; --infra=terraform ejects Terraform and sets the Foundry layer provider to terraform; Bicep keeps the microsoft.foundry provider. --infra=bicep is explicit Bicep. When azure.yaml already declares a Foundry project service, runs as a standalone eject and skips the init prompts; otherwise init runs first and the eject follows it.', + args: [ + { + name: 'infra', + isOptional: true, + }, + ], + }, { name: ['--manifest', '-m'], - description: 'Path or URI to an agent manifest to add to your azd project', + description: 'Path or URI to an agent manifest, or to a sample\'s unified azure.yaml to adopt as the project manifest', args: [ { name: 'manifest', @@ -951,7 +916,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--model'], - description: 'Name of the AI model to use (e.g., \'gpt-4o\'). If not specified, defaults to \'gpt-4.1-mini\'. Mutually exclusive with --model-deployment, with --model-deployment being used if both are provided', + description: 'Name of the AI model to deploy. Defaults to \'gpt-5.4-mini\' during interactive model selection; required to deploy a new model with --no-prompt. If --model-deployment is also provided, --model-deployment takes precedence.', args: [ { name: 'model', @@ -978,7 +943,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--protocol'], - description: 'Protocols supported by the agent (e.g., \'responses\', \'invocations\'). Can be specified multiple times.', + description: 'Protocols supported by the agent (responses, invocations, invocations_ws, activity). Can be specified multiple times.', isRepeatable: true, args: [ { @@ -1020,11 +985,21 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', + name: ['--call-id'], + description: 'Call ID header value (sent as x-agent-foundry-call-id for local invocations only; ignored for remote requests)', args: [ { - name: 'chat-isolation-key', + name: 'call-id', + }, + ], + }, + { + name: ['--client-header'], + description: 'Custom x-client-* request header in "Name: Value" format (repeatable). The responses and invocations protocols forward the x-client-* header family to the agent; other header names are rejected and the flag is not supported with a2a.', + isRepeatable: true, + args: [ + { + name: 'client-header', }, ], }, @@ -1079,7 +1054,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--protocol', '-p'], - description: 'Protocol to use: responses (default) or invocations', + description: 'Protocol to use: responses (default), invocations, or a2a (a2a is remote-only)', args: [ { name: 'protocol', @@ -1105,11 +1080,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -1128,15 +1103,6 @@ const completionSpec: Fig.Spec = { name: ['monitor'], description: 'Monitor logs from a hosted agent.', options: [ - { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, { name: ['--follow', '-f'], description: 'Stream logs in real-time', @@ -1173,11 +1139,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -1197,7 +1163,7 @@ const completionSpec: Fig.Spec = { options: [ { name: ['--agent'], - description: 'Agent service name (auto-detected from azure.yaml)', + description: 'Agent service name from azure.yaml (auto-detected if only one exists)', args: [ { name: 'agent', @@ -1263,7 +1229,7 @@ const completionSpec: Fig.Spec = { options: [ { name: ['--agent'], - description: 'Agent name to deploy to (auto-detected from agent.yaml)', + description: 'Agent service name from azure.yaml, or Foundry agent name outside a project', args: [ { name: 'agent', @@ -1354,6 +1320,16 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, { name: ['--poll-interval'], description: 'Polling interval in seconds', @@ -1382,7 +1358,7 @@ const completionSpec: Fig.Spec = { options: [ { name: ['--agent', '-a'], - description: 'Agent name (auto-detected from azd project if omitted)', + description: 'Agent service name from azure.yaml, or Foundry agent name outside a project', args: [ { name: 'agent', @@ -1477,13 +1453,116 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['pack'], + description: 'Build a ready-to-sideload Teams app package for an activity agent.', + options: [ + { + name: ['--app-version'], + description: 'Version stamped into the Teams app manifest', + args: [ + { + name: 'app-version', + }, + ], + }, + { + name: ['--display-name'], + description: 'Display name for the Teams app (defaults to the agent name)', + args: [ + { + name: 'display-name', + }, + ], + }, + { + name: ['--output-dir'], + description: 'Directory to write appPackage.zip to (defaults to the agent source directory)', + args: [ + { + name: 'output-dir', + }, + ], + }, + { + name: ['--scope'], + description: 'Publish scope for the package (personal: per-user sideload (no admin approval required); shared: shareable link distribution (no tenant-admin approval required); tenant: organization-wide catalog (requires IT-admin approval))', + args: [ + { + name: 'scope', + }, + ], + }, + ], + }, + { + name: ['publish'], + description: 'Publish an activity agent as a Teams app to the Microsoft 365 store.', + options: [ + { + name: ['--app-version'], + description: 'Version stamped into the Teams app manifest', + args: [ + { + name: 'app-version', + }, + ], + }, + { + name: ['--display-name'], + description: 'Display name for the Teams app (defaults to the agent name)', + args: [ + { + name: 'display-name', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'none'], + }, + ], + }, + { + name: ['--scope'], + description: 'Publish scope (shared: shareable link distribution (no tenant-admin approval required); tenant: organization-wide catalog (requires IT-admin approval; alias: org))', + args: [ + { + name: 'scope', + }, + ], + }, + ], + }, { name: ['run'], description: 'Run your agent locally for development.', options: [ { - name: ['--no-inspector'], - description: 'Do not open Agent Inspector', + name: ['--channel'], + description: 'Channel for the Microsoft 365 Agents Playground (activity-protocol agents only)', + args: [ + { + name: 'channel', + }, + ], + }, + { + name: ['--inspector-port'], + description: 'Port the Agent Inspector UI listens on (default: 8087)', + args: [ + { + name: 'inspector-port', + }, + ], + }, + { + name: ['--no-client'], + description: 'Do not open the local client (Agent Inspector or Playground)', }, { name: ['--port', '-p'], @@ -1538,7 +1617,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--type'], - description: 'Filter by template type. Supported values: agent, azd.', + description: 'Filter by template type. Supported values: agent, azd, azure.yaml.', args: [ { name: 'type', @@ -1566,24 +1645,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, - { - name: ['--isolation-key'], - description: 'Session ownership isolation key header value (x-session-isolation-key; derived from Entra token by default)', - args: [ - { - name: 'isolation-key', - }, - ], - }, { name: ['--output', '-o'], description: 'The output format', @@ -1604,11 +1665,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -1637,29 +1698,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, - { - name: ['--isolation-key'], - description: 'Session ownership isolation key header value (x-session-isolation-key; derived from Entra token by default)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'isolation-key', - }, - ], - }, - { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -1678,15 +1721,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', - args: [ - { - name: 'chat-isolation-key', - }, - ], - }, { name: ['--limit'], description: 'Maximum number of sessions to return', @@ -1716,11 +1750,11 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -1740,30 +1774,45 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--chat-isolation-key'], - description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'chat-isolation-key', + name: 'user-identity', }, ], }, + ], + }, + { + name: ['stop'], + description: 'Stop a running session.', + options: [ { - name: ['--output', '-o'], - description: 'The output format', + name: ['--agent-name', '-n'], + description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', args: [ { - name: 'output', - suggestions: ['json', 'table'], + name: 'agent-name', }, ], }, { - name: ['--user-isolation-key'], - description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + name: ['--user-identity'], + description: 'User identity header value (sent as x-agent-user-id for local invocations and x-ms-user-identity for remote requests)', args: [ { - name: 'user-isolation-key', + name: 'user-identity', }, ], }, @@ -1795,7 +1844,7 @@ const completionSpec: Fig.Spec = { }, { name: ['connection'], - description: 'Manage Microsoft Foundry Connections from your terminal. (Preview)', + description: 'Manage Microsoft Foundry Connections from your terminal. (Beta)', subcommands: [ { name: ['context'], @@ -2101,6 +2150,16 @@ const completionSpec: Fig.Spec = { name: ['version'], description: 'Display the extension version', options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json'], + }, + ], + }, { name: ['--project-endpoint', '-p'], description: 'Foundry project endpoint URL (overrides env var and config)', @@ -2573,7 +2632,7 @@ const completionSpec: Fig.Spec = { }, { name: ['inspector'], - description: 'Browser-based inspector UI for locally running Foundry agents. (Preview)', + description: 'Browser-based inspector UI for locally running Foundry agents. (Beta)', subcommands: [ { name: ['launch'], @@ -3424,7 +3483,7 @@ const completionSpec: Fig.Spec = { }, { name: ['project'], - description: 'Manage Microsoft Foundry Project resources from your terminal. (Preview)', + description: 'Manage Microsoft Foundry Project resources from your terminal. (Beta)', subcommands: [ { name: ['context'], @@ -3481,12 +3540,24 @@ const completionSpec: Fig.Spec = { { name: ['version'], description: 'Display the extension version', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json'], + }, + ], + }, + ], }, ], }, { name: ['routine'], - description: 'Manage Microsoft Foundry Routines from your terminal. (Preview)', + description: 'Manage Microsoft Foundry Routines from your terminal. (Beta)', subcommands: [ { name: ['context'], @@ -3501,6 +3572,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, { @@ -3688,6 +3768,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, { name: ['--trigger'], description: 'Trigger type: timer, recurring, github-issue, or custom (required unless --file is used)', @@ -3727,6 +3816,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, { @@ -3752,6 +3850,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, { @@ -3790,6 +3897,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, { @@ -3815,6 +3931,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, { @@ -3840,6 +3965,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, { @@ -3878,6 +4012,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, { name: ['--top'], description: 'Maximum total number of runs to return (0 = no cap)', @@ -3900,6 +4043,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, { @@ -3925,6 +4077,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, { @@ -4094,12 +4255,31 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, { name: ['version'], description: 'Display the extension version', options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json'], + }, + ], + }, { name: ['--project-endpoint', '-p'], description: 'Foundry project endpoint URL (overrides env var and config)', @@ -4109,13 +4289,22 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'HTTP request timeout override (for example, 2m or 90s). Defaults to 30s for reads and 2m0s for writes.', + args: [ + { + name: 'timeout', + }, + ], + }, ], }, ], }, { name: ['skill'], - description: 'Manage Microsoft Foundry skills (reusable agent behavioral guidelines) from your terminal. (Preview)', + description: 'Manage Microsoft Foundry skills (reusable agent behavioral guidelines) from your terminal. (Beta)', subcommands: [ { name: ['context'], @@ -4401,7 +4590,7 @@ const completionSpec: Fig.Spec = { }, { name: ['toolbox'], - description: 'Manage Microsoft Foundry Toolboxes from your terminal. (Preview)', + description: 'Manage Microsoft Foundry Toolboxes from your terminal. (Beta)', subcommands: [ { name: ['connection'], @@ -4420,6 +4609,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--from-version'], + description: 'Version to branch the new version from (defaults to the latest version).', + args: [ + { + name: 'from-version', + }, + ], + }, { name: ['--index'], description: 'Search index name. Only valid for CognitiveSearch (Azure AI Search) connections; required there.', @@ -4493,6 +4691,15 @@ const completionSpec: Fig.Spec = { description: 'Skip confirmation prompts and apply the removal immediately.', isDangerous: true, }, + { + name: ['--from-version'], + description: 'Version to branch the new version from (defaults to the latest version).', + args: [ + { + name: 'from-version', + }, + ], + }, { name: ['--output', '-o'], description: 'The output format', @@ -4701,6 +4908,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--from-version'], + description: 'Version to branch the new version from (defaults to the latest version).', + args: [ + { + name: 'from-version', + }, + ], + }, { name: ['--output', '-o'], description: 'The output format', @@ -4756,6 +4972,15 @@ const completionSpec: Fig.Spec = { description: 'Skip confirmation prompts and apply the removal immediately.', isDangerous: true, }, + { + name: ['--from-version'], + description: 'Version to branch the new version from (defaults to the latest version).', + args: [ + { + name: 'from-version', + }, + ], + }, { name: ['--output', '-o'], description: 'The output format', @@ -4794,6 +5019,16 @@ const completionSpec: Fig.Spec = { name: ['version'], description: 'Display the extension version', options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json'], + }, + ], + }, { name: ['--project-endpoint'], description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', @@ -5385,6 +5620,52 @@ const completionSpec: Fig.Spec = { name: ['context'], description: 'Get the context of the azd project & environment.', }, + { + name: ['copilot'], + description: 'Interactive Copilot chat loop demonstrating the CopilotService gRPC API.', + options: [ + { + name: ['--mode'], + description: 'Agent mode (autopilot, interactive, plan)', + args: [ + { + name: 'mode', + }, + ], + }, + { + name: ['--model'], + description: 'Model to use (empty = default)', + args: [ + { + name: 'model', + }, + ], + }, + { + name: ['--reasoning-effort'], + description: 'Reasoning effort level (low, medium, high)', + args: [ + { + name: 'reasoning-effort', + }, + ], + }, + { + name: ['--resume'], + description: 'Resume an existing session', + }, + { + name: ['--system-message'], + description: 'Custom system message', + args: [ + { + name: 'system-message', + }, + ], + }, + ], + }, { name: ['gh-url-parse'], description: 'Parse a GitHub URL and extract repository information.', @@ -5673,9 +5954,13 @@ const completionSpec: Fig.Spec = { description: 'Force installation, including downgrades and reinstalls', isDangerous: true, }, + { + name: ['--no-dependencies'], + description: 'Install only the specified extension(s) without installing their declared dependencies', + }, { name: ['--source', '-s'], - description: 'The extension source to use for installs', + description: 'The extension source to use for installs. Accepts a registered source name or a registry location (URL or file path) to register and install from.', args: [ { name: 'source', @@ -5706,8 +5991,8 @@ const completionSpec: Fig.Spec = { description: 'List installed extensions', }, { - name: ['--source'], - description: 'Filter extensions by source', + name: ['--source', '-s'], + description: 'Filter extensions by registered source name or registry location (URL or file path).', args: [ { name: 'source', @@ -5732,7 +6017,7 @@ const completionSpec: Fig.Spec = { options: [ { name: ['--source', '-s'], - description: 'The extension source to use.', + description: 'The registered source name or registry location (URL or file path) to use.', args: [ { name: 'source', @@ -5764,7 +6049,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--name', '-n'], - description: 'The name of the extension source', + description: 'The source name: 1-64 lowercase letters, digits, hyphens, or underscores.', args: [ { name: 'name', @@ -5824,20 +6109,20 @@ const completionSpec: Fig.Spec = { }, }, { - name: ['upgrade'], - description: 'Upgrade installed extensions to the latest version.', + name: ['update', 'upgrade'], + description: 'Update installed extensions to the latest version.', options: [ { name: ['--all'], - description: 'Upgrade all installed extensions', + description: 'Update all installed extensions', }, { - name: ['--no-dependency-upgrades'], - description: 'Do not upgrade dependencies when upgrading an extension that has dependencies', + name: ['--no-dependency-updates'], + description: 'Do not update dependencies when updating an extension that has dependencies', }, { name: ['--source', '-s'], - description: 'The extension source to use for upgrades', + description: 'The registered source name or registry location (URL or file path) to use for updates.', args: [ { name: 'source', @@ -5846,7 +6131,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--version', '-v'], - description: 'The version of the extension to upgrade to', + description: 'The version of the extension to update to', args: [ { name: 'version', @@ -6075,7 +6360,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--auth-type'], - description: 'The authentication type used between the pipeline provider and Azure for deployment (Only valid for GitHub provider). Valid values: federated, client-credentials.', + description: 'The authentication type used between the pipeline provider and Azure for deployment. Valid values: federated, client-credentials. Both the GitHub and Azure DevOps providers default to federated (OIDC) credentials.', args: [ { name: 'auth-type', @@ -6336,6 +6621,16 @@ const completionSpec: Fig.Spec = { name: ['install'], description: 'Install specified tools.', options: [ + { + name: ['--agent'], + description: 'Install the skill for the specified agent(s): copilot, claude. Use --agent all for every detected agent (skill tools only)', + isRepeatable: true, + args: [ + { + name: 'agent', + }, + ], + }, { name: ['--all'], description: 'Install all recommended tools', @@ -6344,16 +6639,6 @@ const completionSpec: Fig.Spec = { name: ['--dry-run'], description: 'Preview what would be installed without making changes', }, - { - name: ['--host'], - description: 'Install the skill for the specified agent host(s): copilot, claude. Use --host all for every detected host (skill tools only)', - isRepeatable: true, - args: [ - { - name: 'host', - }, - ], - }, ], args: { name: 'tool-name...', @@ -6375,6 +6660,16 @@ const completionSpec: Fig.Spec = { name: ['uninstall'], description: 'Uninstall installed tools.', options: [ + { + name: ['--agent'], + description: 'Uninstall the skill from the specified agent(s): copilot, claude. Use --agent all (or omit --agent) to remove the skill from every agent it is installed through (skill tools only)', + isRepeatable: true, + args: [ + { + name: 'agent', + }, + ], + }, { name: ['--all'], description: 'Uninstall all installed tools', @@ -6383,16 +6678,6 @@ const completionSpec: Fig.Spec = { name: ['--dry-run'], description: 'Preview what would be uninstalled without making changes', }, - { - name: ['--host'], - description: 'Uninstall the skill from the specified agent host(s): copilot, claude. Use --host all (or omit --host) to remove the skill from every host it is installed through (skill tools only)', - isRepeatable: true, - args: [ - { - name: 'host', - }, - ], - }, ], args: { name: 'tool-name...', @@ -6400,23 +6685,27 @@ const completionSpec: Fig.Spec = { }, }, { - name: ['upgrade'], - description: 'Upgrade installed tools.', + name: ['update', 'upgrade'], + description: 'Update installed tools.', options: [ { - name: ['--dry-run'], - description: 'Preview what would be upgraded without making changes', - }, - { - name: ['--host'], - description: 'Upgrade the skill for the specified agent host(s): copilot, claude. Use --host all for every detected host (skill tools only)', + name: ['--agent'], + description: 'Update the skill for the specified agent(s): copilot, claude. Use --agent all for every detected agent (skill tools only)', isRepeatable: true, args: [ { - name: 'host', + name: 'agent', }, ], }, + { + name: ['--all'], + description: 'Update all installed tools', + }, + { + name: ['--dry-run'], + description: 'Preview what would be updated without making changes', + }, ], args: { name: 'tool-name...', @@ -6510,7 +6799,7 @@ const completionSpec: Fig.Spec = { options: [ { name: ['--capabilities'], - description: 'The list of capabilities for the extension (e.g., custom-commands,lifecycle-events,mcp-server,service-target-provider,framework-service-provider,metadata,provisioning-provider).', + description: 'The list of capabilities for the extension (e.g., custom-commands,lifecycle-events,mcp-server,service-target-provider,framework-service-provider,metadata,provisioning-provider,validation-provider).', isRepeatable: true, args: [ { @@ -6543,7 +6832,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--language'], - description: 'The programming language for the extension (go, dotnet, javascript, python).', + description: 'The programming language for the extension (go (recommended), dotnet, javascript, python).', args: [ { name: 'language', @@ -6775,7 +7064,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--no-prompt'], - description: 'Runs without prompts. Uses existing values; fails if any required value or decision cannot be resolved automatically.', + description: 'Runs without prompts. Uses existing values; fails if any required value or decision cannot be resolved automatically. Automatically enabled when azd detects a CI/CD or AI-agent environment; set AZD_NON_INTERACTIVE=false to opt out of that automatic enablement.', isPersistent: true, }, { diff --git a/package-lock.json b/package-lock.json index 32f01a4db442ae..b16421892b150f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.220", + "@anthropic-ai/claude-agent-sdk": "0.3.239", "@eslint/compat": "^2.1.0", "@openai/codex": "0.146.0", "@playwright/cli": "^0.1.9", @@ -192,23 +192,23 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz", - "integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.239.tgz", + "integrity": "sha512-cIuZhK4u76S5Otq78U890GSA6BFT4SLqOuMqzU/bP/tWRWKhHhNp/3/pvgLwoVGlkdhD7luXWduqXKyLC+VNBQ==", "dev": true, "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.239", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.239", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.239", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.239" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -217,9 +217,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz", - "integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.239.tgz", + "integrity": "sha512-GGVGuCwFEUm6cMlnBX0LTC9JX5NdGzxddbuqWtRxEgo9EetS70SO3FW+reitALlotHghPTfnICQILbBDIRyX+Q==", "cpu": [ "arm64" ], @@ -231,9 +231,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz", - "integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.239.tgz", + "integrity": "sha512-QNbBXz3Pb3pQ7a+Kcbets6t9IrQhStKsfl5D518nYiGFoRMioO7efkZ6zHUcrGDqDC0LIzrs7tY2KNzH4RfwZA==", "cpu": [ "x64" ], @@ -245,9 +245,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz", - "integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.239.tgz", + "integrity": "sha512-RE6tDtzU0xj58tsuxnlXMJO8ckJ4tx/1nUgR+D/fPEQVt84oOyXeVspKt1ffvycogh3Sr3MkCGwZrPmxu/V1nA==", "cpu": [ "arm64" ], @@ -262,9 +262,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz", - "integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.239.tgz", + "integrity": "sha512-Ajc3cuszVdOwfMZVsGdxrCTmgWOeJpQWAIqu8jNEvERIeNnBxvWfGrjmLPxYT3/LJ9Uj/tFTpp52J4IcmrJE5w==", "cpu": [ "arm64" ], @@ -279,9 +279,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz", - "integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.239.tgz", + "integrity": "sha512-q4YaDoPgqh0XM23RM1/Zje7OSKccuCTQE89KoppDFOsyGdRsUj5xr01LTtr5hnYQuZD7dfwAbz9zl1g0MF/7TA==", "cpu": [ "x64" ], @@ -296,9 +296,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz", - "integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.239.tgz", + "integrity": "sha512-zIUHiG4Romm/t6m/S9n8x4BKluyRCPk0147hPVo4xxkHkp4Di/7TnDMRKO3Wau4x361wRqlE2i5EpYT+CyJjHg==", "cpu": [ "x64" ], @@ -313,9 +313,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz", - "integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.239.tgz", + "integrity": "sha512-RxA29NdX9g3ZbpcXvSeWxpbg/Eoo3wXfO2eA1Vc7qa5JyAYjrl9xu6dIGAWMiyk/gnFxNh1WoFER77J9P6LTiQ==", "cpu": [ "arm64" ], @@ -327,9 +327,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz", - "integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==", + "version": "0.3.239", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.239.tgz", + "integrity": "sha512-ylKIX0DfaK1EgWYbVEvBMYATVFdKjFcWvvypTIv2sJhM3KxJT/0lTzvqsai8jYeZN1FBHWlRNEUQJvJMjO/diA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 4b687cd212329e..6b89aa98cf6b8e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.136.0", - "distro": "1c2dba21c7247ff2c7ebecc69aa6015474107e92", + "distro": "5c03086397d13f66eb8c46dc97043a91d9e97c9f", "author": { "name": "Microsoft Corporation" }, @@ -171,7 +171,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.220", + "@anthropic-ai/claude-agent-sdk": "0.3.239", "@eslint/compat": "^2.1.0", "@openai/codex": "0.146.0", "@playwright/cli": "^0.1.9", diff --git a/scripts/mock-policy-server/README.md b/scripts/mock-policy-server/README.md index 25bc9d5156390c..afc7f9bfb78328 100644 --- a/scripts/mock-policy-server/README.md +++ b/scripts/mock-policy-server/README.md @@ -15,7 +15,7 @@ switch beside each endpoint tab to choose mock or passthrough. Presets apply immediately; response behavior, status, and JSON edits auto-save. The GUI opens on the **Policies** workspace. Select **Setup** in the header to -open a modal that guides you through either connection method: +open a modal that guides you through any of these connection methods: - **System proxy (recommended):** works with Code OSS, Stable, Insiders, Copilot CLI, and SDK/runtime clients. The page recommends Proxyman on macOS and Windows @@ -27,11 +27,31 @@ open a modal that guides you through either connection method: - **Code OSS overrides:** the quicker option for Code OSS from this checkout. Select **Apply Overrides**, reload, and sign in. This option does not redirect SDK/runtime requests. +- **File-based settings:** skip proxying altogether by writing the enterprise + `managed-settings.json` to the client device. The client reads it from disk at + startup — before sign-in and with no server round trip — so these requests never + reach this server. Use it to avoid proxy setup, or to test precedence against a + server-managed response. Local clients only. See + [Deploying file-based settings](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings#deploying-file-based-settings). After connecting, open the VS Code Command Palette and run **> Developer: Sync Account Policy**. To refresh the policy used by Local Agent Host, also run **> Developer: Restart Local Agent Host**. +For file-based settings there is nothing to connect: expand **Deploy as a file** +under the Managed Settings response body on the Policies page. It builds a +per-platform one-liner (macOS, Windows PowerShell, Linux) that writes the current +response body to `managed-settings.json` at its documented location. Copy it, run +it on the client device, and restart the client. On macOS and Linux the command +uses `sudo` so the file is the root-owned, non-writable regular file Copilot CLI +requires. + +| Operating system | `managed-settings.json` location | +| --- | --- | +| macOS | `/Library/Application Support/GitHubCopilot/managed-settings.json` | +| Windows | `%ProgramFiles%\GitHubCopilot\managed-settings.json` | +| Linux | `/etc/github-copilot/managed-settings.json` | + The Setup dialog checks Code OSS overrides directly. It tests the system proxy by sending a request without credentials to the managed settings URL and confirming that the response came from this local server. It does not inspect Proxyman or diff --git a/scripts/mock-policy-server/endpoints.ts b/scripts/mock-policy-server/endpoints.ts index f1bef4d0621403..fcd22960745140 100644 --- a/scripts/mock-policy-server/endpoints.ts +++ b/scripts/mock-policy-server/endpoints.ts @@ -177,6 +177,23 @@ declare var MOCK_POLICY_ENDPOINTS: EndpointDef[]; } } }, + { + id: 'sandbox-no-internet', + label: 'Sandbox, no internet', + description: 'Enables the agent runtime sandbox with bypass allowed, but denies outbound network access so sandboxed tools run offline.', + status: 200, + body: { + sandbox: { + enabled: true, + allowBypass: true, + userPolicy: { + network: { + allowOutbound: false + } + } + } + } + }, { id: 'model-auto', label: 'Model: auto', diff --git a/scripts/mock-policy-server/public/app.ts b/scripts/mock-policy-server/public/app.ts index a9c07fe31d1953..b4e64a292bd33e 100644 --- a/scripts/mock-policy-server/public/app.ts +++ b/scripts/mock-policy-server/public/app.ts @@ -83,7 +83,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; editorText: string; } - type SetupMethod = 'proxy' | 'overrides'; + type SetupMethod = 'proxy' | 'overrides' | 'file'; const $ = (id: string): HTMLElement => document.getElementById(id)!; const tabs = $('tabs'); @@ -112,12 +112,77 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; let proxyCheckInFlight = false; let renderedLogSignature = ''; let stateUpdateQueue: Promise = Promise.resolve(); + // Signature of the server state we have already reflected in the UI, plus a + // counter of our own writes in flight. Together they let the background poll + // tell an external change (control API, another tab) from our own edits. + let lastServerSignature = ''; + let stateWritesInFlight = 0; const pendingSaves = new Map>(); function activeEndpoint(): Endpoint | undefined { return endpoints.find(e => e.id === activeId); } + // File-based managed settings: instead of proxying, a client can read + // managed-settings.json straight off disk. These build a per-platform + // one-liner that writes the current Managed Settings body to that file so a + // dev can bypass the proxy entirely or test precedence against a + // server-managed response. Paths are the documented deployment locations. + const macOsManagedSettingsPath = '/Library/Application Support/GitHubCopilot/managed-settings.json'; + const linuxManagedSettingsPath = '/etc/github-copilot/managed-settings.json'; + + function macOsFileDeployCommand(body: string): string { + return `sudo mkdir -p "/Library/Application Support/GitHubCopilot" && sudo tee "${macOsManagedSettingsPath}" >/dev/null <<'JSON'\n${body}\nJSON`; + } + + function linuxFileDeployCommand(body: string): string { + return `sudo mkdir -p /etc/github-copilot && sudo tee ${linuxManagedSettingsPath} >/dev/null <<'JSON'\n${body}\nJSON`; + } + + function windowsFileDeployCommand(body: string): string { + // A PowerShell here-string keeps the JSON literal; WriteAllText writes + // UTF-8 without a BOM on both Windows PowerShell 5.1 and PowerShell 7. + return [ + '$dir = Join-Path $env:ProgramFiles \'GitHubCopilot\'', + 'New-Item -ItemType Directory -Force -Path $dir | Out-Null', + '$json = @\'', + body, + '\'@', + '[System.IO.File]::WriteAllText((Join-Path $dir \'managed-settings.json\'), $json)' + ].join('\n'); + } + + function currentManagedSettingsBody(): string | null { + const raw = editor.value.trim(); + if (raw === '') { + return '{}'; + } + try { + return JSON.stringify(JSON.parse(raw), null, '\t'); + } catch { + return null; + } + } + + function renderFileDeploy(): void { + // File-based deployment only maps to the managed-settings.json document. + const applies = activeEndpoint()?.id === 'managedSettings'; + $('file-deploy-section').hidden = !applies; + if (!applies) { + return; + } + const body = currentManagedSettingsBody(); + const valid = body !== null; + $('file-deploy-commands').hidden = !valid; + $('file-deploy-hint').hidden = valid; + if (!valid) { + return; + } + $('macos-file-command').textContent = macOsFileDeployCommand(body); + $('windows-file-command').textContent = windowsFileDeployCommand(body); + $('linux-file-command').textContent = linuxFileDeployCommand(body); + } + function setStatus(message: string, kind?: string): void { editorStatus.textContent = message; editorStatus.dataset.kind = kind || ''; @@ -417,6 +482,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; $('schema-section').hidden = endpoint.schema !== true; $('validation-results').hidden = true; parseEditor(); + renderFileDeploy(); renderSaveState(); } @@ -446,6 +512,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; editor.value = JSON.stringify(preset.body, null, '\t'); drafts[activeId] = editor.value; parseEditor(); + renderFileDeploy(); clearPendingSave(activeId); void save(); } @@ -496,7 +563,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; } function selectSetupMethod(method: SetupMethod): void { - for (const candidate of ['proxy', 'overrides'] as const) { + for (const candidate of ['proxy', 'overrides', 'file'] as const) { const selected = candidate === method; $(`${candidate}-method`).dataset.selected = String(selected); $(`${candidate}-method-steps`).toggleAttribute('inert', !selected); @@ -592,15 +659,83 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; renderWired(state); renderProxy(); renderTabs(); + lastServerSignature = stateSignature(state); + } + + /** + * Canonical fingerprint of the mutable server state. Compared against + * `lastServerSignature` so the background poll only reacts to changes it did + * not originate. + */ + function stateSignature(state: ServerState): string { + const endpoints = (state.endpoints ?? []).map(e => ({ + id: e.id, status: e.status, mode: e.mode, active: e.active, body: e.body + })); + return JSON.stringify({ endpoints, wired: state.wired }); + } + + function isInteractingWithEditor(): boolean { + const el = document.activeElement; + return el === editor || el === responseStatusInput || el === responseModeSelect || el === presetSelect; + } + + /** + * Poll the server state and reconcile the UI when it changed underneath us — + * e.g. the control API or another tab edited a response body. Skipped while + * the user is editing or our own writes are settling so we never clobber an + * in-progress edit; the next poll catches up once they finish. + */ + async function refreshState(): Promise { + if (stateWritesInFlight > 0 || pendingSaves.size > 0 || isInteractingWithEditor()) { + return; + } + let state: ServerState; + try { + state = await api('/api/state'); + } catch { + return; // the next poll will retry + } + // Re-check after the await: a save may have started meanwhile. + if (stateWritesInFlight > 0 || pendingSaves.size > 0 || isInteractingWithEditor()) { + return; + } + if (stateSignature(state) === lastServerSignature) { + return; + } + applyExternalState(state); + } + + function applyExternalState(state: ServerState): void { + // Only reached when the user is not editing, so replacing the editor + // contents and drafts from the new server truth is safe. + applyState(state); + for (const endpoint of endpoints) { + drafts[endpoint.id] = JSON.stringify(endpoint.body ?? {}, null, '\t'); + } + const endpoint = activeEndpoint(); + if (!endpoint) { + return; + } + editor.value = drafts[activeId] ?? '{}'; + responseStatusInput.value = String(endpoint.status ?? 200); + responseModeSelect.value = endpoint.mode ?? 'json'; + updateResponseConfigurationVisibility(); + parseResponseStatus(); + parseEditor(); + renderFileDeploy(); + renderSaveState(); } function updateState(payload: Record): Promise { + stateWritesInFlight++; const request = stateUpdateQueue.then(() => api('/api/state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })); stateUpdateQueue = request.then(() => undefined, () => undefined); + // Clear the in-flight marker once settled; callers handle errors on `request`. + void request.then(() => { stateWritesInFlight--; }, () => { stateWritesInFlight--; }); return request; } @@ -851,6 +986,27 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; $('schema-toggle').setAttribute('aria-expanded', String(willOpen)); } + function toggleFileDeploySection(): void { + const detailsEl = $('file-deploy-details'); + const willOpen = Boolean(detailsEl.hidden); + detailsEl.hidden = !willOpen; + $('file-deploy-chevron').classList.toggle('open', willOpen); + $('file-deploy-toggle').setAttribute('aria-expanded', String(willOpen)); + } + + function openFileDeploy(): void { + // Jump from the Setup dialog to the live per-platform commands, which + // only exist for the Managed Settings endpoint on the Policies page. + if (activeId !== 'managedSettings' && endpoints.some(e => e.id === 'managedSettings')) { + selectEndpoint('managedSettings'); + } + setupDialog.close(); + if ($('file-deploy-details').hidden) { + toggleFileDeploySection(); + } + $('file-deploy-section').scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + async function refreshLog(): Promise { let entries: LogEntry[]; try { @@ -949,6 +1105,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; editor.addEventListener('input', () => { drafts[activeId] = editor.value; parseEditor(); + renderFileDeploy(); debouncedSave(); }); responseStatusInput.addEventListener('input', () => { @@ -978,7 +1135,17 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; $('copy-windows-cache-command').addEventListener('click', e => { copy(windowsCacheClearCommand, e.currentTarget as HTMLElement); }); - for (const method of ['proxy', 'overrides'] as const) { + $('copy-macos-file-command').addEventListener('click', e => { + copy($('macos-file-command').textContent ?? '', e.currentTarget as HTMLElement); + }); + $('copy-windows-file-command').addEventListener('click', e => { + copy($('windows-file-command').textContent ?? '', e.currentTarget as HTMLElement); + }); + $('copy-linux-file-command').addEventListener('click', e => { + copy($('linux-file-command').textContent ?? '', e.currentTarget as HTMLElement); + }); + $('file-deploy-goto').addEventListener('click', openFileDeploy); + for (const method of ['proxy', 'overrides', 'file'] as const) { $(`setup-method-${method}`).addEventListener('change', () => selectSetupMethod(method)); } $('setup-nav').addEventListener('click', openSetupDialog); @@ -996,6 +1163,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; }); window.addEventListener('hashchange', syncSetupDialog); $('schema-toggle').addEventListener('click', toggleSchemaSection); + $('file-deploy-toggle').addEventListener('click', toggleFileDeploySection); $('hydrate-schema').addEventListener('click', () => { if (!schema) { setStatus('Schema unavailable.', 'error'); @@ -1010,6 +1178,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; editor.value = JSON.stringify(hydrateFromSchema(schema), null, '\t'); drafts[activeId] = editor.value; parseEditor(); + renderFileDeploy(); void save(); setStatus('Generated an example from the schema.', 'ok'); }); @@ -1053,6 +1222,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; await refreshLog(); await checkProxy(); setInterval(refreshLog, 2000); + setInterval(() => { void refreshState(); }, 2000); setInterval(() => { void checkProxy(); }, 5000); } diff --git a/scripts/mock-policy-server/public/index.html b/scripts/mock-policy-server/public/index.html index 7051c806b6f61d..773acca3892f95 100644 --- a/scripts/mock-policy-server/public/index.html +++ b/scripts/mock-policy-server/public/index.html @@ -147,6 +147,7 @@

Request fresh policy in VS Code

+
@@ -179,6 +180,51 @@

Reload and request policy

+ +
+
+
+ + +
+
+

Not detectable from here. Deploy the file, restart the client, then verify inside the client.

+
+
+ +
    +
  1. + +
    +

    Write the policy file

    +

    On the Policies page, edit the Managed Settings response body, then expand Deploy as a file to copy the command for the client platform and run it in a terminal.

    + +
    + File locations +
      +
    • macOS: /Library/Application Support/GitHubCopilot/managed-settings.json
    • +
    • Windows: %ProgramFiles%\GitHubCopilot\managed-settings.json
    • +
    • Linux: /etc/github-copilot/managed-settings.json
    • +
    +

    On macOS and Linux, Copilot CLI requires a regular file owned by root that is not group- or world-writable, so the copied command uses sudo.

    +
    +
    +
  2. +
  3. + +
    +

    Reload the client

    +

    Restart the client so it loads the file, or in VS Code run > Developer: Sync Account Policy. File-based settings persist across accounts and don't expire like the SDK policy cache.

    +

    Use this to avoid proxying entirely, or deploy it alongside a server-managed response to test precedence. Deploying file-based settings

    +
    +
  4. +
+
+
@@ -245,6 +291,39 @@

+ + diff --git a/scripts/mock-policy-server/public/style.css b/scripts/mock-policy-server/public/style.css index bbb2c9fdd5d8b5..ce71259ea601e1 100644 --- a/scripts/mock-policy-server/public/style.css +++ b/scripts/mock-policy-server/public/style.css @@ -264,6 +264,58 @@ h2 { word-break: break-word; } +.file-deploy-details { + display: flex; + flex-direction: column; + gap: 10px; +} + +.file-deploy-details a, +.step-content a { + color: var(--focus); +} + +.file-deploy-badge { + display: inline-flex; + padding: 2px 8px; + border-radius: 999px; + background: var(--surface-alt); + color: var(--text-secondary); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: none; +} + +.file-deploy-commands { + display: flex; + flex-direction: column; + gap: 8px; +} + +.file-deploy-commands[hidden] { + display: none; +} + +.file-deploy-platform { + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.file-deploy-platform > summary { + width: fit-content; + cursor: pointer; + font-weight: 600; +} + +.file-deploy-command { + margin: 8px 0; + max-height: 220px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + .app-main { max-width: 1400px; margin: 0 auto; @@ -351,7 +403,8 @@ h2 { } .section-header h2, -.schema-heading { +.schema-heading, +.file-deploy-heading { font-size: 12px; font-weight: 600; text-transform: uppercase; @@ -433,6 +486,13 @@ h2 { align-items: start; } +.setup-alternatives { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} + .setup-method-picker { min-width: 0; margin: 0; diff --git a/scripts/test.bat b/scripts/test.bat index dee40972d809b0..4dcbdc4cf6a75e 100644 --- a/scripts/test.bat +++ b/scripts/test.bat @@ -14,8 +14,8 @@ set CODE=".build\electron\%NAMESHORT%" :: Download Electron if needed if "%VSCODE_SKIP_PRELAUNCH%"=="" ( - call node build\lib\electron.ts - if %errorlevel% neq 0 node .\node_modules\gulp\bin\gulp.js electron + call node build\lib\preLaunch.ts --only-electron + if errorlevel 1 node .\node_modules\gulp\bin\gulp.js electron ) :: Run tests diff --git a/scripts/test.sh b/scripts/test.sh index 423bfec1ecfcb7..1898916679f08b 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -26,7 +26,7 @@ test -d node_modules || npm i # Get electron if [[ -z "${VSCODE_SKIP_PRELAUNCH}" ]]; then - npm run electron + node build/lib/preLaunch.ts --only-electron fi # Unit Tests diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 773fc2a9ac41a0..bd01fbb3005432 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -37,6 +37,7 @@ export interface IEntitlementsData extends ILegacyQuotaSnapshotData { readonly can_signup_for_limited: boolean; readonly copilot_plan: string; readonly organization_login_list: string[]; + readonly is_staff?: boolean; readonly analytics_tracking_id: string; readonly limited_user_reset_date?: string; // for Copilot Free readonly quota_reset_date?: string; // for all other Copilot SKUs diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts index 6463d89e4c92b7..77acc6f39661c5 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts @@ -210,21 +210,27 @@ export class CodeActionController extends Disposable implements IEditorContribut return; } - let actions: CodeActionSet; + let actions: CodeActionSet | undefined; try { - actions = await newState.actions; + actions = this._model.takeCodeActions(await newState.actions); } catch (e) { onUnexpectedError(e); return; } + if (!actions) { + return; + } + if (this._disposed) { + actions.dispose(); return; } const selection = this._editor.getSelection(); if (selection?.startLineNumber !== newState.position.lineNumber) { + actions.dispose(); return; } diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts index 72757c009f592b..0f0faaeef45216 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts @@ -7,7 +7,7 @@ import { CancelablePromise, createCancelablePromise, TimeoutTimer } from '../../ import { isCancellationError } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js'; -import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -250,99 +250,108 @@ export class CodeActionModel extends Disposable { const actions = createCancelablePromise(async token => { if (this._settingEnabledNearbyQuickfixes() && trigger.trigger.type === CodeActionTriggerType.Invoke && (trigger.trigger.triggerAction === CodeActionTriggerSource.QuickFix || trigger.trigger.filter?.include?.contains(CodeActionKind.QuickFix))) { - const codeActionSet = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token); - this.codeActionsDisposable.value = codeActionSet; - const allCodeActions = [...codeActionSet.allActions]; - if (token.isCancellationRequested) { - codeActionSet.dispose(); - return emptyCodeActionSet; - } + const codeActionSets = new DisposableStore(); + let codeActionSetsOwnedByResult = false; + try { + const codeActionSet = codeActionSets.add(await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token)); + const allCodeActions = [...codeActionSet.allActions]; + if (token.isCancellationRequested) { + return emptyCodeActionSet; + } - // Search for non-AI quickfixes in the current code action set - if AI code actions are the only thing found, continue searching for diagnostics in line. - const foundQuickfix = codeActionSet.validActions?.some(action => { - return action.action.kind && - CodeActionKind.QuickFix.contains(new HierarchicalKind(action.action.kind)) && - !action.action.isAI; - }); - const allMarkers = this._markerService.read({ resource: model.uri }); - if (foundQuickfix) { - for (const action of codeActionSet.validActions) { - if (action.action.command?.arguments?.some(arg => typeof arg === 'string' && arg.includes(APPLY_FIX_ALL_COMMAND_ID))) { - action.action.diagnostics = [...allMarkers.filter(marker => marker.relatedInformation)]; + // Search for non-AI quickfixes in the current code action set - if AI code actions are the only thing found, continue searching for diagnostics in line. + const foundQuickfix = codeActionSet.validActions?.some(action => { + return action.action.kind && + CodeActionKind.QuickFix.contains(new HierarchicalKind(action.action.kind)) && + !action.action.isAI; + }); + const allMarkers = this._markerService.read({ resource: model.uri }); + if (foundQuickfix) { + for (const action of codeActionSet.validActions) { + if (action.action.command?.arguments?.some(arg => typeof arg === 'string' && arg.includes(APPLY_FIX_ALL_COMMAND_ID))) { + action.action.diagnostics = [...allMarkers.filter(marker => marker.relatedInformation)]; + } } - } - return { validActions: codeActionSet.validActions, allActions: allCodeActions, documentation: codeActionSet.documentation, hasAutoFix: codeActionSet.hasAutoFix, hasAIFix: codeActionSet.hasAIFix, allAIFixes: codeActionSet.allAIFixes, dispose: () => { this.codeActionsDisposable.value = codeActionSet; } }; - } else if (!foundQuickfix) { - // If markers exist, and there are no quickfixes found or length is zero, check for quickfixes on that line. - if (allMarkers.length > 0) { - const currPosition = trigger.selection.getPosition(); - let trackedPosition = currPosition; - let distance = Number.MAX_VALUE; - const currentActions = [...codeActionSet.validActions]; - - for (const marker of allMarkers) { - const col = marker.endColumn; - const row = marker.endLineNumber; - const startRow = marker.startLineNumber; - - // Found quickfix on the same line and check relative distance to other markers - if ((row === currPosition.lineNumber || startRow === currPosition.lineNumber)) { - trackedPosition = new Position(row, col); - const newCodeActionTrigger: CodeActionTrigger = { - type: trigger.trigger.type, - triggerAction: trigger.trigger.triggerAction, - filter: { include: trigger.trigger.filter?.include ? trigger.trigger.filter?.include : CodeActionKind.QuickFix }, - autoApply: trigger.trigger.autoApply, - context: { notAvailableMessage: trigger.trigger.context?.notAvailableMessage || '', position: trackedPosition } - }; - - const selectionAsPosition = new Selection(trackedPosition.lineNumber, trackedPosition.column, trackedPosition.lineNumber, trackedPosition.column); - const actionsAtMarker = await getCodeActions(this._registry, model, selectionAsPosition, newCodeActionTrigger, Progress.None, token); - if (token.isCancellationRequested) { - actionsAtMarker.dispose(); - return emptyCodeActionSet; - } + const result = this._setCodeActions({ validActions: codeActionSet.validActions, allActions: allCodeActions, documentation: codeActionSet.documentation, hasAutoFix: codeActionSet.hasAutoFix, hasAIFix: codeActionSet.hasAIFix, allAIFixes: codeActionSet.allAIFixes, dispose: () => codeActionSets.dispose() }); + codeActionSetsOwnedByResult = true; + return result; + } else if (!foundQuickfix) { + // If markers exist, and there are no quickfixes found or length is zero, check for quickfixes on that line. + if (allMarkers.length > 0) { + const currPosition = trigger.selection.getPosition(); + let trackedPosition = currPosition; + let distance = Number.MAX_VALUE; + const currentActions = [...codeActionSet.validActions]; + + for (const marker of allMarkers) { + const col = marker.endColumn; + const row = marker.endLineNumber; + const startRow = marker.startLineNumber; + + // Found quickfix on the same line and check relative distance to other markers + if ((row === currPosition.lineNumber || startRow === currPosition.lineNumber)) { + trackedPosition = new Position(row, col); + const newCodeActionTrigger: CodeActionTrigger = { + type: trigger.trigger.type, + triggerAction: trigger.trigger.triggerAction, + filter: { include: trigger.trigger.filter?.include ? trigger.trigger.filter?.include : CodeActionKind.QuickFix }, + autoApply: trigger.trigger.autoApply, + context: { notAvailableMessage: trigger.trigger.context?.notAvailableMessage || '', position: trackedPosition } + }; + + const selectionAsPosition = new Selection(trackedPosition.lineNumber, trackedPosition.column, trackedPosition.lineNumber, trackedPosition.column); + const actionsAtMarker = codeActionSets.add(await getCodeActions(this._registry, model, selectionAsPosition, newCodeActionTrigger, Progress.None, token)); + if (token.isCancellationRequested) { + return emptyCodeActionSet; + } - if (actionsAtMarker.validActions.length !== 0) { - for (const action of actionsAtMarker.validActions) { - if (action.action.command?.arguments?.some(arg => typeof arg === 'string' && arg.includes(APPLY_FIX_ALL_COMMAND_ID))) { - action.action.diagnostics = [...allMarkers.filter(marker => marker.relatedInformation)]; + if (actionsAtMarker.validActions.length !== 0) { + for (const action of actionsAtMarker.validActions) { + if (action.action.command?.arguments?.some(arg => typeof arg === 'string' && arg.includes(APPLY_FIX_ALL_COMMAND_ID))) { + action.action.diagnostics = [...allMarkers.filter(marker => marker.relatedInformation)]; + } } - } - if (codeActionSet.allActions.length === 0) { - allCodeActions.push(...actionsAtMarker.allActions); - } + if (codeActionSet.allActions.length === 0) { + allCodeActions.push(...actionsAtMarker.allActions); + } - // Already filtered through to only get quickfixes, so no need to filter again. - if (Math.abs(currPosition.column - col) < distance) { - currentActions.unshift(...actionsAtMarker.validActions); - } else { - currentActions.push(...actionsAtMarker.validActions); + // Already filtered through to only get quickfixes, so no need to filter again. + if (Math.abs(currPosition.column - col) < distance) { + currentActions.unshift(...actionsAtMarker.validActions); + } else { + currentActions.push(...actionsAtMarker.validActions); + } } + distance = Math.abs(currPosition.column - col); } - distance = Math.abs(currPosition.column - col); - } - } - const filteredActions = currentActions.filter((action, index, self) => - self.findIndex((a) => a.action.title === action.action.title) === index); - - filteredActions.sort((a, b) => { - if (a.action.isPreferred && !b.action.isPreferred) { - return -1; - } else if (!a.action.isPreferred && b.action.isPreferred) { - return 1; - } else if (a.action.isAI && !b.action.isAI) { - return 1; - } else if (!a.action.isAI && b.action.isAI) { - return -1; - } else { - return 0; } - }); + const filteredActions = currentActions.filter((action, index, self) => + self.findIndex((a) => a.action.title === action.action.title) === index); + + filteredActions.sort((a, b) => { + if (a.action.isPreferred && !b.action.isPreferred) { + return -1; + } else if (!a.action.isPreferred && b.action.isPreferred) { + return 1; + } else if (a.action.isAI && !b.action.isAI) { + return 1; + } else if (!a.action.isAI && b.action.isAI) { + return -1; + } else { + return 0; + } + }); - // Only retriggers if actually found quickfix on the same line as cursor - return { validActions: filteredActions, allActions: allCodeActions, documentation: codeActionSet.documentation, hasAutoFix: codeActionSet.hasAutoFix, hasAIFix: codeActionSet.hasAIFix, allAIFixes: codeActionSet.allAIFixes, dispose: () => { this.codeActionsDisposable.value = codeActionSet; } }; + // Only retriggers if actually found quickfix on the same line as cursor + const result = this._setCodeActions({ validActions: filteredActions, allActions: allCodeActions, documentation: codeActionSet.documentation, hasAutoFix: codeActionSet.hasAutoFix, hasAIFix: codeActionSet.hasAIFix, allAIFixes: codeActionSet.allAIFixes, dispose: () => codeActionSets.dispose() }); + codeActionSetsOwnedByResult = true; + return result; + } + } + } finally { + if (!codeActionSetsOwnedByResult) { + codeActionSets.dispose(); } } } @@ -350,13 +359,19 @@ export class CodeActionModel extends Disposable { // Case for manual triggers - specifically Source Actions and Refactors if (trigger.trigger.type === CodeActionTriggerType.Invoke) { const codeActions = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token); - this.codeActionsDisposable.value = codeActions; - return codeActions; + if (token.isCancellationRequested) { + codeActions.dispose(); + return emptyCodeActionSet; + } + return this._setCodeActions(codeActions); } const codeActionSet = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token); - this.codeActionsDisposable.value = codeActionSet; - return codeActionSet; + if (token.isCancellationRequested) { + codeActionSet.dispose(); + return emptyCodeActionSet; + } + return this._setCodeActions(codeActionSet); }); if (trigger.trigger.type === CodeActionTriggerType.Invoke) { @@ -390,6 +405,22 @@ export class CodeActionModel extends Disposable { } } + private _setCodeActions(actions: CodeActionSet): CodeActionSet { + this.codeActionsDisposable.value = actions; + return actions; + } + + /** + * Transfers ownership of the current code actions to the caller. + */ + public takeCodeActions(actions: CodeActionSet): CodeActionSet | undefined { + if (this.codeActionsDisposable.value !== actions) { + return undefined; + } + this.codeActionsDisposable.clearAndLeak(); + return actions; + } + public trigger(trigger: CodeActionTrigger) { this._codeActionOracle.value?.trigger(trigger); this.codeActionsDisposable.clear(); diff --git a/src/vs/editor/contrib/codeAction/test/browser/codeActionModel.test.ts b/src/vs/editor/contrib/codeAction/test/browser/codeActionModel.test.ts index 4f72102ae3749a..7c4b2445bec4b1 100644 --- a/src/vs/editor/contrib/codeAction/test/browser/codeActionModel.test.ts +++ b/src/vs/editor/contrib/codeAction/test/browser/codeActionModel.test.ts @@ -9,6 +9,7 @@ import { assertType } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { MarkerService } from '../../../../../platform/markers/common/markerService.js'; import { ICodeEditor } from '../../../../browser/editorBrowser.js'; @@ -199,4 +200,70 @@ suite('CodeActionModel', () => { editor.setModel(null); assert.strictEqual(disposeCount, 1); }); + + async function assertManualActionsSurviveCanceledAutomaticRequest(claimBeforeAutomaticResultCompletes: boolean): Promise { + const { promise: automaticResult, resolve: resolveAutomaticResult } = promiseWithResolvers(); + let provideCount = 0; + let manualDisposeCount = 0; + store.add(registry.register(languageId, { + provideCodeActions(_model, _range, context): languages.ProviderResult { + if (provideCount++ === 0) { + return automaticResult; + } + assert.strictEqual(context.trigger, languages.CodeActionTriggerType.Invoke); + return { + actions: [{ + title: 'test', + kind: 'quickfix', + command: { id: 'test-command', title: 'test', arguments: [{ value: true }] } + }], + dispose() { + manualDisposeCount++; + } + }; + } + })); + + const contextKeys = new MockContextKeyService(); + const configurationService = new TestConfigurationService({ 'editor.codeActionWidget.includeNearbyQuickFixes': true }); + const codeActionModel = store.add(new CodeActionModel(editor, registry, markerService, contextKeys, undefined, configurationService)); + const { promise, resolve } = promiseWithResolvers(); + store.add(codeActionModel.onDidChangeState(state => { + if (state.type === CodeActionsState.Type.Triggered && state.trigger.type === languages.CodeActionTriggerType.Invoke) { + resolve(state); + } + })); + + codeActionModel.trigger({ + type: languages.CodeActionTriggerType.Invoke, + triggerAction: CodeActionTriggerSource.QuickFix, + }); + const manualActionSet = await (await promise).actions; + let manualActions = claimBeforeAutomaticResultCompletes ? codeActionModel.takeCodeActions(manualActionSet) : undefined; + + resolveAutomaticResult({ actions: [], dispose() { } }); + await new Promise(resolve => setTimeout(resolve, 0)); + manualActions ??= codeActionModel.takeCodeActions(manualActionSet); + const wasClaimed = !!manualActions; + const disposeCountBeforeRelease = manualDisposeCount; + manualActions?.dispose(); + + assert.deepStrictEqual({ + wasClaimed, + disposeCountBeforeRelease, + disposeCountAfterRelease: manualDisposeCount, + }, { + wasClaimed: true, + disposeCountBeforeRelease: 0, + disposeCountAfterRelease: 1, + }); + } + + test('does not dispose claimed manual code actions when a canceled automatic request completes', async () => { + await assertManualActionsSurviveCanceledAutomaticRequest(true); + }); + + test('does not replace manual code actions when a canceled automatic request completes before claim', async () => { + await assertManualActionsSurviveCanceledAutomaticRequest(false); + }); }); diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 1e6d5492b5c4fc..ce5eadb7faf9cf 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -228,7 +228,7 @@ Claude and Codex each use one memoized initial path: resolve/download the SDK, e If a provider cannot enumerate yet, its initial discovery attempt emits nothing; once ready, it emits the resulting chats through `onDidDiscoverChats`. Registry provenance is projected into `IAgentSessionMetadata._meta` with `readSessionExternal` / `withSessionExternal`, and the normal AHP listSessions round trip carries it to the Sessions provider. There is no external-specific UI behavior. -`listSessions()` coalesces concurrent computations per external-sessions mode, so the burst of calls a multi-window restore produces shares one registry traversal instead of one per window. The shared entry records the registry epoch it started at and is invalidated by every registry mutation. A computation whose epoch changes restarts against the new registry, so both existing and later callers receive a complete post-mutation snapshot; each caller receives its own array. +`listSessions()` coalesces concurrent computations per external-sessions mode, so the burst of calls a multi-window restore produces shares one registry traversal instead of one per window. The shared entry records the registry epoch it started at and is invalidated by every registry mutation, so a caller arriving after a mutation starts a fresh pass rather than joining a possibly pre-mutation one; each caller receives its own array. Legacy registry migration uses the `listChatsToMigrate()` contract. An array is authoritative even when empty. `undefined` means the catalog is unavailable and must not advance migration markers; Agent Service retries an unavailable registration-time catalog once before listing, and persistent unavailability rejects aggregate `listSessions()` with a typed provider-catalog error so clients preserve their last successful snapshots. `AgentChatMigrationDeferred` means the catalog cannot be enumerated until an external readiness action, such as downloading an optional SDK: it does not advance the provider marker and does not block healthy providers' aggregate listing. A provider's later discovery signal force-retries its migration partition before additively registering the signal's unknown/external entries, and a subsequent list refresh can retry a still-deferred provider. `BaseAgentHostSessionsProvider` retries failures with exponential backoff; `AgentHostSessionListStore` leaves its cache invalid and retries on the next controller, lifecycle, or workspace refresh trigger. Replacement retry ownership is compare-and-swap single-flight: overlapping list computations that observed the same failed attempt await the first caller's installed retry rather than queueing another provider enumeration. Successful providers retain their completed migration state when a sibling provider is unavailable. diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 169c1511b12a0d..2f38b51ceec75d 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -50,15 +50,11 @@ import type { Implementation, InitializeResult } from '../common/state/protocol/ import { observableValue, type IObservable } from '../../../base/common/observable.js'; import { isFileResourceRead } from '../common/resourceReadLogging.js'; import { ResourceSet } from '../../../base/common/map.js'; +import { computeReconnectDelay, DEFAULT_RECONNECT_POLICY, hasExhaustedReconnectAttempts, type IRemoteAgentHostReconnectPolicy } from '../common/reconnectPolicy.js'; +import type { IRemoteAgentHostProtocolClient } from '../common/remoteAgentHostService.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; -/** Initial delay before the first transport-level reconnect attempt. */ -const RECONNECT_INITIAL_DELAY_MS = 1_000; - -/** Upper bound on the exponential backoff between reconnect attempts. */ -const RECONNECT_MAX_DELAY_MS = 30_000; - /** * After this much inbound silence, send an application-level `ping` to * the remote so we have something to time out on. Reset on every received @@ -164,6 +160,18 @@ type ClientState = | { readonly kind: AgentHostClientState.Reconnecting; readonly reconnect: IReconnectState } | { readonly kind: AgentHostClientState.Closed; readonly error: ProtocolError }; +/** Optional configuration for an {@link AgentHostProtocolClient}. */ +export interface IAgentHostProtocolClientOptions { + /** Estimator used to suppress watchdog closes while the local event loop is saturated. Defaults to the shared instance. */ + readonly loadEstimator?: ILoadEstimator; + /** Stable client identity. Generated when omitted. */ + readonly clientId?: string; + /** Client implementation metadata advertised during the handshake. */ + readonly clientInfo?: Implementation; + /** How a dropped transport is restored. Defaults to {@link DEFAULT_RECONNECT_POLICY}. */ + readonly reconnectPolicy?: IRemoteAgentHostReconnectPolicy; +} + /** * A protocol-level client for a single agent host connection. * Manages the transport, handshake, subscriptions, action dispatch, @@ -172,7 +180,7 @@ type ClientState = * Implements {@link IAgentConnection} so consumers can program against * a single interface regardless of whether the agent host is local or remote. */ -export class AgentHostProtocolClient extends Disposable implements IAgentConnection { +export class AgentHostProtocolClient extends Disposable implements IAgentConnection, IRemoteAgentHostProtocolClient { declare readonly _serviceBrand: undefined; @@ -273,6 +281,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * reconnect cycle that aborts in-flight requests. */ private readonly _loadEstimator: ILoadEstimator; + private readonly _clientInfo: Implementation | undefined; + private readonly _reconnectPolicy: IRemoteAgentHostReconnectPolicy; /** * URIs we have already granted implicit read access for on this connection. @@ -310,9 +320,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect constructor( identity: AgentHostResourceIdentity, transportOrFactory: IProtocolTransport | (() => IProtocolTransport), - loadEstimator: ILoadEstimator | undefined, - clientId: string | undefined = undefined, - private readonly _clientInfo: Implementation | undefined, + options: IAgentHostProtocolClientOptions | undefined, @ILogService private readonly _logService: ILogService, @IAgentHostResourceService private readonly _resourceService: IAgentHostResourceService, @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -321,12 +329,14 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect super(); this._resourceIdentity = identity; this._address = identity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY ? AMBIENT_AGENT_HOST_AUTHORITY : identity; - this._clientId = clientId ?? generateUuid(); + this._clientId = options?.clientId ?? generateUuid(); this._connectionAuthority = identity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY ? AMBIENT_AGENT_HOST_AUTHORITY : agentHostAuthority(identity); this.resourceUris = identity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY ? identityAgentHostResourceUriMapper : createAgentHostResourceUriMapper(this._connectionAuthority); - this._loadEstimator = loadEstimator ?? LoadEstimator.getInstance(); + this._loadEstimator = options?.loadEstimator ?? LoadEstimator.getInstance(); + this._clientInfo = options?.clientInfo; + this._reconnectPolicy = options?.reconnectPolicy ?? DEFAULT_RECONNECT_POLICY; if (typeof transportOrFactory === 'function') { this._transportFactory = transportOrFactory; @@ -499,8 +509,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect throw error; } if (error instanceof NonReconnectableTransportError) { - this._onDidFatalClose.fire(protocolError); - this._handleClose(protocolError); + this._handleFatalClose(protocolError); throw error; } if (this._state.kind === AgentHostClientState.Reconnecting) { @@ -559,6 +568,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._handleClose(connectionClosedError(this._address)); return; } + if (!this._reconnectPolicy.autoRestore) { + const error = connectionClosedError(this._address); + this._logService.info(`[RemoteAgentHostProtocol] Transport lost for ${this._address}; automatic reconnect is disabled.`); + this._handleFatalClose(error); + return; + } this._logService.info(`[RemoteAgentHostProtocol] Transport lost for ${this._address}; scheduling reconnect.`); this._transitionTo({ kind: AgentHostClientState.Reconnecting, reconnect: this._newReconnectState() }); this._cancelLivenessTimers(); @@ -586,6 +601,11 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (this._state.kind !== AgentHostClientState.Connecting || !this._transportFactory) { return false; } + if (!this._reconnectPolicy.autoRestore) { + this._logService.info(`[RemoteAgentHostProtocol] Transport lost while connecting to ${this._address}; automatic reconnect is disabled.`); + this._handleFatalClose(error); + return true; + } this._logService.info(`[RemoteAgentHostProtocol] Transport lost while connecting to ${this._address}; scheduling a fresh initialize.`); // Carry the pre-handshake outbox into the reconnect state so queued // messages are replayed once the fresh initialize succeeds. @@ -610,11 +630,11 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return false; } this._transitionTo({ kind: AgentHostClientState.Reconnecting, reconnect: this._newReconnectState() }); - this._scheduleReconnect(); + this._scheduleReconnect(true); return true; } - private _scheduleReconnect(): void { + private _scheduleReconnect(userInitiated = false): void { if (this._state.kind !== AgentHostClientState.Reconnecting || !this._transportFactory) { return; } @@ -622,8 +642,20 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (reconnect.timeoutHandle !== undefined) { return; } + if (!userInitiated && !this._reconnectPolicy.autoRestore) { + const error = connectionClosedError(this._address); + this._logService.info(`[RemoteAgentHostProtocol] Automatic reconnect to ${this._address} is disabled.`); + this._handleFatalClose(error); + return; + } + if (!userInitiated && hasExhaustedReconnectAttempts(this._reconnectPolicy, reconnect.attempt)) { + const error = new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, `Automatic reconnect gave up after ${reconnect.attempt} attempts.`); + this._logService.warn(`[RemoteAgentHostProtocol] Automatic reconnect to ${this._address} gave up after ${reconnect.attempt} attempts.`); + this._handleFatalClose(error); + return; + } const attempt = reconnect.attempt + 1; - const delay = Math.min(RECONNECT_INITIAL_DELAY_MS * Math.pow(2, attempt - 1), RECONNECT_MAX_DELAY_MS); + const delay = computeReconnectDelay(this._reconnectPolicy, attempt); this._logService.info(`[RemoteAgentHostProtocol] Reconnecting to ${this._address} in ${delay}ms (attempt ${attempt}).`); reconnect.timeoutHandle = setTimeout(() => { if (this._state.kind === AgentHostClientState.Reconnecting) { @@ -698,8 +730,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } if (err instanceof NonReconnectableTransportError) { const protocolError = new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, err.message); - this._onDidFatalClose.fire(protocolError); - this._handleClose(protocolError); + this._handleFatalClose(protocolError); return; } // Replace the gate so awaiting callers see the failure but new @@ -1530,6 +1561,11 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } } + private _handleFatalClose(error: ProtocolError): void { + this._onDidFatalClose.fire(error); + this._handleClose(error); + } + private _handleClose(error: ProtocolError): void { if (this._state.kind === AgentHostClientState.Closed) { return; diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index edb749fcac164c..a2f73cb658f4f2 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -3,19 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Service implementation that manages WebSocket connections to remote agent -// host processes. Reads WebSocket addresses from the `chat.remoteAgentHosts` -// setting and SSH connection details from storage, then maintains connections. +// Service implementation that manages remote agent host connections from +// entries supplied by registered connection factories. -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; -import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js'; +import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../base/common/observable.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILabelService } from '../../label/common/label.js'; import { ILogService } from '../../log/common/log.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; import { hasKey } from '../../../base/common/types.js'; import { AgentHostAhpJsonlLoggingSettingId, type IAgentConnection } from '../common/agentService.js'; @@ -24,14 +23,17 @@ import { RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, - SSH_ENTRY_TYPE_CONFIG, - WEBSOCKET_ENTRY_TYPE_CONFIG, getEntryTypeConfig, - parseLegacyRawEntry, - type IRawRemoteAgentHostEntry, + readWebSocketRemoteAgentHostEntries, + type IRemoteAgentHostConnectionFactory, type IRemoteAgentHostConnectionInfo, + type IRemoteAgentHostConnectOptions, + type IRemoteAgentHostCreatedConnection, type IRemoteAgentHostEntry, + type IRemoteAgentHostProtocolClient, + RemoteAgentHostEntryType, } from '../common/remoteAgentHostService.js'; +import { computeReconnectDelay, hasExhaustedReconnectAttempts } from '../common/reconnectPolicy.js'; import { AgentHostProtocolClient, AgentHostClientState } from './agentHostProtocolClient.js'; import { WebSocketClientTransport } from './webSocketClientTransport.js'; import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority, normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; @@ -39,12 +41,10 @@ import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../common/agentHostClientInfo.js'; -const SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY = 'remoteAgentHost.sshConnections'; - /** Tracks a single remote connection through its lifecycle. */ interface IConnectionEntry { readonly store: DisposableStore; - readonly client: AgentHostProtocolClient; + readonly client: IRemoteAgentHostProtocolClient; /** * Optional teardown for the shared-process tunnel that this entry's * transport is using (SSH or dev-tunnels). Tracked separately from @@ -63,34 +63,55 @@ function disposeEntry(entry: IConnectionEntry): void { entry.transportDisposable?.dispose(); } -function isRawRemoteAgentHostEntry(value: unknown): value is IRawRemoteAgentHostEntry { - if (typeof value !== 'object' || value === null) { - return false; +/** Builds WebSocket-backed protocol clients without performing their handshake. */ +class WebSocketConnectionFactory extends Disposable implements IRemoteAgentHostConnectionFactory { + readonly kind = RemoteAgentHostEntryType.WebSocket; + readonly entries: IObservable; + + constructor( + private readonly _instantiationService: IInstantiationService, + private readonly _configurationService: IConfigurationService, + private readonly _environmentService: IEnvironmentService, + private readonly _clientInfo: () => typeof editorWindowAgentHostClientInfo, + ) { + super(); + this.entries = observableFromEvent( + this, + Event.filter( + this._configurationService.onDidChangeConfiguration, + event => event.affectsConfiguration(RemoteAgentHostsSettingId), + ), + () => this._getEntries(), + ); } - const candidate = value as Partial>; - return typeof candidate.address === 'string' - && typeof candidate.name === 'string' - && (candidate.connectionToken === undefined || typeof candidate.connectionToken === 'string') - && (candidate.sshConfigHost === undefined || typeof candidate.sshConfigHost === 'string') - && (candidate.sshHostName === undefined || typeof candidate.sshHostName === 'string') - && (candidate.sshUser === undefined || typeof candidate.sshUser === 'string') - && (candidate.sshPort === undefined || typeof candidate.sshPort === 'number'); -} + createConnection(entry: IRemoteAgentHostEntry, _options: IRemoteAgentHostConnectOptions): Promise { + if (entry.connection.type !== RemoteAgentHostEntryType.WebSocket) { + throw new Error(`WebSocket factory cannot create a ${entry.connection.type} connection.`); + } -function isLegacySshRawEntry(entry: IRawRemoteAgentHostEntry): boolean { - return entry.sshConfigHost !== undefined - || entry.sshHostName !== undefined - || entry.sshUser !== undefined - || entry.sshPort !== undefined; + const address = entry.connection.address; + const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); + // The protocol client creates a transport for every soft-reconnect attempt. + const transportFactory = () => this._instantiationService.createInstance( + WebSocketClientTransport, + address, + entry.connectionToken, + ahpLoggingEnabled + ? { logsHome: this._environmentService.logsHome, connectionId: address, transport: 'websocket' } + : undefined, + ); + const connection = this._instantiationService.createInstance(AgentHostProtocolClient, address, transportFactory, { clientInfo: this._clientInfo() }); + return Promise.resolve({ connection }); + } + + private _getEntries(): IRemoteAgentHostEntry[] { + return readWebSocketRemoteAgentHostEntries(this._configurationService); + } } export class RemoteAgentHostService extends Disposable implements IRemoteAgentHostService { private static readonly ConnectionWaitTimeout = 10000; - /** Initial reconnect delay in milliseconds. */ - private static readonly ReconnectInitialDelay = 1000; - /** Maximum reconnect delay in milliseconds. */ - private static readonly ReconnectMaxDelay = 30000; /** * How long to wait for a server-upgrade trigger to be acknowledged. * The CLI awaits the binary download synchronously before responding, @@ -104,6 +125,19 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo readonly onDidChangeConnections = this._onDidChangeConnections.event; private readonly _entries = new Map(); + private readonly _connectionFactories = new Map(); + private readonly _connectionFactoriesObservable = observableValue(this, [] as readonly IRemoteAgentHostConnectionFactory[]); + private readonly _configuredEntries = derived(this, reader => { + let entries: IRemoteAgentHostEntry[] = []; + for (const factory of this._connectionFactoriesObservable.read(reader)) { + for (const entry of factory.entries.read(reader)) { + entries = this._upsertEntry(entries, entry); + } + } + return entries; + }); + /** In-flight connection attempts, keyed by normalized address. */ + private readonly _pendingConnects = new Map>(); private readonly _names = new Map(); private readonly _tokens = new Map(); /** @@ -136,25 +170,28 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo @ILogService private readonly _logService: ILogService, @ILabelService private readonly _labelService: ILabelService, @IEnvironmentService private readonly _environmentService: IEnvironmentService, - @IStorageService private readonly _storageService: IStorageService, ) { super(); - // React to setting changes + // The service creates these built-in factories, so it owns their + // lifetime too; `registerConnectionFactory` only manages registry + // membership so externally-supplied factories stay owned by their producer. + this._register(this.registerConnectionFactory(this._register(new WebSocketConnectionFactory( + this._instantiationService, + this._configurationService, + this._environmentService, + () => this.clientInfo, + )))); + this._register(autorun(reader => { + this._configuredEntries.read(reader); + this._reconcileConnections(); + })); this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { + if (e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { this._reconcileConnections(); } })); - this._register(this._storageService.onDidChangeValue(StorageScope.APPLICATION, SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, this._store)(() => { - this._reconcileConnections(); - this._onDidChangeConnections.fire(); - })); - - this._migrateSSHEntriesFromSetting(); - // Initial connection - this._reconcileConnections(); } private _entryAddress(entry: IRemoteAgentHostEntry): string { @@ -188,7 +225,22 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } get configuredEntries(): readonly IRemoteAgentHostEntry[] { - return this._getConfiguredEntries().map(entry => this._normalizeEntry(entry)); + return this._configuredEntries.get().map(entry => this._normalizeEntry(entry)); + } + + registerConnectionFactory(factory: IRemoteAgentHostConnectionFactory): IDisposable { + if (this._connectionFactories.has(factory.kind)) { + throw new Error(`A remote agent host connection factory is already registered for ${factory.kind}.`); + } + + this._connectionFactories.set(factory.kind, factory); + this._connectionFactoriesObservable.set([...this._connectionFactories.values()], undefined); + return toDisposable(() => { + if (this._connectionFactories.get(factory.kind) === factory) { + this._connectionFactories.delete(factory.kind); + this._connectionFactoriesObservable.set([...this._connectionFactories.values()], undefined); + } + }); } getConnection(address: string): IAgentConnection | undefined { @@ -242,18 +294,20 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo return result; } - reconnect(address: string): void { + reconnect(address: string, userInitiated = true): void { const normalized = normalizeRemoteAgentHostAddress(address); - // SSH/tunnel entries are reconnected by their respective services - const configuredEntry = this._getConfiguredEntries().find( + const configuredEntry = this._configuredEntries.get().find( entry => this._entryAddress(entry) === normalized ); - if (configuredEntry && !getEntryTypeConfig(configuredEntry.connection.type).selfConnecting) { + if (!configuredEntry || !getEntryTypeConfig(configuredEntry.connection.type).dialableByService) { return; } - const token = this._tokens.get(normalized); + const entryToReconnect = { + ...configuredEntry, + connectionToken: this._tokens.get(normalized) ?? configuredEntry.connectionToken, + }; // Cancel any pending reconnect this._cancelReconnect(normalized); @@ -263,53 +317,55 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo const entry = this._entries.get(normalized); if (entry) { this._entries.delete(normalized); + // SSH reconnects replace the relay in the shared process using the + // same connection id. Disposing its previous transport here would + // race that replacement and disconnect the fresh relay. The SSH + // factory transfers teardown ownership to the new entry. entry.store.dispose(); + if (configuredEntry.connection.type !== RemoteAgentHostEntryType.SSH) { + entry.transportDisposable?.dispose(); + } } // Start fresh connection attempt - this._connectTo(normalized, token); + void this._connectTo(entryToReconnect, { userInitiated }); } - async addRemoteAgentHost(input: IRemoteAgentHostEntry): Promise { + async waitForConnection(address: string): Promise { if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { throw new Error('Remote agent host connections are not enabled.'); } - const entry = this._normalizeEntry(input); - const address = this._entryAddress(entry); - const existingConnection = this._getConnectionInfo(address); - const config = getEntryTypeConfig(entry.connection.type); - if (config.store !== 'runtime') { - await this._storeConfiguredEntries(this._upsertEntry(this._getConfiguredEntries(true), entry)); - } - + const normalizedAddress = normalizeRemoteAgentHostAddress(address); + const existingConnection = this._getConnectionInfo(normalizedAddress); if (existingConnection) { - return { - ...existingConnection, - name: entry.name, - }; - } - - if (!config.selfConnecting) { - return { - address, - name: entry.name, - clientId: '', - status: RemoteAgentHostConnectionStatus.disconnected, - }; - } - - const connectedConnection = this._getConnectionInfo(address); - if (connectedConnection) { - return connectedConnection; + return existingConnection; + } + + const wait = this._getOrCreateConnectionWait(normalizedAddress); + + // Follow an in-flight dial rather than a wall clock. Establishment cost + // varies by kind — an SSH host may install the remote CLI first, taking + // far longer than a WebSocket dial — and a timeout here would report + // failure while that attempt keeps running and later succeeds. The + // timeout only guards the case where nothing is in flight to follow. + const pendingConnect = this._pendingConnects.get(normalizedAddress); + if (pendingConnect) { + await pendingConnect; + const connected = this._getConnectionInfo(normalizedAddress); + if (connected) { + return connected; + } + // The dial finished without producing a usable connection: surface + // the reason it recorded rather than waiting out the timeout. + return wait.p; } - const wait = this._getOrCreateConnectionWait(address); const connection = await raceTimeout(wait.p, RemoteAgentHostService.ConnectionWaitTimeout, () => { - this._pendingConnectionWaits.delete(address); + this._pendingConnectionWaits.delete(normalizedAddress); }); if (!connection) { - throw new Error(`Timed out connecting to ${address}`); + throw new Error(`Timed out connecting to ${normalizedAddress}`); } return connection; @@ -359,10 +415,27 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } })); - const config = getEntryTypeConfig(entry.connection.type); - if (config.store !== 'runtime') { - await this._storeConfiguredEntries(this._upsertEntry(this._getConfiguredEntries(true), entry)); - } + store.add(protocolClient.onDidChangeConnectionState(state => { + if (this._entries.get(address) !== connEntry) { + return; + } + switch (state) { + case AgentHostClientState.Reconnecting: + connEntry.connected = false; + connEntry.status = RemoteAgentHostConnectionStatus.reconnecting; + this._onDidChangeConnections.fire(); + break; + case AgentHostClientState.Connected: + connEntry.connected = true; + connEntry.status = RemoteAgentHostConnectionStatus.connected; + this._onDidChangeConnections.fire(); + break; + case AgentHostClientState.Connecting: + case AgentHostClientState.Incompatible: + case AgentHostClientState.Closed: + break; + } + })); this._onDidChangeConnections.fire(); @@ -377,15 +450,6 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo async removeRemoteAgentHost(address: string): Promise { const normalized = normalizeRemoteAgentHostAddress(address); - const entry = this._registeredEntries.get(normalized) ?? this._getConfiguredEntries().find(entry => this._entryAddress(entry) === normalized); - if (entry) { - const config = getEntryTypeConfig(entry.connection.type); - if (config.store !== 'runtime') { - const entries = this._getConfiguredEntries(true).filter(entry => this._entryAddress(entry) !== normalized); - await this._storeConfiguredEntries(entries); - } - } - // Eagerly clear in-memory state so the UI updates immediately // (the config change listener will reconcile, but this is instant). this._names.delete(normalized); @@ -420,6 +484,13 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } private _reconcileConnections(): void { + // Disposing a factory invalidates its `entries` observable, which can + // re-enter this autorun while the service is tearing down. Reconciling + // then would re-register state the dispose path has already cleared. + if (this._store.isDisposed) { + return; + } + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { // Disconnect all when disabled for (const address of [...this._entries.keys()]) { @@ -441,7 +512,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo return; } - const configuredEntries = this._getConfiguredEntries(); + const configuredEntries = this._configuredEntries.get(); const entriesWithAddress = configuredEntries.map(entry => ({ entry, address: this._entryAddress(entry) })); const desired = new Set(entriesWithAddress.map(e => e.address)); @@ -489,8 +560,9 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // Add entries that this service owns. for (const { entry, address } of entriesWithAddress) { - if (!this._entries.has(address) && getEntryTypeConfig(entry.connection.type).selfConnecting) { - this._connectTo(address, entry.connectionToken); + // This gate becomes redundant once every entry type has a registered factory. + if (!this._entries.has(address) && !this._pendingConnects.has(address) && getEntryTypeConfig(entry.connection.type).dialableByService) { + void this._connectTo(entry, { userInitiated: false }); } } @@ -500,35 +572,82 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } } - private _connectTo(address: string, connectionToken?: string): void { + private _connectTo(entryToConnect: IRemoteAgentHostEntry, options: IRemoteAgentHostConnectOptions): Promise { + const entryToCreate = this._normalizeEntry(entryToConnect); + const address = this._entryAddress(entryToCreate); + const existingPendingConnect = this._pendingConnects.get(address); + if (existingPendingConnect) { + return existingPendingConnect; + } + + const pendingConnect = new DeferredPromise(); + this._pendingConnects.set(address, pendingConnect.p); + void (async () => { + try { + await this._createAndConnect(entryToCreate, address, options); + } catch (err) { + this._logService.error(`[RemoteAgentHost] Unexpected error connecting to ${address}`, err); + } finally { + if (this._pendingConnects.get(address) === pendingConnect.p) { + this._pendingConnects.delete(address); + } + void pendingConnect.complete(); + } + })(); + return pendingConnect.p; + } + + private async _createAndConnect(entryToCreate: IRemoteAgentHostEntry, address: string, options: IRemoteAgentHostConnectOptions): Promise { if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { return; } + const factory = this._connectionFactories.get(entryToCreate.connection.type); + if (!factory) { + this._logService.error(`[RemoteAgentHost] No connection factory registered for ${entryToCreate.connection.type} at ${address}`); + return; + } + // Dispose any existing entry for this address before creating a new one // to avoid leaking disposables on reconnect. const existingEntry = this._entries.get(address); if (existingEntry) { this._entries.delete(address); - existingEntry.store.dispose(); + disposeEntry(existingEntry); + } + + let createdConnection: IRemoteAgentHostCreatedConnection; + try { + createdConnection = await factory.createConnection(entryToCreate, options); + } catch (err) { + this._logService.error(`[RemoteAgentHost] Failed to create a connection to ${address}. Verify address and connectionToken`, err); + this._rejectPendingConnectionWait(address, err); + if (!this._store.isDisposed && this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + this._scheduleReconnect(address, entryToCreate.connectionToken); + } + return; + } + + if ( + this._store.isDisposed + || !this._configurationService.getValue(RemoteAgentHostsEnabledSettingId) + || !this._configuredEntries.get().some(entry => this._entryAddress(entry) === address) + || this._entries.has(address) + ) { + createdConnection.connection.dispose(); + createdConnection.transportDisposable?.dispose(); + return; } const store = new DisposableStore(); - const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); - // Factory so the protocol client can replace the underlying transport - // across transient drops and use the `reconnect` RPC to resume — see - // {@link AgentHostProtocolClient}. The store owns only the client; - // individual transports are owned by the client itself. - const transportFactory = () => this._instantiationService.createInstance( - WebSocketClientTransport, - address, - connectionToken, - ahpLoggingEnabled - ? { logsHome: this._environmentService.logsHome, connectionId: address, transport: 'websocket' } - : undefined, - ); - const client = store.add(this._instantiationService.createInstance(AgentHostProtocolClient, address, transportFactory, undefined, undefined, this.clientInfo)); - const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.connecting }; + const client = store.add(createdConnection.connection); + const entry: IConnectionEntry = { + store, + client, + transportDisposable: createdConnection.transportDisposable, + connected: false, + status: RemoteAgentHostConnectionStatus.connecting, + }; this._entries.set(address, entry); // Guard against stale callbacks: only act if the @@ -547,38 +666,46 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // the "fatal" path — the protocol client already gave up its own // soft-reconnect attempts (or it was never enabled), so we rebuild // from scratch. - this._scheduleReconnect(address, connectionToken); + this._scheduleReconnect(address, entryToCreate.connectionToken); })); - // Reflect transient transport drops as `connecting` status (rather - // than `disconnected`) so the UI doesn't flicker session lists into - // an empty state during a soft reconnect. + // Surface self-healing transport drops separately so outer reconnect + // loops do not replace the protocol client while it restores itself. store.add(client.onDidChangeConnectionState(state => { if (!isCurrentEntry()) { return; } switch (state) { - case AgentHostClientState.Reconnecting: + case 'reconnecting': entry.connected = false; - entry.status = RemoteAgentHostConnectionStatus.connecting; + entry.status = RemoteAgentHostConnectionStatus.reconnecting; this._onDidChangeConnections.fire(); break; - case AgentHostClientState.Connected: + case 'connected': entry.connected = true; entry.status = RemoteAgentHostConnectionStatus.connected; + // A soft reconnect that restores the transport settles any + // wait started before the drop, which would otherwise sit + // until its timeout even though the host is reachable again. + this._reconnectAttempts.delete(address); + this._resolvePendingConnectionWait(address); this._onDidChangeConnections.fire(); break; - case AgentHostClientState.Connecting: - case AgentHostClientState.Incompatible: - case AgentHostClientState.Closed: + case 'connecting': + case 'incompatible': + case 'closed': break; } })); - this._logService.info(`[RemoteAgentHost] Connecting to ${address}`); - this._onDidChangeConnections.fire(); - client.connect().then(() => { - if (store.isDisposed) { + try { + this._logService.info(`[RemoteAgentHost] Connecting to ${address}`); + this._onDidChangeConnections.fire(); + await client.connect(); + if (store.isDisposed || !isCurrentEntry()) { + if (!store.isDisposed) { + disposeEntry(entry); + } return; // removed before connect resolved } this._logService.info(`[RemoteAgentHost] Connected to ${address}`); @@ -587,8 +714,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo this._reconnectAttempts.delete(address); this._resolvePendingConnectionWait(address); this._onDidChangeConnections.fire(); - }).catch((err: unknown) => { + } catch (err) { if (!isCurrentEntry()) { + if (!store.isDisposed) { + disposeEntry(entry); + } return; } @@ -610,15 +740,26 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } this._logService.error(`[RemoteAgentHost] Failed to connect to ${address}. Verify address and connectionToken`, err); + + // A transport that dropped mid-handshake leaves the protocol client + // in `reconnecting` with its own retry already scheduled, and only + // then rejects this promise. Tearing the entry down here would + // cancel that retry and lose the client's replay state, so let it + // restore itself instead of rebuilding from scratch. + if (RemoteAgentHostConnectionStatus.isReconnecting(entry.status)) { + this._logService.info(`[RemoteAgentHost] Handshake with ${address} was interrupted; the protocol client is restoring the connection`); + return; + } + entry.status = RemoteAgentHostConnectionStatus.disconnected; // Clean up the failed entry this._entries.delete(address); - entry.store.dispose(); + disposeEntry(entry); this._rejectPendingConnectionWait(address, err); this._onDidChangeConnections.fire(); // Schedule reconnect if the address is still configured - this._scheduleReconnect(address, connectionToken); - }); + this._scheduleReconnect(address, entryToCreate.connectionToken); + } } /** @@ -626,26 +767,43 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo * Only reconnects if the address is still in the configured entries. */ private _scheduleReconnect(address: string, connectionToken?: string): void { - // Don't reconnect if the address was removed from settings - if (!this._getConfiguredEntries().some(entry => this._entryAddress(entry) === address)) { + // Don't reconnect if the address was removed from settings. + const configuredEntry = this._configuredEntries.get().find(entry => this._entryAddress(entry) === address); + if (!configuredEntry) { this._logService.info(`[RemoteAgentHost] Not reconnecting to ${address}: no longer configured`); return; } - const attempt = (this._reconnectAttempts.get(address) ?? 0) + 1; + const reconnectPolicy = getEntryTypeConfig(configuredEntry.connection.type).reconnect; + if (!reconnectPolicy.autoRestore) { + this._logService.info(`[RemoteAgentHost] Not reconnecting to ${address}: automatic restore is disabled`); + return; + } + + // Check the recorded count before adding this attempt, so a policy of + // `maxAttempts: n` actually performs n attempts rather than n - 1. + const previousAttempts = this._reconnectAttempts.get(address) ?? 0; + if (hasExhaustedReconnectAttempts(reconnectPolicy, previousAttempts)) { + this._logService.warn(`[RemoteAgentHost] Stopped reconnecting to ${address}: reached attempt limit (${previousAttempts})`); + return; + } + + const attempt = previousAttempts + 1; this._reconnectAttempts.set(address, attempt); - const delay = Math.min( - RemoteAgentHostService.ReconnectInitialDelay * Math.pow(2, attempt - 1), - RemoteAgentHostService.ReconnectMaxDelay, - ); + + const delay = computeReconnectDelay(reconnectPolicy, attempt); this._logService.info(`[RemoteAgentHost] Scheduling reconnect to ${address} in ${delay}ms (attempt ${attempt})`); this._cancelReconnect(address); const timeout = setTimeout(() => { this._reconnectTimeouts.delete(address); - if (this._getConfiguredEntries().some(entry => this._entryAddress(entry) === address)) { - this._connectTo(address, connectionToken ?? this._tokens.get(address)); + const currentEntry = this._configuredEntries.get().find(entry => this._entryAddress(entry) === address); + if (currentEntry) { + void this._connectTo({ + ...currentEntry, + connectionToken: connectionToken ?? this._tokens.get(address) ?? currentEntry.connectionToken, + }, { userInitiated: false }); } }, delay); this._reconnectTimeouts.set(address, timeout); @@ -664,17 +822,6 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo return this.connections.find(connection => connection.address === address && RemoteAgentHostConnectionStatus.isConnected(connection.status)); } - private _getConfiguredEntries(targetSettings = false): IRemoteAgentHostEntry[] { - let entries = this._getSettings(targetSettings).entries - .filter(isRawRemoteAgentHostEntry) - .filter(entry => !isLegacySshRawEntry(entry)) - .map(entry => WEBSOCKET_ENTRY_TYPE_CONFIG.fromRaw(entry)); - for (const entry of this._getStoredSSHEntries()) { - entries = this._upsertEntry(entries, entry); - } - return entries; - } - private _upsertEntry(entries: readonly IRemoteAgentHostEntry[], entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] { const address = this._entryAddress(entry); const existingIndex = entries.findIndex(candidate => this._entryAddress(candidate) === address); @@ -683,104 +830,6 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo : entries.map((candidate, index) => index === existingIndex ? entry : candidate); } - private _getSettings(targetOnly = false): { readonly target: ConfigurationTarget; readonly entries: readonly IRawRemoteAgentHostEntry[] } { - const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); - const target = inspected.userLocalValue !== undefined - ? ConfigurationTarget.USER_LOCAL - : inspected.userRemoteValue !== undefined - ? ConfigurationTarget.USER_REMOTE - : ConfigurationTarget.USER; - return { - target, - entries: !targetOnly - ? this._configurationService.getValue(RemoteAgentHostsSettingId) ?? [] - : target === ConfigurationTarget.USER_LOCAL - ? inspected.userLocalValue ?? [] - : target === ConfigurationTarget.USER_REMOTE - ? inspected.userRemoteValue ?? [] - : inspected.userValue ?? [], - }; - } - - /** - * Writes both durable projections of `entries`, which must be the full - * merged set. Entries are keyed globally by normalized address, so a - * replacement can move an address between stores; writing only the - * destination would leave the source row behind for - * {@link _getConfiguredEntries} to resurrect. Each store is left - * untouched when its projection is unchanged. - */ - private async _storeConfiguredEntries(entries: readonly IRemoteAgentHostEntry[]): Promise { - const settingsRaw: IRawRemoteAgentHostEntry[] = []; - const storageRaw: IRawRemoteAgentHostEntry[] = []; - for (const entry of entries) { - const config = getEntryTypeConfig(entry.connection.type); - if (config.store === 'runtime') { - continue; - } - (config.store === 'storage' ? storageRaw : settingsRaw).push(config.toRaw(entry, entry.connection)); - } - - this._storeStoredSSHEntries(storageRaw); - - const settings = this._getSettings(true); - if (JSON.stringify(settings.entries) !== JSON.stringify(settingsRaw)) { - await this._configurationService.updateValue(RemoteAgentHostsSettingId, settingsRaw, settings.target); - } - } - - private _getStoredSSHEntries(): IRemoteAgentHostEntry[] { - const raw = this._storageService.get(SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, StorageScope.APPLICATION); - if (!raw) { - return []; - } - try { - const parsed: unknown = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed.filter(isRawRemoteAgentHostEntry).filter(isLegacySshRawEntry).map(entry => SSH_ENTRY_TYPE_CONFIG.fromRaw(entry)) - : []; - } catch { - return []; - } - } - - private _storeStoredSSHEntries(entries: readonly IRawRemoteAgentHostEntry[]): void { - const raw = JSON.stringify(entries); - const stored = this._storageService.get(SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, StorageScope.APPLICATION); - if (stored === raw) { - return; - } - if (entries.length === 0) { - if (stored !== undefined) { - this._storageService.remove(SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, StorageScope.APPLICATION); - } - return; - } - this._storageService.store(SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, raw, StorageScope.APPLICATION, StorageTarget.USER); - } - - private _migrateSSHEntriesFromSetting(): void { - const settings = this._getSettings(true); - const legacyEntries = settings.entries - .filter(isRawRemoteAgentHostEntry) - .map(parseLegacyRawEntry); - const sshEntries = legacyEntries.filter(entry => getEntryTypeConfig(entry.connection.type).store === 'storage'); - if (sshEntries.length === 0) { - return; - } - - let migratedEntries = this._getStoredSSHEntries(); - for (const entry of sshEntries) { - migratedEntries = this._upsertEntry(migratedEntries, entry); - } - const settingsEntries = legacyEntries.filter(entry => getEntryTypeConfig(entry.connection.type).store === 'settings'); - // One write of the whole merged set: SSH entries land in storage and - // are dropped from settings in the same pass. - this._storeConfiguredEntries([...migratedEntries, ...settingsEntries]).catch(err => { - this._logService.error('[RemoteAgentHost] Failed to migrate SSH connection details from settings to storage', err); - }); - } - private _getOrCreateConnectionWait(address: string): DeferredPromise { let wait = this._pendingConnectionWaits.get(address); if (wait) { @@ -797,6 +846,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } wait = new DeferredPromise(); + // Always-attached handler so a rejection with no awaiter (e.g. the + // service being disposed while a background dial is in flight) is not + // reported as an unhandled rejection. Real consumers await `wait.p` + // and handle their own failures. + wait.p.then(undefined, () => { /* swallow — each real consumer handles its own await */ }); this._pendingConnectionWaits.set(address, wait); return wait; } @@ -856,6 +910,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } this._reconnectTimeouts.clear(); this._reconnectAttempts.clear(); + this._pendingConnects.clear(); for (const [address, wait] of this._pendingConnectionWaits) { void wait.error(new Error(`Remote agent host service disposed before connecting to ${address}`)); } @@ -884,8 +939,7 @@ export class AgentsWindowRemoteAgentHostService extends RemoteAgentHostService { @ILogService logService: ILogService, @ILabelService labelService: ILabelService, @IEnvironmentService environmentService: IEnvironmentService, - @IStorageService storageService: IStorageService, ) { - super(configurationService, instantiationService, logService, labelService, environmentService, storageService); + super(configurationService, instantiationService, logService, labelService, environmentService); } } diff --git a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts index b697fa4c0b45ec..0d4056c87cf409 100644 --- a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts @@ -16,14 +16,16 @@ export const IAgentHostChatContributions = createDecorator; /** @@ -271,7 +313,8 @@ export interface IAgentHostChatContributions extends IDisposable { /** Returns the currently registered host, if AgentSideEffects has been constructed. */ getHost(): IAgentHostChatContributionHost | undefined; turnEnd(turn: ITurnEnd): void; - action(action: IObservedAction): void; + didApplyClientAction(action: IAppliedClientAction): void; + didDispatchAction(dispatched: IDispatchedAction): void; outgoingTurn(turn: IOutgoingTurn): Promise; incomingRequest(request: IIncomingRequest): IncomingRequestDisposition; hydrateTurns(context: IHydrationContext, turns: readonly Turn[]): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostManagedSettings.ts b/src/vs/platform/agentHost/common/agentHostManagedSettings.ts index 989bcb1cecff72..ab5389b6a107a1 100644 --- a/src/vs/platform/agentHost/common/agentHostManagedSettings.ts +++ b/src/vs/platform/agentHost/common/agentHostManagedSettings.ts @@ -26,6 +26,18 @@ * they cannot — a regular-expression terminal rule, an allow list that blocks * what it omits — the restriction is skipped rather than approximated, since a * near-miss silently changes what an administrator configured. + * - **One deliberate exception, erring more restrictive.** + * `chat.tools.eligibleForAutoApproval` marks individual tools ineligible for + * auto-approval, but the SDK grammar has no tool-name family ({@link + * ManagedRuleFamily} is only `Shell`, `Read`, `Write`, and `Domain`, and an + * unknown family rejects the whole document). Rather than skip a genuine + * restriction, a policy that marks any tool ineligible maps onto the + * family-agnostic bypass lock — coarser and more restrictive, since the lock + * is session-wide and does not itself force per-request confirmation (only an + * `ask` rule sets `managedApprovalRequired`). A policy that leaves every tool + * eligible (all `true`, or empty) expresses no restriction and contributes + * nothing. Replace this with a per-tool translation once the grammar gains a + * tool-name family. * - **Copilot sessions on a local host.** The renderer sends an empty * contribution to remote hosts, and other agents do not consume managed * settings, so restrictions bridged here do not reach them. Those agents @@ -41,7 +53,7 @@ import { AgentNetworkDomainSettingId } from '../../networkFilter/common/settings import { extractDomainPattern, normalizeDomain } from '../../networkFilter/common/domainMatcher.js'; import { buildManagedFamilyRule, buildManagedRule, ManagedRuleFamily } from './agentHostManagedRules.js'; import { getGlobalConfigurationValue, inspectValue } from './agentHostConfigurationSync.js'; -import { GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, type AgentHostTerminalAutoApproveRules, type AgentHostTerminalAutoApproveRuleValue } from './agentHostSchema.js'; +import { ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, type AgentHostTerminalAutoApproveRules, type AgentHostTerminalAutoApproveRuleValue } from './agentHostSchema.js'; /** * The restrictions this bridge contributes to the Copilot SDK. There is @@ -220,11 +232,34 @@ function isLiteralCommandKey(command: string): boolean { return !AUTO_APPROVE_REGEX_KEY.test(command) && !command.includes('*'); } +/** + * Maps `chat.tools.eligibleForAutoApproval` onto the family-agnostic bypass + * lock; the module comment explains why a per-tool translation is impossible. + * A policy that leaves every tool eligible (all `true`, or empty) expresses no + * restriction and contributes nothing; a tool marked ineligible — or a + * malformed member we fail closed on — locks the bypass session-wide. + */ +function contributeEligibleForAutoApprovalRestriction(value: Record): IAgentHostManagedSettingsPermissions | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + // A `true` entry (or the empty default) leaves a tool eligible and removes + // nothing; only a tool marked ineligible, or a malformed member we fail + // closed on, locks the bypass. + if (Object.values(value).every(eligible => eligible === true)) { + return undefined; + } + return { disableBypassPermissionsMode: 'disable' }; +} + /** Compatibility mappings for legacy settings only; new controls belong directly in the SDK. */ const managedPermissionsSettings: readonly IManagedPermissionsSettingMapping[] = [ // Disabling the SDK's bypass mode takes "Allow All" away from the user for // good, so only an administrator may drive it. managedPermissionsSetting(GLOBAL_AUTO_APPROVE_SETTING_ID, 'policyOnly', value => value === false ? { disableBypassPermissionsMode: 'disable' } : undefined), + // No SDK tool-name family exists, so a policy that marks any tool ineligible + // falls back to the bypass lock (see the transform). + managedPermissionsSetting>(ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID, 'policyOnly', contributeEligibleForAutoApprovalRestriction), // Matches VS Code, where a user or application value already suppresses // terminal auto-approval outright. managedPermissionsSetting(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, 'anyGlobal', value => value === false ? { ask: [buildManagedFamilyRule(ManagedRuleFamily.Shell)] } : undefined), diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 1cddfcefa92e7b..8cc3f0de13c064 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -435,6 +435,9 @@ export const TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID = 'chat.tools.terminal.ena /** The VS Code setting ID for global auto approve enablement. */ export const GLOBAL_AUTO_APPROVE_SETTING_ID = 'chat.tools.global.autoApprove'; +/** The VS Code setting ID for per-tool auto-approval eligibility. */ +export const ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID = 'chat.tools.eligibleForAutoApproval'; + /** * Root config key forwarded from the renderer when VS Code's * `chat.tools.global.autoApprove` setting changes. When `true`, the global diff --git a/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts b/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts index cde1704eaeb261..686761f0444d2b 100644 --- a/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts +++ b/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts @@ -71,11 +71,18 @@ export type AgentFeedbackKindValue = 'user' | 'codeReview' | 'prReview'; */ export type AgentFeedbackStateValue = 'created' | 'accepted' | 'submitted' | 'resolved'; +/** Pull request that originated a PR-review feedback item. */ +export interface IFeedbackPullRequest { + readonly owner: string; + readonly repo: string; + readonly number: number; +} + /** * Feedback semantics carried in an annotation's {@link Annotation._meta}. * * The optional client-only fields ({@link suggestion}, {@link codeSelection}, - * {@link diffHunks}, {@link sourcePRReviewCommentId}) are populated when a + * {@link diffHunks}, {@link sourcePRReviewCommentId}, {@link sourcePullRequest}) are populated when a * feedback item is converted from a code- or PR-review comment on the client; * server tools only ever write {@link kind} / {@link state} / * {@link sessionResource}. {@link suggestion} is typed loosely here because @@ -89,6 +96,7 @@ export interface IFeedbackAnnotationMeta { readonly codeSelection?: string; readonly diffHunks?: string; readonly sourcePRReviewCommentId?: string; + readonly sourcePullRequest?: IFeedbackPullRequest; /** * Transient marker set by the client when the user reveals this comment to * the agent via the `viewUnreviewedComments` tool. The marker persists until @@ -106,6 +114,14 @@ function isAgentFeedbackStateValue(value: unknown): value is AgentFeedbackStateV return value === 'created' || value === 'accepted' || value === 'submitted' || value === 'resolved'; } +function isFeedbackPullRequest(value: unknown): value is IFeedbackPullRequest { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const candidate = value as Partial; + return typeof candidate.owner === 'string' && typeof candidate.repo === 'string' && typeof candidate.number === 'number'; +} + /** * Who wrote a specific {@link AnnotationEntry} within a feedback comment. * @@ -190,6 +206,7 @@ export function readFeedbackAnnotationMeta(annotation: Annotation): IFeedbackAnn if (typeof raw['codeSelection'] === 'string') { result.codeSelection = raw['codeSelection']; } if (typeof raw['diffHunks'] === 'string') { result.diffHunks = raw['diffHunks']; } if (typeof raw['sourcePRReviewCommentId'] === 'string') { result.sourcePRReviewCommentId = raw['sourcePRReviewCommentId']; } + if (isFeedbackPullRequest(raw['sourcePullRequest'])) { result.sourcePullRequest = raw['sourcePullRequest']; } if (typeof raw['pendingAgentReveal'] === 'boolean') { result.pendingAgentReveal = raw['pendingAgentReveal']; } return result; } diff --git a/src/vs/platform/agentHost/common/reconnectPolicy.ts b/src/vs/platform/agentHost/common/reconnectPolicy.ts new file mode 100644 index 00000000000000..c74e6d0c6637d7 --- /dev/null +++ b/src/vs/platform/agentHost/common/reconnectPolicy.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** How a remote agent host kind should be re-established after its transport drops. */ +export interface IRemoteAgentHostReconnectPolicy { + /** + * Whether a dropped transport may be restored automatically, with no user intent. + * `false` means the connection is surfaced as lost and recovery waits for an explicit user action. + */ + readonly autoRestore: boolean; + /** First retry delay, in milliseconds. */ + readonly initialDelayMs: number; + /** Ceiling for exponential backoff, in milliseconds. */ + readonly maxDelayMs: number; + /** + * Consecutive failed attempts before automatic restore gives up. Exhaustion is + * terminal for the automatic path; recovery falls back to an explicit user action. + */ + readonly maxAttempts: number; +} + +/** Default automatic reconnect policy for remote agent hosts. */ +export const DEFAULT_RECONNECT_POLICY: IRemoteAgentHostReconnectPolicy = { + autoRestore: true, + initialDelayMs: 1000, + maxDelayMs: 30_000, + maxAttempts: 10, +}; + +/** + * Computes a retry delay after a failed automatic reconnect attempt. + * `attempt` is 1-based: pass 1 after the first failure, 2 after the second, and so on. + */ +export function computeReconnectDelay(policy: IRemoteAgentHostReconnectPolicy, attempt: number): number { + return Math.min(policy.initialDelayMs * Math.pow(2, attempt - 1), policy.maxDelayMs); +} + +/** + * Whether automatic reconnect has reached its failure limit. + * `attempt` is the 1-based count of consecutive failed automatic reconnect attempts. + */ +export function hasExhaustedReconnectAttempts(policy: IRemoteAgentHostReconnectPolicy, attempt: number): boolean { + return attempt >= policy.maxAttempts; +} diff --git a/src/vs/platform/agentHost/common/reconnectingTransport.ts b/src/vs/platform/agentHost/common/reconnectingTransport.ts new file mode 100644 index 00000000000000..e2f148e30fe1a5 --- /dev/null +++ b/src/vs/platform/agentHost/common/reconnectingTransport.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import type { AgentHostClientConnectionKind } from './agentHostTelemetry.js'; +import type { AhpServerNotification, JsonRpcNotification, JsonRpcParseErrorResponse, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from './state/sessionProtocol.js'; +import type { IClientTransport, IProtocolTransport } from './state/sessionTransport.js'; + +/** + * A connected transport and the optional owner-specific resource teardown for its establishment attempt. + */ +export interface IEstablishedTransport { + /** The live inner transport to route traffic through. */ + readonly transport: IProtocolTransport; + /** Tear down whatever this attempt established. Omitted when the caller owns teardown. */ + close?(): Promise; +} + +/** Owns one adopted transport and the listeners and resources associated with it. */ +class EstablishedTransportStore extends DisposableStore { + constructor(readonly transport: IProtocolTransport) { + super(); + } +} + +/** + * Establishes an inner transport on demand and forwards its protocol traffic. + */ +export class ReconnectingTransport extends Disposable implements IClientTransport { + + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + + private readonly _establishedTransport = this._register(new MutableDisposable()); + private _connectPromise: Promise | undefined; + + constructor( + private readonly _establish: () => Promise, + private readonly _logService: ILogService, + private readonly _logPrefix: string, + readonly clientConnectionKind: AgentHostClientConnectionKind | undefined = undefined, + private readonly _sendBeforeConnectWarning = 'send before the transport was established; dropping message', + ) { + super(); + } + + /** Establishes and adopts the inner transport. */ + connect(): Promise { + if (this._store.isDisposed) { + return Promise.reject(new Error('Transport is disposed')); + } + + this._connectPromise ??= this._establishTransport(); + return this._connectPromise; + } + + /** Routes a protocol message through the established inner transport. */ + send(message: ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcParseErrorResponse | JsonRpcResponse | JsonRpcRequest): void { + const establishedTransport = this._establishedTransport.value; + if (!establishedTransport) { + this._logService.warn(`${this._logPrefix} ${this._sendBeforeConnectWarning}`); + return; + } + + establishedTransport.transport.send(message); + } + + private async _establishTransport(): Promise { + // Do not catch: NonReconnectableTransportError must reach the protocol client unchanged. + const establishedTransport = await this._establish(); + if (this._store.isDisposed) { + // A late establishment needs releasing, while its caller must not proceed without a live channel. + await this._disposeEstablishedTransport(establishedTransport); + throw new Error(`${this._logPrefix} transport was disposed while establishing`); + } + + const transportStore = new EstablishedTransportStore(establishedTransport.transport); + transportStore.add(toDisposable(() => void this._disposeEstablishedTransport(establishedTransport))); + try { + transportStore.add(establishedTransport.transport.onMessage(message => this._onMessage.fire(message))); + transportStore.add(establishedTransport.transport.onClose(() => this._onClose.fire())); + this._establishedTransport.value = transportStore; + } catch (err) { + transportStore.dispose(); + throw err; + } + } + + private async _disposeEstablishedTransport(establishedTransport: IEstablishedTransport): Promise { + try { + establishedTransport.transport.dispose(); + } catch (err) { + this._logService.error(`${this._logPrefix} inner transport dispose failed`, err); + } + + try { + await establishedTransport.close?.(); + } catch (err) { + this._logService.error(`${this._logPrefix} transport close failed`, err); + } + } +} diff --git a/src/vs/platform/agentHost/common/relayTransport.ts b/src/vs/platform/agentHost/common/relayTransport.ts index 16a6a3e5b55e17..c7dac140b2a572 100644 --- a/src/vs/platform/agentHost/common/relayTransport.ts +++ b/src/vs/platform/agentHost/common/relayTransport.ts @@ -8,8 +8,10 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import { AgentHostClientConnectionKind } from './agentHostTelemetry.js'; import { AhpJsonlLogger, getAhpLogByteLength } from './ahpJsonlLogger.js'; +import { type IEstablishedTransport, ReconnectingTransport } from './reconnectingTransport.js'; import type { AhpServerNotification, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from './state/sessionProtocol.js'; import type { IProtocolTransport } from './state/sessionTransport.js'; +import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from './transportConstants.js'; /** * A message relayed from a remote agent host through a tunnel managed @@ -32,6 +34,16 @@ export interface IRelayChannel { relaySend(connectionId: string, message: string): Promise; } +/** + * Handle for a relay channel established by a {@link ReconnectingRelayTransport}. + */ +export interface IRelayConnectionHandle { + /** Identifier of the freshly established relay channel. */ + readonly connectionId: string; + /** Tear down this relay channel. Invoked when the transport is disposed. */ + close?(): Promise; +} + /** * A protocol transport that relays messages through a shared-process * tunnel via IPC, instead of using a direct WebSocket connection. @@ -48,12 +60,15 @@ export class RelayTransport extends Disposable implements IProtocolTransport { private readonly _onClose = this._register(new Emitter()); readonly onClose = this._onClose.event; + private _malformedFrames = 0; + private _closeFired = false; + constructor( - private readonly _connectionId: string, + protected _connectionId: string | undefined, private readonly _channel: IRelayChannel, private readonly _ahpLogger: AhpJsonlLogger | undefined, - private readonly _logService: ILogService, - private readonly _logPrefix: string, + protected readonly _logService: ILogService, + protected readonly _logPrefix: string, readonly clientConnectionKind: AgentHostClientConnectionKind, ) { super(); @@ -62,30 +77,90 @@ export class RelayTransport extends Disposable implements IProtocolTransport { } this._register(this._channel.onDidRelayMessage((msg: IRelayMessage) => { - if (msg.connectionId === this._connectionId) { + if (this._connectionId !== undefined && msg.connectionId === this._connectionId) { + let parsed: ProtocolMessage; try { - const parsed = JSON.parse(msg.data) as ProtocolMessage; - this._ahpLogger?.log(parsed, 's2c', getAhpLogByteLength(msg.data)); - this._onMessage.fire(parsed); - } catch { - // Malformed message — drop + parsed = JSON.parse(msg.data) as ProtocolMessage; + } catch (err) { + this._malformedFrames++; + if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { + const preview = msg.data.length > 80 ? msg.data.slice(0, 80) + '…' : msg.data; + this._logService.warn( + `${this._logPrefix} Malformed frame #${this._malformedFrames} (len=${msg.data.length}): ${preview}`, + err instanceof Error ? err.message : String(err) + ); + } + if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD && !this._closeFired) { + this._logService.warn(`${this._logPrefix} Malformed frame threshold exceeded; closing relay.`); + this._fireClose(); + } + return; } + this._ahpLogger?.log(parsed, 's2c', getAhpLogByteLength(msg.data)); + this._onMessage.fire(parsed); } })); this._register(this._channel.onDidRelayClose((closedId: string) => { - if (closedId === this._connectionId) { + if (this._connectionId !== undefined && closedId === this._connectionId) { this._logService.info(`${this._logPrefix} onDidRelayClose`); - this._onClose.fire(); + this._fireClose(); } })); } send(message: ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest): void { + const connectionId = this._connectionId; + if (connectionId === undefined) { + // No channel adopted yet (or already released). Dropping silently + // would strand the sender, so make it visible in the log. + this._logService.warn(`${this._logPrefix} send before the relay channel was established; dropping message`); + return; + } + const text = JSON.stringify(message); this._ahpLogger?.log(message, 'c2s', getAhpLogByteLength(text)); - this._channel.relaySend(this._connectionId, text).catch((err) => { + this._channel.relaySend(connectionId, text).catch((err) => { this._logService.error(`${this._logPrefix} relaySend failed`, err); }); } + + private _fireClose(): void { + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); + } + } +} + +/** + * A relay transport that establishes and owns its shared-process relay channel on connect. + */ +export class ReconnectingRelayTransport extends ReconnectingTransport { + + constructor( + establish: () => Promise, + channel: IRelayChannel, + createAhpLogger: () => AhpJsonlLogger | undefined, + logService: ILogService, + logPrefix: string, + clientConnectionKind: AgentHostClientConnectionKind, + ) { + super( + async (): Promise => { + const connectionHandle = await establish(); + // Create the logger here rather than up front: the inner transport + // is what owns and disposes it, so a logger built before `establish` + // resolves would be leaked on every failed attempt. + return { + transport: new RelayTransport(connectionHandle.connectionId, channel, createAhpLogger(), logService, logPrefix, clientConnectionKind), + close: connectionHandle.close, + }; + }, + logService, + logPrefix, + clientConnectionKind, + 'send before the relay channel was established; dropping message', + ); + } } diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index 08d1b4e43f74d5..595b29772b628d 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -5,13 +5,20 @@ import { Event } from '../../../base/common/event.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; +import type { IObservable } from '../../../base/common/observable.js'; import { connectionTokenQueryName } from '../../../base/common/network.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ConfigurationTarget, type IConfigurationService } from '../../configuration/common/configuration.js'; +import { StorageScope, StorageTarget, type IStorageService } from '../../storage/common/storage.js'; import type { IAgentConnection } from './agentService.js'; import type { UnsupportedProtocolVersionErrorData } from './state/protocol/errors.js'; import { AHP_UNSUPPORTED_PROTOCOL_VERSION, ProtocolError } from './state/sessionProtocol.js'; import { readUnsupportedProtocolVersionErrorMeta, type IVscodeUpgradeResult } from './state/protocolUpgrade.js'; import { TUNNEL_ADDRESS_PREFIX } from './tunnelAgentHost.js'; +import { DEFAULT_RECONNECT_POLICY, type IRemoteAgentHostReconnectPolicy } from './reconnectPolicy.js'; +import { normalizeRemoteAgentHostAddress } from './agentHostUri.js'; +import type { SSHAgentHostLifecycle } from './sshRemoteAgentHost.js'; +import type { AgentHostServerType } from './agentHostEndpointRegistry.js'; /** * Connection status for a remote agent host. @@ -23,6 +30,12 @@ import { TUNNEL_ADDRESS_PREFIX } from './tunnelAgentHost.js'; export type RemoteAgentHostConnectionStatus = | { readonly kind: 'connected' } | { readonly kind: 'connecting' } + /** + * The transport dropped and the protocol client is re-establishing it itself, + * preserving session state. Distinct from `connecting` (initial dial) and + * `disconnected` (no connection, nothing in flight). + */ + | { readonly kind: 'reconnecting' } | { readonly kind: 'disconnected' } | { readonly kind: 'incompatible'; @@ -46,6 +59,8 @@ export namespace RemoteAgentHostConnectionStatus { export const connected: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'connected' }); /** Singleton "connecting" status. */ export const connecting: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'connecting' }); + /** Singleton "reconnecting" status. */ + export const reconnecting: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'reconnecting' }); /** Singleton "disconnected" status. */ export const disconnected: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'disconnected' }); /** Build an "incompatible" status from a host-supplied message and the versions involved. */ @@ -60,6 +75,10 @@ export namespace RemoteAgentHostConnectionStatus { export function isConnecting(status: RemoteAgentHostConnectionStatus | undefined): boolean { return status?.kind === 'connecting'; } + /** Whether the protocol client is restoring a dropped transport. */ + export function isReconnecting(status: RemoteAgentHostConnectionStatus | undefined): boolean { + return status?.kind === 'reconnecting'; + } /** Whether the connection is in the plain disconnected state. */ export function isDisconnected(status: RemoteAgentHostConnectionStatus | undefined): boolean { return status?.kind === 'disconnected'; @@ -97,7 +116,7 @@ export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled'; /** * Configuration key that controls whether online dev tunnels and - * configured SSH remote agent hosts are auto-connected at startup. + * WSL remote agent hosts are auto-connected at startup. */ export const RemoteAgentHostAutoConnectSettingId = 'chat.remoteAgentHostsAutoConnect'; @@ -142,6 +161,14 @@ export interface IRemoteAgentHostSSHConnection { readonly user?: string; /** SSH port on the remote machine (default 22). */ readonly port?: number; + /** Server type selected for the most recent SSH connection. */ + readonly serverType?: AgentHostServerType; + /** Registry instance id selected for the most recent SSH connection. */ + readonly instanceId?: string; + /** Whether the most recently selected SSH connection was primary. */ + readonly primary?: boolean; + /** Ownership of the remote agent host selected for the most recent SSH connection. */ + readonly lifecycle?: SSHAgentHostLifecycle; } export interface IRemoteAgentHostTunnelConnection { @@ -206,6 +233,62 @@ export interface IRemoteAgentHostEntry { readonly connection: RemoteAgentHostConnection; } +/** + * Connection states surfaced by a protocol client while the service owns it. + * Mirrors the browser client's states without making this common API depend on + * a browser implementation. + */ +export type RemoteAgentHostProtocolClientState = 'connecting' | 'incompatible' | 'connected' | 'reconnecting' | 'closed'; + +/** + * The service-owned protocol-client surface needed to establish and manage a + * remote connection. Browser implementations provide this with + * `AgentHostProtocolClient`. + */ +export interface IRemoteAgentHostProtocolClient extends IAgentConnection, IDisposable { + readonly defaultDirectory: string | undefined; + readonly onDidClose: Event; + readonly onDidChangeConnectionState: Event; + connect(): Promise; + notifyTransportClosed(): void; + triggerVscodeUpgrade(method: string): Promise; +} + +/** Options describing why a connection is being built. */ +export interface IRemoteAgentHostConnectOptions { + /** + * Whether an explicit user action triggered this attempt. Background attempts + * must never open prompts, pickers or modals. + */ + readonly userInitiated: boolean; +} + +/** A built, not-yet-handshaken connection and its owned resources. */ +export interface IRemoteAgentHostCreatedConnection { + /** The client the service will handshake and own. */ + readonly connection: IRemoteAgentHostProtocolClient; + /** + * Teardown for resources the factory established alongside the client + * (e.g. a shared-process relay channel). Disposed with the connection entry. + */ + readonly transportDisposable?: IDisposable; +} + +/** Builds agent host connections of one {@link RemoteAgentHostEntryType}. */ +export interface IRemoteAgentHostConnectionFactory { + /** The entry type this factory builds. */ + readonly kind: RemoteAgentHostEntryType; + /** Entries owned by this factory. */ + readonly entries: IObservable; + /** + * Build a client bound to a transport for `entry`. + * + * Must NOT perform the protocol handshake — the service calls `connect()` + * itself so handshake outcome classification lives in exactly one place. + */ + createConnection(entry: IRemoteAgentHostEntry, options: IRemoteAgentHostConnectOptions): Promise; +} + /** Raw shape of persisted remote agent host entries. */ export interface IRawRemoteAgentHostEntry { readonly address: string; @@ -215,6 +298,41 @@ export interface IRawRemoteAgentHostEntry { readonly sshHostName?: string; readonly sshUser?: string; readonly sshPort?: number; + readonly sshServerType?: AgentHostServerType; + readonly sshInstanceId?: string; + readonly sshPrimary?: boolean; + readonly sshLifecycle?: SSHAgentHostLifecycle; +} + +/** Storage key for SSH remote agent host entries. */ +export const SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY = 'remoteAgentHost.sshConnections'; + +/** Tests whether a value has the persisted remote agent host entry shape. */ +export function isRawRemoteAgentHostEntry(value: unknown): value is IRawRemoteAgentHostEntry { + if (typeof value !== 'object' || value === null) { + return false; + } + + const candidate = value as Partial>; + return typeof candidate.address === 'string' + && typeof candidate.name === 'string' + && (candidate.connectionToken === undefined || typeof candidate.connectionToken === 'string') + && (candidate.sshConfigHost === undefined || typeof candidate.sshConfigHost === 'string') + && (candidate.sshHostName === undefined || typeof candidate.sshHostName === 'string') + && (candidate.sshUser === undefined || typeof candidate.sshUser === 'string') + && (candidate.sshPort === undefined || typeof candidate.sshPort === 'number') + && (candidate.sshServerType === undefined || candidate.sshServerType === 'editor' || candidate.sshServerType === 'standalone') + && (candidate.sshInstanceId === undefined || typeof candidate.sshInstanceId === 'string') + && (candidate.sshPrimary === undefined || typeof candidate.sshPrimary === 'boolean') + && (candidate.sshLifecycle === undefined || candidate.sshLifecycle === 'managed' || candidate.sshLifecycle === 'external'); +} + +/** Tests whether a persisted entry uses the legacy SSH-in-settings shape. */ +export function isLegacySshRawEntry(entry: IRawRemoteAgentHostEntry): boolean { + return entry.sshConfigHost !== undefined + || entry.sshHostName !== undefined + || entry.sshUser !== undefined + || entry.sshPort !== undefined; } /** Where durable copies of a remote agent host entry live. */ @@ -229,13 +347,14 @@ export type RemoteAgentHostEntryStore = 'settings' | 'storage' | 'runtime'; interface IRemoteAgentHostEntryTypeConfigBase { readonly type: TConnection['type']; /** - * Whether RemoteAgentHostService dials this entry itself. When `false`, - * an owning transport service establishes the connection and registers - * it via `addManagedConnection`. + * Whether RemoteAgentHostService can dial this entry from its address alone. + * When `false`, an owning transport service registers the connection. */ - readonly selfConnecting: boolean; + readonly dialableByService: boolean; /** Whether the address is subject to `normalizeRemoteAgentHostAddress`. */ readonly normalizedAddress: boolean; + /** Policy for restoring a dropped transport. */ + readonly reconnect: IRemoteAgentHostReconnectPolicy; /** Stable identity for the entry. */ address(connection: TConnection): string; } @@ -265,8 +384,9 @@ export type IRemoteAgentHostEntryTypeConfig = { type: RemoteAgentHostEntryType.WebSocket, store: 'settings', - selfConnecting: true, + dialableByService: true, normalizedAddress: true, + reconnect: DEFAULT_RECONNECT_POLICY, address: connection => connection.address, toRaw: (entry, connection) => ({ address: connection.address, name: entry.name, connectionToken: entry.connectionToken, @@ -277,27 +397,195 @@ export const WEBSOCKET_ENTRY_TYPE_CONFIG: IPersistedEntryTypeConfig = { type: RemoteAgentHostEntryType.SSH, store: 'storage', - selfConnecting: false, + dialableByService: true, normalizedAddress: true, + reconnect: DEFAULT_RECONNECT_POLICY, address: connection => connection.address, toRaw: (entry, connection) => ({ address: connection.address, name: entry.name, connectionToken: entry.connectionToken, sshConfigHost: connection.sshConfigHost, sshHostName: connection.hostName, sshUser: connection.user, sshPort: connection.port, + sshServerType: connection.serverType, sshInstanceId: connection.instanceId, sshPrimary: connection.primary, sshLifecycle: connection.lifecycle, }), fromRaw: raw => ({ name: raw.name, connectionToken: raw.connectionToken, - connection: { type: RemoteAgentHostEntryType.SSH, address: raw.address, sshConfigHost: raw.sshConfigHost, hostName: raw.sshHostName ?? raw.address, user: raw.sshUser, port: raw.sshPort }, + connection: { + type: RemoteAgentHostEntryType.SSH, + address: raw.address, + sshConfigHost: raw.sshConfigHost, + hostName: raw.sshHostName ?? raw.address, + user: raw.sshUser, + port: raw.sshPort, + ...(raw.sshServerType ? { serverType: raw.sshServerType } : undefined), + ...(raw.sshInstanceId ? { instanceId: raw.sshInstanceId } : undefined), + ...(raw.sshPrimary !== undefined ? { primary: raw.sshPrimary } : undefined), + ...(raw.sshLifecycle ? { lifecycle: raw.sshLifecycle } : undefined), + }, }), }; -function runtimeEntryTypeConfig(type: TConnection['type'], normalizedAddress: boolean, address: (connection: TConnection) => string): IRuntimeEntryTypeConfig { - return { type, store: 'runtime', selfConnecting: false, normalizedAddress, address }; +/** The resolved configuration target and raw remote agent host entries at that target. */ +export interface IRemoteAgentHostSettings { + readonly target: ConfigurationTarget; + readonly entries: readonly IRawRemoteAgentHostEntry[]; +} + +function entryAddressKey(entry: IRemoteAgentHostEntry): string { + const config = getEntryTypeConfig(entry.connection.type); + const address = config.address(entry.connection); + return config.normalizedAddress ? normalizeRemoteAgentHostAddress(address) : address; +} + +/** Replaces the entry sharing an address, or appends it. */ +export function upsertRemoteAgentHostEntry(entries: readonly IRemoteAgentHostEntry[], entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] { + const address = entryAddressKey(entry); + const existingIndex = entries.findIndex(candidate => entryAddressKey(candidate) === address); + return existingIndex === -1 + ? [...entries, entry] + : entries.map((candidate, index) => index === existingIndex ? entry : candidate); +} + +/** + * Serializes the entries belonging to one kind, skipping the rest. Written as a + * loop rather than `filter().map()` because a `filter` predicate does not narrow + * the {@link RemoteAgentHostConnection} union for the following `toRaw` call. + */ +export function toRawEntriesOfKind(entries: readonly IRemoteAgentHostEntry[], config: IPersistedEntryTypeConfig): IRawRemoteAgentHostEntry[] { + const raw: IRawRemoteAgentHostEntry[] = []; + for (const entry of entries) { + if (entry.connection.type === config.type) { + raw.push(config.toRaw(entry, entry.connection)); + } + } + return raw; +} + +/** Reads raw remote agent host settings from the configuration target that owns them. */ +export function readRemoteAgentHostSettings(configurationService: IConfigurationService): IRemoteAgentHostSettings { + const inspected = configurationService.inspect(RemoteAgentHostsSettingId); + const target = inspected.userLocalValue !== undefined + ? ConfigurationTarget.USER_LOCAL + : inspected.userRemoteValue !== undefined + ? ConfigurationTarget.USER_REMOTE + : ConfigurationTarget.USER; + return { + target, + entries: target === ConfigurationTarget.USER_LOCAL + ? inspected.userLocalValue ?? [] + : target === ConfigurationTarget.USER_REMOTE + ? inspected.userRemoteValue ?? [] + : inspected.userValue ?? [], + }; +} + +/** Reads WebSocket entries from the effective configuration or its owning target. */ +export function readWebSocketRemoteAgentHostEntries(configurationService: IConfigurationService, targetOnly = false): IRemoteAgentHostEntry[] { + const entries = targetOnly + ? readRemoteAgentHostSettings(configurationService).entries + : configurationService.getValue(RemoteAgentHostsSettingId) ?? []; + return entries + .filter(isRawRemoteAgentHostEntry) + .filter(entry => !isLegacySshRawEntry(entry)) + .map(entry => WEBSOCKET_ENTRY_TYPE_CONFIG.fromRaw(entry)); +} + +async function storeWebSocketRemoteAgentHostEntries(configurationService: IConfigurationService, entries: readonly IRemoteAgentHostEntry[]): Promise { + const settings = readRemoteAgentHostSettings(configurationService); + const preservedEntries = settings.entries.filter(entry => !isRawRemoteAgentHostEntry(entry) || isLegacySshRawEntry(entry)); + const rawEntries = [ + ...preservedEntries, + ...toRawEntriesOfKind(entries, WEBSOCKET_ENTRY_TYPE_CONFIG), + ]; + if (JSON.stringify(settings.entries) !== JSON.stringify(rawEntries)) { + await configurationService.updateValue(RemoteAgentHostsSettingId, rawEntries, settings.target); + } +} + +/** Adds or replaces a WebSocket entry in its configuration store. */ +export async function addWebSocketRemoteAgentHostEntry(configurationService: IConfigurationService, entry: IRemoteAgentHostEntry): Promise { + if (entry.connection.type !== RemoteAgentHostEntryType.WebSocket) { + throw new Error(`Expected a WebSocket remote agent host entry, got ${entry.connection.type}.`); + } + const normalizedEntry: IRemoteAgentHostEntry = { + ...entry, + connection: { ...entry.connection, address: normalizeRemoteAgentHostAddress(entry.connection.address) }, + }; + await storeWebSocketRemoteAgentHostEntries(configurationService, upsertRemoteAgentHostEntry(readWebSocketRemoteAgentHostEntries(configurationService, true), normalizedEntry)); +} + +/** Removes a WebSocket entry from its configuration store. */ +export async function removeWebSocketRemoteAgentHostEntry(configurationService: IConfigurationService, address: string): Promise { + const normalizedAddress = normalizeRemoteAgentHostAddress(address); + await storeWebSocketRemoteAgentHostEntries( + configurationService, + readWebSocketRemoteAgentHostEntries(configurationService, true).filter(entry => entryAddressKey(entry) !== normalizedAddress), + ); +} + +/** Reads SSH entries from their application storage store. */ +export function readSSHRemoteAgentHostEntries(storageService: IStorageService): IRemoteAgentHostEntry[] { + const raw = storageService.get(SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, StorageScope.APPLICATION); + if (!raw) { + return []; + } + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter(isRawRemoteAgentHostEntry).filter(isLegacySshRawEntry).map(entry => SSH_ENTRY_TYPE_CONFIG.fromRaw(entry)) + : []; + } catch { + return []; + } +} + +/** Replaces the SSH entries in their application storage store. */ +export function storeSSHRemoteAgentHostEntries(storageService: IStorageService, entries: readonly IRemoteAgentHostEntry[]): void { + const rawEntries = toRawEntriesOfKind(entries, SSH_ENTRY_TYPE_CONFIG); + const raw = JSON.stringify(rawEntries); + const stored = storageService.get(SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, StorageScope.APPLICATION); + if (stored === raw) { + return; + } + if (rawEntries.length === 0) { + if (stored !== undefined) { + storageService.remove(SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, StorageScope.APPLICATION); + } + return; + } + storageService.store(SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, raw, StorageScope.APPLICATION, StorageTarget.USER); +} + +/** Adds or replaces an SSH entry in its application storage store. */ +export function addSSHRemoteAgentHostEntry(storageService: IStorageService, entry: IRemoteAgentHostEntry): void { + if (entry.connection.type !== RemoteAgentHostEntryType.SSH) { + throw new Error(`Expected an SSH remote agent host entry, got ${entry.connection.type}.`); + } + storeSSHRemoteAgentHostEntries(storageService, upsertRemoteAgentHostEntry(readSSHRemoteAgentHostEntries(storageService), entry)); +} + +/** Removes an SSH entry from its application storage store. */ +export function removeSSHRemoteAgentHostEntry(storageService: IStorageService, address: string): void { + const normalizedAddress = normalizeRemoteAgentHostAddress(address); + storeSSHRemoteAgentHostEntries( + storageService, + readSSHRemoteAgentHostEntries(storageService).filter(entry => entryAddressKey(entry) !== normalizedAddress), + ); +} + +function runtimeEntryTypeConfig(type: TConnection['type'], normalizedAddress: boolean, address: (connection: TConnection) => string, reconnect: IRemoteAgentHostReconnectPolicy = DEFAULT_RECONNECT_POLICY): IRuntimeEntryTypeConfig { + return { type, store: 'runtime', dialableByService: false, normalizedAddress, reconnect, address }; } const WSL_ENTRY_TYPE_CONFIG = runtimeEntryTypeConfig(RemoteAgentHostEntryType.WSL, true, connection => connection.address); const TUNNEL_ENTRY_TYPE_CONFIG = runtimeEntryTypeConfig(RemoteAgentHostEntryType.Tunnel, false, connection => `${TUNNEL_ADDRESS_PREFIX}${connection.tunnelId}`); const CLOUD_SANDBOX_ENTRY_TYPE_CONFIG = runtimeEntryTypeConfig(RemoteAgentHostEntryType.CloudSandbox, true, connection => connection.address); -const DEV_CONTAINER_ENTRY_TYPE_CONFIG = runtimeEntryTypeConfig(RemoteAgentHostEntryType.DevContainer, true, connection => connection.address); +// Relay failures are cheap, but a cold container can make `devcontainer up` rebuild Docker for minutes; retry slower and favor explicit recovery. +const DEV_CONTAINER_RECONNECT_POLICY: IRemoteAgentHostReconnectPolicy = { + autoRestore: true, + initialDelayMs: 2000, + maxDelayMs: 60_000, + maxAttempts: 3, +}; +const DEV_CONTAINER_ENTRY_TYPE_CONFIG = runtimeEntryTypeConfig(RemoteAgentHostEntryType.DevContainer, true, connection => connection.address, DEV_CONTAINER_RECONNECT_POLICY); const ENTRY_TYPE_CONFIGS: { readonly [K in RemoteAgentHostEntryType]: IRemoteAgentHostEntryTypeConfig> } = { [RemoteAgentHostEntryType.WebSocket]: WEBSOCKET_ENTRY_TYPE_CONFIG, @@ -346,8 +634,8 @@ export type RemoteAgentHostInputParseResult = export const IRemoteAgentHostService = createDecorator('remoteAgentHostService'); /** - * Manages connections to one or more remote agent host processes over - * WebSocket. Each connection is identified by its address string and + * Manages connections to one or more remote agent host processes. Each + * connection is identified by its address string and * exposed as an {@link IAgentConnection}, the same interface used for * the local agent host. */ @@ -363,6 +651,9 @@ export interface IRemoteAgentHostService { /** All configured remote agent host entries, regardless of connection status. */ readonly configuredEntries: readonly IRemoteAgentHostEntry[]; + /** Registers a factory for one connection kind. Throws if that kind already has one. */ + registerConnectionFactory(factory: IRemoteAgentHostConnectionFactory): IDisposable; + /** * Get a per-connection {@link IAgentConnection} for subscribing to * state, dispatching actions, creating sessions, etc. @@ -381,15 +672,11 @@ export interface IRemoteAgentHostService { */ getConnectionByAuthority(authority: string): IAgentConnection | undefined; - /** - * Adds or updates a configured remote host and resolves once a connection - * to that host is available. - */ - addRemoteAgentHost(entry: IRemoteAgentHostEntry): Promise; + /** Waits for a configured remote host to establish a connection. */ + waitForConnection(address: string): Promise; /** - * Removes a configured remote host entry by address. - * Disconnects any active connection and removes the entry from settings. + * Disconnects an active remote host connection by address. */ removeRemoteAgentHost(address: string): Promise; @@ -398,12 +685,12 @@ export interface IRemoteAgentHostService { * Tears down any existing connection and starts a fresh connect attempt * with reset backoff. */ - reconnect(address: string): void; + reconnect(address: string, userInitiated?: boolean): void; /** * Register a pre-connected agent connection. - * Used by the SSH and tunnel services to inject relay-backed connections - * without going through the WebSocket connect flow. + * Used by transport services that do not yet provide a connection factory + * to inject relay-backed connections. * * The optional `transportDisposable` represents the underlying transport * (e.g. an SSH tunnel relay or tunnel-relay session) and is owned by this @@ -475,13 +762,16 @@ export class NullRemoteAgentHostService implements IRemoteAgentHostService { readonly onDidChangeConnections = Event.None; readonly connections: readonly IRemoteAgentHostConnectionInfo[] = []; readonly configuredEntries: readonly IRemoteAgentHostEntry[] = []; + registerConnectionFactory(): IDisposable { + throw new Error('Remote agent host connections are not supported in this environment.'); + } getConnection(): IAgentConnection | undefined { return undefined; } getConnectionByAuthority(): IAgentConnection | undefined { return undefined; } - async addRemoteAgentHost(): Promise { + async waitForConnection(): Promise { throw new Error('Remote agent host connections are not supported in this environment.'); } async removeRemoteAgentHost(_address: string): Promise { } - reconnect(_address: string): void { } + reconnect(_address: string, _userInitiated?: boolean): void { } notifyConnectionClosed(_address: string): void { } async addManagedConnection(): Promise { throw new Error('Remote agent host connections are not supported in this environment.'); diff --git a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts index 0648e099dd32ed..5c4cecb57ba9c7 100644 --- a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts +++ b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts @@ -170,7 +170,7 @@ export interface ISSHRemoteAgentHostService { * 2. Downloads and installs the VS Code CLI if needed * 3. Starts `code agent-host` * 4. Creates a WebSocket relay over the SSH channel - * 5. Registers the connection with {@link IRemoteAgentHostService} + * 5. Waits for {@link IRemoteAgentHostService} to complete the protocol handshake * * Resolves with the connection handle once the agent host is reachable. */ @@ -178,8 +178,8 @@ export interface ISSHRemoteAgentHostService { /** * Disconnect an SSH-bootstrapped connection by host address. - * Tears down the SSH tunnel, stops the remote agent host, and - * removes the entry from {@link IRemoteAgentHostService}. + * Tears down the SSH tunnel, stops the remote agent host, and removes its + * persisted SSH entry. */ disconnect(host: string): Promise; @@ -205,7 +205,7 @@ export interface ISSHRemoteAgentHostService { /** * Re-establish an SSH tunnel on startup for a previously connected host. - * Returns the new local forwarded address and registers it. + * Resolves once the service-owned protocol connection is ready. * * @param userInitiated See {@link ISSHAgentHostConfig.userInitiated}. * Defaults to `true` (picker-eligible) when omitted; background/auto diff --git a/src/vs/platform/agentHost/common/tunnelAgentHost.ts b/src/vs/platform/agentHost/common/tunnelAgentHost.ts index 9032d122ba79e6..5baccfbf750963 100644 --- a/src/vs/platform/agentHost/common/tunnelAgentHost.ts +++ b/src/vs/platform/agentHost/common/tunnelAgentHost.ts @@ -301,6 +301,25 @@ export function isTunnelGatewaySelectionRejectedError(error: unknown): boolean { return error instanceof Error && error.name === TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME; } +/** + * Error name for a connect attempt whose requested tunnel no longer exists. + * Matching on the name survives the shared-process IPC boundary. + */ +export const TUNNEL_NOT_FOUND_ERROR_NAME = 'TunnelNotFoundError'; + +/** Raised when the requested tunnel cannot be resolved. */ +export class TunnelNotFoundError extends Error { + constructor(tunnelId: string) { + super(`[TunnelAgentHost] Tunnel ${tunnelId} not found`); + this.name = TUNNEL_NOT_FOUND_ERROR_NAME; + } +} + +/** Whether `error` is a {@link TunnelNotFoundError}, including across IPC. */ +export function isTunnelNotFoundError(error: unknown): boolean { + return error instanceof Error && error.name === TUNNEL_NOT_FOUND_ERROR_NAME; +} + /** * Serializable result from a successful tunnel connect operation. * Returned over IPC from the shared process. diff --git a/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts b/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts index 20103ae736e285..7be720d2761734 100644 --- a/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts +++ b/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts @@ -16,6 +16,7 @@ import { TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION, TUNNEL_GATEWAY_SELECT_PATH, TUNNEL_MIN_PROTOCOL_VERSION, + TunnelNotFoundError, TunnelTags, type ITunnelConnectResult, type ITunnelGatewayInventory, @@ -269,7 +270,7 @@ export class TunnelAgentHostConnector extends Disposable { const session = await this._relayClientFactory.getTunnel(tunnelId, clusterId, authProvider, token); if (!session) { - throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`); + throw new TunnelNotFoundError(tunnelId); } const { tunnel } = session; @@ -315,7 +316,7 @@ export class TunnelAgentHostConnector extends Disposable { async prepareSelection(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { const session = await this._relayClientFactory.getTunnel(tunnelId, clusterId, authProvider, token); if (!session) { - throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`); + throw new TunnelNotFoundError(tunnelId); } const { tunnel } = session; diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index d7a7d057a31c74..12fcbe87ee0889 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -217,9 +217,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos AgentHostProtocolClient, LOCAL_AGENT_HOST_RESOURCE_IDENTITY, () => this._createTransport(), - undefined, - this.clientId, - this._clientInfo, + { clientId: this.clientId, clientInfo: this._clientInfo }, )); this._register(this._protocolClient.onDidChangeConnectionState(state => this._handleConnectionState(state))); this._register(this._protocolClient.onDidFatalClose(() => { diff --git a/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts deleted file mode 100644 index fe078e7398e2c4..00000000000000 --- a/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts +++ /dev/null @@ -1,21 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ILogService } from '../../log/common/log.js'; -import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; -import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; -import { RelayTransport } from '../common/relayTransport.js'; -import type { ISSHRemoteAgentHostMainService } from '../common/sshRemoteAgentHost.js'; - -export class SSHRelayTransport extends RelayTransport { - constructor( - connectionId: string, - sshService: ISSHRemoteAgentHostMainService, - ahpLogger: AhpJsonlLogger | undefined, - @ILogService logService: ILogService, - ) { - super(connectionId, sshService, ahpLogger, logService, '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH); - } -} diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts index 0ee136cd8fa824..ea6b97acd96533 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -7,6 +7,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Codicon } from '../../../base/common/codicons.js'; import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { IObservable, observableFromEvent } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; @@ -16,24 +17,28 @@ import { IEnvironmentService } from '../../environment/common/environment.js'; import { INotificationService, Severity } from '../../notification/common/notification.js'; import { toAction } from '../../../base/common/actions.js'; import { IProductService } from '../../product/common/productService.js'; +import { IStorageService, StorageScope } from '../../storage/common/storage.js'; import { ISharedProcessService } from '../../ipc/electron-browser/services.js'; import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, getEntryAddress, isLegacySshRawEntry, isRawRemoteAgentHostEntry, parseLegacyRawEntry, readRemoteAgentHostSettings, readSSHRemoteAgentHostEntries, removeSSHRemoteAgentHostEntry, storeSSHRemoteAgentHostEntries, upsertRemoteAgentHostEntry, type IRemoteAgentHostConnectOptions, type IRemoteAgentHostConnectionFactory, type IRemoteAgentHostCreatedConnection, type IRemoteAgentHostEntry } from '../common/remoteAgentHostService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IQuickInputService } from '../../quickinput/common/quickInput.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../common/agentService.js'; import type { AgentHostServerType } from '../common/agentHostEndpointRegistry.js'; +import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import { IRemoteAgentHostLocationPreferenceService } from '../common/remoteAgentHostLocationPreference.js'; import { promptRemoteAgentHostLocationPreference } from '../common/remoteAgentHostLocationPreferenceDialog.js'; -import { SSHRelayTransport } from './sshRelayTransport.js'; +import { ReconnectingRelayTransport, type IRelayConnectionHandle } from '../common/relayTransport.js'; import { AgentHostProtocolClient } from '../browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../common/agentHostClientInfo.js'; -import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; +import { NonReconnectableTransportError } from '../common/state/sessionTransport.js'; import { ISSHRemoteAgentHostService, SSH_REMOTE_AGENT_HOST_CHANNEL, computeSSHConnectionKey, + isSSHHostKeyDeniedError, + SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHConnectResult, @@ -72,7 +77,7 @@ export const ISSHRelayClientFactory = createDecorator('s export interface ISSHRelayClientFactory { readonly _serviceBrand: undefined; - createClient(mainService: ISSHRemoteAgentHostMainService, connectionId: string, address: string): AgentHostProtocolClient; + createClient(mainService: ISSHRemoteAgentHostMainService, connectionId: string, address: string, reestablish: () => Promise): AgentHostProtocolClient; } export class SSHRelayClientFactory implements ISSHRelayClientFactory { @@ -82,28 +87,316 @@ export class SSHRelayClientFactory implements ISSHRelayClientFactory { @IInstantiationService private readonly _instantiationService: IInstantiationService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentService private readonly _environmentService: IEnvironmentService, + @ILogService private readonly _logService: ILogService, ) { } - createClient(mainService: ISSHRemoteAgentHostMainService, connectionId: string, address: string): AgentHostProtocolClient { + createClient(mainService: ISSHRemoteAgentHostMainService, connectionId: string, address: string, reestablish: () => Promise): AgentHostProtocolClient { const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); - const logger = ahpLoggingEnabled ? this._instantiationService.createInstance( - AhpJsonlLogger, - { logsHome: this._environmentService.logsHome, connectionId, transport: 'ssh' }, - ) : undefined; - const transport = this._instantiationService.createInstance(SSHRelayTransport, connectionId, mainService, logger); - return this._instantiationService.createInstance(AgentHostProtocolClient, address, transport, undefined, undefined, agentsWindowAgentHostClientInfo); + let seedConnection = true; + const establish = async () => { + if (seedConnection) { + seedConnection = false; + // The initial channel is owned by the connection handle registered by the caller. + return { connectionId }; + } + try { + const result = await reestablish(); + return { + connectionId: result.connectionId, + close: () => mainService.disconnect(result.connectionId), + }; + } catch (error) { + if (isSSHHostKeyDeniedError(error)) { + throw new NonReconnectableTransportError(error.message); + } + throw error; + } + }; + return this._instantiationService.createInstance(AgentHostProtocolClient, address, () => { + // Logged under the seed channel id: the re-established id is not known + // until `establish()` resolves, after the logger has to exist. + const createLogger = () => ahpLoggingEnabled ? this._instantiationService.createInstance( + AhpJsonlLogger, + { logsHome: this._environmentService.logsHome, connectionId, transport: 'ssh' }, + ) : undefined; + return new ReconnectingRelayTransport( + establish, + mainService, + createLogger, + this._logService, + '[SSHRelayTransport]', + AgentHostClientConnectionKind.SSH, + ); + }, { clientInfo: agentsWindowAgentHostClientInfo }); + } +} + +/** Creates SSH relay clients for {@link SSHRemoteAgentHostService}. */ +class SSHConnectionFactory extends Disposable implements IRemoteAgentHostConnectionFactory { + readonly kind = RemoteAgentHostEntryType.SSH; + readonly entries: IObservable; + + private readonly _stagedConfigurations = new Map(); + // Survives connection cleanup so an automatic reconnect can identify an + // editor-to-standalone endpoint failover after a successful handshake. + private readonly _lastConnectedServerTypeByAddress = new Map(); + + constructor( + private readonly _storageService: IStorageService, + private readonly _configurationService: IConfigurationService, + private readonly _logService: ILogService, + private readonly _mainService: ISSHRemoteAgentHostMainService, + private readonly _remoteAgentHostService: IRemoteAgentHostService, + private readonly _relayClientFactory: ISSHRelayClientFactory, + private readonly _locationPreferenceService: IRemoteAgentHostLocationPreferenceService, + private readonly _notificationService: INotificationService, + private readonly _connections: Map, + private readonly _onDidChangeConnections: () => void, + ) { + super(); + this.entries = observableFromEvent( + this, + this._storageService.onDidChangeValue(StorageScope.APPLICATION, SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY, this._store), + () => readSSHRemoteAgentHostEntries(this._storageService), + ); + void this._migrateLegacyEntries(); + } + + stageConfiguration(config: ISSHAgentHostConfig): IRemoteAgentHostEntry { + const address = computeSSHConnectionKey(config); + const entry: IRemoteAgentHostEntry = { + name: config.name, + connection: { + type: RemoteAgentHostEntryType.SSH, + address, + sshConfigHost: config.sshConfigHost, + hostName: config.host, + user: config.username, + port: config.port, + }, + }; + this._stagedConfigurations.set(address, config); + storeSSHRemoteAgentHostEntries(this._storageService, upsertRemoteAgentHostEntry(readSSHRemoteAgentHostEntries(this._storageService), entry)); + return entry; + } + + getEntryForSSHConfigHost(sshConfigHost: string): IRemoteAgentHostEntry | undefined { + return readSSHRemoteAgentHostEntries(this._storageService).find(entry => + entry.connection.type === RemoteAgentHostEntryType.SSH && entry.connection.sshConfigHost === sshConfigHost + ); + } + + async createConnection(entry: IRemoteAgentHostEntry, options: IRemoteAgentHostConnectOptions): Promise { + if (entry.connection.type !== RemoteAgentHostEntryType.SSH) { + throw new Error(`SSH factory cannot create a ${entry.connection.type} connection.`); + } + + const stagedConfig = this._stagedConfigurations.get(entry.connection.address); + this._stagedConfigurations.delete(entry.connection.address); + const result = stagedConfig + ? await this._mainService.connect(this._augmentConfig({ ...stagedConfig, userInitiated: stagedConfig.userInitiated ?? options.userInitiated })) + : entry.connection.sshConfigHost + ? await this._mainService.reconnect( + entry.connection.sshConfigHost, + entry.name, + this._getRemoteAgentHostCommand(), + this._isSSHAgentForwardingEnabled(), + options.userInitiated, + this._locationPreferenceService.getPreference(computeSSHConnectionKey({ sshConfigHost: entry.connection.sshConfigHost })), + ) + : await this._mainService.connect(this._augmentConfig({ + host: entry.connection.hostName, + port: entry.connection.port, + username: entry.connection.user ?? entry.connection.hostName, + authMethod: SSHAuthMethod.Agent, + name: entry.name, + userInitiated: options.userInitiated, + })); + this._logService.trace(`[SSHRemoteAgentHost] SSH tunnel established, connectionId=${result.connectionId}`); + + const existing = this._connections.get(result.connectionId); + const persistedEntry: IRemoteAgentHostEntry = { + name: result.name, + connectionToken: result.connectionToken, + connection: { + type: RemoteAgentHostEntryType.SSH, + address: result.address, + sshConfigHost: result.sshConfigHost, + hostName: result.config.host, + user: result.config.username || undefined, + port: result.config.port, + serverType: result.serverType, + instanceId: result.instanceId, + primary: result.primary, + lifecycle: result.lifecycle, + }, + }; + if (existing) { + if (this._remoteAgentHostService.getConnection(result.address)) { + this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle'); + this.storeEntry(persistedEntry); + return { + connection: this._createRelayClient(result), + transportDisposable: this._createTransportDisposable(result.connectionId, existing, this._observeSuccessfulConnection(result, options.userInitiated)), + }; + } + this._logService.info(`[SSHRemoteAgentHost] Replacing stale connection handle for ${result.address}`); + this._connections.delete(result.connectionId); + // The main service retained the SSH client while replacing its relay. + // Marking this handle closed keeps disposal from disconnecting it. + existing.fireClose(); + existing.dispose(); + this._onDidChangeConnections(); + } + + const handle = new SSHAgentHostConnectionHandle( + result.config, + result.address, + result.name, + result.serverType, + result.instanceId, + result.primary, + result.lifecycle, + () => this._mainService.disconnect(result.connectionId), + ); + try { + this._connections.set(result.connectionId, handle); + this._onDidChangeConnections(); + this.storeEntry(persistedEntry); + const endpointSelectionObserver = this._observeSuccessfulConnection(result, options.userInitiated); + return { + connection: this._createRelayClient(result), + transportDisposable: this._createTransportDisposable(result.connectionId, handle, endpointSelectionObserver), + }; + } catch (err) { + this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err); + if (this._connections.get(result.connectionId) === handle) { + this._connections.delete(result.connectionId); + this._onDidChangeConnections(); + } + handle.dispose(); + this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); + throw err; + } + } + + storeEntry(entry: IRemoteAgentHostEntry): void { + storeSSHRemoteAgentHostEntries(this._storageService, upsertRemoteAgentHostEntry(readSSHRemoteAgentHostEntries(this._storageService), entry)); + } + + private _observeSuccessfulConnection(result: ISSHConnectResult, userInitiated: boolean): IDisposable { + const listener = this._remoteAgentHostService.onDidChangeConnections(() => { + const status = this._remoteAgentHostService.connections.find(connection => connection.address === result.address)?.status; + if (RemoteAgentHostConnectionStatus.isConnected(status)) { + listener?.dispose(); + this._recordEndpointSelection(result, userInitiated); + } else if (!status || RemoteAgentHostConnectionStatus.isIncompatible(status)) { + listener?.dispose(); + } + }); + return listener; + } + + private _recordEndpointSelection(result: ISSHConnectResult, userInitiated: boolean): void { + if (!result.serverType) { + return; + } + const previousServerType = this._lastConnectedServerTypeByAddress.get(result.address); + const isUnattendedFailoverFromEditor = userInitiated === false + && previousServerType === 'editor' + && result.serverType === 'standalone'; + this._lastConnectedServerTypeByAddress.set(result.address, result.serverType); + if (isUnattendedFailoverFromEditor) { + this._notificationService.info(localize( + 'sshEditorAgentHostReplacedByStandalone', + "The editor agent host exited. Reconnected to a dedicated agent host. In-progress work may have been interrupted." + )); + } + } + + private _createTransportDisposable(connectionId: string, handle: SSHAgentHostConnectionHandle, endpointSelectionObserver?: IDisposable): IDisposable { + return toDisposable(() => { + endpointSelectionObserver?.dispose(); + if (this._connections.get(connectionId) === handle) { + this._connections.delete(connectionId); + this._onDidChangeConnections(); + } + handle.fireClose(); + handle.dispose(); + this._mainService.disconnect(connectionId).catch(() => { /* best effort */ }); + }); + } + + private _createRelayClient(result: Pick): AgentHostProtocolClient { + const reestablish = async (): Promise => { + if (!result.sshConfigHost) { + throw new NonReconnectableTransportError('Cannot automatically reconnect an SSH connection without an SSH config host.'); + } + const preferredAgentLocation = this._locationPreferenceService.getPreference(computeSSHConnectionKey({ sshConfigHost: result.sshConfigHost })); + const reconnected = await this._mainService.reconnect(result.sshConfigHost, result.name, this._getRemoteAgentHostCommand(), this._isSSHAgentForwardingEnabled(), false, preferredAgentLocation); + return { connectionId: reconnected.connectionId }; + }; + return this._relayClientFactory.createClient(this._mainService, result.connectionId, result.address, reestablish); + } + + private _augmentConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfig { + const result = { ...config }; + const commandOverride = this._getRemoteAgentHostCommand(); + if (commandOverride) { + result.remoteAgentHostCommand = commandOverride; + } + if (this._isSSHAgentForwardingEnabled() && config.agentForward) { + result.agentForward = true; + } + const preferredAgentLocation = this._locationPreferenceService.getPreference(computeSSHConnectionKey(config)); + if (preferredAgentLocation) { + result.preferredAgentLocation = preferredAgentLocation; + } + return result; + } + + private _getRemoteAgentHostCommand(): string | undefined { + return this._configurationService.getValue('chat.sshRemoteAgentHostCommand') || undefined; + } + + private _isSSHAgentForwardingEnabled(): boolean | undefined { + return this._configurationService.getValue('chat.agentHost.forwardSSHAgent') || undefined; + } + + private async _migrateLegacyEntries(): Promise { + try { + const settings = readRemoteAgentHostSettings(this._configurationService); + const legacyEntries = settings.entries + .filter(isRawRemoteAgentHostEntry) + .filter(isLegacySshRawEntry) + .map(parseLegacyRawEntry) + .filter(entry => entry.connection.type === RemoteAgentHostEntryType.SSH); + if (legacyEntries.length === 0) { + return; + } + + let sshEntries = readSSHRemoteAgentHostEntries(this._storageService); + for (const entry of legacyEntries) { + sshEntries = upsertRemoteAgentHostEntry(sshEntries, entry); + } + storeSSHRemoteAgentHostEntries(this._storageService, sshEntries); + const remainingEntries = settings.entries.filter(entry => !isRawRemoteAgentHostEntry(entry) || !isLegacySshRawEntry(entry)); + await this._configurationService.updateValue(RemoteAgentHostsSettingId, remainingEntries, settings.target); + } catch (err) { + this._logService.error('[RemoteAgentHost] Failed to migrate SSH connection details from settings to storage', err); + } } } /** * Renderer-side implementation of {@link ISSHRemoteAgentHostService} that - * delegates the actual SSH work to the main process via IPC, then registers - * the resulting connection with the renderer-local {@link IRemoteAgentHostService}. + * delegates the actual SSH work to the main process via IPC. */ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteAgentHostService { declare readonly _serviceBrand: undefined; private readonly _mainService: ISSHRemoteAgentHostMainService; + private readonly _connectionFactory: SSHConnectionFactory; private readonly _onDidChangeConnections = this._register(new Emitter()); readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; @@ -112,17 +405,6 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA private readonly _connections = new Map(); - /** - * The server type ('editor' or 'standalone') of the last successfully - * established connection for a given (stable) connection address. - * Deliberately NOT cleared when a connection closes (see - * `onDidCloseConnection` below) — it needs to survive disconnect cleanup - * so a later automatic reconnect can detect an editor→standalone - * failover and surface a one-time notification. Only ever updated after - * a connection has fully and successfully registered. - */ - private readonly _lastConnectedServerTypeByAddress = new Map(); - /** * The host key that authenticated the most recent session for a given * connection key. Used to decide whether an `UpdateHostKeys` announcement @@ -143,12 +425,26 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA @IDialogService private readonly _dialogService: IDialogService, @IProductService private readonly _productService: IProductService, @ISSHHostKeyTrustService private readonly _hostKeyTrustService: ISSHHostKeyTrustService, + @IStorageService private readonly _storageService: IStorageService, ) { super(); this._mainService = ProxyChannel.toService( sharedProcessService.getChannel(SSH_REMOTE_AGENT_HOST_CHANNEL), ); + this._connectionFactory = this._register(new SSHConnectionFactory( + this._storageService, + this._configurationService, + this._logService, + this._mainService, + this._remoteAgentHostService, + this._relayClientFactory, + this._locationPreferenceService, + this._notificationService, + this._connections, + () => this._onDidChangeConnections.fire(), + )); + this._register(this._remoteAgentHostService.registerConnectionFactory(this._connectionFactory)); this.onDidReportConnectProgress = this._mainService.onDidReportConnectProgress; @@ -166,7 +462,7 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA this._onDidChangeConnections.fire(); // Defense-in-depth: also signal the protocol client directly. The - // SSHRelayTransport normally observes `onDidRelayClose` (fired from + // ReconnectingRelayTransport normally observes `onDidRelayClose` (fired from // the same shared-process code path as this event) and calls back // into the client. If that IPC delivery is missed for any reason, // the renderer-side client would stay in `Connected` until its @@ -220,14 +516,15 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA throw new Error('Remote agent host connections are not enabled.'); } - const augmentedConfig = this._augmentConfig(config); - this._logService.info(`[SSHRemoteAgentHost] Connecting to ${config.host}`); - const result = await this._mainService.connect(augmentedConfig); - this._logService.trace(`[SSHRemoteAgentHost] SSH tunnel established, connectionId=${result.connectionId}`); - return this._setupConnection(result, config.userInitiated ?? true); + const entry = this._connectionFactory.stageConfiguration({ ...config, userInitiated: config.userInitiated ?? true }); + const address = getEntryAddress(entry); + this._remoteAgentHostService.reconnect(address, true); + await this._remoteAgentHostService.waitForConnection(address); + return this._getConnectionHandle(address); } async disconnect(host: string): Promise { + removeSSHRemoteAgentHostEntry(this._storageService, host); await this._mainService.disconnect(host); } @@ -247,218 +544,29 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA return this._mainService.resolveSSHConfig(host); } - async reconnect(sshConfigHost: string, name: string, userInitiated?: boolean): Promise { + async reconnect(sshConfigHost: string, _name: string, userInitiated?: boolean): Promise { if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { throw new Error('Remote agent host connections are not enabled.'); } - const commandOverride = this._getRemoteAgentHostCommand(); - const agentForward = this._isSSHAgentForwardingEnabled(); - const preferredAgentLocation = this._locationPreferenceService.getPreference(computeSSHConnectionKey({ sshConfigHost })); - this._logService.info(`[SSHRemoteAgentHost] Reconnecting to ${sshConfigHost} (userInitiated=${userInitiated ?? true})`); - const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride, agentForward, userInitiated, preferredAgentLocation); - return this._setupConnection(result, userInitiated ?? true); - } - - /** - * Build the renderer-side handle, do the protocol handshake, and register - * with IRemoteAgentHostService. Any failure after the shared-process tunnel - * was established tears it back down so we don't leak it. - */ - private async _setupConnection(result: ISSHConnectResult, userInitiated: boolean): Promise { - const existing = this._connections.get(result.connectionId); - if (existing) { - // Reuse the existing handle only if the managed entry is still - // in a usable state. After a `reconnect` that replaced the - // underlying SSH relay (e.g. following a CLI-driven server - // upgrade), the previous protocol client is bound to a - // torn-down transport and — if its handshake had failed with - // `incompatible` — will never re-handshake on its own. Drop - // the stale local state and fall through to a fresh - // handshake; the subsequent `addManagedConnection` call - // disposes the stale protocol client by replacing the entry. - if (this._remoteAgentHostService.getConnection(result.address)) { - this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle'); - return existing; - } - this._logService.info(`[SSHRemoteAgentHost] Replacing stale connection handle for ${result.address}`); - this._connections.delete(result.connectionId); - // Mark closed-by-main so disposing the handle does NOT call - // disconnect() — the main service kept the SSH client alive - // across `replaceRelay`, and we'd kill the brand-new tunnel - // otherwise. - existing.fireClose(); - existing.dispose(); - this._onDidChangeConnections.fire(); - } - let registeredHandle = false; - const protocolClient = this._createRelayClient(result); - let status = RemoteAgentHostConnectionStatus.connected; - let connectError: unknown; - try { - await protocolClient.connect(); - this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed'); - } catch (err) { - const incompatible = RemoteAgentHostConnectionStatus.fromConnectError(err, [PROTOCOL_VERSION]); - if (!RemoteAgentHostConnectionStatus.isIncompatible(incompatible)) { - this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err); - protocolClient.dispose(); - this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); - throw err; - } - this._logService.warn(`[SSHRemoteAgentHost] Incompatible with ${result.address}: ${incompatible.message}`); - status = incompatible; - connectError = err; - } - - const handle = new SSHAgentHostConnectionHandle( - result.config, - result.address, - result.name, - result.serverType, - result.instanceId, - result.primary, - result.lifecycle, - () => this._mainService.disconnect(result.connectionId), - ); - - try { - this._connections.set(result.connectionId, handle); - registeredHandle = true; - this._onDidChangeConnections.fire(); - - await this._remoteAgentHostService.addManagedConnection({ - name: result.name, - connectionToken: result.connectionToken, - connection: { - type: RemoteAgentHostEntryType.SSH, - address: result.address, - sshConfigHost: result.sshConfigHost, - hostName: result.config.host, - user: result.config.username || undefined, - port: result.config.port, - }, - }, protocolClient, this._createTransportDisposable(result.connectionId, handle), status); - } catch (err) { - this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err); - if (registeredHandle && this._connections.get(result.connectionId) === handle) { - this._connections.delete(result.connectionId); - this._onDidChangeConnections.fire(); - } - handle.dispose(); - protocolClient.dispose(); - this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); - throw err; - } - - if (connectError) { - throw connectError; + const entry = this._connectionFactory.getEntryForSSHConfigHost(sshConfigHost); + if (!entry) { + throw new Error(`No SSH remote agent host entry found for ${sshConfigHost}.`); } - - // Only track/notify for a fully successful setup — an incompatible - // handshake (connectError above) still registers a managed entry - // but isn't a usable connection, so it must not count as "reconnect - // succeeded" for failover-detection purposes. - this._recordEndpointSelection(result, userInitiated); - - return handle; + const address = getEntryAddress(entry); + this._remoteAgentHostService.reconnect(address, userInitiated ?? true); + await this._remoteAgentHostService.waitForConnection(address); + return this._getConnectionHandle(address); } - /** - * Update the last-known server type for {@link result.address}, and — if - * this was an automatic/background reconnect (`userInitiated === false`) - * that moved this stable remote address from a previously connected - * `editor`-owned endpoint to a newly selected `standalone` endpoint — - * surface a single informational notification. Never fires for the - * initial connect to a remote (no prior recorded server type), a - * user-initiated reconnect, or a same-kind transition - * (editor→editor/standalone→standalone). - */ - private _recordEndpointSelection(result: ISSHConnectResult, userInitiated: boolean): void { - if (!result.serverType) { - return; - } - const previousServerType = this._lastConnectedServerTypeByAddress.get(result.address); - const isUnattendedFailoverFromEditor = userInitiated === false - && previousServerType === 'editor' - && result.serverType === 'standalone'; - this._lastConnectedServerTypeByAddress.set(result.address, result.serverType); - if (isUnattendedFailoverFromEditor) { - this._notificationService.info(localize( - 'sshEditorAgentHostReplacedByStandalone', - "The editor agent host exited. Reconnected to a dedicated agent host. In-progress work may have been interrupted." - )); + private _getConnectionHandle(address: string): SSHAgentHostConnectionHandle { + const handle = [...this._connections.values()].find(candidate => candidate.localAddress === address); + if (!handle) { + throw new Error(`SSH connection handle not found for ${address}.`); } + return handle; } - /** - * Build a disposable that the {@link IRemoteAgentHostService} will own - * for the lifetime of this entry. When the entry is removed (either by - * the user via "Remove Remote" or by config reconciliation), this runs - * and tears down the renderer-side handle and the shared-process SSH - * tunnel together. Without this hookup, the SSH tunnel would leak and - * the next `connect()` would silently reuse it. - */ - private _createTransportDisposable(connectionId: string, handle: SSHAgentHostConnectionHandle): IDisposable { - return toDisposable(() => { - // Drop the renderer-side handle map entry first so a concurrent - // `connect()` for the same key doesn't latch onto a being-torn-down - // connection. - if (this._connections.get(connectionId) === handle) { - this._connections.delete(connectionId); - this._onDidChangeConnections.fire(); - } - // Mark the handle as already closed-from-main so disposing it - // doesn't kick off a redundant second disconnect IPC. The actual - // disconnect is initiated below. - handle.fireClose(); - handle.dispose(); - this._mainService.disconnect(connectionId).catch(() => { /* best effort */ }); - }); - } - - private _createRelayClient(result: { connectionId: string; address: string }): AgentHostProtocolClient { - return this._relayClientFactory.createClient(this._mainService, result.connectionId, result.address); - } - - private _augmentConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfig { - const result = { ...config }; - const commandOverride = this._getRemoteAgentHostCommand(); - if (commandOverride) { - result.remoteAgentHostCommand = commandOverride; - } - // Agent forwarding requires both the global setting (security opt-in) - // and the per-host SSH config `ForwardAgent yes` to be enabled. - if (this._isSSHAgentForwardingEnabled() && config.agentForward) { - result.agentForward = true; - } - // Thread the stored per-host location preference through to the main - // process so `selectEndpoint` can honor it directly — without ever - // emitting an endpoint-selection request — for both user-initiated - // and silent/background connects (see `ISSHAgentHostConfig.preferredAgentLocation`). - const preferredAgentLocation = this._locationPreferenceService.getPreference(computeSSHConnectionKey(config)); - if (preferredAgentLocation) { - result.preferredAgentLocation = preferredAgentLocation; - } - return result; - } - - private _getRemoteAgentHostCommand(): string | undefined { - return this._configurationService.getValue('chat.sshRemoteAgentHostCommand') || undefined; - } - - private _isSSHAgentForwardingEnabled(): boolean | undefined { - return this._configurationService.getValue('chat.agentHost.forwardSSHAgent') || undefined; - } - - /** - * Show a quick-input prompt for each entry in a keyboard-interactive - * challenge and forward the responses (or cancel) back to the main service. - * - * The renderer collects all prompts up front before responding so the - * server gets a single batched answer set, matching how OpenSSH presents - * keyboard-interactive challenges. - */ private async _handleKeyboardInteractiveRequest(request: ISSHKeyboardInteractiveRequest): Promise { this._logService.info(`[SSHRemoteAgentHost] Keyboard-interactive prompt for ${request.displayHost} (${request.prompts.length} prompt(s))`); diff --git a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts deleted file mode 100644 index 6f9c39f6713102..00000000000000 --- a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts +++ /dev/null @@ -1,91 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { AhpJsonlLogger, getAhpLogByteLength } from '../common/ahpJsonlLogger.js'; -import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; -import type { AhpServerNotification, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; -import type { IProtocolTransport } from '../common/state/sessionTransport.js'; -import type { ITunnelAgentHostMainService, ITunnelRelayMessage } from '../common/tunnelAgentHost.js'; -import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; - -/** - * A protocol transport that relays messages through the shared process - * tunnel relay via IPC, instead of using a direct WebSocket connection. - * - * The shared process manages the actual dev tunnel relay connection - * and forwards messages bidirectionally through this IPC channel. - */ -export class TunnelRelayTransport extends Disposable implements IProtocolTransport { - readonly clientConnectionKind = AgentHostClientConnectionKind.DevTunnel; - - private readonly _onMessage = this._register(new Emitter()); - readonly onMessage = this._onMessage.event; - - private readonly _onClose = this._register(new Emitter()); - readonly onClose = this._onClose.event; - - private _malformedFrames = 0; - - constructor( - private readonly _connectionId: string, - private readonly _tunnelService: ITunnelAgentHostMainService, - private readonly _ahpLogger?: AhpJsonlLogger, - ) { - super(); - if (this._ahpLogger) { - this._register(this._ahpLogger); - } - - // Listen for relay messages from the shared process - this._register(this._tunnelService.onDidRelayMessage((msg: ITunnelRelayMessage) => { - if (msg.connectionId !== this._connectionId) { - return; - } - let parsed: ProtocolMessage; - try { - parsed = JSON.parse(msg.data) as ProtocolMessage; - } catch (err) { - this._malformedFrames++; - if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { - const preview = msg.data.length > 80 ? msg.data.slice(0, 80) + '…' : msg.data; - console.warn( - `[TunnelRelayTransport] Malformed frame #${this._malformedFrames} (len=${msg.data.length}): ${preview}`, - err instanceof Error ? err.message : String(err) - ); - } - if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { - console.warn('[TunnelRelayTransport] Malformed frame threshold exceeded; closing relay.'); - this._tunnelService.disconnect(this._connectionId).catch(() => { /* best effort */ }); - } - return; - } - this._ahpLogger?.log(parsed, 's2c', getAhpLogByteLength(msg.data)); - this._onMessage.fire(parsed); - })); - - // Listen for relay close - this._register(this._tunnelService.onDidRelayClose((closedId: string) => { - if (closedId === this._connectionId) { - this._onClose.fire(); - } - })); - } - - override dispose(): void { - // Tear down the shared-process relay connection - this._tunnelService.disconnect(this._connectionId).catch(() => { /* best effort */ }); - super.dispose(); - } - - send(message: ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest): void { - const text = JSON.stringify(message); - this._ahpLogger?.log(message, 'c2s', getAhpLogByteLength(text)); - this._tunnelService.relaySend(this._connectionId, text).catch(() => { - // Send failed — connection probably closed - }); - } -} diff --git a/src/vs/platform/agentHost/electron-browser/wslRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/wslRelayTransport.ts deleted file mode 100644 index dc7c5333af82d3..00000000000000 --- a/src/vs/platform/agentHost/electron-browser/wslRelayTransport.ts +++ /dev/null @@ -1,21 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ILogService } from '../../log/common/log.js'; -import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; -import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; -import { RelayTransport } from '../common/relayTransport.js'; -import type { IWSLRemoteAgentHostMainService } from '../common/wslRemoteAgentHost.js'; - -export class WSLRelayTransport extends RelayTransport { - constructor( - connectionId: string, - wslService: IWSLRemoteAgentHostMainService, - ahpLogger: AhpJsonlLogger | undefined, - @ILogService logService: ILogService, - ) { - super(connectionId, wslService, ahpLogger, logService, '[WSLRelayTransport]', AgentHostClientConnectionKind.WSL); - } -} diff --git a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts index cfc03d07077a1c..b22bd7c540b081 100644 --- a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts @@ -16,7 +16,9 @@ import { IRemoteAgentHostService, RemoteAgentHostEntryType, RemoteAgentHostsEnab import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../common/agentService.js'; -import { WSLRelayTransport } from './wslRelayTransport.js'; +import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; +import { ReconnectingRelayTransport } from '../common/relayTransport.js'; +import { NonReconnectableTransportError } from '../common/state/sessionTransport.js'; import { AgentHostProtocolClient } from '../browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../common/agentHostClientInfo.js'; import { @@ -35,7 +37,7 @@ export const IWSLRelayClientFactory = createDecorator('w export interface IWSLRelayClientFactory { readonly _serviceBrand: undefined; - createClient(mainService: IWSLRemoteAgentHostMainService, connectionId: string, address: string): AgentHostProtocolClient; + createClient(mainService: IWSLRemoteAgentHostMainService, connectionId: string, address: string, connection: IWSLConnectResult, remoteAgentHostCommand: string | undefined): AgentHostProtocolClient; } export class WSLRelayClientFactory implements IWSLRelayClientFactory { @@ -45,16 +47,57 @@ export class WSLRelayClientFactory implements IWSLRelayClientFactory { @IInstantiationService private readonly _instantiationService: IInstantiationService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentService private readonly _environmentService: IEnvironmentService, + @ILogService private readonly _logService: ILogService, ) { } - createClient(mainService: IWSLRemoteAgentHostMainService, connectionId: string, address: string): AgentHostProtocolClient { - const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); - const logger = ahpLoggingEnabled ? this._instantiationService.createInstance( - AhpJsonlLogger, - { logsHome: this._environmentService.logsHome, connectionId, transport: 'wsl' }, - ) : undefined; - const transport = this._instantiationService.createInstance(WSLRelayTransport, connectionId, mainService, logger); - return this._instantiationService.createInstance(AgentHostProtocolClient, address, transport, undefined, undefined, agentsWindowAgentHostClientInfo); + createClient(mainService: IWSLRemoteAgentHostMainService, connectionId: string, address: string, connection: IWSLConnectResult, remoteAgentHostCommand: string | undefined): AgentHostProtocolClient { + const config: IWSLAgentHostConfig = { + distro: connection.distro, + name: connection.name, + remoteAgentHostCommand, + }; + let seedConnection = true; + const establish = async () => { + // WSL disconnect is distro-scoped, so handles own no teardown; reconnect supersedes stale channels. + if (seedConnection) { + // The caller owns teardown of the channel established before the protocol client was created. + seedConnection = false; + return { connectionId }; + } + + try { + const result = await mainService.reconnect(config.distro, config.name, config.remoteAgentHostCommand); + return { + connectionId: result.connectionId, + }; + } catch (error) { + const [isWSLAvailable, distros] = await Promise.all([ + mainService.isWSLAvailable().catch(() => true), + mainService.listDistros().catch(() => []), + ]); + if (!isWSLAvailable || (distros.length > 0 && !distros.some(distro => distro.name === config.distro))) { + throw new NonReconnectableTransportError(error instanceof Error ? error.message : String(error)); + } + throw error; + } + }; + const transportFactory = () => { + const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); + const createLogger = () => ahpLoggingEnabled ? this._instantiationService.createInstance( + AhpJsonlLogger, + { logsHome: this._environmentService.logsHome, connectionId, transport: 'wsl' }, + ) : undefined; + return this._instantiationService.createInstance( + ReconnectingRelayTransport, + establish, + mainService, + createLogger, + this._logService, + '[WSLRelayTransport]', + AgentHostClientConnectionKind.WSL, + ); + }; + return this._instantiationService.createInstance(AgentHostProtocolClient, address, transportFactory, { clientInfo: agentsWindowAgentHostClientInfo }); } } @@ -109,7 +152,7 @@ export class WSLRemoteAgentHostService extends Disposable implements IWSLRemoteA this._onDidChangeConnections.fire(); // Defense-in-depth: also signal the protocol client directly. - // The WSLRelayTransport normally observes `onDidRelayClose` + // ReconnectingRelayTransport normally observes `onDidRelayClose` // (fired from the same shared-process code path as this // event) and calls back into the client. If that IPC // delivery is missed for any reason, the renderer-side @@ -148,7 +191,7 @@ export class WSLRemoteAgentHostService extends Disposable implements IWSLRemoteA this._logService.info(`[WSLRemoteAgentHost] Connecting to distro ${config.distro}`); const result = await this._mainService.connect(augmentedConfig); this._logService.trace(`[WSLRemoteAgentHost] WSL relay established, connectionId=${result.connectionId}`); - return this._setupConnection(result); + return this._setupConnection(result, augmentedConfig.remoteAgentHostCommand); } async disconnect(distro: string): Promise { @@ -164,7 +207,7 @@ export class WSLRemoteAgentHostService extends Disposable implements IWSLRemoteA const commandOverride = this._getRemoteAgentHostCommand(); this._logService.info(`[WSLRemoteAgentHost] Reconnecting to distro ${distro}`); const result = await this._mainService.reconnect(distro, name, commandOverride); - return this._setupConnection(result); + return this._setupConnection(result, commandOverride); } /** @@ -172,7 +215,7 @@ export class WSLRemoteAgentHostService extends Disposable implements IWSLRemoteA * with IRemoteAgentHostService. Any failure after the shared-process tunnel * was established tears it back down so we don't leak it. */ - private async _setupConnection(result: IWSLConnectResult): Promise { + private async _setupConnection(result: IWSLConnectResult, remoteAgentHostCommand: string | undefined): Promise { const existing = this._connections.get(result.connectionId); if (existing) { if (this._remoteAgentHostService.getConnection(result.address)) { @@ -194,7 +237,7 @@ export class WSLRemoteAgentHostService extends Disposable implements IWSLRemoteA connectionKey: result.address, message: localize('wslProgressHandshake', "Establishing connection to {0}...", result.name), }); - protocolClient = this._relayClientFactory.createClient(this._mainService, result.connectionId, result.address); + protocolClient = this._relayClientFactory.createClient(this._mainService, result.connectionId, result.address, result, remoteAgentHostCommand); await protocolClient.connect(); this._logService.trace('[WSLRemoteAgentHost] Protocol handshake completed'); diff --git a/src/vs/platform/agentHost/node/agentHostChatContributionsService.ts b/src/vs/platform/agentHost/node/agentHostChatContributionsService.ts index 2f8c377836c120..6f273a6101dff7 100644 --- a/src/vs/platform/agentHost/node/agentHostChatContributionsService.ts +++ b/src/vs/platform/agentHost/node/agentHostChatContributionsService.ts @@ -8,7 +8,7 @@ import { NKeyMap } from '../../../base/common/map.js'; import { observableValue, type ISettableObservable } from '../../../base/common/observable.js'; import { IInstantiationService, type IConstructorSignature } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; -import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IAgentHostChatContributionHost, IAgentHostChatContributions, IChatMementoKey, IHydrationContext, IIncomingRequest, IObservedAction, IOutgoingTurn, IOutgoingTurnContributionResult, IncomingRequestDisposition, IRestoredChat, ISessionMementoKey, ITurnEnd } from '../common/agentHostChatContributionsService.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IAgentHostChatContributionHost, IAgentHostChatContributions, IChatMementoKey, IHydrationContext, IIncomingRequest, IAppliedClientAction, IDispatchedAction, IOutgoingTurn, IOutgoingTurnContributionResult, IncomingRequestDisposition, IRestoredChat, ISessionMementoKey, ITurnEnd } from '../common/agentHostChatContributionsService.js'; import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js'; type MementoKeySegment = string | boolean | number; @@ -146,14 +146,28 @@ export class AgentHostChatContributions extends Disposable implements IAgentHost } } - action(action: IObservedAction): void { + didApplyClientAction(action: IAppliedClientAction): void { for (const registration of this._getOrderedContributions()) { const { contribution } = registration; - if (!contribution.onAction) { + if (!contribution.onDidApplyClientAction) { continue; } try { - contribution.onAction(action); + contribution.onDidApplyClientAction(action); + } catch (err) { + this._logContributionFailure(registration, err); + } + } + } + + didDispatchAction(dispatched: IDispatchedAction): void { + for (const registration of this._getOrderedContributions()) { + const { contribution } = registration; + if (!contribution.onDidDispatchAction) { + continue; + } + try { + contribution.onDidDispatchAction(dispatched); } catch (err) { this._logContributionFailure(registration, err); } diff --git a/src/vs/platform/agentHost/node/agentHostServices.ts b/src/vs/platform/agentHost/node/agentHostServices.ts index fcaef8c309305e..6cee16d28a5312 100644 --- a/src/vs/platform/agentHost/node/agentHostServices.ts +++ b/src/vs/platform/agentHost/node/agentHostServices.ts @@ -55,6 +55,7 @@ import { AgentHostSessionOpenTelemetry, IAgentHostSessionOpenTelemetry } from '. import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js'; import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; +import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from './agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from './agentHostTurnTracker.js'; import { AgentHostProviderService, IAgentHostProviderService } from './agentHostProviderService.js'; import { AgentEditAttributionService } from './shared/agentEditAttributionService.js'; @@ -100,6 +101,7 @@ export function registerAgentHostCoreServices(services: ServiceCollection, input services.set(IAgentHostChatContributions, new SyncDescriptor(AgentHostChatContributions)); services.set(IAgentHostTelemetryReporter, new SyncDescriptor(AgentHostTelemetryReporter)); services.set(IAgentHostTurnTracker, new SyncDescriptor(AgentHostTurnTracker)); + services.set(IAgentHostToolCallTracker, new SyncDescriptor(AgentHostToolCallTracker)); services.set(IAgentHostProviderService, new SyncDescriptor(AgentHostProviderService)); services.set(IAgentBranchNameGenerator, new SyncDescriptor(AgentBranchNameGenerator)); services.set(IAgentHostWorktreeIsolation, new SyncDescriptor(WorktreeIsolation)); diff --git a/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts b/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts index 7eaa895b9caa78..1406ab8c073987 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts @@ -9,13 +9,15 @@ import { StopWatch } from '../../../base/common/stopwatch.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AgentSession } from '../common/agent.js'; +import type { AgentProvider } from '../common/agent.js'; import { isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; +import { IAgentHostProviderService } from './agentHostProviderService.js'; -export const AgentHostCopilotSessionSubscribeTimeoutMs = 60_000; +export const AgentHostSessionSubscribeTimeoutMs = 60_000; -export type AgentHostCopilotSessionSubscribeChannel = 'session' | 'defaultChat' | 'chat'; -export type AgentHostCopilotSessionSubscribeOutcome = 'success' | 'failure' | 'timeout'; +export type AgentHostSessionSubscribeProvider = AgentProvider; +export type AgentHostSessionSubscribeChannel = 'session' | 'defaultChat' | 'chat'; +export type AgentHostSessionSubscribeOutcome = 'success' | 'failure' | 'timeout'; export type AgentHostCopilotSdkResumeOutcome = 'success' | 'failure' | 'fallbackCreate' | 'incomplete' | 'notStarted'; export interface IAgentHostSessionOpenTelemetryScope { @@ -35,13 +37,14 @@ export interface IAgentHostSessionOpenTelemetry { export const IAgentHostSessionOpenTelemetry = createDecorator('agentHostSessionOpenTelemetry'); -type AgentHostCopilotSessionSubscribeEvent = { +type AgentHostSessionSubscribeEvent = { + provider: string; channel: string; outcome: string; servedFromMemory: boolean | undefined; joinedRestore: boolean | undefined; - sdkResumeOutcome: string; - sdkResumeAttemptCount: number; + sdkResumeOutcome: string | undefined; + sdkResumeAttemptCount: number | undefined; timeToRestoreStartMs: number | undefined; timeToSdkResumeStartMs: number | undefined; sdkResumeDurationMs: number | undefined; @@ -50,9 +53,10 @@ type AgentHostCopilotSessionSubscribeEvent = { totalDurationMs: number; }; -type AgentHostCopilotSessionSubscribeClassification = { +type AgentHostSessionSubscribeClassification = { owner: 'roblourens'; - comment: 'Measures Copilot Agent Host subscription latency from the subscribe request through session restoration and SDK resume to the returned snapshot.'; + comment: 'Measures Agent Host subscription latency from the subscribe request through session restoration and provider-specific resume work to the returned snapshot.'; + provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Agent provider identifier.' }; channel: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Bounded subscribed channel kind: session, defaultChat, or chat.' }; outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Terminal subscription outcome: success, failure, or timeout.' }; servedFromMemory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the subscribed snapshot was already materialized when the request was received.' }; @@ -72,7 +76,7 @@ class AgentHostSessionOpenTelemetryAttempt extends Disposable { readonly resources = this._register(new DisposableStore()); servedFromMemory: boolean | undefined; joinedRestore: boolean | undefined; - sdkResumeOutcome: AgentHostCopilotSdkResumeOutcome = 'notStarted'; + sdkResumeOutcome: AgentHostCopilotSdkResumeOutcome | undefined; sdkResumeAttemptCount = 0; timeToRestoreStartMs: number | undefined; timeToSdkResumeStartMs: number | undefined; @@ -84,9 +88,11 @@ class AgentHostSessionOpenTelemetryAttempt extends Disposable { constructor( readonly id: number, readonly session: URI, - readonly channel: AgentHostCopilotSessionSubscribeChannel, + readonly provider: AgentHostSessionSubscribeProvider, + readonly channel: AgentHostSessionSubscribeChannel, ) { super(); + this.sdkResumeOutcome = provider === 'copilotcli' ? 'notStarted' : undefined; } } @@ -99,6 +105,7 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH constructor( @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, ) { super(); this._register(toDisposable(() => { @@ -170,7 +177,7 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH return undefined; } - const attempt = new AgentHostSessionOpenTelemetryAttempt(this._nextAttemptId++, info.session, info.channel); + const attempt = new AgentHostSessionOpenTelemetryAttempt(this._nextAttemptId++, info.session, info.provider, info.channel); this._attempts.set(attempt.id, attempt); let sessionAttempts = this._attemptsBySession.get(info.session.toString()); if (!sessionAttempts) { @@ -178,7 +185,7 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH this._attemptsBySession.set(info.session.toString(), sessionAttempts); } sessionAttempts.add(attempt); - attempt.resources.add(disposableTimeout(() => this._finish(attempt, 'timeout', attempt.servedFromMemory), AgentHostCopilotSessionSubscribeTimeoutMs)); + attempt.resources.add(disposableTimeout(() => this._finish(attempt, 'timeout', attempt.servedFromMemory), AgentHostSessionSubscribeTimeoutMs)); return attempt; } @@ -221,7 +228,7 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH } } - private _finish(attempt: AgentHostSessionOpenTelemetryAttempt, outcome: AgentHostCopilotSessionSubscribeOutcome, servedFromMemory: boolean | undefined): void { + private _finish(attempt: AgentHostSessionOpenTelemetryAttempt, outcome: AgentHostSessionSubscribeOutcome, servedFromMemory: boolean | undefined): void { if (!this._attempts.delete(attempt.id)) { return; } @@ -252,13 +259,14 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH const totalDurationMs = Math.max(timeToRestoreCompleteMs ?? timeToSdkResumeCompleteMs ?? timeToRestoreStartMs ?? 0, elapsed); attempt.dispose(); - this._telemetryService.publicLog2('agentHost.copilotSessionSubscribe', { + this._telemetryService.publicLog2('agentHost.sessionSubscribe', { + provider: attempt.provider, channel: attempt.channel, outcome, servedFromMemory, joinedRestore: attempt.joinedRestore, sdkResumeOutcome: attempt.sdkResumeOutcome, - sdkResumeAttemptCount: attempt.sdkResumeAttemptCount, + sdkResumeAttemptCount: attempt.provider === 'copilotcli' ? attempt.sdkResumeAttemptCount : undefined, timeToRestoreStartMs, timeToSdkResumeStartMs, sdkResumeDurationMs: attempt.sdkResumeAttemptCount > 0 ? attempt.sdkResumeDurationMs : undefined, @@ -280,16 +288,18 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH return Math.max(0, Math.round(attempt.stopwatch.elapsed())); } - private _classify(resource: URI): { readonly session: URI; readonly channel: AgentHostCopilotSessionSubscribeChannel } | undefined { + private _classify(resource: URI): { readonly session: URI; readonly provider: AgentHostSessionSubscribeProvider; readonly channel: AgentHostSessionSubscribeChannel } | undefined { const resourceString = resource.toString(); const session = isAhpChatChannel(resourceString) ? URI.parse(parseRequiredSessionUriFromChatUri(resourceString)) : resource; - if (AgentSession.provider(session) !== 'copilotcli') { + const provider = this._providerService.getProviderForSession(session)?.id; + if (!provider) { return undefined; } return { session, + provider, channel: !isAhpChatChannel(resourceString) ? 'session' : isDefaultChatUri(resource) ? 'defaultChat' : 'chat', }; } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index 7b20e8f366e910..d6b7dbc68bb071 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -54,6 +54,9 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT private _restrictedTelemetryEnabled = false; private _internalTelemetryEnabled = false; + /** Whether the machine itself is internal, captured before any account can override it. */ + private readonly _internalMachine: boolean; + constructor( private readonly _delegate: ITelemetryService, private readonly _restricted?: IAgentHostRestrictedTelemetry, @@ -63,6 +66,7 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT ) { super(); this._telemetryLevel = initialTelemetryLevel; + this._internalMachine = _delegate.msftInternal === true; if (isDisposable(_delegate)) { this._register(_delegate); } @@ -188,6 +192,10 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT setInternalTelemetryContext(context: IAgentHostInternalTelemetryContext | undefined): void { this._internalTelemetryEnabled = context?.isInternal === true; + if (context) { + // Signing in to an external account has to clear the account half of the signal again. + this._delegate.setCommonProperty('common.msftInternal', this._internalMachine || this._internalTelemetryEnabled); + } this._restricted?.setInternalTelemetryContext(context); } diff --git a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts index 6092322756fd82..4da05ff7c3c195 100644 --- a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts @@ -6,10 +6,13 @@ import { disposableTimeout } from '../../../base/common/async.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import type { SessionToolAuthenticationRequest, SessionToolClientExecutionRequest, SessionToolConfirmationRequest } from '../common/state/protocol/state.js'; -import { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js'; -import type { AgentHostModelTelemetryKind, AgentHostTelemetryReporter, IAgentHostToolInvokedReport } from './agentHostTelemetryReporter.js'; +import { type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js'; +import { IAgentHostTelemetryReporter, type AgentHostModelTelemetryKind, type AgentHostTelemetryReporter, type IAgentHostToolInvokedReport } from './agentHostTelemetryReporter.js'; +import { IAgentHostTurnTracker, type AgentHostTurnTracker } from './agentHostTurnTracker.js'; +import { canRefineContributor, toolSourceKindFromContributor } from './shared/toolCallContributor.js'; export type ToolInvokedResult = 'success' | 'error' | 'userCancelled'; @@ -34,41 +37,6 @@ export function deriveToolInvokedResult(result: ToolCallResult): ToolInvokedResu return 'error'; } -/** - * Maps a tool call's contributor to the telemetry `toolSourceKind`. A tool with - * no contributor is provided by the agent host itself; an MCP contributor maps - * to `mcp` and a client contributor to `client`. - */ -export function toolSourceKindFromContributor(contributor: ToolCallContributor | undefined): string { - if (!contributor) { - return 'agentHost'; - } - // Widen to `string` so an unrecognized kind from a newer protocol version - // falls through to a valid telemetry value rather than `undefined`. - const kind: string = contributor.kind; - switch (kind) { - case ToolCallContributorKind.MCP: - return 'mcp'; - case ToolCallContributorKind.Client: - return 'client'; - default: - return kind; - } -} - -/** - * Whether `next` is a safe refinement of the currently recorded contributor. - * A client contributor may only be refined by the same client, and a client - * contributor is never replaced by a non-client one, so execution ownership - * cannot be reassigned by a later, less specific signal. - */ -export function canRefineContributor(current: ToolCallContributor | undefined, next: ToolCallContributor): boolean { - if (current?.kind === ToolCallContributorKind.Client) { - return next.kind === ToolCallContributorKind.Client && next.clientId === current.clientId; - } - return next.kind !== ToolCallContributorKind.Client; -} - /** Per-tool-call timing state, keyed by `session:toolCallId`. */ interface IToolCallTiming { readonly lifecycleStopWatch: StopWatch; @@ -104,8 +72,12 @@ interface IStalledToolCall { * tool call) are dropped via {@link clearSession} / {@link clear} so the * tracking map cannot leak. */ +export const IAgentHostToolCallTracker = createDecorator('agentHostToolCallTracker'); + export class AgentHostToolCallTracker extends Disposable { + declare readonly _serviceBrand: undefined; + private readonly _toolCalls = new Map(); private readonly _turnModels = new Map(); private readonly _pendingToolReports = new Map(); @@ -113,8 +85,8 @@ export class AgentHostToolCallTracker extends Disposable { private readonly _stalledToolCalls = new Map(); constructor( - private readonly _reporter: AgentHostTelemetryReporter, - private readonly _getClientContext: (session: string, turnId: string) => IAgentHostClientTelemetryContext | undefined = () => undefined, + @IAgentHostTelemetryReporter private readonly _reporter: AgentHostTelemetryReporter, + @IAgentHostTurnTracker private readonly _turnTracker: AgentHostTurnTracker, ) { super(); } @@ -132,7 +104,7 @@ export class AgentHostToolCallTracker extends Disposable { model: resolvedModel?.model ?? model, modelTelemetryKind: resolvedModel?.modelTelemetryKind ?? modelTelemetryKind, modelResolvedFromUsage: resolvedModel !== undefined, - clientContext: this._getClientContext(session, turnId), + clientContext: this._turnTracker.getClientTelemetryContext(session, turnId), }); } diff --git a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts index cda07388f6dcf2..8c0987167dc7b2 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts @@ -10,7 +10,7 @@ import type { IAgent } from '../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; import { ActionType } from '../common/state/sessionActions.js'; -import { createErrorResponsePart, type Message, type URI as ProtocolURI } from '../common/state/sessionState.js'; +import { createErrorResponsePart, type ErrorInfo, type Message, type URI as ProtocolURI } from '../common/state/sessionState.js'; import { createAgentChatContext } from './agentChatContext.js'; import { IAgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; @@ -37,7 +37,22 @@ export interface ITurnStartRequest { readonly turnStopWatch: StopWatch; } -/** Admits a turn, records its telemetry, and resolves the provider that will send it. */ +/** + * Admits a turn, records its telemetry, and resolves the provider that will send it. + * Both the direct (`handleAction`) and queued (`QueueDrainContribution`) admission paths + * run this, so the preamble has one copy. + * + * This is a plain function rather than a service because it holds no state: registering it + * in DI bought nothing and cost a registration in every hand-built test service graph. Keep + * it a function unless it acquires state. + * + * The gate runs before {@link IAgentHostTurnTracker.turnStarted}, so a refused request has + * no turn to complete: it reports neither `userMessageSent` nor `turnCompleted`, and does + * not call `_completeTurn` or clear the tool-call tracker. That is deliberate, not an + * oversight — a rejection is observable through `onTurnEnd` with `kind === 'rejected'`, so + * measure rejection volume from a contribution rather than by reviving the turn-completion + * path. + */ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest): IStartedTurn | undefined { const chatContributions = accessor.get(IAgentHostChatContributions); const stateManager = accessor.get(IAgentHostStateManager); @@ -66,6 +81,13 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest duration: Math.max(0, request.turnStopWatch.elapsed()), part: createErrorResponsePart(disposition.error), }); + chatContributions.turnEnd({ + session: request.session, + channel: request.turnChannel, + turnId: request.turnId, + reason: { kind: 'rejected', error: disposition.error }, + clientContext: request.clientContext, + }); return undefined; } @@ -77,11 +99,19 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest const agent = providerService.getProviderForSession(request.session); if (!agent) { + const error: ErrorInfo = { errorType: 'noAgent', message: 'No agent found for session' }; stateManager.dispatchServerAction(request.turnChannel, { type: ActionType.ChatError, turnId: request.turnId, duration: Math.max(0, request.turnStopWatch.elapsed()), - part: createErrorResponsePart({ errorType: 'noAgent', message: 'No agent found for session' }), + part: createErrorResponsePart(error), + }); + chatContributions.turnEnd({ + session: request.session, + channel: request.turnChannel, + turnId: request.turnId, + reason: { kind: 'rejected', error }, + clientContext: request.clientContext, }); return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 482a1dfc69bbd7..81caac77519b06 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -16,7 +16,7 @@ import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTele import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; import { ILogService } from '../../log/common/log.js'; -import { canRefineContributor, toolSourceKindFromContributor } from './agentHostToolCallTracker.js'; +import { canRefineContributor, toolSourceKindFromContributor } from './shared/toolCallContributor.js'; import { SessionInputRequestKind } from '../common/state/protocol/state.js'; import type { ITurnTokenTotal, ToolCallContributor } from '../common/state/sessionState.js'; import { IAgentHostTelemetryReporter, type AgentHostInitiatorClientConnectionState, type AgentHostMessageOriginTelemetryKind, type AgentHostModelTelemetryKind, type AgentHostProviderDiagnosticState, type AgentHostTelemetryReporter, type AgentHostTurnFailureStage, type AgentHostTurnHangReason, type AgentHostTurnResult, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; @@ -374,11 +374,17 @@ export class AgentHostTurnTracker extends Disposable { return this._turnTimings.get(this._key(session, turnId))?.initiatorClientId; } - turnCompleted(session: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure, workspace?: { readonly isMultiRoot: boolean; readonly folderCount: number }): void { + /** + * Records a turn's completion. Returns whether a tracked turn actually ended: + * `false` means no turn was in flight for `turnId`, which happens for a stale or + * duplicate terminal action that the reducer also no-ops. Callers that run + * end-of-turn side effects should skip them when this returns `false`. + */ + turnCompleted(session: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure, workspace?: { readonly isMultiRoot: boolean; readonly folderCount: number }): boolean { const key = this._key(session, turnId); const timing = this._turnTimings.get(key); if (!timing) { - return; + return false; } const usage = this._turnUsages.get(key); this._disposeTurn(key, timing); @@ -425,6 +431,7 @@ export class AgentHostTurnTracker extends Disposable { timeAfterHangMs: timing.lastHangStopWatch?.elapsed() ?? 0, }); } + return true; } /** diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e1fcc51e24ee51..8a3206b03583c6 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -2050,9 +2050,6 @@ export class AgentService extends Disposable implements IAgentService { const startedAt = Date.now(); // The first list waits for registration-time legacy migration if it is still in flight. await this._awaitInitialProviderMigration(); - if (epoch !== this._registryEpoch) { - return this.listSessions(mode); - } // The registry is the source of truth for top-level sessions. Internal // chat backings and subagent sessions never enter it; ephemeral sessions // are tombstoned at creation. A transiently missing provider snapshot no @@ -2297,9 +2294,6 @@ export class AgentService extends Disposable implements IAgentService { } else { this._logService.trace(message); } - if (epoch !== this._registryEpoch) { - return this.listSessions(mode); - } return visible; } @@ -5087,7 +5081,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.warn(`[AgentService] Failed to surface adopted session ${sessionStr} before restore`, err); } } - const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', registeredSession, awaitCatalogReadable, !!registeredSession, adoption.worktree); + const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', awaitCatalogReadable, !!registeredSession, adoption.worktree); await this._restoreAnnotations(session); if (adopted) { // Discovery never surfaced this chat when migration was enabled after @@ -5193,14 +5187,14 @@ export class AgentService extends Disposable implements IAgentService { * Returns the facts used for migration telemetry; throws if any required step * fails so the caller can report the outcome accurately. */ - private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], registryFallback: Pick | undefined, awaitCatalogReadable: () => Promise, sessionKnownToRegistry: boolean, adoptionWorktree: IAgentAdoptedWorktree | undefined): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { + private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], awaitCatalogReadable: () => Promise, sessionKnownToRegistry: boolean, adoptionWorktree: IAgentAdoptedWorktree | undefined): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { this._logService.trace(`[AgentService] restore: reading provider metadata for ${sessionStr}`); - let meta = await this._getSessionMetadataForRestore(agent, session, external, registryFallback); + let meta = await this._getSessionMetadataForRestore(agent, session, external); if (!meta) { // Only a miss needs the catalogue: it decides whether the session is // genuinely absent, and warming it may enumerate thousands of sessions. const catalogReadable = await awaitCatalogReadable(); - meta = await this._getSessionMetadataForRestore(agent, session, external, registryFallback); + meta = await this._getSessionMetadataForRestore(agent, session, external); // The registry is backfilled by that same pass, so re-read it before // concluding the session is unknown. const knownToRegistry = sessionKnownToRegistry || (await this._listRegisteredSessions()).some(entry => entry.session.toString() === sessionStr); @@ -5444,6 +5438,9 @@ export class AgentService extends Disposable implements IAgentService { session: sessionStr, chat: defaultChatUri.toString(), }, {}); + // This overlay stays here rather than moving into `ChatDraftContribution`: it seeds + // the draft's model from `IAgent`-supplied session metadata, so it is provider-shaped, + // and moving it would put provider metadata into `IHydrationContext` for one consumer. const restoredDraft = meta.model ? { ...(defaultDraft ?? { text: '', origin: { kind: MessageKind.User } }), model: meta.model } : defaultDraft; @@ -6010,14 +6007,11 @@ export class AgentService extends Disposable implements IAgentService { } } - private async _getSessionMetadataForRestore(agent: IAgent, session: URI, external: boolean, registryFallback: Pick | undefined): Promise { + private async _getSessionMetadataForRestore(agent: IAgent, session: URI, external: boolean): Promise { const sessionStr = session.toString(); const chat = URI.parse(buildDefaultChatUri(session)); try { - const metadata = await agent.getChatMetadata(chat, this._chatContext(session, chat), await this._readDefaultChatProviderData(session), { - activation: 'restore', - ...(registryFallback ? { registryFallback } : {}), - }); + const metadata = await agent.getChatMetadata(chat, this._chatContext(session, chat), await this._readDefaultChatProviderData(session), { activation: 'restore' }); return await this._withWorktreeProject(session, metadata ? this._toSessionMetadata(metadata) : undefined); } catch (err) { if (err instanceof ProtocolError) { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index ef64a75885f465..c6cbdfbab0d942 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -5,7 +5,7 @@ import { getErrorCode } from '../../../base/common/errors.js'; import type { Event } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable, IReference } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { NKeyMap } from '../../../base/common/map.js'; import { equals } from '../../../base/common/objects.js'; import { autorun, IObservable, IReader } from '../../../base/common/observable.js'; @@ -24,16 +24,15 @@ import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPending import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { resolveChatAttachment } from '../common/state/chatAttachmentContext.js'; import { buildOpenSessionLinkForChatResource } from '../common/openSessionLink.js'; -import { SessionInputRequestKind, ToolCallContributorKind, type AgentInfo, type SessionActiveClient, type SessionInputRequest } from '../common/state/protocol/state.js'; +import { ToolCallContributorKind, type AgentInfo, type SessionActiveClient } from '../common/state/protocol/state.js'; import type { CustomizationEnablement } from '../common/state/protocol/channels-session/state.js'; -import { ActionType, isChatAction, StateAction, type ChatAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; +import { ActionType, isChatAction, StateAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; import { buildSubagentChatUri, - chatStorageUri, createErrorResponsePart, getErrorResponsePart, getToolFileEdits, @@ -42,8 +41,6 @@ import { buildDefaultChatUri, isSubagentChatUri, mergeLogicalTurnUsage, - AH_META_IS_ARCHIVED_DB_KEY, - AH_META_IS_READ_DB_KEY, MessageAttachmentKind, MessageKind, parseRequiredSessionUriFromChatUri, @@ -60,7 +57,6 @@ import { type Message, type MessageAttachment, type URI as ProtocolURI, - type ToolCallState, type ToolCallResult, type ToolResultContent, type Turn, @@ -76,7 +72,7 @@ import { AgentHostStateManager, resolveChatStateForUri } from './agentHostStateM import { IAgentConfigurationService } from './agentConfigurationService.js'; import { createAgentChatContext, getSessionChatsForFanOut } from './agentChatContext.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter, type AgentHostTurnFailureStage, type AgentHostTurnResult, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; -import { AgentHostToolCallTracker } from './agentHostToolCallTracker.js'; +import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from './agentHostToolCallTracker.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { getConfiguredSessionMode, getModelTelemetryContext, getTurnTelemetryContext } from './agentHostTurnTelemetryContext.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from './agentHostTurnTracker.js'; @@ -85,7 +81,7 @@ import { startTurn } from './agentHostTurnStarter.js'; import './localCommands/localChatCommands.contribution.js'; import { SessionPermissionManager } from './sessionPermissions.js'; import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/proxyChatError.js'; -import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; import { targetForMcpServer, targetForPlugin } from './shared/customizationEnablementGate.js'; import { IAgentHostWorktreeIsolation } from './shared/worktreeIsolation.js'; @@ -217,7 +213,6 @@ export class AgentSideEffects extends Disposable { * */ private readonly _pendingSubagentSignals = new NKeyMap(); - private readonly _toolCallTracker: AgentHostToolCallTracker; private readonly _inputRequestTracker: AgentHostInputRequestTracker; /** * Fires with the provider id whenever a turn starts. Surfaced so @@ -239,11 +234,11 @@ export class AgentSideEffects extends Disposable { @IAgentHostSessionTitleController private readonly _titleController: IAgentHostSessionTitleController, @IAgentHostTelemetryReporter private readonly _telemetryReporter: AgentHostTelemetryReporter, @IAgentHostTurnTracker private readonly _turnTracker: AgentHostTurnTracker, + @IAgentHostToolCallTracker private readonly _toolCallTracker: AgentHostToolCallTracker, @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, ) { super(); this.onDidStartTurn = this._turnTracker.onDidStartTurn; - this._toolCallTracker = this._register(new AgentHostToolCallTracker(this._telemetryReporter, (session, turnId) => this._turnTracker.getClientTelemetryContext(session, turnId))); this._inputRequestTracker = new AgentHostInputRequestTracker(this._telemetryReporter, undefined, (session, turnId) => this._turnTracker.getClientTelemetryContext(session, turnId)); this._permissionManager = this._register(this._instantiationService.createInstance(SessionPermissionManager, this._stateManager, {})); this._register(this._stateManager.onDidSnapshotDefaultChatTitle(event => this._persistDefaultChatTitleSnapshot(event.session, event.chat, event.title))); @@ -352,8 +347,6 @@ export class AgentSideEffects extends Disposable { const sessionChannel = parseRequiredSessionUriFromChatUri(envelope.channel); void this._checkpointService.discardTurnStartCheckpoint(URI.parse(sessionChannel), URI.parse(envelope.channel), envelope.action.turnId).catch(() => undefined); } - this._syncSessionInputNeededForChatAction(envelope.channel, envelope.action); - this._trackTurnUsage(envelope.channel, envelope.action); } if (!envelope.origin && envelope.action.type === ActionType.ChatToolCallComplete) { const action = envelope.action; @@ -361,11 +354,10 @@ export class AgentSideEffects extends Disposable { // agents are keyed by session URI, so resolve back to the // owning session before notifying the agent. Pass the chat URI // alongside so agents that track peer chats can route correctly. - if (!isAhpChatChannel(envelope.channel)) { - return; // Not a chat channel; ignore (already logged elsewhere). + if (isAhpChatChannel(envelope.channel)) { + const sessionChannel = parseRequiredSessionUriFromChatUri(envelope.channel); + this._notifyClientToolCallComplete(sessionChannel, envelope.channel, action.toolCallId, action.result, 'server-envelope'); } - const sessionChannel = parseRequiredSessionUriFromChatUri(envelope.channel); - this._notifyClientToolCallComplete(sessionChannel, envelope.channel, action.toolCallId, action.result, 'server-envelope'); } // A chat joining the catalog changes the session's authoritative // membership, so every already-contributing client is re-fanned-out @@ -377,22 +369,12 @@ export class AgentSideEffects extends Disposable { this._fanOutActiveClient(envelope.channel, activeClient); } } - if (envelope.action.type === ActionType.SessionConfigChanged) { - const values = this._stateManager.getSessionState(envelope.channel)?.config?.values; - if (values) { - this._persistSessionFlag(envelope.channel, 'configValues', JSON.stringify(values)); - } - } - // Persisting here rather than in `handleAction` covers client- and - // server-dispatched changes alike, so no dispatch path can skip it. - // Rejected actions never reached state and must not be written. - if (!envelope.rejectionReason) { - if (envelope.action.type === ActionType.SessionIsReadChanged) { - this._persistSessionFlag(envelope.channel, AH_META_IS_READ_DB_KEY, envelope.action.isRead ? 'true' : ''); - } else if (envelope.action.type === ActionType.SessionIsArchivedChanged) { - this._persistSessionFlag(envelope.channel, AH_META_IS_ARCHIVED_DB_KEY, envelope.action.isArchived ? 'true' : ''); - } - } + this._chatContributions.didDispatchAction({ + channel: envelope.channel, + session: isAhpChatChannel(envelope.channel) ? parseRequiredSessionUriFromChatUri(envelope.channel) : envelope.channel, + action: envelope.action, + ...(envelope.rejectionReason !== undefined ? { rejectionReason: envelope.rejectionReason } : {}), + }); })); } @@ -544,184 +526,6 @@ export class AgentSideEffects extends Disposable { } } - // ---- Session input-needed aggregation ---------------------------------- - // - // Mirrors per-chat blockers (user-input elicitations, tool confirmations, - // client-tool executions, and MCP authentication) into the owning session's - // `inputNeeded` list so clients subscribed only to the session channel can - // discover and answer them without subscribing to each chat. This handler - // only produces the state; it does not consume it. - - private _syncSessionInputNeededForChatAction(chatUri: ProtocolURI, action: ChatAction): void { - switch (action.type) { - case ActionType.ChatInputRequested: - this._syncChatInputNeeded(chatUri, action.request.id); - break; - case ActionType.ChatInputAnswerChanged: - this._syncChatInputNeeded(chatUri, action.requestId); - break; - case ActionType.ChatInputCompleted: - this._removeSessionInputNeeded(chatUri, this._chatInputNeededId(chatUri, action.requestId)); - break; - case ActionType.ChatToolCallStart: - case ActionType.ChatToolCallReady: - case ActionType.ChatToolCallConfirmed: - case ActionType.ChatToolCallComplete: - case ActionType.ChatToolCallResultConfirmed: - case ActionType.ChatToolCallAuthRequired: - case ActionType.ChatToolCallAuthResolved: - this._syncToolInputNeeded(chatUri, action.turnId, action.toolCallId); - break; - case ActionType.ChatTurnComplete: - case ActionType.ChatTurnCancelled: - case ActionType.ChatError: - case ActionType.ChatTruncated: - this._removeSessionInputNeededForChat(chatUri); - break; - } - } - - private _syncChatInputNeeded(chatUri: ProtocolURI, requestId: string): void { - const state = this._stateManager.getSessionState(chatUri); - const part = state?.activeTurn?.responseParts.find(part => - part.kind === ResponsePartKind.InputRequest - && part.response === undefined - && part.request.id === requestId - ); - const id = this._chatInputNeededId(chatUri, requestId); - if (!part || part.kind !== ResponsePartKind.InputRequest) { - this._removeSessionInputNeeded(chatUri, id); - return; - } - this._setSessionInputNeeded(chatUri, { - id, - kind: SessionInputRequestKind.ChatInput, - chat: chatUri, - request: part.request, - }); - } - - private _syncToolInputNeeded(chatUri: ProtocolURI, turnId: string, toolCallId: string): void { - const confirmationId = this._toolConfirmationNeededId(chatUri, turnId, toolCallId); - const clientExecutionId = this._toolClientExecutionNeededId(chatUri, turnId, toolCallId); - const authenticationId = this._toolAuthenticationNeededId(chatUri, turnId, toolCallId); - const toolCall = this._findToolCall(chatUri, turnId, toolCallId); - - // A parameter gate auto-approved by the session's bypass setting never - // blocks on the user, so keep it out of the session `inputNeeded` queue - // (which would flash "input needed" in the sessions list). - // `autoApproveBySetting` covers only the parameter gate; a - // `PendingResultConfirmation` is a genuine prompt and is still surfaced. - const autoApproved = !!toolCall && readToolCallMeta(toolCall).autoApproveBySetting === true; - - const suppressAutoApprovedConfirmation = autoApproved && toolCall?.status === ToolCallStatus.PendingConfirmation; - const needsConfirmation = !suppressAutoApprovedConfirmation && (toolCall?.status === ToolCallStatus.PendingConfirmation || toolCall?.status === ToolCallStatus.PendingResultConfirmation); - if (needsConfirmation && toolCall) { - this._setSessionInputNeeded(chatUri, { - id: confirmationId, - kind: SessionInputRequestKind.ToolConfirmation, - chat: chatUri, - turnId, - toolCall, - }); - } else { - this._removeSessionInputNeeded(chatUri, confirmationId); - } - - const contributor = toolCall?.contributor; - if (toolCall?.status === ToolCallStatus.Running && contributor?.kind === ToolCallContributorKind.Client) { - this._setSessionInputNeeded(chatUri, { - id: clientExecutionId, - kind: SessionInputRequestKind.ToolClientExecution, - chat: chatUri, - turnId, - clientId: contributor.clientId, - toolCall, - }); - } else { - this._removeSessionInputNeeded(chatUri, clientExecutionId); - } - - if (toolCall?.status === ToolCallStatus.AuthRequired) { - this._setSessionInputNeeded(chatUri, { - id: authenticationId, - kind: SessionInputRequestKind.ToolAuthentication, - chat: chatUri, - turnId, - toolCall, - }); - } else { - this._removeSessionInputNeeded(chatUri, authenticationId); - } - } - - private _findToolCall(chatUri: ProtocolURI, turnId: string, toolCallId: string): ToolCallState | undefined { - const state = this._stateManager.getSessionState(chatUri); - const turn = state?.activeTurn?.id === turnId ? state.activeTurn : state?.turns.find(t => t.id === turnId); - const part = turn?.responseParts.find(p => p.kind === ResponsePartKind.ToolCall && p.toolCall.toolCallId === toolCallId); - return part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; - } - - private _setSessionInputNeeded(chatUri: ProtocolURI, request: SessionInputRequest): void { - const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); - const existing = this._stateManager.getSessionState(sessionUri)?.inputNeeded?.find(r => r.id === request.id); - if (existing && equals(existing, request)) { - return; - } - this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededSet, request }); - // Record the blocker on the turn so a hang reported while the request is - // outstanding is tagged as an expected wait on the user rather than as - // an unexplained stall, and so the report can name the tool it gates. A - // `ChatInput` elicitation carries neither `turnId` nor a tool call, so - // fall back to the chat's active turn. - const blockedTurnId = hasKey(request, { turnId: true }) ? request.turnId : this._stateManager.getActiveTurnId(chatUri); - if (blockedTurnId) { - const blockedToolCallId = hasKey(request, { toolCall: true }) ? request.toolCall.toolCallId : undefined; - this._turnTracker.turnBlocked(chatUri, blockedTurnId, request.id, request.kind, blockedToolCallId); - } - if (request.kind !== SessionInputRequestKind.ChatInput) { - const agent = this._options.getAgent(sessionUri); - if (agent) { - this._toolCallTracker.toolCallBlocked(agent.id, chatUri, request); - } - } - } - - private _removeSessionInputNeeded(chatUri: ProtocolURI, id: string): void { - const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); - this._toolCallTracker.toolCallUnblocked(chatUri, id); - this._turnTracker.turnUnblocked(chatUri, id); - if (!this._stateManager.getSessionState(sessionUri)?.inputNeeded?.some(r => r.id === id)) { - return; - } - this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededRemoved, id }); - } - - private _removeSessionInputNeededForChat(chatUri: ProtocolURI): void { - const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); - for (const request of this._stateManager.getSessionState(sessionUri)?.inputNeeded ?? []) { - if (request.chat === chatUri) { - this._removeSessionInputNeeded(chatUri, request.id); - } - } - } - - private _chatInputNeededId(chatUri: ProtocolURI, requestId: string): string { - return `chatInput:${chatUri}:${requestId}`; - } - - private _toolConfirmationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { - return `toolConfirmation:${chatUri}:${turnId}:${toolCallId}`; - } - - private _toolClientExecutionNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { - return `toolClientExecution:${chatUri}:${turnId}:${toolCallId}`; - } - - private _toolAuthenticationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { - return `toolAuthentication:${chatUri}:${turnId}:${toolCallId}`; - } - // ---- Initialization ---------------------------------------------------- /** @@ -1097,10 +901,15 @@ export class AgentSideEffects extends Disposable { this._turnTracker.modelCallCompleted(sessionKey, turnId, signal.modelCallId); } - private _completeTurn(channel: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure): void { + /** + * Records the turn's completion with the tracker. Returns whether a tracked turn + * actually ended, so callers can skip end-of-turn side effects for a stale or + * duplicate terminal action that ended nothing. + */ + private _completeTurn(channel: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure): boolean { const sessionUri = isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel; const folderCount = this._agentConfigService.getEffectiveWorkingDirectories(sessionUri)?.length ?? 0; - this._turnTracker.turnCompleted(channel, turnId, result, failure, { isMultiRoot: folderCount > 1, folderCount }); + return this._turnTracker.turnCompleted(channel, turnId, result, failure, { isMultiRoot: folderCount > 1, folderCount }); } private _runTurnCompleteSideEffects(sessionKey: ProtocolURI, turnId: string | undefined, clientContext?: IAgentHostClientTelemetryContext): void { @@ -1593,10 +1402,21 @@ export class AgentSideEffects extends Disposable { type: ActionType.ChatError, turnId: action.turnId, duration: execution.duration + execution.stopWatch.elapsed(), - part: createErrorResponsePart(failure.error), + part: createErrorResponsePart(failure.error, true), }); - this._completeTurn(channel, action.turnId, 'error', failure); + const endedTurn = this._completeTurn(channel, action.turnId, 'error', failure); this._toolCallTracker.clearSession(channel); + // A rejected resume is a terminal outcome like any other failed send, so it + // reports through the same contract rather than staying invisible. + if (endedTurn) { + this._chatContributions.turnEnd({ + session: sessionChannel, + channel, + turnId: action.turnId, + reason: { kind: 'error', error: failure.error, resumable: false }, + clientContext, + }); + } this._resumedTurnExecutions.delete(key); }); break; @@ -1645,9 +1465,22 @@ export class AgentSideEffects extends Disposable { if (!chatChannel) { throw new Error(`ChatTurnCancelled must be handled on an AHP chat channel: ${channel}`); } - this._completeTurn(channel, action.turnId, 'cancelled'); + const endedTurn = this._completeTurn(channel, action.turnId, 'cancelled'); this._resumedTurnExecutions.delete(this._resumedTurnExecutionKey(channel, action.turnId)); this._toolCallTracker.clearSession(channel); + // Keep client cancellations aligned with the agent-signal cancellation path, + // but only when a turn actually ended: the reducer no-ops a stale or duplicate + // cancellation, and reporting one would mark a read session unread for a turn + // that never stopped. + if (endedTurn) { + this._chatContributions.turnEnd({ + session: sessionChannel, + channel, + turnId: action.turnId, + reason: { kind: 'cancelled' }, + clientContext, + }); + } void this._checkpointService.discardTurnStartCheckpoint(URI.parse(sessionChannel), URI.parse(channel), action.turnId).catch(() => undefined); // Cancel all subagent sessions for this parent this.cancelSubagentSessions(channel); @@ -1667,7 +1500,7 @@ export class AgentSideEffects extends Disposable { break; } case ActionType.SessionTitleChanged: { - break; // Session title updates live in SessionTitleContribution via onAction. + break; // Session title updates live in SessionTitleContribution via onDidApplyClientAction. } case ActionType.ChatPendingMessageSet: case ActionType.ChatPendingMessageRemoved: @@ -1675,7 +1508,7 @@ export class AgentSideEffects extends Disposable { if (!chatChannel) { throw new Error(`${action.type} must be handled on an AHP chat channel: ${channel}`); } - break; // Queue policy lives in QueueDrainContribution via onAction. + break; // Queue policy lives in QueueDrainContribution via onDidApplyClientAction. } case ActionType.ChatTruncated: { if (!chatChannel) { @@ -1788,7 +1621,7 @@ export class AgentSideEffects extends Disposable { break; } } - this._chatContributions.action({ channel, session: sessionChannel, action, clientId, clientContext }); + this._chatContributions.didApplyClientAction({ channel, session: sessionChannel, action, clientId, clientContext }); } private _recordCustomizationEnablement(session: ProtocolURI, candidate: ICustomizationEnablementCandidate, enablement: readonly CustomizationEnablement[]): void { @@ -1828,15 +1661,6 @@ export class AgentSideEffects extends Disposable { this._titleController.markTitleRenamed(channel, chatChannel); } - /** - * Persists a session metadata key/value pair to the session database. - * Used for fields the host needs to remember across restarts (custom - * title, isRead/isArchived flags, merged config values). - */ - private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { - persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value); - } - private _persistDefaultChatTitleSnapshot(session: ProtocolURI, chat: ProtocolURI, title: string): void { const ref = (() => { try { @@ -1865,53 +1689,6 @@ export class AgentSideEffects extends Disposable { }).finally(() => ref.dispose()); } - /** - * Persists the usage reported for a chat's turn. - * - * Agent backends do not durably record token/credit usage themselves (the - * Copilot SDK's `assistant.usage` event is explicitly ephemeral, and the - * Claude transcript replay produces none), so a restored session would - * otherwise come back with no context-usage gauge and a session cost of 0. - * See `AgentService._applyPersistedTurnUsage` for which providers can - * currently match these rows back on restore. - * - * Written on every report rather than buffered until the turn ends: the row - * is keyed by turn id and written with `INSERT OR REPLACE` through a - * sequencer, so "last report wins" is already a property of the storage - * layer, and persisting eagerly means a turn cut short by a crash or - * disconnect keeps the usage it had already accrued. - * - * Subagent chats are skipped: their cost is already folded into the parent - * turn's aggregate, so recording it again would double-count. - */ - private _trackTurnUsage(channel: ProtocolURI, action: ChatAction): void { - if (action.type !== ActionType.ChatUsage || isSubagentChatUri(channel)) { - return; - } - // Usage reported with no active turn carries an empty turn id (see - // `CopilotAgentSession._turnId`). No turn can ever match it, and no - // prune path can remove it, so it would be a permanent orphan row. - if (!action.turnId) { - return; - } - // Agents key their storage by the chat's own URI, which is where the - // `turns` rows that `getTurnUsages` joins against live. - const storage = chatStorageUri(channel); - if (!storage) { - return; - } - let ref: IReference; - try { - ref = this._options.sessionDataService.openDatabase(storage); - } catch (err) { - this._logService.warn(`[AgentSideEffects] Failed to open database to persist turn usage for ${channel}`, err); - return; - } - ref.object.setTurnUsage(action.turnId, JSON.stringify(action.usage)).catch(err => { - this._logService.warn(`[AgentSideEffects] Failed to persist turn usage for ${channel}/${action.turnId}`, err); - }).finally(() => ref.dispose()); - } - /** * Applies a turn message's model/agent selection (see * {@link _applyMessageSelection}) and forwards it to the agent's @@ -1973,8 +1750,20 @@ export class AgentSideEffects extends Disposable { duration: this._turnDuration(turnStopWatch), part: createErrorResponsePart(error), }); - this._completeTurn(turnChannel, turnId, 'error', failure); + const endedTurn = this._completeTurn(turnChannel, turnId, 'error', failure); this._toolCallTracker.clearSession(turnChannel); + // Notify contributions so failed sends recompute changesets and mark the session + // unread. Skipped when the turn already ended — a send can reject after the turn + // was cancelled, and reporting again would double-report the same turn. + if (endedTurn) { + this._chatContributions.turnEnd({ + session: sessionChannel, + channel: turnChannel, + turnId, + reason: { kind: 'error', error, resumable: false }, + clientContext, + }); + } this._failSessionCreationIfStillCreating(sessionChannel, error); } } diff --git a/src/vs/platform/agentHost/node/chatContributions/TODO.md b/src/vs/platform/agentHost/node/chatContributions/TODO.md index 73e0a773e73603..ac9ee4ffec311b 100644 --- a/src/vs/platform/agentHost/node/chatContributions/TODO.md +++ b/src/vs/platform/agentHost/node/chatContributions/TODO.md @@ -1,208 +1,40 @@ # Chat Contributions Backlog -Each contribution has its own subfolder so its implementation, helpers, and tests can grow -without path churn. `IAgentHostChatContributions` owns explicitly registered, -dependency-injected contributions; their `order` controls sequencing, with registration -order only breaking ties. Ordering is per hook, so the same 100-series is reused -independently by different hooks. - -## Hooks at a glance - -| Hook | Fires | Contributions, in order | -|---|---|---| -| `onIncomingRequest` | A turn request asks to proceed to a provider. Synchronous, fails closed. | `localCommand` 50, `turnAdmission` 100 | -| `onTurnEnd` | A turn reached a terminal outcome. | `checkpointAndChangeset` 100, `queueDrain` 200, `githubReferences` 300, `sessionTitle` 400, `markUnread` 500, `sideChat` 500 | -| `onAction` | A client action was reduced into host state. | `queueDrain` 200, `sessionTitle` 400, `chatDraft` 600 | -| `onOutgoingTurn` | A turn is about to be sent. | `turnDelegation` 50, `markdownPlanRichLinks` 100, `artifactTools` 200, `chatSurface` 300, `sessionTitle` 400, `sideChat` 500 | -| `onHydrateTurns` | A provider returned a restored turn list. | `turnDelegation` 50, `persistedTurnUsage` 100, `worktreeAnnouncement` 200, `sideChat` 500 | -| `onHydrateChat` | A chat is being restored, before it enters the catalog. | `sessionTitle` 400, `chatDraft` 600 | - -`markUnread` and `sideChat` both declare 500 on `onTurnEnd`; registration order breaks -that tie. Give a new contribution a distinct order rather than adding to the tie. - -## Completed extractions - -### `onIncomingRequest` - -- `localCommand` (50) intercepts host-handled commands (`/rename`, `!command`) and returns - `handled`. Its order is load-bearing: local commands were historically intercepted - before the read-only guard ran, so a `/rename` on a read-only or archived chat succeeds. - Running below `turnAdmission` preserves that. -- `turnAdmission` (100) rejects turns on read-only chats and archived sessions. - -The shared admission preamble — the gate, title seeding, provider lookup, the `noAgent` -error, `userMessageSent`, and `turnStarted` — lives in the `startTurn` function in -`node/agentHostTurnStarter.ts`. Both `handleAction`'s `ChatTurnStarted` case and -`QueueDrainContribution._admitQueuedTurn` invoke it through -`IInstantiationService.invokeFunction`, differing only in their `source` and in who -dispatches `ChatTurnStarted`. - -`startTurn` is a plain function taking `ServicesAccessor`, not a service. It is stateless, -so registering it in DI bought nothing and cost a registration in every hand-built test -service graph. Keep it a function unless it acquires state. - -### `onTurnEnd` - -- `checkpointAndChangeset` (100) captures the checkpoint before scheduling the changeset - recompute; filters to `kind === 'success' || kind === 'error'`. -- `queueDrain` (200) owns queued-sender mementos and pending-message synchronization, and - decides when a queued message may start a turn; drains for - `kind === 'success' || kind === 'localCommand'`. -- `githubReferences` (300) attaches the owning session's GitHub pull request, only for - `kind === 'success'`. This is its only hook. -- `sessionTitle` (400) refines the first-turn title, only for `kind === 'success'`. -- `markUnread` (500) is the terminal tail, kept at 500 because it was originally dispatched - after all turn-complete side effects. -- `onTurnConsumable` is gone. Host-handled local commands end through `onTurnEnd` with - `TurnEndReason.kind === 'localCommand'`; the other turn-end contributions exclude that - reason to preserve their previous behavior. - -### `onAction` - -- `queueDrain` (200) tracks queued senders and synchronizes pending messages. -- `sessionTitle` (400) persists user-renamed titles, updates chat titles, and cascades - default-chat titles to the owning session. -- `chatDraft` (600) persists chat drafts. Its trigger moved from `onDidEmitEnvelope` to - `onAction`, narrowing it from client-and-server dispatch to client dispatch only. That is - deliberate: `ChatDraftChangedAction` is a `ClientChatAction` that nothing in production - server-dispatches, and `onAction` runs after the reject-and-return guards in - `_dispatchActionNow`, so a rejected draft is no longer written. - -### `onOutgoingTurn` - -- `turnDelegation` (50) persists agent-authored delegation metadata before provider send so - replay can restore request origins. -- `markdownPlanRichLinks` (100) adds Markdown plan rich-link guidance when - `AgentHostMarkdownPlanRichLinksEnabledConfigKey` is enabled. -- `artifactTools` (200) adds artifact-tool guidance when `AgentHostArtifactToolsConfigKey` - is enabled. -- `chatSurface` (300) adds terminal or editor-inline guidance from the session surface - metadata. -- `sessionTitle` (400) asynchronously adds the automatic-title rename reminder. -- Orders 100-400 are reserved for the original host-instruction sequence. - -`onOutgoingTurn` runs after admission and after the provider lookup, so a rejected turn and -a turn that fails with `noAgent` never reach it. That follows from only enriching messages -that are actually sent. - -### `onHydrateTurns` - -- `turnDelegation` (50) restores agent authorship and delegation metadata by host or - provider turn id. -- `persistedTurnUsage` (100) restores persisted per-turn usage with one database read for - the complete list. -- `worktreeAnnouncement` (200) restores the isolated-worktree notice for default chats - through `IAgentHostWorktreeIsolation`. - -### `onHydrateChat` - -Restores host-owned chat state — today a title and a draft — from persistence before a chat -is registered in the session catalog. - -- `sessionTitle` (400) restores the user-set custom chat title it persists in `onAction`. -- `chatDraft` (600) restores the draft it persists in `onAction`. -- Both keys are disjoint, so their relative order is nominal today; they declare it anyway. - -The `meta.model` overlay in `_doRestoreSession` deliberately stays in `AgentService`: it -seeds the draft's model from `IAgent`-supplied session metadata, so it is provider-shaped, -and moving it would mean putting provider metadata into `IHydrationContext` for one -consumer. - -## Design decisions worth keeping - -### `onIncomingRequest` fails closed - -Every other hook logs a throwing contribution, skips it, and continues. A throwing -`onIncomingRequest` contribution *rejects* the request with `internalError` at stage -`validation`. This gate is the enforcement behind the UI hiding the composer, so failing -open would let a buggy or remote client run work in a session that may no longer have its -isolated worktree on disk. Losing one enrichment is survivable; letting a request past a -guard is not. - -### `onIncomingRequest` is synchronous - -A gate has to decide before the send path performs any await, so the state it reads cannot -change between the decision and its effect. This is not a limitation: every admission check -the host performs today — read-only and archived chats, `ILocalChatCommand.tryHandle`, the -missing-provider check — is already synchronous. Making the hook async was tried first and -broke three `agentSideEffects` tests by deferring the read-only guard one microtask past the -assertions, which was the design saying the gate belongs before the first await. - -### `onHydrateChat` is separate from `onHydrateTurns` - -The two fire at different lifecycle moments, not because they carry different payloads: - -| | `onHydrateTurns` | `onHydrateChat` | -|---|---|---| -| Call site | `_getChatMessages`, after `provider.chats.getMessages` | `_doRestoreSession`, `_restorePeerChatsFromCatalog`, subagent discovery | -| For peer chats | Lazy, on first content request | Eager, at catalog registration | -| Needs a provider | Yes | No — a metadata-only database read | - -Fusing them would force drafts and titles to be read lazily, so a restored peer chat tab -would render untitled until it was opened, and the subagent site registers a title with no -turn hydration at all. Adding a `{ kind: 'summary' | 'turns' }` discriminant to one hook was -rejected for the same reason: the discriminant guidance covers payload variants *within* one -moment, the way `TurnEndReason` discriminates outcomes of a single turn ending. - -`onHydrateChat` reuses `IHydrationContext` verbatim and copies `hydrateTurns`' dispatch -semantics — threading accumulator, per-hook ordering, and failure isolation that preserves -the previous value. `IRestoredChat` is an object from the start, like `ISendContribution`, -so the next host-owned restorable field does not need another hook. - -### Hoisting the admission gate dropped rejection telemetry - -The gate originally ran inside `_sendTurnMessage`, *after* `userMessageSent` and -`turnTracker.turnStarted`. It now runs at the front of the shared preamble, before both. -This was accepted deliberately and is not an oversight: - -- A read-only or archived rejection no longer emits `userMessageSent`. That event now means - "a message was actually sent to a provider", which is what its name claims. -- It also no longer emits a `turnCompleted` report. `AgentHostTurnTracker.turnCompleted` - returns early when no `turnStarted` timing exists, so the previous `result: 'error'`, - `stage: 'validation'` report is simply absent rather than malformed. -- `_completeTurn` and `_toolCallTracker.clearSession` were dropped from the rejection path - for the same reason: both finalize a turn that started, and a rejected turn no longer - starts one. - -The tradeoff is losing per-rejection failure telemetry in exchange for a gate that runs -before any side effect. If rejection volume needs measuring later, report it from the -contribution rather than reviving the turn-completion path. - -`_sendTurnMessage` now performs no admission at all; it is purely the send tail. - -### Not contributions - -- Subagent signal routing/buffering (`_handleAgentSignal`) and turn-id remap - (`_dispatchActionForSession`) are routing fabric and correctness invariants. -- The three SDK event mappers and three attachment serializers are genuinely - provider-shaped. -- `onAction` observes the post-reduction client dispatch path, not `onDidEmitEnvelope`: - client actions already emit envelopes, so observing both would double-drain, and - pending-message server envelopes historically did not enter `handleAction`. - -## Known coverage gaps - -- `onTurnEnd` fires from the agent signal path and from local command completion, but not - for a client dispatched cancellation, nor for failures that report `ChatError` directly - (a rejected admission, a missing provider, a send that throws). A contribution cannot yet - rely on seeing every terminal outcome. Admission is now unified behind - `onIncomingRequest`, so the remaining step is having those paths report through it. -- Making a rejection fire `onTurnEnd` is a behavior change, not a refactor: - `checkpointAndChangeset` filters on `success || error`, so firing `error` for a rejected - turn would schedule changeset work for a turn that never captured a checkpoint. -- Mementos with extra key segments must be deleted with `deleteMemento` when their segment - value goes out of use. Setting the value to `undefined` keeps the entry until the owning - chat or session is disposed. -- `onHydrateChat` runs before its chat is registered in the catalog, and chat mementos are - evicted by chat disposal. A contribution that took a chat memento during hydration for a - chat that then failed to register would leak that entry. Neither `chatDraft` nor - `sessionTitle` takes a memento, so this is currently theoretical. -- `TurnEndReason` intentionally omits `AgentHostTurnFailureStage`: current error-hook - dispatch comes only from `ChatError`, where the stage is hardcoded `provider`. Add it only - when a contribution needs a stage that reflects every error source. -- `IIncomingRequest` carries no provider or resolved-agent information, because the gate - runs before provider lookup. A contribution that needs to reject based on provider - capability would need that lookup hoisted first. +Open work and standing caveats for `IAgentHostChatContributions`. The architecture, the hook +contracts, and the rules for adding a contribution live in +`.github/skills/agent-host-chat-contributions/SKILL.md` and in the JSDoc on +`common/agentHostChatContributionsService.ts`. Completed extractions are recorded in git +history, not here. + +`order` is load-bearing and is per hook, so the same 100-series is reused independently by +different hooks; registration order only breaks ties. + +## Standing caveats + +- A contribution on `onDidDispatchAction` must filter tightly, because the hook fires for + every dispatched action. `sessionInputNeeded` and `persistedTurnUsage` both re-check + `isAhpChatChannel` + `isChatAction`, reproducing the guard their code sat behind in + `AgentSideEffects`. All three contributions on this hook also skip rejected actions, + except that `sessionFlags` still persists config values for one, matching the behavior + it was extracted from. +- `TurnEndReason` intentionally omits `AgentHostTurnFailureStage`: error-hook dispatch comes + only from `ChatError`, where the stage is hardcoded `provider`. Add it only when a + contribution needs a stage that reflects every error source. +- `IIncomingRequest` carries no provider or resolved-agent information, because the gate runs + before provider lookup. A contribution that needs to reject based on provider capability + would need that lookup hoisted first. + +## Known gaps + +- `onTurnEnd` does not fire for an agent-emitted `ChatError` or `ChatTurnCancelled` that + arrives when no turn is active. The reducer's `endTurn` no-ops for those, so there is no + turn to end; only `ChatTurnComplete` reports on that path, with an undefined `turnId`. + Every terminal outcome a started turn can reach is otherwise covered. +- A rejected queued turn consumes its queued message through `ChatTurnStarted` but does not + retrigger the drain, so the remaining queue stalls. Do not fix this by draining on + `rejected`: an archived chat would cascade through every queued message with immediate + rejections rather than retain them until it is unarchived. The fix is to return a rejected + queued message to the queue. ## Remaining work @@ -214,35 +46,36 @@ contribution rather than reviving the turn-completion path. `AgentSideEffects`. - An alternative that would delete `startTurn` outright: have `_admitQueuedTurn` re-enter `handleAction` after dispatching `ChatTurnStarted`, the way `AgentService`'s - `_startSessionPrompt` and `_startAgentMergePrompt` already do, and derive `source` from - the action's existing `queuedMessageId` rather than passing it. The bridge would swap + `_startSessionPrompt` and `_startAgentMergePrompt` already do, and derive `source` from the + action's existing `queuedMessageId` rather than passing it. The bridge would swap `sendTurnMessage` for `handleAction`. This is a behavior change, not a shape change: - `handleAction` ends by calling `_chatContributions.action(...)`, so queued turns would - begin firing `onAction` for their `ChatTurnStarted`. + `handleAction` ends by calling `_chatContributions.didApplyClientAction(...)`, so queued turns + would begin firing `onDidApplyClientAction` for their `ChatTurnStarted`. ### Side chat +Both remaining items are defects, not extraction work; the btw/sideChat migration is done. + - Prefer `IAgentHostStateManager.getChatInheritedTurnId()` in - `SideChatContribution.onOutgoingTurn`: it is the provider's ground-truth inherited - boundary, handles dropped forks and Claude's fresh fallback, and avoids recomputing the - requested anchor. Codex must first report `inheritedTurnId`; it currently computes - `keepThroughIndex` without exposing it, and resolving its host-versus-thread turn IDs is - the same id-space problem behind active-turn side chats. -- The source turn can complete between `createChat` and the first side-chat - `onOutgoingTurn`. The fork was anchored before that turn, but the contribution then sees - no active turn and injects no context, so the source turn is absent from both. This - pre-existing race also occurs on main. -- Migrating btw/sideChat fully to the contribution deletes the six per-harness wiring sites - in `copilot/copilotAgent.ts` and `claude/claudeAgent.ts` and the `sideChat` field from - both `IPersistedChat` blobs. Codex gains btw support by deletion rather than addition. + `SideChatContribution.onOutgoingTurn`. `onHydrateTurns` already uses it; `onOutgoingTurn` + still recomputes the anchor with `resolveLastNonLocalTurnId`. The state manager value is the + provider's ground-truth inherited boundary, handles dropped forks and Claude's fresh + fallback, and avoids recomputing the requested anchor. Codex must first report + `inheritedTurnId`; it currently computes `keepThroughIndex` without exposing it, and + resolving its host-versus-thread turn IDs is the same id-space problem behind active-turn + side chats. +- The source turn can complete between `createChat` and the first side-chat `onOutgoingTurn`. + The fork was anchored before that turn, but the contribution then sees no active turn and + injects no context, so the source turn is absent from both. This pre-existing race also + occurs on main. ### Mementos -- Add disposable-value semantics only when a contribution needs to store disposables. - Memento eviction currently drops observables without disposing their values. +- Add disposable-value semantics only when a contribution needs to store disposables. Memento + eviction currently drops observables without disposing their values. - Consider a `chatDisposable` helper only when a real contribution needs it; do not add it speculatively. ### Future hooks -- `onAgentSignal` — observes or redirects signals before they reach state. +- `onAgentSignal` — observes or redirects signals before they reach state. \ No newline at end of file diff --git a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts index e0a077a79bfbc5..61cd96cc1b8342 100644 --- a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts +++ b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts @@ -15,6 +15,8 @@ import { MarkdownPlanRichLinksContribution } from './markdownPlanRichLinks/markd import { MarkUnreadContribution } from './markUnread/markUnreadContribution.js'; import { PersistedTurnUsageContribution } from './persistedTurnUsage/persistedTurnUsageContribution.js'; import { QueueDrainContribution } from './queueDrain/queueDrainContribution.js'; +import { SessionFlagsContribution } from './sessionFlags/sessionFlagsContribution.js'; +import { SessionInputNeededContribution } from './sessionInputNeeded/sessionInputNeededContribution.js'; import { SessionTitleContribution } from './sessionTitle/sessionTitleContribution.js'; import { SideChatContribution } from './sideChat/sideChatContribution.js'; import { TurnAdmissionContribution } from './turnAdmission/turnAdmissionContribution.js'; @@ -33,6 +35,7 @@ export function registerBuiltInChatContributions( registrations.add(contributions.registerContribution(WorktreeAnnouncementContribution)); registrations.add(contributions.registerContribution(CheckpointAndChangesetContribution)); registrations.add(contributions.registerContribution(QueueDrainContribution)); + registrations.add(contributions.registerContribution(SessionInputNeededContribution)); registrations.add(contributions.registerContribution(GitHubReferencesContribution)); registrations.add(contributions.registerContribution(SessionTitleContribution)); registrations.add(contributions.registerContribution(MarkUnreadContribution)); @@ -41,5 +44,6 @@ export function registerBuiltInChatContributions( registrations.add(contributions.registerContribution(ArtifactToolsContribution)); registrations.add(contributions.registerContribution(ChatSurfaceContribution)); registrations.add(contributions.registerContribution(SideChatContribution)); + registrations.add(contributions.registerContribution(SessionFlagsContribution)); return registrations; } diff --git a/src/vs/platform/agentHost/node/chatContributions/chatDraft/chatDraftContribution.ts b/src/vs/platform/agentHost/node/chatContributions/chatDraft/chatDraftContribution.ts index 23cd49a08db735..d84ba429d61099 100644 --- a/src/vs/platform/agentHost/node/chatContributions/chatDraft/chatDraftContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/chatDraft/chatDraftContribution.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ILogService } from '../../../../log/common/log.js'; -import { type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IHydrationContext, type IObservedAction, type IRestoredChat } from '../../../common/agentHostChatContributionsService.js'; +import { type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IHydrationContext, type IAppliedClientAction, type IRestoredChat } from '../../../common/agentHostChatContributionsService.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; import { ActionType } from '../../../common/state/sessionActions.js'; import { isAhpChatChannel, parseChatUri } from '../../../common/state/sessionState.js'; @@ -15,6 +15,12 @@ import { isAhpChatChannel, parseChatUri } from '../../../common/state/sessionSta * Owns chat draft persistence in both directions. Restore runs eagerly at * catalog-registration time, before turns load, so it belongs on * {@link onHydrateChat} rather than {@link onHydrateTurns}. + * + * Persisting through `onDidApplyClientAction` covers client dispatch only, which is narrower than the + * `onDidEmitEnvelope` observer this replaced. That is deliberate: `ChatDraftChangedAction` + * is a `ClientChatAction` that nothing in production server-dispatches, and `onDidApplyClientAction` runs + * after the reject-and-return guards in `_dispatchActionNow`, so a rejected draft is no + * longer written. */ export class ChatDraftContribution extends Disposable implements IAgentHostChatContribution { @@ -29,7 +35,7 @@ export class ChatDraftContribution extends Disposable implements IAgentHostChatC super(); } - onAction(observed: IObservedAction): void { + onDidApplyClientAction(observed: IAppliedClientAction): void { if (observed.action.type !== ActionType.ChatDraftChanged || !isAhpChatChannel(observed.channel) || !parseChatUri(observed.channel)) { return; } diff --git a/src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts b/src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts index 7dd351d5247bb4..a4b05bfce9f538 100644 --- a/src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts @@ -25,10 +25,8 @@ export class MarkUnreadContribution extends Disposable implements IAgentHostChat } onTurnEnd(turn: ITurnEnd): void { - // Deliberately ignore agent turn outcomes: success, cancellation, and - // error all retain the current behavior of marking a read session unread. - // Local commands deliberately preserve the existing read state. - if (turn.reason.kind === 'localCommand') { + // Rejected requests never ran; marking an archived session unread would resurface it. Local commands preserve read state. + if (turn.reason.kind === 'localCommand' || turn.reason.kind === 'rejected') { return; } // Route subagent turns to their owning session too (a background subagent diff --git a/src/vs/platform/agentHost/node/chatContributions/persistedTurnUsage/persistedTurnUsageContribution.ts b/src/vs/platform/agentHost/node/chatContributions/persistedTurnUsage/persistedTurnUsageContribution.ts index 37d195bb051b13..5ceca327da6f7b 100644 --- a/src/vs/platform/agentHost/node/chatContributions/persistedTurnUsage/persistedTurnUsageContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/persistedTurnUsage/persistedTurnUsageContribution.ts @@ -3,14 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, IReference } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ILogService } from '../../../../log/common/log.js'; -import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IHydrationContext } from '../../../common/agentHostChatContributionsService.js'; -import { ISessionDataService } from '../../../common/sessionDataService.js'; -import { chatStorageUri, hasReportedUsage, isSubagentChatUri, type Turn, type UsageInfo } from '../../../common/state/sessionState.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IHydrationContext, IDispatchedAction } from '../../../common/agentHostChatContributionsService.js'; +import { ISessionDatabase, ISessionDataService } from '../../../common/sessionDataService.js'; +import { ActionType, isChatAction, type ChatAction } from '../../../common/state/sessionActions.js'; +import { chatStorageUri, hasReportedUsage, isAhpChatChannel, isSubagentChatUri, type Turn, type URI as ProtocolURI, type UsageInfo } from '../../../common/state/sessionState.js'; -/** Re-attaches persisted per-turn usage to restored turns. */ +/** Owns persisted per-turn usage in both directions, recording it live and re-attaching it to restored turns. */ export class PersistedTurnUsageContribution extends Disposable implements IAgentHostChatContribution { static readonly id = 'persistedTurnUsage'; @@ -24,6 +25,19 @@ export class PersistedTurnUsageContribution extends Disposable implements IAgent super(); } + onDidDispatchAction(dispatched: IDispatchedAction): void { + // A rejected action never reduced, so its payload must not be persisted as though + // it had. `ChatUsage` is server-only today and cannot be rejected, but the guard + // keeps that from becoming a silent durable-state bug if it ever becomes + // client-dispatchable. + if (dispatched.rejectionReason !== undefined) { + return; + } + if (isAhpChatChannel(dispatched.channel) && isChatAction(dispatched.action)) { + this._trackTurnUsage(dispatched.channel, dispatched.action); + } + } + /** * Re-attaches persisted per-turn {@link UsageInfo} to reconstructed turns. * @@ -31,9 +45,9 @@ export class PersistedTurnUsageContribution extends Disposable implements IAgent * SDK's `assistant.usage` event is explicitly ephemeral and the Claude * transcript replay produces none - so restored turns come back without it. * Without this the chat's context-usage gauge stays hidden after a reload - * and the session cost total restarts from zero. Usage recorded live by - * {@link AgentSideEffects} is looked up by turn id (or the turn's SDK event - * id, which is what a restored turn is keyed by). + * and the session cost total restarts from zero. Usage recorded live by this + * contribution is looked up by turn id (or the turn's SDK event id, which is + * what a restored turn is keyed by). * * NOTE: the lookup only lands for providers that record the bridge between * the live protocol turn id (a host-generated uuid) and the id a restored @@ -101,4 +115,51 @@ export class PersistedTurnUsageContribution extends Disposable implements IAgent } }); } + + /** + * Persists the usage reported for a chat's turn. + * + * Agent backends do not durably record token/credit usage themselves (the + * Copilot SDK's `assistant.usage` event is explicitly ephemeral, and the + * Claude transcript replay produces none), so a restored session would + * otherwise come back with no context-usage gauge and a session cost of 0. + * See this contribution's {@link onHydrateTurns} for which providers can + * currently match these rows back on restore. + * + * Written on every report rather than buffered until the turn ends: the row + * is keyed by turn id and written with `INSERT OR REPLACE` through a + * sequencer, so "last report wins" is already a property of the storage + * layer, and persisting eagerly means a turn cut short by a crash or + * disconnect keeps the usage it had already accrued. + * + * Subagent chats are skipped: their cost is already folded into the parent + * turn's aggregate, so recording it again would double-count. + */ + private _trackTurnUsage(channel: ProtocolURI, action: ChatAction): void { + if (action.type !== ActionType.ChatUsage || isSubagentChatUri(channel)) { + return; + } + // Usage reported with no active turn carries an empty turn id (see + // `CopilotAgentSession._turnId`). No turn can ever match it, and no + // prune path can remove it, so it would be a permanent orphan row. + if (!action.turnId) { + return; + } + // Agents key their storage by the chat's own URI, which is where the + // `turns` rows that `getTurnUsages` joins against live. + const storage = chatStorageUri(channel); + if (!storage) { + return; + } + let ref: IReference; + try { + ref = this._sessionDataService.openDatabase(storage); + } catch (err) { + this._logService.warn(`[AgentSideEffects] Failed to open database to persist turn usage for ${channel}`, err); + return; + } + ref.object.setTurnUsage(action.turnId, JSON.stringify(action.usage)).catch(err => { + this._logService.warn(`[AgentSideEffects] Failed to persist turn usage for ${channel}/${action.turnId}`, err); + }).finally(() => ref.dispose()); + } } diff --git a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts index af8a5a2678cf5c..0761febd2102a8 100644 --- a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts @@ -11,7 +11,7 @@ import { IInstantiationService } from '../../../../instantiation/common/instanti import { ILogService } from '../../../../log/common/log.js'; import { AgentHostClientType } from '../../../common/agentHostClientInfo.js'; import { createUnknownAgentHostClientTelemetryContext } from '../../../common/agentHostTelemetry.js'; -import { IAgentHostChatContributions, createChatMementoKey, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IAgentHostChatContributionHost, type IObservedAction, type IQueuedMessageSender, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import { IAgentHostChatContributions, createChatMementoKey, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IAgentHostChatContributionHost, type IAppliedClientAction, type IQueuedMessageSender, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; import { ActionType } from '../../../common/state/sessionActions.js'; import { getErrorResponsePart, isAhpChatChannel, parseRequiredSessionUriFromChatUri, PendingMessageKind, TurnState, type Message, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; @@ -43,7 +43,7 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat } } - onAction(observed: IObservedAction): void { + onDidApplyClientAction(observed: IAppliedClientAction): void { if (!isAhpChatChannel(observed.channel)) { return; } diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts new file mode 100644 index 00000000000000..b545b84731975a --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../../log/common/log.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDispatchedAction } from '../../../common/agentHostChatContributionsService.js'; +import { ISessionDataService } from '../../../common/sessionDataService.js'; +import { ActionType } from '../../../common/state/sessionActions.js'; +import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY } from '../../../common/state/sessionState.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; +import { persistSessionMetadata } from '../../shared/persistSessionMetadata.js'; + +/** + * Persists host-owned read state, archived state, and merged config values that must survive a + * restart. It uses `onDidDispatchAction` rather than `onDidApplyClientAction` because the client and host both dispatch + * these changes. + */ +export class SessionFlagsContribution extends Disposable implements IAgentHostChatContribution { + + static readonly id = 'sessionFlags'; + readonly order = 700; + + constructor( + protected readonly _context: IAgentHostChatContributionContext, + @ILogService private readonly _logService: ILogService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + ) { + super(); + } + + onDidDispatchAction(dispatched: IDispatchedAction): void { + if (dispatched.action.type === ActionType.SessionConfigChanged) { + const values = this._stateManager.getSessionState(dispatched.channel)?.config?.values; + if (values) { + persistSessionMetadata(this._sessionDataService, this._logService, dispatched.channel, 'configValues', JSON.stringify(values)); + } + } + // Persisting here rather than in `handleAction` covers client- and + // server-dispatched changes alike, so no dispatch path can skip it. + // Rejected actions never reached state and must not be written. + if (!dispatched.rejectionReason) { + if (dispatched.action.type === ActionType.SessionIsReadChanged) { + persistSessionMetadata(this._sessionDataService, this._logService, dispatched.channel, AH_META_IS_READ_DB_KEY, dispatched.action.isRead ? 'true' : ''); + } else if (dispatched.action.type === ActionType.SessionIsArchivedChanged) { + persistSessionMetadata(this._sessionDataService, this._logService, dispatched.channel, AH_META_IS_ARCHIVED_DB_KEY, dispatched.action.isArchived ? 'true' : ''); + } + } + } +} diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionInputNeeded/sessionInputNeededContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionInputNeeded/sessionInputNeededContribution.ts new file mode 100644 index 00000000000000..cce0573ce889d0 --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/sessionInputNeeded/sessionInputNeededContribution.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 { Disposable } from '../../../../../base/common/lifecycle.js'; +import { equals } from '../../../../../base/common/objects.js'; +import { hasKey } from '../../../../../base/common/types.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDispatchedAction } from '../../../common/agentHostChatContributionsService.js'; +import { readToolCallMeta } from '../../../common/meta/agentToolCallMeta.js'; +import { SessionInputRequestKind, type SessionInputRequest } from '../../../common/state/protocol/state.js'; +import { ActionType, isChatAction, type ChatAction } from '../../../common/state/sessionActions.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, type ToolCallState, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; +import { IAgentHostProviderService } from '../../agentHostProviderService.js'; +import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../agentHostToolCallTracker.js'; +import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../agentHostTurnTracker.js'; + +/** + * Mirrors per-chat blockers (user-input elicitations, tool confirmations, + * client-tool executions, and MCP authentication) into the owning session's + * `inputNeeded` list so clients subscribed only to the session channel can + * discover and answer them without subscribing to each chat. This handler + * only produces the state; it does not consume it. + */ +export class SessionInputNeededContribution extends Disposable implements IAgentHostChatContribution { + + static readonly id = 'sessionInputNeeded'; + readonly order = 200; + + constructor( + protected readonly _context: IAgentHostChatContributionContext, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IAgentHostTurnTracker private readonly _turnTracker: AgentHostTurnTracker, + @IAgentHostToolCallTracker private readonly _toolCallTracker: AgentHostToolCallTracker, + @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, + ) { + super(); + } + + onDidDispatchAction(dispatched: IDispatchedAction): void { + // A rejected action never reduced, so there is no state change to mirror. The + // removal branches below clear a mirrored blocker without re-reading state, so + // acting on one could drop a request that is still outstanding. + if (dispatched.rejectionReason !== undefined || !isAhpChatChannel(dispatched.channel) || !isChatAction(dispatched.action)) { + return; + } + this._syncSessionInputNeededForChatAction(dispatched.channel, dispatched.action); + } + + private _syncSessionInputNeededForChatAction(chatUri: ProtocolURI, action: ChatAction): void { + switch (action.type) { + case ActionType.ChatInputRequested: + this._syncChatInputNeeded(chatUri, action.request.id); + break; + case ActionType.ChatInputAnswerChanged: + this._syncChatInputNeeded(chatUri, action.requestId); + break; + case ActionType.ChatInputCompleted: + this._removeSessionInputNeeded(chatUri, this._chatInputNeededId(chatUri, action.requestId)); + break; + case ActionType.ChatToolCallStart: + case ActionType.ChatToolCallReady: + case ActionType.ChatToolCallConfirmed: + case ActionType.ChatToolCallComplete: + case ActionType.ChatToolCallResultConfirmed: + case ActionType.ChatToolCallAuthRequired: + case ActionType.ChatToolCallAuthResolved: + this._syncToolInputNeeded(chatUri, action.turnId, action.toolCallId); + break; + case ActionType.ChatTurnComplete: + case ActionType.ChatTurnCancelled: + case ActionType.ChatError: + case ActionType.ChatTruncated: + this._removeSessionInputNeededForChat(chatUri); + break; + } + } + + private _syncChatInputNeeded(chatUri: ProtocolURI, requestId: string): void { + const state = this._stateManager.getSessionState(chatUri); + const part = state?.activeTurn?.responseParts.find(part => + part.kind === ResponsePartKind.InputRequest + && part.response === undefined + && part.request.id === requestId + ); + const id = this._chatInputNeededId(chatUri, requestId); + if (!part || part.kind !== ResponsePartKind.InputRequest) { + this._removeSessionInputNeeded(chatUri, id); + return; + } + this._setSessionInputNeeded(chatUri, { + id, + kind: SessionInputRequestKind.ChatInput, + chat: chatUri, + request: part.request, + }); + } + + private _syncToolInputNeeded(chatUri: ProtocolURI, turnId: string, toolCallId: string): void { + const confirmationId = this._toolConfirmationNeededId(chatUri, turnId, toolCallId); + const clientExecutionId = this._toolClientExecutionNeededId(chatUri, turnId, toolCallId); + const authenticationId = this._toolAuthenticationNeededId(chatUri, turnId, toolCallId); + const toolCall = this._findToolCall(chatUri, turnId, toolCallId); + + // A parameter gate auto-approved by the session's bypass setting never + // blocks on the user, so keep it out of the session `inputNeeded` queue + // (which would flash "input needed" in the sessions list). + // `autoApproveBySetting` covers only the parameter gate; a + // `PendingResultConfirmation` is a genuine prompt and is still surfaced. + const autoApproved = !!toolCall && readToolCallMeta(toolCall).autoApproveBySetting === true; + + const suppressAutoApprovedConfirmation = autoApproved && toolCall?.status === ToolCallStatus.PendingConfirmation; + const needsConfirmation = !suppressAutoApprovedConfirmation && (toolCall?.status === ToolCallStatus.PendingConfirmation || toolCall?.status === ToolCallStatus.PendingResultConfirmation); + if (needsConfirmation && toolCall) { + this._setSessionInputNeeded(chatUri, { + id: confirmationId, + kind: SessionInputRequestKind.ToolConfirmation, + chat: chatUri, + turnId, + toolCall, + }); + } else { + this._removeSessionInputNeeded(chatUri, confirmationId); + } + + const contributor = toolCall?.contributor; + if (toolCall?.status === ToolCallStatus.Running && contributor?.kind === ToolCallContributorKind.Client) { + this._setSessionInputNeeded(chatUri, { + id: clientExecutionId, + kind: SessionInputRequestKind.ToolClientExecution, + chat: chatUri, + turnId, + clientId: contributor.clientId, + toolCall, + }); + } else { + this._removeSessionInputNeeded(chatUri, clientExecutionId); + } + + if (toolCall?.status === ToolCallStatus.AuthRequired) { + this._setSessionInputNeeded(chatUri, { + id: authenticationId, + kind: SessionInputRequestKind.ToolAuthentication, + chat: chatUri, + turnId, + toolCall, + }); + } else { + this._removeSessionInputNeeded(chatUri, authenticationId); + } + } + + private _findToolCall(chatUri: ProtocolURI, turnId: string, toolCallId: string): ToolCallState | undefined { + const state = this._stateManager.getSessionState(chatUri); + const turn = state?.activeTurn?.id === turnId ? state.activeTurn : state?.turns.find(t => t.id === turnId); + const part = turn?.responseParts.find(p => p.kind === ResponsePartKind.ToolCall && p.toolCall.toolCallId === toolCallId); + return part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; + } + + private _setSessionInputNeeded(chatUri: ProtocolURI, request: SessionInputRequest): void { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + const existing = this._stateManager.getSessionState(sessionUri)?.inputNeeded?.find(r => r.id === request.id); + if (existing && equals(existing, request)) { + return; + } + this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededSet, request }); + // Record the blocker on the turn so a hang reported while the request is + // outstanding is tagged as an expected wait on the user rather than as + // an unexplained stall, and so the report can name the tool it gates. A + // `ChatInput` elicitation carries neither `turnId` nor a tool call, so + // fall back to the chat's active turn. + const blockedTurnId = hasKey(request, { turnId: true }) ? request.turnId : this._stateManager.getActiveTurnId(chatUri); + if (blockedTurnId) { + const blockedToolCallId = hasKey(request, { toolCall: true }) ? request.toolCall.toolCallId : undefined; + this._turnTracker.turnBlocked(chatUri, blockedTurnId, request.id, request.kind, blockedToolCallId); + } + if (request.kind !== SessionInputRequestKind.ChatInput) { + const agent = this._providerService.getProviderForSession(sessionUri); + if (agent) { + this._toolCallTracker.toolCallBlocked(agent.id, chatUri, request); + } + } + } + + private _removeSessionInputNeeded(chatUri: ProtocolURI, id: string): void { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + this._toolCallTracker.toolCallUnblocked(chatUri, id); + this._turnTracker.turnUnblocked(chatUri, id); + if (!this._stateManager.getSessionState(sessionUri)?.inputNeeded?.some(r => r.id === id)) { + return; + } + this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededRemoved, id }); + } + + private _removeSessionInputNeededForChat(chatUri: ProtocolURI): void { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + for (const request of this._stateManager.getSessionState(sessionUri)?.inputNeeded ?? []) { + if (request.chat === chatUri) { + this._removeSessionInputNeeded(chatUri, request.id); + } + } + } + + private _chatInputNeededId(chatUri: ProtocolURI, requestId: string): string { + return `chatInput:${chatUri}:${requestId}`; + } + + private _toolConfirmationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { + return `toolConfirmation:${chatUri}:${turnId}:${toolCallId}`; + } + + private _toolClientExecutionNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { + return `toolClientExecution:${chatUri}:${turnId}:${toolCallId}`; + } + + private _toolAuthenticationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { + return `toolAuthentication:${chatUri}:${turnId}:${toolCallId}`; + } +} diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts index 20398051940279..35e2f997ba097b 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ILogService } from '../../../../log/common/log.js'; -import { type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IHydrationContext, type IObservedAction, type IOutgoingTurn, type IRestoredChat, type ISendContribution, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import { type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IHydrationContext, type IAppliedClientAction, type IOutgoingTurn, type IRestoredChat, type ISendContribution, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; import { ActionType } from '../../../common/state/sessionActions.js'; import { isAhpChatChannel, isDefaultChatUri } from '../../../common/state/sessionState.js'; @@ -43,7 +43,7 @@ export class SessionTitleContribution extends Disposable implements IAgentHostCh return instruction ? { instructions: [instruction] } : undefined; } - onAction(observed: IObservedAction): void { + onDidApplyClientAction(observed: IAppliedClientAction): void { if (observed.action.type !== ActionType.SessionTitleChanged) { return; } @@ -68,7 +68,7 @@ export class SessionTitleContribution extends Disposable implements IAgentHostCh } /** - * Restores the user-set custom chat title recorded by {@link onAction}. This runs at + * Restores the user-set custom chat title recorded by {@link onDidApplyClientAction}. This runs at * catalog-registration time so a restored peer chat shows its title before its turns load. */ async onHydrateChat(context: IHydrationContext, restored: IRestoredChat): Promise { diff --git a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts index 19312781aba307..12c33cbf54623a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts +++ b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts @@ -50,6 +50,7 @@ export interface IClaudeCanUseToolOptions { readonly signal: AbortSignal; readonly blockedPath?: string; readonly toolUseID: string; + readonly requestId: string; /** * Phase 12 step 5 — SDK-supplied subagent id for inner-tool * confirmations. When set, the bridge resolves the parent diff --git a/src/vs/platform/agentHost/node/claude/claudeElicitationBridge.ts b/src/vs/platform/agentHost/node/claude/claudeElicitationBridge.ts index 7bf5a232e434ae..b5b50e5b9e979a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeElicitationBridge.ts +++ b/src/vs/platform/agentHost/node/claude/claudeElicitationBridge.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import type { ElicitationRequest, ElicitationResult } from '@anthropic-ai/claude-agent-sdk'; -import { generateUuid } from '../../../../base/common/uuid.js'; import { ChatInputResponseKind } from '../../common/state/sessionState.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { buildElicitationRequest, cancelledElicitationResult, elicitationResultFromAnswers } from './claudeElicitation.js'; @@ -38,14 +37,14 @@ export async function handleElicitation( deps: IClaudeElicitationDeps, sessionId: string, request: ElicitationRequest, - options: { readonly signal: AbortSignal }, + options: { readonly signal: AbortSignal; readonly requestId: string }, ): Promise { const session = deps.getSession(sessionId); if (!session) { return cancelledElicitationResult(); } - const requestId = generateUuid(); + const requestId = options.requestId; if (options.signal.aborted) { return cancelledElicitationResult(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 573e0ad7666979..7fc2505c53d931 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -42,7 +42,7 @@ import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliCo import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, agentHostProxyConfigSchema, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; -import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, type IAgentChatMetadataOptions, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; +import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -1627,6 +1627,13 @@ export class CopilotAgent extends Disposable implements IAgent { this._telemetryService.setRestrictedTelemetryEnabled(rtEnabled); this._telemetryService.setCopilotTrackingId(context?.trackingId); this._telemetryService.setRestrictedTelemetryEndpoint(context?.telemetryEndpoint); + // Marks the account as internal, which is separate from the `rt` opt-in above. + this._telemetryService.setInternalTelemetryContext(context && { + isInternal: context.isInternal === true, + trackingId: context.trackingId, + userName: context.userName, + isVscodeTeamMember: context.isVscodeTeamMember === true, + }); } } @@ -2614,27 +2621,13 @@ export class CopilotAgent extends Disposable implements IAgent { } } - async getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string, options?: IAgentChatMetadataOptions): Promise { + async getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string): Promise { const session = resolveAgentChatContext(context, chat).configurationResource; const sessionId = providerData ? decodeProviderData(providerData)?.sdkSessionId : AgentSession.id(session); if (!sessionId) { return undefined; } const storedMetadata = await this._readStoredSessionMetadata(session); - if (options?.activation === 'restore' && options.registryFallback && storedMetadata?.workingDirectories?.length) { - let project = storedMetadata.project; - if (!storedMetadata.resolved) { - const projectLimiter = new Limiter(1); - project = await this._resolveSessionProject({ cwd: storedMetadata.workingDirectories[0].fsPath }, projectLimiter, new Map>()); - void this._storeSessionProjectResolution(session, project); - } - return { - chat, - ...options.registryFallback, - project, - workingDirectories: storedMetadata.workingDirectories, - }; - } const sessionMetadata = await this._retryAfterClosedConnection('getSessionMetadata', client => client.getSessionMetadata(sessionId), createCopilotFailureCorrelation(session, chat, undefined, sessionId)); if (!sessionMetadata) { @@ -3401,9 +3394,13 @@ export class CopilotAgent extends Disposable implements IAgent { await projectFromCopilotContext({ cwd: (adoptedWorktree?.repositoryRoot ?? workingDirectory).fsPath }, this._gitService), sessionId, ); - // Carry over the user-chosen session name (EH `customTitle`) so the - // adopted session keeps its title instead of regenerating one. + // Title precedence mirrors the extension's getSessionTitleImpl: the CLI `name`, then the VS Code staged title, then the summary. const customTitle = await this._readExtensionHostCliCustomTitle(sessionId); + // The SDK's typed metadata omits `name`; it is present at runtime as the `workspace.yaml` title. + const sdkName = (sdkMetadata as { readonly name?: string } | undefined)?.name; + const cliName = typeof sdkName === 'string' && sdkName.trim() ? sdkName.trim() : undefined; + const cliSummary = typeof sdkMetadata?.summary === 'string' && sdkMetadata.summary.trim() ? sdkMetadata.summary.trim() : undefined; + const adoptedTitle = cliName ?? customTitle ?? cliSummary; const archived = await this._isExtensionHostCliSessionArchived(sessionId); if (archived === undefined) { // Adoption commits the archived state, and the extension host stops listing @@ -3419,9 +3416,9 @@ export class CopilotAgent extends Disposable implements IAgent { // `isolation: 'folder'` keeps the session in place in the reused cwd — // a git repo would otherwise default to worktree and show a spurious // "Creating worktree…". - await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, customTitle, /* markRead */ true, archived, /* ehcliAdopted */ true); + await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, adoptedTitle, /* markRead */ true, archived, /* ehcliAdopted */ true); await this._adoptLegacyTurnUsage(session, sessionId); - this._logService.info(`[Copilot] Adopted legacy session ${sessionId}: project=${project ? project.uri.fsPath : '(unresolved)'} archived=${archived} customTitle=${customTitle !== undefined} worktreeBridged=${!!adoptedWorktree}`); + this._logService.info(`[Copilot] Adopted legacy session ${sessionId}: project=${project ? project.uri.fsPath : '(unresolved)'} archived=${archived} title=${adoptedTitle !== undefined ? (cliName ? 'name' : customTitle ? 'custom' : 'summary') : 'none'} worktreeBridged=${!!adoptedWorktree}`); return { adopted: true, eligible: true, reason: 'adopted', ...(adoptedWorktree ? { worktree: adoptedWorktree } : {}) }; }); } @@ -4894,12 +4891,11 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionUri = AgentSession.uri(this.id, sessionId); const storedMetadata = await this._readSessionMetadata(sessionUri); - const storedWorkingDirectory = storedMetadata.workingDirectory ?? storedMetadata.workingDirectories?.[0]; - const sessionMetadata = storedWorkingDirectory ? undefined : await client.getSessionMetadata(sessionId).catch(err => { + const sessionMetadata = await client.getSessionMetadata(sessionId).catch(err => { this._logService.warn(`[Copilot:${sessionId}] getSessionMetadata failed`, err); return undefined; }); - const workingDirectory = storedWorkingDirectory ?? (typeof sessionMetadata?.context?.workingDirectory === 'string' ? URI.file(sessionMetadata.context.workingDirectory) : undefined); + const workingDirectory = storedMetadata.workingDirectory ?? (typeof sessionMetadata?.context?.workingDirectory === 'string' ? URI.file(sessionMetadata.context.workingDirectory) : undefined); if (!workingDirectory) { throw new Error(`workingDirectory is required to resume Copilot session '${sessionId}'`); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index c1153265d72a06..0d4a1dcbe41aa1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1594,7 +1594,7 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatError, turnId: turn.id, duration: turn.duration, - part: createErrorResponsePart(error), + part: createErrorResponsePart(error, true), }); this._clearActiveTurn(); return turn.id; @@ -2644,7 +2644,7 @@ export class CopilotAgentSession extends Disposable { part: createErrorResponsePart({ errorType: 'developmentRecoverableError', message: localize('copilotAgent.developmentRecoverableError', "Injected recoverable development error ({0}/{1}).", attempt, totalFailures), - }), + }, true), }); this._clearActiveTurn(); } @@ -5003,7 +5003,7 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatError, turnId: this._turnId, duration: turn?.duration ?? 0, - part: createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data)), + part: createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data), !e.agentId && turn !== undefined), }, parentToolCallId); if (!parentToolCallId) { this._clearActiveTurn(); diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 0cb4c8e162186c..23d417ef13972d 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -672,7 +672,7 @@ export async function mapSessionEvents( rootRequestActive = false; parentTurnState = TurnState.Error; parentTurnTerminated = true; - parentBuilder.responseParts.push(createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data))); + parentBuilder.responseParts.push(createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data), true)); parentBuilder.waitingStartedAt = currentEventTimestamp; touch(parentBuilder); } @@ -784,7 +784,7 @@ export async function mapSessionEvents( } if (options && !(options instanceof URI) && options.interruptedTurnError && parentBuilder && rootRequestActive && parentTurnState !== TurnState.Error) { - parentBuilder.responseParts.push(createErrorResponsePart(options.interruptedTurnError)); + parentBuilder.responseParts.push(createErrorResponsePart(options.interruptedTurnError, true)); parentTurnState = TurnState.Error; } flushParent(); diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index 7735f9c7bdd991..580f5cabc884a7 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -7,7 +7,7 @@ import type Anthropic from '@anthropic-ai/sdk'; import { CAPIClient, RequestType, type CCAModel, type IExtensionInformation } from '@vscode/copilot-api'; import { generateUuid } from '../../../../base/common/uuid.js'; import { getDevDeviceId, getMachineId } from '../../../../base/node/id.js'; -import { getInternalOrg } from '../../../assignment/common/assignment.js'; +import { getInternalOrg, isInternalAccount } from '../../../assignment/common/assignment.js'; import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgreement.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; @@ -390,7 +390,7 @@ export interface IRestrictedTelemetryContext { readonly trackingId: string | undefined; /** The CAPI `endpoints.telemetry` base URL, resolved only when enabled; `undefined` otherwise. */ readonly telemetryEndpoint: string | undefined; - /** Whether the token belongs to a GitHub or Microsoft internal organization. */ + /** Whether the account is staff or belongs to an internal organization. */ readonly isInternal?: boolean; /** GitHub login returned by `/copilot_internal/user`. */ readonly userName?: string; @@ -978,7 +978,7 @@ export class CopilotApiService implements ICopilotApiService { copilotIgnoreEnabled: envelope.copilotignore_enabled, restrictedTelemetryEnabled: envelope.restricted_telemetry === true, trackingId: envelope.analytics_tracking_id, - isInternal: envelope.is_staff === true || internalOrganization !== undefined, + isInternal: isInternalAccount(envelope.is_staff, envelope.organization_login_list), isVscodeTeamMember: internalOrganization === 'vscode', }; } diff --git a/src/vs/platform/agentHost/node/shared/toolCallContributor.ts b/src/vs/platform/agentHost/node/shared/toolCallContributor.ts new file mode 100644 index 00000000000000..1e82bab4c63f2a --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/toolCallContributor.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ToolCallContributorKind, type ToolCallContributor } from '../../common/state/sessionState.js'; + +/** + * Pure helpers describing a tool call's contributor. + * + * These live outside `agentHostToolCallTracker.ts` because the turn tracker needs them too, + * and the tool-call tracker injects the turn tracker. Keeping them here means neither + * tracker has to import the other at runtime. + */ + +/** + * Maps a tool call's contributor to the telemetry `toolSourceKind`. A tool with + * no contributor is provided by the agent host itself; an MCP contributor maps + * to `mcp` and a client contributor to `client`. + */ +export function toolSourceKindFromContributor(contributor: ToolCallContributor | undefined): string { + if (!contributor) { + return 'agentHost'; + } + // Widen to `string` so an unrecognized kind from a newer protocol version + // falls through to a valid telemetry value rather than `undefined`. + const kind: string = contributor.kind; + switch (kind) { + case ToolCallContributorKind.MCP: + return 'mcp'; + case ToolCallContributorKind.Client: + return 'client'; + default: + return kind; + } +} + +/** + * Whether `next` is a safe refinement of the currently recorded contributor. + * A client contributor may only be refined by the same client, and a client + * contributor is never replaced by a non-client one, so execution ownership + * cannot be reassigned by a later, less specific signal. + */ +export function canRefineContributor(current: ToolCallContributor | undefined, next: ToolCallContributor): boolean { + if (current?.kind === ToolCallContributorKind.Client) { + return next.kind === ToolCallContributorKind.Client && next.clientId === current.clientId; + } + return next.kind !== ToolCallContributorKind.Client; +} diff --git a/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts b/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts index c303ea09a676d1..5bc511a4bd54b9 100644 --- a/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts @@ -8,7 +8,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import type { IConfigurationService, IConfigurationValue } from '../../../configuration/common/configuration.js'; import { AgentHostMapLegacySettingsToManagedSettingsSettingId, resolveManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; import { AgentNetworkDomainSettingId } from '../../../networkFilter/common/settings.js'; -import { GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID } from '../../common/agentHostSchema.js'; +import { ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID } from '../../common/agentHostSchema.js'; function createConfigurationService(values: Record>): IConfigurationService { return { @@ -239,4 +239,96 @@ suite('AgentHostManagedSettings', () => { ask: ['Shell(rm)'], }); }); + + test('locks the bypass mode when a tool is marked ineligible for auto-approval', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {}, policyValue: { fetch: false } }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + disableBypassPermissionsMode: 'disable', + }); + }); + + test('contributes nothing when every tool is left eligible for auto-approval', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + // Only `true` entries: the policy re-affirms the default and removes nothing. + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {}, policyValue: { fetch: true, runTask: true } }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), {}); + }); + + test('contributes nothing from the default empty per-tool auto-approval map', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {}, policyValue: {} }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), {}); + }); + + test('does not promote user, application, or workspace per-tool auto-approval values', () => { + const userConfigurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {}, userValue: { fetch: false } }, + }); + const applicationConfigurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {}, applicationValue: { fetch: false } }, + }); + const workspaceConfigurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {}, workspaceValue: { fetch: false } }, + }); + + assert.deepStrictEqual([ + resolveManagedSettingsPermissions(userConfigurationService), + resolveManagedSettingsPermissions(applicationConfigurationService), + resolveManagedSettingsPermissions(workspaceConfigurationService), + ], [{}, {}, {}]); + }); + + test('clears the bypass lock after the per-tool auto-approval policy is removed', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {} }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), {}); + }); + + test('ignores a malformed per-tool auto-approval value without throwing', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {}, policyValue: ['fetch'] }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), {}); + }); + + test('fails closed and locks the bypass mode for a non-boolean per-tool auto-approval entry', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {}, policyValue: { fetch: 'no' } }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + disableBypassPermissionsMode: 'disable', + }); + }); + + test('does not duplicate the bypass lock across the global and per-tool auto-approval policies', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [GLOBAL_AUTO_APPROVE_SETTING_ID]: { defaultValue: false, policyValue: false }, + [ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID]: { defaultValue: {}, policyValue: { fetch: false } }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + disableBypassPermissionsMode: 'disable', + }); + }); }); diff --git a/src/vs/platform/agentHost/test/common/reconnectingTransport.test.ts b/src/vs/platform/agentHost/test/common/reconnectingTransport.test.ts new file mode 100644 index 00000000000000..f26af2ff474166 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/reconnectingTransport.test.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 assert from 'assert'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; +import { type IEstablishedTransport, ReconnectingTransport } from '../../common/reconnectingTransport.js'; +import type { AhpServerNotification, JsonRpcNotification, JsonRpcParseErrorResponse, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from '../../common/state/sessionProtocol.js'; +import { NonReconnectableTransportError, type IProtocolTransport } from '../../common/state/sessionTransport.js'; + +type TransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcParseErrorResponse | JsonRpcResponse | JsonRpcRequest; + +class TestProtocolTransport extends Disposable implements IProtocolTransport { + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + + readonly sentMessages: TransportMessage[] = []; + disposeCount = 0; + + send(message: TransportMessage): void { + this.sentMessages.push(message); + } + + fireMessage(message: ProtocolMessage): void { + this._onMessage.fire(message); + } + + fireClose(): void { + this._onClose.fire(); + } + + override dispose(): void { + if (!this._store.isDisposed) { + this.disposeCount++; + } + super.dispose(); + } +} + +class RecordingLogService extends NullLogService { + readonly warnings: string[] = []; + + override warn(message: string, ...args: unknown[]): void { + this.warnings.push([message, ...args.map(arg => String(arg))].join(' ')); + } +} + +suite('ReconnectingTransport', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('forwards messages and close events from the established transport', async () => { + const innerTransport = new TestProtocolTransport(); + const transport = disposables.add(new ReconnectingTransport( + async () => ({ transport: innerTransport }), + new NullLogService(), + '[ReconnectingTransport]', + AgentHostClientConnectionKind.SSH, + )); + const received: ProtocolMessage[] = []; + let closeCount = 0; + disposables.add(transport.onMessage(message => received.push(message))); + disposables.add(transport.onClose(() => closeCount++)); + + await transport.connect(); + transport.send({ jsonrpc: '2.0', method: 'outbound', id: 2 }); + innerTransport.fireMessage({ jsonrpc: '2.0', id: 1, result: {} }); + innerTransport.fireClose(); + + assert.deepStrictEqual({ received, sentMessages: innerTransport.sentMessages, closeCount }, { + received: [{ jsonrpc: '2.0', id: 1, result: {} }], + sentMessages: [{ jsonrpc: '2.0', method: 'outbound', id: 2 }], + closeCount: 1, + }); + }); + + test('warns and drops messages before an inner transport is adopted', () => { + const logService = new RecordingLogService(); + const transport = disposables.add(new ReconnectingTransport( + async () => { throw new Error('Unexpected establish'); }, + logService, + '[ReconnectingTransport]', + )); + + transport.send({ jsonrpc: '2.0', method: 'test', id: 1 }); + + assert.deepStrictEqual(logService.warnings, ['[ReconnectingTransport] send before the transport was established; dropping message']); + }); + + test('closes the established handle once and disposes its inner transport', async () => { + const innerTransport = new TestProtocolTransport(); + let closeCount = 0; + const transport = disposables.add(new ReconnectingTransport( + async () => ({ + transport: innerTransport, + close: async () => { closeCount++; }, + }), + new NullLogService(), + '[ReconnectingTransport]', + )); + + await transport.connect(); + transport.dispose(); + + assert.deepStrictEqual({ closeCount, disposeCount: innerTransport.disposeCount }, { closeCount: 1, disposeCount: 1 }); + }); + + test('does not require teardown for caller-owned resources', async () => { + const innerTransport = new TestProtocolTransport(); + const transport = disposables.add(new ReconnectingTransport( + async () => ({ transport: innerTransport }), + new NullLogService(), + '[ReconnectingTransport]', + )); + + await transport.connect(); + transport.dispose(); + + assert.strictEqual(innerTransport.disposeCount, 1); + }); + + test('releases a late establishment and rejects connect after disposal', async () => { + let resolveEstablish: (establishedTransport: IEstablishedTransport) => void = () => { throw new Error('Unexpected establish resolution'); }; + const establish = new Promise(resolve => { + resolveEstablish = resolve; + }); + const innerTransport = new TestProtocolTransport(); + let closeCount = 0; + const transport = disposables.add(new ReconnectingTransport( + async () => establish, + new NullLogService(), + '[ReconnectingTransport]', + )); + + const connectPromise = transport.connect(); + transport.dispose(); + resolveEstablish({ + transport: innerTransport, + close: async () => { closeCount++; }, + }); + + await assert.rejects(connectPromise); + assert.deepStrictEqual({ closeCount, disposeCount: innerTransport.disposeCount }, { closeCount: 1, disposeCount: 1 }); + }); + + test('preserves NonReconnectableTransportError from establish', async () => { + const error = new NonReconnectableTransportError('terminal failure'); + const transport = disposables.add(new ReconnectingTransport( + async () => { throw error; }, + new NullLogService(), + '[ReconnectingTransport]', + )); + + await assert.rejects(transport.connect(), caughtError => caughtError === error); + }); + + test('establishes one inner transport when connect is called twice', async () => { + let establishCount = 0; + const innerTransport = new TestProtocolTransport(); + const transport = disposables.add(new ReconnectingTransport( + async () => { + establishCount++; + return { transport: innerTransport }; + }, + new NullLogService(), + '[ReconnectingTransport]', + )); + + await Promise.all([transport.connect(), transport.connect()]); + + assert.strictEqual(establishCount, 1); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/relayTransport.test.ts b/src/vs/platform/agentHost/test/common/relayTransport.test.ts new file mode 100644 index 00000000000000..28030f3c7ed414 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/relayTransport.test.ts @@ -0,0 +1,353 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; +import { ReconnectingRelayTransport, RelayTransport, type IRelayChannel, type IRelayConnectionHandle, type IRelayMessage } from '../../common/relayTransport.js'; +import { NonReconnectableTransportError } from '../../common/state/sessionTransport.js'; +import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../common/transportConstants.js'; + +/** + * Minimal SSH-shaped relay channel for testing the transport. + */ +class MockRelayChannel implements IRelayChannel { + private readonly _onDidRelayMessage = new Emitter(); + readonly onDidRelayMessage = this._onDidRelayMessage.event; + + private readonly _onDidRelayClose = new Emitter(); + readonly onDidRelayClose = this._onDidRelayClose.event; + + readonly sentMessages: { connectionId: string; message: string }[] = []; + + async relaySend(connectionId: string, message: string): Promise { + this.sentMessages.push({ connectionId, message }); + } + + // Test helpers + fireRelayMessage(msg: IRelayMessage): void { + this._onDidRelayMessage.fire(msg); + } + + fireRelayClose(connectionId: string): void { + this._onDidRelayClose.fire(connectionId); + } + + dispose(): void { + this._onDidRelayMessage.dispose(); + this._onDidRelayClose.dispose(); + } +} + +class RecordingLogService extends NullLogService { + readonly warnings: string[] = []; + readonly errors: string[] = []; + + override warn(message: string, ...args: unknown[]): void { + this.warnings.push([message, ...args.map(arg => String(arg))].join(' ')); + } + + override error(message: string | Error, ...args: unknown[]): void { + this.errors.push([message, ...args.map(arg => String(arg))].join(' ')); + } +} + +suite('RelayTransport', () => { + + const disposables = new DisposableStore(); + let mockChannel: MockRelayChannel; + + setup(() => { + mockChannel = new MockRelayChannel(); + disposables.add({ dispose: () => mockChannel.dispose() }); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('receives messages matching connectionId', () => { + const transport = disposables.add(new RelayTransport('conn-1', mockChannel, undefined, new NullLogService(), '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + const received: unknown[] = []; + disposables.add(transport.onMessage(msg => received.push(msg))); + + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: '{"jsonrpc":"2.0","id":1}' }); + + assert.strictEqual(received.length, 1); + assert.deepStrictEqual(received[0], { jsonrpc: '2.0', id: 1 }); + }); + + test('ignores messages for other connectionIds', () => { + const transport = disposables.add(new RelayTransport('conn-1', mockChannel, undefined, new NullLogService(), '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + const received: unknown[] = []; + disposables.add(transport.onMessage(msg => received.push(msg))); + + mockChannel.fireRelayMessage({ connectionId: 'conn-2', data: '{"jsonrpc":"2.0","id":1}' }); + + assert.strictEqual(received.length, 0); + }); + + test('drops malformed JSON messages', () => { + const transport = disposables.add(new RelayTransport('conn-1', mockChannel, undefined, new NullLogService(), '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + const received: unknown[] = []; + disposables.add(transport.onMessage(msg => received.push(msg))); + + // Should not throw + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: 'not-json{{{' }); + + assert.strictEqual(received.length, 0); + }); + + test('caps malformed-frame warning logs', () => { + const logService = new RecordingLogService(); + disposables.add(new RelayTransport('conn-1', mockChannel, undefined, logService, '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + for (let index = 0; index < MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD; index++) { + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: 'not-json{{{' }); + } + + assert.strictEqual(logService.warnings.length, MALFORMED_FRAMES_LOG_CAP); + assert.ok(logService.warnings.every(message => message.startsWith('[SSHRelayTransport] Malformed frame #'))); + assert.ok(logService.warnings[0].includes('(len=11): not-json{{{')); + }); + + test('fires onClose once after too many malformed frames', () => { + const logService = new RecordingLogService(); + const transport = disposables.add(new RelayTransport('conn-1', mockChannel, undefined, logService, '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + let closeCount = 0; + disposables.add(transport.onClose(() => closeCount++)); + + for (let index = 0; index < MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD + 2; index++) { + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: 'not-json{{{' }); + } + + assert.deepStrictEqual({ closeCount, warnings: logService.warnings.length }, { closeCount: 1, warnings: MALFORMED_FRAMES_LOG_CAP + 1 }); + assert.strictEqual(logService.warnings.at(-1), '[SSHRelayTransport] Malformed frame threshold exceeded; closing relay.'); + }); + + test('fires onClose when relay closes for matching connectionId', () => { + const transport = disposables.add(new RelayTransport('conn-1', mockChannel, undefined, new NullLogService(), '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + let closed = false; + disposables.add(transport.onClose(() => { closed = true; })); + + mockChannel.fireRelayClose('conn-1'); + + assert.strictEqual(closed, true); + }); + + test('does not fire onClose for other connectionIds', () => { + const transport = disposables.add(new RelayTransport('conn-1', mockChannel, undefined, new NullLogService(), '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + let closed = false; + disposables.add(transport.onClose(() => { closed = true; })); + + mockChannel.fireRelayClose('conn-2'); + + assert.strictEqual(closed, false); + }); + + test('send() calls relaySend with correct connectionId', async () => { + const transport = disposables.add(new RelayTransport('conn-1', mockChannel, undefined, new NullLogService(), '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + const msg = { jsonrpc: '2.0' as const, method: 'test', id: 42 }; + transport.send(msg); + + // Give the async relaySend a tick to register + await new Promise(r => queueMicrotask(r)); + + assert.strictEqual(mockChannel.sentMessages.length, 1); + assert.strictEqual(mockChannel.sentMessages[0].connectionId, 'conn-1'); + assert.deepStrictEqual(JSON.parse(mockChannel.sentMessages[0].message), msg); + }); + + test('receives multiple messages in order', () => { + const transport = disposables.add(new RelayTransport('conn-1', mockChannel, undefined, new NullLogService(), '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + const received: unknown[] = []; + disposables.add(transport.onMessage(msg => received.push(msg))); + + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' }); + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":2}' }); + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":3}' }); + + assert.strictEqual(received.length, 3); + assert.deepStrictEqual(received, [{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + + test('no events after dispose', () => { + const transport = disposables.add(new RelayTransport('conn-1', mockChannel, undefined, new NullLogService(), '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH)); + + const received: unknown[] = []; + let closed = false; + disposables.add(transport.onMessage(msg => received.push(msg))); + disposables.add(transport.onClose(() => { closed = true; })); + + transport.dispose(); + + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' }); + mockChannel.fireRelayClose('conn-1'); + + assert.strictEqual(received.length, 0); + assert.strictEqual(closed, false); + }); +}); + +suite('ReconnectingRelayTransport', () => { + + const disposables = new DisposableStore(); + let mockChannel: MockRelayChannel; + + setup(() => { + mockChannel = new MockRelayChannel(); + disposables.add({ dispose: () => mockChannel.dispose() }); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('adopts and routes messages only for its established connectionId', async () => { + const transport = disposables.add(new ReconnectingRelayTransport( + async () => ({ connectionId: 'conn-1' }), + mockChannel, + () => undefined, + new NullLogService(), + '[ReconnectingRelayTransport]', + AgentHostClientConnectionKind.SSH + )); + const received: unknown[] = []; + disposables.add(transport.onMessage(message => received.push(message))); + + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":"before-connect"}' }); + mockChannel.fireRelayMessage({ connectionId: 'conn-2', data: '{"id":"other"}' }); + await transport.connect(); + mockChannel.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":"connected"}' }); + mockChannel.fireRelayMessage({ connectionId: 'conn-2', data: '{"id":"other"}' }); + + assert.deepStrictEqual(received, [{ id: 'connected' }]); + }); + + test('warns and drops messages sent before adopting a channel', () => { + const logService = new RecordingLogService(); + const transport = disposables.add(new ReconnectingRelayTransport( + async () => ({ connectionId: 'conn-1' }), + mockChannel, + () => undefined, + logService, + '[ReconnectingRelayTransport]', + AgentHostClientConnectionKind.SSH + )); + + transport.send({ jsonrpc: '2.0', method: 'test', id: 42 }); + + assert.deepStrictEqual({ sentMessages: mockChannel.sentMessages, warnings: logService.warnings }, { + sentMessages: [], + warnings: ['[ReconnectingRelayTransport] send before the relay channel was established; dropping message'], + }); + }); + + test('closes its established channel once when disposed', async () => { + let closeCount = 0; + const transport = disposables.add(new ReconnectingRelayTransport( + async () => ({ + connectionId: 'conn-1', + close: async () => { closeCount++; }, + }), + mockChannel, + () => undefined, + new NullLogService(), + '[ReconnectingRelayTransport]', + AgentHostClientConnectionKind.SSH + )); + + await transport.connect(); + transport.dispose(); + + assert.strictEqual(closeCount, 1); + }); + + test('does not close a seeded channel with no close handle', async () => { + const logService = new RecordingLogService(); + const transport = disposables.add(new ReconnectingRelayTransport( + async () => ({ connectionId: 'conn-1' }), + mockChannel, + () => undefined, + logService, + '[ReconnectingRelayTransport]', + AgentHostClientConnectionKind.WSL + )); + + await transport.connect(); + transport.dispose(); + await new Promise(resolve => queueMicrotask(resolve)); + + assert.deepStrictEqual(logService.errors, []); + }); + + test('closes a channel established after disposal and rejects connect', async () => { + let resolveEstablish: (connectionHandle: IRelayConnectionHandle) => void = () => { throw new Error('Unexpected establish resolution'); }; + const establish = new Promise(resolve => { + resolveEstablish = resolve; + }); + let closeCount = 0; + const transport = disposables.add(new ReconnectingRelayTransport( + async () => establish, + mockChannel, + () => undefined, + new NullLogService(), + '[ReconnectingRelayTransport]', + AgentHostClientConnectionKind.SSH + )); + + const connectPromise = transport.connect(); + transport.dispose(); + resolveEstablish({ + connectionId: 'conn-1', + close: async () => { closeCount++; }, + }); + + await assert.rejects(connectPromise); + assert.strictEqual(closeCount, 1); + }); + + test('preserves NonReconnectableTransportError from establish', async () => { + const error = new NonReconnectableTransportError('terminal failure'); + const transport = disposables.add(new ReconnectingRelayTransport( + async () => { throw error; }, + mockChannel, + () => undefined, + new NullLogService(), + '[ReconnectingRelayTransport]', + AgentHostClientConnectionKind.SSH + )); + + await assert.rejects(transport.connect(), caughtError => caughtError === error); + }); + + test('establishes only one channel when connect is called twice', async () => { + let establishCount = 0; + const transport = disposables.add(new ReconnectingRelayTransport( + async () => { + establishCount++; + return { connectionId: 'conn-1' }; + }, + mockChannel, + () => undefined, + new NullLogService(), + '[ReconnectingRelayTransport]', + AgentHostClientConnectionKind.SSH + )); + + await Promise.all([transport.connect(), transport.connect()]); + + assert.strictEqual(establishCount, 1); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 602190a6bf14df..ca7040b981a515 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -33,7 +33,7 @@ import { NonReconnectableTransportError, type IClientTransport, type IProtocolTr import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; -import { AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; +import { AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; import { AgentHostMapLegacySettingsToManagedSettingsSettingId } from '../../common/agentHostManagedSettings.js'; import { AgentHostConfigurationSyncScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../configuration/common/configurationRegistry.js'; import { Registry } from '../../../registry/common/platform.js'; @@ -81,6 +81,7 @@ const syncTestConfigurationNode = { import type { Implementation } from '../../common/state/protocol/common/commands.js'; import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; +import type { IRemoteAgentHostReconnectPolicy } from '../../common/reconnectPolicy.js'; type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest; type RootConfigValue = boolean | string | AgentHostTerminalAutoApproveRules | undefined; @@ -233,6 +234,7 @@ class TerminalAutoApproveConfigurationService extends TestConfigurationService { class ManagedPermissionsConfigurationService extends TestConfigurationService { private globalAutoApprovePolicyValue: boolean | undefined = false; + private eligibleForAutoApprovalPolicyValue: Record | undefined; override inspect(key: string): IConfigurationValue { if (key === GLOBAL_AUTO_APPROVE_SETTING_ID) { @@ -241,12 +243,22 @@ class ManagedPermissionsConfigurationService extends TestConfigurationService { policyValue: this.globalAutoApprovePolicyValue as T | undefined, }; } + if (key === ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID) { + return { + ...super.inspect(key), + policyValue: this.eligibleForAutoApprovalPolicyValue as T | undefined, + }; + } return super.inspect(key); } clearGlobalAutoApprovePolicy(): void { this.globalAutoApprovePolicyValue = undefined; } + + setEligibleForAutoApprovalPolicy(value: Record | undefined): void { + this.eligibleForAutoApprovalPolicyValue = value; + } } suite('AgentHostProtocolClient', () => { @@ -316,8 +328,11 @@ suite('AgentHostProtocolClient', () => { }; } - function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService): { client: AgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { - const client = disposables.add(new AgentHostProtocolClient(identity, transport, loadEstimator, clientId, clientInfo, logService, permissionService, configurationService, telemetryService)); + function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService, reconnectPolicy?: IRemoteAgentHostReconnectPolicy): { client: AgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { + const options = loadEstimator !== undefined || clientId !== undefined || clientInfo !== undefined || reconnectPolicy !== undefined + ? { loadEstimator, clientId, clientInfo, reconnectPolicy } + : undefined; + const client = disposables.add(new AgentHostProtocolClient(identity, transport, options, logService, permissionService, configurationService, telemetryService)); return { client, transport, configurationService }; } @@ -945,7 +960,7 @@ suite('AgentHostProtocolClient', () => { assert.ok(err instanceof ProtocolError); // Late response for the same request id — the shared - // SSHRelayTransport feeds both old and new clients for the + // The SSH relay transport feeds both old and new clients for the // same connectionId, so this can happen in production. The // pending request was already rejected; if _handleMessage // processed the response it would log a "unknown request id" @@ -1070,9 +1085,7 @@ suite('AgentHostProtocolClient', () => { const client = disposables.add(new AgentHostProtocolClient( 'test.example:1234', transport, - undefined, - 'telemetry-disabled-client', - editorWindowAgentHostClientInfo, + { clientId: 'telemetry-disabled-client', clientInfo: editorWindowAgentHostClientInfo }, new NullLogService(), createPermissionService(), configurationService, @@ -1250,6 +1263,41 @@ suite('AgentHostProtocolClient', () => { }); }); + test('forwards and clears the mapped per-tool auto-approval policy for the local host', async () => { + const configurationService = new ManagedPermissionsConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: true, + }); + // Isolate this setting's notification path from the global auto-approve mapping. + configurationService.clearGlobalAutoApprovePolicy(); + configurationService.setEligibleForAutoApprovalPolicy({ runTask: false }); + const { client, transport } = createClientForIdentity( + LOCAL_AGENT_HOST_RESOURCE_IDENTITY, + disposables.add(new TestProtocolTransport()), + createPermissionService(), + undefined, + new NullLogService(), + configurationService, + ); + + await connectClient(client, transport); + + assert.deepStrictEqual(findLastManagedSettingsNotification(transport.sentMessages), { + jsonrpc: '2.0', + method: 'setClientManagedSettingsPermissions', + params: { permissions: { disableBypassPermissionsMode: 'disable' } }, + }); + + transport.sentMessages.length = 0; + configurationService.setEligibleForAutoApprovalPolicy(undefined); + fireConfigurationChange(configurationService, ELIGIBLE_FOR_AUTO_APPROVAL_SETTING_ID); + + assert.deepStrictEqual(findLastManagedSettingsNotification(transport.sentMessages), { + jsonrpc: '2.0', + method: 'setClientManagedSettingsPermissions', + params: { permissions: {} }, + }); + }); + test('forwards terminal auto-approve rules on connect', async () => { const configurationService = new TestConfigurationService({ [TERMINAL_AUTO_APPROVE_SETTING_ID]: { @@ -2127,7 +2175,7 @@ suite('AgentHostProtocolClient', () => { * client plus a `transports` array recording each transport handed * out, so tests can drive handshake/reconnect interactions. */ - function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService): { client: AgentHostProtocolClient; transports: TestClientProtocolTransport[] } { + function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService, reconnectPolicy?: IRemoteAgentHostReconnectPolicy): { client: AgentHostProtocolClient; transports: TestClientProtocolTransport[] } { const transports: TestClientProtocolTransport[] = []; const factory = () => { const t = disposables.add(new TestClientProtocolTransport()); @@ -2135,7 +2183,7 @@ suite('AgentHostProtocolClient', () => { return t; }; const client = disposables.add(new AgentHostProtocolClient( - 'test.example:1234', factory, undefined, undefined, clientInfo, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, + 'test.example:1234', factory, clientInfo !== undefined || reconnectPolicy !== undefined ? { clientInfo, reconnectPolicy } : undefined, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, )); return { client, transports }; } @@ -2230,6 +2278,75 @@ suite('AgentHostProtocolClient', () => { }); }); + test('stops after exhausting automatic reconnect attempts and permits an explicit reconnect', async function () { + this.timeout(10_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const reconnectPolicy: IRemoteAgentHostReconnectPolicy = { + autoRestore: true, + initialDelayMs: 1, + maxDelayMs: 1, + maxAttempts: 1, + }; + const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, reconnectPolicy); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + const fatalError = Event.toPromise(client.onDidFatalClose); + transports[0].fireClose(); + await waitForReconnecting(client); + const failedReconnect = await waitForTransport(transports, 1); + failedReconnect.connectDeferred.error(new Error('reconnect failed')); + + assert.deepStrictEqual({ + fatalError: (await fatalError).message, + state: client.connectionState, + transportCount: transports.length, + }, { + fatalError: 'Automatic reconnect gave up after 1 attempts.', + state: AgentHostClientState.Closed, + transportCount: 2, + }); + + assert.strictEqual(client.reconnectFromClosed(), true); + const explicitReconnect = await waitForTransport(transports, 2); + explicitReconnect.connectDeferred.complete(); + const reconnect = await waitForRequest(explicitReconnect, 'reconnect'); + explicitReconnect.fireMessage({ + jsonrpc: '2.0', + id: reconnect.id, + result: { type: ReconnectResultType.Replay, actions: [], missing: [] }, + }); + await flushMicrotasks(); + + assert.strictEqual(client.connectionState, AgentHostClientState.Connected); + }); + }); + + test('does not automatically reconnect when the policy disables automatic restore', async () => { + const reconnectPolicy: IRemoteAgentHostReconnectPolicy = { + autoRestore: false, + initialDelayMs: 1, + maxDelayMs: 1, + maxAttempts: 1, + }; + const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, reconnectPolicy); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + const fatalError = Event.toPromise(client.onDidFatalClose); + transports[0].fireClose(); + + assert.deepStrictEqual({ + fatalError: (await fatalError).message, + state: client.connectionState, + transportCount: transports.length, + }, { + fatalError: 'Connection closed: test.example:1234', + state: AgentHostClientState.Closed, + transportCount: 1, + }); + }); + test('can reconnect a terminal connection after an explicit host restart', async function () { this.timeout(10_000); const { client, transports } = createFactoryClient(); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index 7051bb56da5ab5..03c54135b4cd59 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -15,7 +15,8 @@ import { IConfigurationService, type IConfigurationChangeEvent } from '../../../ import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILabelService, type ResourceLabelFormatter } from '../../../label/common/label.js'; import { AgentsWindowRemoteAgentHostService, RemoteAgentHostService } from '../../browser/remoteAgentHostServiceImpl.js'; -import { getEntryTypeConfig, parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, type IRawRemoteAgentHostEntry, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js'; +import type { IAgentHostProtocolClientOptions } from '../../browser/agentHostProtocolClient.js'; +import { addSSHRemoteAgentHostEntry, addWebSocketRemoteAgentHostEntry, getEntryTypeConfig, parseRemoteAgentHostInput, removeWebSocketRemoteAgentHostEntry, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, type IRawRemoteAgentHostEntry, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js'; import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../common/agentHostUri.js'; import { DeferredPromise } from '../../../../base/common/async.js'; import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../storage/common/storage.js'; @@ -44,7 +45,8 @@ class MockProtocolClient extends Disposable { readonly onDidClose = this._onDidClose.event; readonly onDidAction = Event.None; readonly onDidNotification = Event.None; - readonly onDidChangeConnectionState = Event.None; + private readonly _onDidChangeConnectionState = this._register(new Emitter()); + readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; readonly onDidReceiveOtlpLogs = Event.None; readonly connectionState = 'connecting' as const; readonly initializeResult = undefined; @@ -69,6 +71,10 @@ class MockProtocolClient extends Disposable { fireClose(): void { this._onDidClose.fire(); } + + fireConnectionState(state: 'connecting' | 'reconnecting' | 'connected' | 'incompatible' | 'closed'): void { + this._onDidChangeConnectionState.fire(state); + } } // ---- Test configuration service --------------------------------------------- @@ -192,8 +198,8 @@ suite('RemoteAgentHostService', () => { } as Partial); // Mock the instantiation service to capture created protocol clients. - // `_connectTo` calls `createInstance` for `WebSocketClientTransport` - // and `AgentHostProtocolClient`. We only care about tracking + // `WebSocketConnectionFactory` calls `createInstance` for + // `WebSocketClientTransport` and `AgentHostProtocolClient`. We only care about tracking // the protocol client; for the transport we return a no-op // disposable so the test can keep asserting on `createdClients.length`. const mockInstantiationService: Partial = { @@ -203,7 +209,7 @@ suite('RemoteAgentHostService', () => { return disposables.add(new MockTransport()); } const client = new MockProtocolClient(args[0] as string); - createdClientInfos.push(args[4] as Implementation | undefined); + createdClientInfos.push((args[2] as IAgentHostProtocolClientOptions | undefined)?.clientInfo); disposables.add(client); createdClients.push(client); return client; @@ -236,6 +242,13 @@ suite('RemoteAgentHostService', () => { } } + /** Wait for factories to build the requested number of protocol clients. */ + async function waitForCreatedClients(count: number): Promise { + while (createdClients.length < count) { + await Event.toPromise(service.onDidChangeConnections); + } + } + test('starts with no connections when setting is empty', () => { assert.deepStrictEqual(service.connections, []); }); @@ -270,6 +283,7 @@ suite('RemoteAgentHostService', () => { configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); // Resolve the connect promise + await waitForCreatedClients(1); assert.strictEqual(createdClients.length, 1); createdClients[0].connectDeferred.complete(); await waitForConnected(); @@ -288,6 +302,7 @@ suite('RemoteAgentHostService', () => { service.dispose(); service = disposables.add(instantiationService.createInstance(AgentsWindowRemoteAgentHostService)); configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await waitForConnected(); @@ -296,6 +311,7 @@ suite('RemoteAgentHostService', () => { test('getConnection returns client after successful connect', async () => { configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await waitForConnected(); @@ -307,6 +323,7 @@ suite('RemoteAgentHostService', () => { test('removes connection when setting entry is removed', async () => { // Add a connection configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await waitForConnected(); @@ -321,6 +338,7 @@ suite('RemoteAgentHostService', () => { test('fires onDidChangeConnections when connection closes', async () => { configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await waitForConnected(); @@ -338,6 +356,7 @@ suite('RemoteAgentHostService', () => { test('removes connection on connect failure', async () => { configService.setEntries([{ name: 'Bad', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://bad:9999' } }]); + await waitForCreatedClients(1); assert.strictEqual(createdClients.length, 1); // Fail the connection and wait for the service to react @@ -355,6 +374,7 @@ suite('RemoteAgentHostService', () => { { name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:8080' } }, ]); + await waitForCreatedClients(2); assert.strictEqual(createdClients.length, 2); createdClients[0].connectDeferred.complete(); createdClients[1].connectDeferred.complete(); @@ -369,8 +389,21 @@ suite('RemoteAgentHostService', () => { assert.notStrictEqual(conn1.clientId, conn2.clientId); }); + test('does not re-create an in-flight connection on setting update', async () => { + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + configService.setEntries([{ name: 'Renamed', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + + await waitForCreatedClients(1); + assert.strictEqual(createdClients.length, 1); + createdClients[0].connectDeferred.complete(); + await waitForConnected(); + + assert.strictEqual(service.connections.find(connection => connection.address === 'host1:8080')?.name, 'Renamed'); + }); + test('does not re-create existing connections on setting update', async () => { configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await waitForConnected(); @@ -392,13 +425,15 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(entry?.name, 'Renamed'); }); - test('addRemoteAgentHost stores the entry and waits for connection', async () => { - const connectionPromise = service.addRemoteAgentHost({ + test('waitForConnection resolves after a configured entry connects', async () => { + await addWebSocketRemoteAgentHostEntry(configService as unknown as IConfigurationService, { name: 'Host 1', connectionToken: 'secret-token', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' }, }); + const connectionPromise = service.waitForConnection('ws://host1:8080'); + await waitForCreatedClients(1); assert.deepStrictEqual(configService.entries, [{ address: 'host1:8080', name: 'Host 1', @@ -418,16 +453,18 @@ suite('RemoteAgentHostService', () => { }); }); - test('addRemoteAgentHost updates existing configured entries without reconnecting', async () => { + test('updating a configured entry does not reconnect it', async () => { configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await waitForConnected(); - const connection = await service.addRemoteAgentHost({ + await addWebSocketRemoteAgentHostEntry(configService as unknown as IConfigurationService, { name: 'Updated Host', connectionToken: 'new-token', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' }, }); + const connection = await service.waitForConnection('ws://host1:8080'); assert.strictEqual(createdClients.length, 1); assert.deepStrictEqual(configService.entries, [{ @@ -444,20 +481,24 @@ suite('RemoteAgentHostService', () => { }); }); - test('addRemoteAgentHost appends when adding a second host', async () => { + test('configured WebSocket entries append a second host', async () => { // Add first host - const firstPromise = service.addRemoteAgentHost({ + await addWebSocketRemoteAgentHostEntry(configService as unknown as IConfigurationService, { name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' }, }); + const firstPromise = service.waitForConnection('host1:8080'); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await firstPromise; // Add second host - const secondPromise = service.addRemoteAgentHost({ + await addWebSocketRemoteAgentHostEntry(configService as unknown as IConfigurationService, { name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host2:9090' }, }); + const secondPromise = service.waitForConnection('host2:9090'); + await waitForCreatedClients(2); createdClients[1].connectDeferred.complete(); await secondPromise; @@ -469,23 +510,21 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(service.connections.length, 2); }); - test('addRemoteAgentHost resolves when connection completes before wait is created', async () => { - // Simulate a fast connect: the mock client resolves synchronously - // during the config change handler, before addRemoteAgentHost has a - // chance to create its DeferredPromise wait. + test('waitForConnection resolves when connection completes before it is called', async () => { + // Simulate a fast connect: the mock client resolves during the + // configuration update, before waitForConnection is called. const originalUpdateValue = configService.updateValue.bind(configService); configService.updateValue = async (key: string, value: unknown) => { await originalUpdateValue(key, value); - // Complete the connection synchronously inside the config change callback - if (createdClients.length > 0) { - createdClients[createdClients.length - 1].connectDeferred.complete(); - } + await waitForCreatedClients(1); + createdClients[createdClients.length - 1].connectDeferred.complete(); }; - const connection = await service.addRemoteAgentHost({ + await addWebSocketRemoteAgentHostEntry(configService as unknown as IConfigurationService, { name: 'Fast Host', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'fast-host:1234' }, }); + const connection = await service.waitForConnection('fast-host:1234'); assert.strictEqual(connection.address, 'fast-host:1234'); assert.strictEqual(connection.name, 'Fast Host'); @@ -493,6 +532,7 @@ suite('RemoteAgentHostService', () => { test('disabling the enabled setting disconnects all remotes', async () => { configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await waitForConnected(); assert.strictEqual(service.connections.filter(c => RemoteAgentHostConnectionStatus.isConnected(c.status)).length, 1); @@ -502,17 +542,49 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(service.connections.length, 0); }); - test('addRemoteAgentHost throws when disabled', async () => { + test('waitForConnection throws when disabled', async () => { configService.setEnabled(false); await assert.rejects( - () => service.addRemoteAgentHost({ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }), + () => service.waitForConnection('host1:8080'), /not enabled/, ); }); + test('a handshake interrupted by a transport drop leaves the client to restore itself', async () => { + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); + await waitForCreatedClients(1); + + // The protocol client schedules its own retry and only then rejects the + // original connect(), so the service must not tear the entry down. + createdClients[0].fireConnectionState('reconnecting'); + createdClients[0].connectDeferred.error(new Error('transport closed')); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepStrictEqual({ + status: service.connections.find(connection => connection.address === 'host1:8080')?.status, + clientsCreated: createdClients.length, + }, { + status: RemoteAgentHostConnectionStatus.reconnecting, + clientsCreated: 1, + }); + }); + + test('a soft reconnect settles a wait started before the drop', async () => { + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); + await waitForCreatedClients(1); + createdClients[0].fireConnectionState('reconnecting'); + createdClients[0].connectDeferred.error(new Error('transport closed')); + + const waiting = service.waitForConnection('host1:8080'); + createdClients[0].fireConnectionState('connected'); + + assert.strictEqual((await waiting).address, 'host1:8080'); + }); + test('re-enabling reconnects configured remotes', async () => { configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await waitForConnected(); assert.strictEqual(service.connections.filter(c => RemoteAgentHostConnectionStatus.isConnected(c.status)).length, 1); @@ -521,22 +593,25 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(service.connections.length, 0); configService.setEnabled(true); + await waitForCreatedClients(2); assert.strictEqual(createdClients.length, 2); // new client created createdClients[1].connectDeferred.complete(); await waitForConnected(); assert.strictEqual(service.connections.filter(c => RemoteAgentHostConnectionStatus.isConnected(c.status)).length, 1); }); - test('removeRemoteAgentHost removes entry and disconnects', async () => { + test('removing a configured entry and connection keeps it removed', async () => { configService.setEntries([ { name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }, { name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:9090' } }, ]); + await waitForCreatedClients(2); createdClients[0].connectDeferred.complete(); createdClients[1].connectDeferred.complete(); await waitForConnected(); assert.strictEqual(service.connections.filter(c => RemoteAgentHostConnectionStatus.isConnected(c.status)).length, 2); + await removeWebSocketRemoteAgentHostEntry(configService as unknown as IConfigurationService, 'ws://host1:8080'); await service.removeRemoteAgentHost('ws://host1:8080'); assert.deepStrictEqual(configService.entries, [ @@ -549,9 +624,11 @@ suite('RemoteAgentHostService', () => { test('removeRemoteAgentHost normalizes address before removing', async () => { configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); + await waitForCreatedClients(1); createdClients[0].connectDeferred.complete(); await waitForConnected(); + await removeWebSocketRemoteAgentHostEntry(configService as unknown as IConfigurationService, 'ws://host1:8080'); await service.removeRemoteAgentHost('ws://host1:8080'); assert.deepStrictEqual(configService.entries, []); @@ -661,44 +738,6 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(t.disposed(), true, 'transport disposable runs when service is disposed'); }); - test('stores SSH connection details outside the remote hosts setting', async () => { - const mockClient = disposables.add(new MockProtocolClient('ssh:remote.example')); - await service.addManagedConnection( - { - name: 'SSH Host', - connectionToken: 'ssh-token', - connection: { - type: RemoteAgentHostEntryType.SSH, - address: 'ssh:remote.example', - sshConfigHost: 'remote', - hostName: 'remote.example', - user: 'me', - port: 2222, - }, - }, - mockClient as unknown as Parameters[1], - ); - - assert.deepStrictEqual({ - settings: configService.entries, - configured: service.configuredEntries, - }, { - settings: [], - configured: [{ - name: 'SSH Host', - connectionToken: 'ssh-token', - connection: { - type: RemoteAgentHostEntryType.SSH, - address: 'ssh:remote.example', - sshConfigHost: 'remote', - hostName: 'remote.example', - user: 'me', - port: 2222, - }, - }], - }); - }); - test('does not persist runtime managed connections or their removal', async () => { const entries: IRemoteAgentHostEntry[] = [ { name: 'Tunnel', connection: { type: RemoteAgentHostEntryType.Tunnel, tunnelId: 'runtime-tunnel', clusterId: 'cluster' } }, @@ -739,75 +778,22 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(service.getConnection('tunnel:live'), tunnel); }); - test('migrates legacy SSH connection details from settings to storage', async () => { - service.dispose(); - configService.setRawEntries([{ - address: 'ssh:legacy', - name: 'Legacy SSH Host', - connectionToken: 'ssh-token', - sshConfigHost: 'legacy', - sshHostName: 'legacy.example', - sshUser: 'me', - sshPort: 2222, + test('does not surface storage-only SSH entries without an SSH factory', async () => { + configService.setEntries([{ + name: 'WebSocket Host', + connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' }, }]); - service = disposables.add(instantiationService.createInstance(RemoteAgentHostService)); - - assert.deepStrictEqual({ - settings: configService.entries, - configured: service.configuredEntries, - }, { - settings: [], - configured: [{ - name: 'Legacy SSH Host', - connectionToken: 'ssh-token', - connection: { - type: RemoteAgentHostEntryType.SSH, - address: 'ssh:legacy', - sshConfigHost: 'legacy', - hostName: 'legacy.example', - user: 'me', - port: 2222, - }, - }], - }); - - service.dispose(); - service = disposables.add(instantiationService.createInstance(RemoteAgentHostService)); - - assert.deepStrictEqual({ - settings: configService.entries, - configured: service.configuredEntries, - }, { - settings: [], - configured: [{ - name: 'Legacy SSH Host', - connectionToken: 'ssh-token', - connection: { - type: RemoteAgentHostEntryType.SSH, - address: 'ssh:legacy', - sshConfigHost: 'legacy', - hostName: 'legacy.example', - user: 'me', - port: 2222, - }, - }], + await removeWebSocketRemoteAgentHostEntry(configService as unknown as IConfigurationService, 'host1:8080'); + addSSHRemoteAgentHostEntry(storageService, { + name: 'SSH Host', + connection: { + type: RemoteAgentHostEntryType.SSH, + address: 'host1:8080', + sshConfigHost: 'host1', + hostName: 'host1.example', + }, }); - }); - - test('fires change when removing a storage-only SSH entry', async () => { - service.dispose(); - configService.setRawEntries([{ - address: 'ssh:legacy', - name: 'Legacy SSH Host', - sshConfigHost: 'legacy', - sshHostName: 'legacy.example', - }]); - service = disposables.add(instantiationService.createInstance(RemoteAgentHostService)); - - const changed = Event.toPromise(service.onDidChangeConnections); - await service.removeRemoteAgentHost('ssh:legacy'); - await changed; assert.deepStrictEqual({ settings: configService.entries, @@ -818,29 +804,6 @@ suite('RemoteAgentHostService', () => { }); }); - test('replacing a stored SSH entry with a WebSocket entry clears the storage row', async () => { - service.dispose(); - configService.setRawEntries([{ - address: 'host1:8080', - name: 'SSH Host', - sshConfigHost: 'legacy', - sshHostName: 'legacy.example', - }]); - service = disposables.add(instantiationService.createInstance(RemoteAgentHostService)); - - const added = service.addRemoteAgentHost({ name: 'WebSocket Host', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }); - createdClients[createdClients.length - 1].connectDeferred.complete(); - await added; - - // The stale SSH row must not survive in storage, or _getConfiguredEntries - // overlays it back on top of the new WebSocket entry. - assert.deepStrictEqual(service.configuredEntries, [{ - name: 'WebSocket Host', - connectionToken: undefined, - connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' }, - }]); - }); - test('keeps runtime connection names across reconciliation', async () => { const tunnel: IRemoteAgentHostEntry = { name: 'My Tunnel', connection: { type: RemoteAgentHostEntryType.Tunnel, tunnelId: 'tunnel', clusterId: 'cluster' } }; const client = disposables.add(new MockProtocolClient('tunnel:tunnel')); diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts deleted file mode 100644 index be8dea69746f48..00000000000000 --- a/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { SSHRelayTransport } from '../../electron-browser/sshRelayTransport.js'; -import type { ISSHRemoteAgentHostMainService, ISSHConnectProgress, ISSHConnectResult, ISSHAgentHostConfig, ISSHResolvedConfig } from '../../common/sshRemoteAgentHost.js'; -import type { IRelayMessage } from '../../common/relayTransport.js'; - -/** - * Minimal mock of ISSHRemoteAgentHostMainService for testing the relay transport. - */ -class MockSSHMainService { - private readonly _onDidRelayMessage = new Emitter(); - readonly onDidRelayMessage = this._onDidRelayMessage.event; - - private readonly _onDidRelayClose = new Emitter(); - readonly onDidRelayClose = this._onDidRelayClose.event; - - readonly onDidChangeConnections = Event.None; - readonly onDidCloseConnection = Event.None; - readonly onDidReportConnectProgress = Event.None as Event; - - readonly sentMessages: { connectionId: string; message: string }[] = []; - - async relaySend(connectionId: string, message: string): Promise { - this.sentMessages.push({ connectionId, message }); - } - - async connect(_config: ISSHAgentHostConfig): Promise { - throw new Error('Not implemented'); - } - async disconnect(_host: string): Promise { } - async listSSHConfigHosts(): Promise { return []; } - async ensureUserSSHConfig(): Promise { return URI.file('/tmp/ssh-config'); } - async listSSHConfigFiles(): Promise { return [URI.file('/tmp/ssh-config')]; } - async resolveSSHConfig(_host: string): Promise { - throw new Error('Not implemented'); - } - async reconnect(_sshConfigHost: string, _name: string): Promise { - throw new Error('Not implemented'); - } - - // Test helpers - fireRelayMessage(msg: IRelayMessage): void { - this._onDidRelayMessage.fire(msg); - } - - fireRelayClose(connectionId: string): void { - this._onDidRelayClose.fire(connectionId); - } - - dispose(): void { - this._onDidRelayMessage.dispose(); - this._onDidRelayClose.dispose(); - } -} - -suite('SSHRelayTransport', () => { - - const disposables = new DisposableStore(); - let mockService: MockSSHMainService; - - setup(() => { - mockService = new MockSSHMainService(); - disposables.add({ dispose: () => mockService.dispose() }); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('receives messages matching connectionId', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService, undefined, new NullLogService())); - - const received: unknown[] = []; - disposables.add(transport.onMessage(msg => received.push(msg))); - - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"jsonrpc":"2.0","id":1}' }); - - assert.strictEqual(received.length, 1); - assert.deepStrictEqual(received[0], { jsonrpc: '2.0', id: 1 }); - }); - - test('ignores messages for other connectionIds', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService, undefined, new NullLogService())); - - const received: unknown[] = []; - disposables.add(transport.onMessage(msg => received.push(msg))); - - mockService.fireRelayMessage({ connectionId: 'conn-2', data: '{"jsonrpc":"2.0","id":1}' }); - - assert.strictEqual(received.length, 0); - }); - - test('drops malformed JSON messages', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService, undefined, new NullLogService())); - - const received: unknown[] = []; - disposables.add(transport.onMessage(msg => received.push(msg))); - - // Should not throw - mockService.fireRelayMessage({ connectionId: 'conn-1', data: 'not-json{{{' }); - - assert.strictEqual(received.length, 0); - }); - - test('fires onClose when relay closes for matching connectionId', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService, undefined, new NullLogService())); - - let closed = false; - disposables.add(transport.onClose(() => { closed = true; })); - - mockService.fireRelayClose('conn-1'); - - assert.strictEqual(closed, true); - }); - - test('does not fire onClose for other connectionIds', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService, undefined, new NullLogService())); - - let closed = false; - disposables.add(transport.onClose(() => { closed = true; })); - - mockService.fireRelayClose('conn-2'); - - assert.strictEqual(closed, false); - }); - - test('send() calls relaySend with correct connectionId', async () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService, undefined, new NullLogService())); - - const msg = { jsonrpc: '2.0' as const, method: 'test', id: 42 }; - transport.send(msg as never); - - // Give the async relaySend a tick to register - await new Promise(r => queueMicrotask(r)); - - assert.strictEqual(mockService.sentMessages.length, 1); - assert.strictEqual(mockService.sentMessages[0].connectionId, 'conn-1'); - assert.deepStrictEqual(JSON.parse(mockService.sentMessages[0].message), msg); - }); - - test('receives multiple messages in order', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService, undefined, new NullLogService())); - - const received: unknown[] = []; - disposables.add(transport.onMessage(msg => received.push(msg))); - - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' }); - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":2}' }); - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":3}' }); - - assert.strictEqual(received.length, 3); - assert.deepStrictEqual(received, [{ id: 1 }, { id: 2 }, { id: 3 }]); - }); - - test('no events after dispose', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService, undefined, new NullLogService())); - - const received: unknown[] = []; - let closed = false; - disposables.add(transport.onMessage(msg => received.push(msg))); - disposables.add(transport.onClose(() => { closed = true; })); - - transport.dispose(); - - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' }); - mockService.fireRelayClose('conn-1'); - - assert.strictEqual(received.length, 0); - assert.strictEqual(closed, false); - }); -}); diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts index 32718a3e725e0e..5653791e2d7ca0 100644 --- a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { DeferredPromise } from '../../../../base/common/async.js'; import type { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -21,24 +21,25 @@ import { IProductService } from '../../../product/common/productService.js'; import { ISharedProcessService } from '../../../ipc/electron-browser/services.js'; import { IQuickInputService } from '../../../quickinput/common/quickInput.js'; -import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../common/remoteAgentHostService.js'; +import { addSSHRemoteAgentHostEntry, IRemoteAgentHostService, readSSHRemoteAgentHostEntries, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, type IRawRemoteAgentHostEntry, type IRemoteAgentHostConnectionFactory, type IRemoteAgentHostConnectionInfo } from '../../common/remoteAgentHostService.js'; import type { IAgentConnection } from '../../common/agentService.js'; import { AHP_UNSUPPORTED_PROTOCOL_VERSION, ProtocolError } from '../../common/state/sessionProtocol.js'; import { IRemoteAgentHostLocationPreferenceService, type RemoteAgentHostLocationPreference } from '../../common/remoteAgentHostLocationPreference.js'; import { ISSHHostKeyTrustService } from '../../common/sshHostKeyTrust.js'; import { SSHHostKeyTrustService } from '../../browser/sshHostKeyTrustService.js'; -import { InMemoryStorageService } from '../../../storage/common/storage.js'; -import type { - ISSHAgentHostConfig, - ISSHConnectResult, - ISSHEndpointCandidate, - ISSHEndpointSelection, - ISSHEndpointSelectionRequest, - ISSHHostKeyVerificationRequest, - ISSHHostKeysAnnouncement, - ISSHKeyboardInteractiveRequest, - ISSHResolvedConfig, - ISSHRemoteAgentHostMainService, +import { InMemoryStorageService, IStorageService } from '../../../storage/common/storage.js'; +import { + SSHAuthMethod, + type ISSHAgentHostConfig, + type ISSHConnectResult, + type ISSHEndpointCandidate, + type ISSHEndpointSelection, + type ISSHEndpointSelectionRequest, + type ISSHHostKeyVerificationRequest, + type ISSHHostKeysAnnouncement, + type ISSHKeyboardInteractiveRequest, + type ISSHResolvedConfig, + type ISSHRemoteAgentHostMainService, } from '../../common/sshRemoteAgentHost.js'; import type { IRelayMessage } from '../../common/relayTransport.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; @@ -166,6 +167,9 @@ class MockSSHMainService { config: { host: config.host, username: config.username, authMethod: config.authMethod, name: config.name, sshConfigHost: config.sshConfigHost }, sshConfigHost: config.sshConfigHost, serverType: this.connectResult?.serverType, + instanceId: this.connectResult?.instanceId, + primary: this.connectResult?.primary, + lifecycle: this.connectResult?.lifecycle, }; } @@ -176,9 +180,12 @@ class MockSSHMainService { address: this.connectResult?.address ?? `ssh:${sshConfigHost}`, name, connectionToken: 'test-token', - config: { host: sshConfigHost, username: 'u', authMethod: 0 as never, name, sshConfigHost }, + config: { host: sshConfigHost, username: 'u', authMethod: SSHAuthMethod.Agent, name, sshConfigHost }, sshConfigHost, serverType: this.connectResult?.serverType, + instanceId: this.connectResult?.instanceId, + primary: this.connectResult?.primary, + lifecycle: this.connectResult?.lifecycle, }; } @@ -231,32 +238,115 @@ function asChannel(target: object): IChannel { }; } -/** Captures addManagedConnection calls so tests can inspect transportDisposable. */ +/** Drives registered connection factories like RemoteAgentHostService. */ class MockRemoteAgentHostService extends Disposable { readonly added: Array<{ address: string; status?: RemoteAgentHostConnectionStatus; transport?: IDisposable }> = []; private readonly _entries = new Map void }; status: RemoteAgentHostConnectionStatus }>(); - // Holds transport disposables from prior registrations that were - // replaced by a later `addManagedConnection` for the same address. + // Holds transport disposables from prior service-owned connections that + // were replaced by a later connection for the same address. // Production deliberately does NOT run them at replacement time (doing // so would call _mainService.disconnect on the brand-new tunnel and // kill it). They are released when the service itself is disposed. private readonly _abandonedTransports: IDisposable[] = []; + private readonly _onDidChangeConnections = this._register(new Emitter()); + readonly onDidChangeConnections = this._onDidChangeConnections.event; + private readonly _connectionWaits = new Map>(); + private readonly _factories = new Map(); + + get connections(): readonly IRemoteAgentHostConnectionInfo[] { + return [...this._entries].map(([address, entry]) => ({ + address, + name: address, + clientId: 'mock', + defaultDirectory: undefined, + status: entry.status, + })); + } + + registerConnectionFactory(factory: IRemoteAgentHostConnectionFactory): IDisposable { + this._factories.set(factory.kind, factory); + return toDisposable(() => this._factories.delete(factory.kind)); + } + + reconnect(address: string, userInitiated = true): void { + void this._createAndConnect(address, userInitiated); + } + + async waitForConnection(address: string): Promise { + const existing = this.connections.find(connection => connection.address === address && RemoteAgentHostConnectionStatus.isConnected(connection.status)); + if (existing) { + return existing; + } + let wait = this._connectionWaits.get(address); + if (!wait) { + wait = new DeferredPromise(); + this._connectionWaits.set(address, wait); + } + return wait.p; + } + + private async _createAndConnect(address: string, userInitiated: boolean): Promise { + const factory = this._factories.get(RemoteAgentHostEntryType.SSH); + const entry = factory?.entries.get().find(candidate => candidate.connection.type === RemoteAgentHostEntryType.SSH && candidate.connection.address === address); + if (!factory || !entry) { + this._rejectConnectionWait(address, new Error(`No SSH entry found for ${address}.`)); + return; + } - async addManagedConnection(entry: { name: string; connection: { address?: string; sshConfigHost?: string } }, client: IAgentConnection, transportDisposable?: IDisposable, status: RemoteAgentHostConnectionStatus = RemoteAgentHostConnectionStatus.connected): Promise { - const address = entry.connection.address ?? `ssh:${entry.connection.sshConfigHost}`; - // Mirror RemoteAgentHostService: re-registering an address replaces - // the previous entry and disposes its protocol client (but NOT its - // transport disposable — the new entry owns the underlying tunnel). const previous = this._entries.get(address); if (previous) { previous.client.dispose?.(); if (previous.transport) { this._abandonedTransports.push(previous.transport); } + this._entries.delete(address); + } + + try { + const created = await factory.createConnection(entry, { userInitiated }); + const added = { address, status: RemoteAgentHostConnectionStatus.connecting, transport: created.transportDisposable }; + this.added.push(added); + const managed = { client: created.connection as { dispose?: () => void }, transport: created.transportDisposable, status: RemoteAgentHostConnectionStatus.connecting }; + this._entries.set(address, managed); + this._onDidChangeConnections.fire(); + try { + await created.connection.connect(); + managed.status = RemoteAgentHostConnectionStatus.connected; + added.status = managed.status; + this._resolveConnectionWait(address); + } catch (err) { + const incompatible = RemoteAgentHostConnectionStatus.fromConnectError(err, [PROTOCOL_VERSION]); + if (incompatible) { + managed.status = incompatible; + added.status = managed.status; + } else { + this._entries.delete(address); + managed.client.dispose?.(); + managed.transport?.dispose(); + } + this._rejectConnectionWait(address, err); + } + this._onDidChangeConnections.fire(); + } catch (err) { + this._rejectConnectionWait(address, err); + } + } + + private _resolveConnectionWait(address: string): void { + const wait = this._connectionWaits.get(address); + const connection = this.connections.find(candidate => candidate.address === address && RemoteAgentHostConnectionStatus.isConnected(candidate.status)); + if (wait && connection) { + this._connectionWaits.delete(address); + wait.complete(connection); + } + } + + private _rejectConnectionWait(address: string, err: unknown): void { + const wait = this._connectionWaits.get(address); + if (wait) { + this._connectionWaits.delete(address); + wait.error(err); } - this.added.push({ address, status, transport: transportDisposable }); - this._entries.set(address, { client: client as { dispose?: () => void }, transport: transportDisposable, status }); - return { address, name: entry.name, clientId: 'mock', defaultDirectory: undefined, status }; } /** Mirrors IRemoteAgentHostService.getConnection: returns the client only when the entry is connected. */ @@ -278,6 +368,7 @@ class MockRemoteAgentHostService extends Disposable { this._entries.delete(address); e.client.dispose?.(); e.transport?.dispose(); + this._onDidChangeConnections.fire(); } override dispose(): void { @@ -288,6 +379,10 @@ class MockRemoteAgentHostService extends Disposable { e.transport?.dispose(); } this._entries.clear(); + for (const wait of this._connectionWaits.values()) { + wait.error(new Error('Mock remote agent host service disposed.')); + } + this._connectionWaits.clear(); // Release abandoned transports from prior registrations as well. for (const t of this._abandonedTransports) { t.dispose(); @@ -309,8 +404,32 @@ class MockProtocolClient extends Disposable { class TestConfigurationService { readonly onDidChangeConfiguration = Event.None; + private _entries: readonly IRawRemoteAgentHostEntry[] = []; + updateValueCalls = 0; + constructor(private _remoteAgentHostsEnabled = true) { } - getValue(key?: string): unknown { return key === RemoteAgentHostsEnabledSettingId ? this._remoteAgentHostsEnabled : undefined; } + getValue(key?: string): unknown { + if (key === RemoteAgentHostsEnabledSettingId) { + return this._remoteAgentHostsEnabled; + } + if (key === RemoteAgentHostsSettingId) { + return this._entries; + } + return undefined; + } + inspect(): { userValue: readonly IRawRemoteAgentHostEntry[] } { + return { userValue: this._entries }; + } + async updateValue(_key: string, value: readonly IRawRemoteAgentHostEntry[]): Promise { + this.updateValueCalls++; + this._entries = value; + } + setRawEntries(entries: readonly IRawRemoteAgentHostEntry[]): void { + this._entries = entries; + } + get entries(): readonly IRawRemoteAgentHostEntry[] { + return this._entries; + } setRemoteAgentHostsEnabled(enabled: boolean): void { this._remoteAgentHostsEnabled = enabled; } } @@ -363,9 +482,11 @@ suite('SSHRemoteAgentHostService (renderer)', () => { let createdClients: MockProtocolClient[]; let waitForClient: (index: number) => Promise; let service: SSHRemoteAgentHostService; + let instantiationService: TestInstantiationService; let quickInputServiceStub: Partial; let locationPreferenceService: TestRemoteAgentHostLocationPreferenceService; let hostKeyTrustService: SSHHostKeyTrustService; + let sshStorageService: InMemoryStorageService; setup(() => { mainService = new MockSSHMainService(); @@ -377,10 +498,12 @@ suite('SSHRemoteAgentHostService (renderer)', () => { getChannel: () => asChannel(mainService), }; - const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(ILogService, new NullLogService()); configurationService = new TestConfigurationService(); instantiationService.stub(IConfigurationService, configurationService as Partial); + sshStorageService = disposables.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, sshStorageService); quickInputServiceStub = {}; instantiationService.stub(IQuickInputService, quickInputServiceStub as Partial); instantiationService.stub(ISharedProcessService, sharedProcessService as ISharedProcessService); @@ -424,7 +547,7 @@ suite('SSHRemoteAgentHostService (renderer)', () => { const sampleConfig: ISSHAgentHostConfig = { host: 'remote.example', username: 'user', - authMethod: 0 as never, + authMethod: SSHAuthMethod.Agent, name: 'My Remote', sshConfigHost: 'remote.example', }; @@ -435,17 +558,107 @@ suite('SSHRemoteAgentHostService (renderer)', () => { client.connectDeferred.complete(); } - test('connect registers a managed connection with a transport disposable', async () => { + test('connect registers an SSH factory connection with a transport disposable', async () => { + mainService.connectResult = { serverType: 'standalone', instanceId: 'standalone-1', primary: true, lifecycle: 'managed' }; const connectPromise = service.connect(sampleConfig); await awaitClientThenResolve(0); const handle = await connectPromise; - assert.strictEqual(remoteAgentHostService.added.length, 1); - assert.strictEqual(remoteAgentHostService.added[0].address, 'ssh:remote.example'); - assert.strictEqual(remoteAgentHostService.added[0].status?.kind, 'connected'); - assert.ok(remoteAgentHostService.added[0].transport, 'a transport disposable is passed so removal can tear down the SSH tunnel'); - assert.strictEqual(service.connections.length, 1); - assert.strictEqual(handle.localAddress, 'ssh:remote.example'); + assert.deepStrictEqual({ + managedConnection: remoteAgentHostService.added.length, + address: remoteAgentHostService.added[0].address, + status: remoteAgentHostService.added[0].status?.kind, + hasTransport: !!remoteAgentHostService.added[0].transport, + stored: readSSHRemoteAgentHostEntries(sshStorageService), + connectionCount: service.connections.length, + handleAddress: handle.localAddress, + }, { + managedConnection: 1, + address: 'ssh:remote.example', + status: 'connected', + hasTransport: true, + stored: [{ + name: 'My Remote', + connectionToken: 'test-token', + connection: { + type: RemoteAgentHostEntryType.SSH, + address: 'ssh:remote.example', + sshConfigHost: 'remote.example', + hostName: 'remote.example', + user: 'user', + port: undefined, + serverType: 'standalone', + instanceId: 'standalone-1', + primary: true, + lifecycle: 'managed', + }, + }], + connectionCount: 1, + handleAddress: 'ssh:remote.example', + }); + }); + + test('migrates legacy SSH settings to the SSH factory store once and preserves WebSocket settings', async () => { + service.dispose(); + configurationService.setRawEntries([{ + address: 'ssh:legacy', + name: 'Legacy SSH Host', + connectionToken: 'ssh-token', + sshConfigHost: 'legacy', + sshHostName: 'legacy.example', + sshUser: 'me', + sshPort: 2222, + }, { + address: 'host1:8080', + name: 'WebSocket Host', + connectionToken: 'websocket-token', + }]); + + service = disposables.add(instantiationService.createInstance(SSHRemoteAgentHostService)); + while (configurationService.updateValueCalls === 0) { + await Promise.resolve(); + } + + assert.deepStrictEqual({ + settings: configurationService.entries, + stored: readSSHRemoteAgentHostEntries(sshStorageService), + settingsWrites: configurationService.updateValueCalls, + }, { + settings: [{ + address: 'host1:8080', + name: 'WebSocket Host', + connectionToken: 'websocket-token', + }], + stored: [{ + name: 'Legacy SSH Host', + connectionToken: 'ssh-token', + connection: { + type: RemoteAgentHostEntryType.SSH, + address: 'ssh:legacy', + sshConfigHost: 'legacy', + hostName: 'legacy.example', + user: 'me', + port: 2222, + }, + }], + settingsWrites: 1, + }); + }); + + test('disconnect removes the persisted SSH entry', async () => { + const connectPromise = service.connect(sampleConfig); + await awaitClientThenResolve(0); + await connectPromise; + + await service.disconnect('ssh:remote.example'); + + assert.deepStrictEqual({ + stored: readSSHRemoteAgentHostEntries(sshStorageService), + disconnectCalls: mainService.disconnectCalls, + }, { + stored: [], + disconnectCalls: ['ssh:remote.example'], + }); }); test('connect threads the stored location preference for the stable connection key into the main-process config', async () => { @@ -457,6 +670,7 @@ suite('SSHRemoteAgentHostService (renderer)', () => { assert.strictEqual(mainService.connectCalls.length, 1); assert.strictEqual(mainService.connectCalls[0].preferredAgentLocation, 'editor'); + assert.strictEqual(mainService.connectCalls[0].userInitiated, true); }); test('connect omits preferredAgentLocation from the main-process config when no preference is stored', async () => { @@ -470,6 +684,10 @@ suite('SSHRemoteAgentHostService (renderer)', () => { test('reconnect threads the stored location preference for sshConfigHost into the main-process reconnect call', async () => { locationPreferenceService.setPreference('ssh:remote.example', 'dedicated'); + addSSHRemoteAgentHostEntry(sshStorageService, { + name: 'My Remote', + connection: { type: RemoteAgentHostEntryType.SSH, address: 'ssh:remote.example', sshConfigHost: 'remote.example', hostName: 'remote.example', user: 'user' }, + }); const reconnectPromise = service.reconnect('remote.example', 'My Remote'); await awaitClientThenResolve(0); @@ -478,9 +696,15 @@ suite('SSHRemoteAgentHostService (renderer)', () => { assert.strictEqual(mainService.reconnectCalls.length, 1); assert.strictEqual(mainService.reconnectCalls[0].sshConfigHost, 'remote.example'); assert.strictEqual(mainService.reconnectCalls[0].preferredAgentLocation, 'dedicated'); + assert.strictEqual(mainService.reconnectCalls[0].userInitiated, true); }); test('reconnect omits preferredAgentLocation from the main-process call when no preference is stored', async () => { + addSSHRemoteAgentHostEntry(sshStorageService, { + name: 'My Remote', + connection: { type: RemoteAgentHostEntryType.SSH, address: 'ssh:remote.example', sshConfigHost: 'remote.example', hostName: 'remote.example', user: 'user' }, + }); + const reconnectPromise = service.reconnect('remote.example', 'My Remote'); await awaitClientThenResolve(0); await reconnectPromise; @@ -617,7 +841,7 @@ suite('SSHRemoteAgentHostService (renderer)', () => { await c2; assert.strictEqual(service.connections.length, 1); - assert.strictEqual(remoteAgentHostService.added.length, 2, 'each connect produces a fresh managed-connection registration'); + assert.strictEqual(remoteAgentHostService.added.length, 2, 'each connect produces a fresh service-owned connection'); }); test('main-process onDidCloseConnection cleans up renderer handle without double-disconnecting', async () => { @@ -678,6 +902,7 @@ suite('SSHRemoteAgentHostService (renderer)', () => { await awaitClientThenResolve(1); await r; + assert.strictEqual(mainService.reconnectCalls[0].userInitiated, false); assert.deepStrictEqual(notificationService.infoMessages, [NOTIFICATION_MESSAGE]); }); @@ -766,19 +991,19 @@ suite('SSHRemoteAgentHostService (renderer)', () => { assert.deepStrictEqual(notificationService.infoMessages, []); }); - test('a duplicate setup that reuses an already-connected handle does not notify', async () => { + test('a duplicate setup reconnects through the service without notifying', async () => { mainService.connectResult = { connectionId: 'conn-1', serverType: 'editor' }; const c1 = service.connect(sampleConfig); await awaitClientThenResolve(0); await c1; - // Second connect resolves to the same connectionId while the entry - // is still connected — SSHRemoteAgentHostService short-circuits to - // the existing handle and never re-runs endpoint-selection tracking. + // Reconnecting through the shared service replaces its protocol client + // and performs a new handshake without treating it as a failover. const c2 = service.connect(sampleConfig); + await awaitClientThenResolve(1); await c2; - assert.strictEqual(createdClients.length, 1, 'no second protocol client is created for the duplicate setup'); + assert.strictEqual(createdClients.length, 2, 'the service owns a new protocol client for the reconnect'); assert.deepStrictEqual(notificationService.infoMessages, []); }); }); @@ -801,6 +1026,7 @@ suite('SSHRemoteAgentHostService endpoint selection preference (renderer)', () = const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IConfigurationService, new TestConfigurationService() as Partial); + instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); instantiationService.stub(IQuickInputService, {} as Partial); instantiationService.stub(ISharedProcessService, sharedProcessService as ISharedProcessService); instantiationService.stub(IRemoteAgentHostService, disposables.add(new MockRemoteAgentHostService()) as Partial); @@ -1072,6 +1298,7 @@ suite('SSHRemoteAgentHostService host key verification (renderer)', () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IConfigurationService, new TestConfigurationService() as Partial); + instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); instantiationService.stub(IQuickInputService, {} as Partial); instantiationService.stub(ISharedProcessService, sharedProcessService as ISharedProcessService); instantiationService.stub(IRemoteAgentHostService, disposables.add(new MockRemoteAgentHostService()) as Partial); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionOpenTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionOpenTelemetry.test.ts index f427b01327cd16..20307bd1edb50c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionOpenTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionOpenTelemetry.test.ts @@ -6,12 +6,16 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; -import { AgentSession } from '../../common/agent.js'; +import { AgentSession, CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, type IAgent } from '../../common/agent.js'; import { buildDefaultChatUri } from '../../common/state/sessionState.js'; -import { AgentHostCopilotSessionSubscribeTimeoutMs, AgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js'; +import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; +import { AgentHostSessionOpenTelemetry, AgentHostSessionSubscribeTimeoutMs } from '../../node/agentHostSessionOpenTelemetry.js'; function isTelemetryData(data: unknown): data is Record { return typeof data === 'object' && data !== null; @@ -31,11 +35,28 @@ suite('AgentHostSessionOpenTelemetry', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const session = AgentSession.uri('copilotcli', 'session'); const defaultChat = URI.parse(buildDefaultChatUri(session)); + const createService = (telemetryService: TestTelemetryService, providers: readonly string[] = ['copilotcli']) => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(ITelemetryService, telemetryService); + instantiationService.stub(IAgentHostProviderService, { + getProviderForSession: session => { + const provider = AgentSession.provider(session); + if (!provider || !providers.includes(provider)) { + return undefined; + } + const providerId = provider; + return new class extends mock() { + override readonly id = providerId; + }; + }, + }); + return disposables.add(instantiationService.createInstance(AgentHostSessionOpenTelemetry)); + }; test('emits ordered subscribe, restore, and SDK resume milestones', async () => { await runWithFakedTimers({ useFakeTimers: true, startTime: 1_000 }, async () => { const telemetryService = new TestTelemetryService(); - const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService)); + const service = createService(telemetryService); await service.withSubscription(defaultChat, async telemetry => { telemetry.setServedFromMemory(false); await timeout(5); @@ -48,8 +69,9 @@ suite('AgentHostSessionOpenTelemetry', () => { }); assert.deepStrictEqual(telemetryService.events, [{ - name: 'agentHost.copilotSessionSubscribe', + name: 'agentHost.sessionSubscribe', data: { + provider: 'copilotcli', channel: 'defaultChat', outcome: 'success', servedFromMemory: false, @@ -70,10 +92,11 @@ suite('AgentHostSessionOpenTelemetry', () => { test('records warm subscriptions without restore or SDK resume milestones', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { const telemetryService = new TestTelemetryService(); - const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService)); + const service = createService(telemetryService); await service.withSubscription(session, async telemetry => telemetry.setServedFromMemory(true)); assert.deepStrictEqual(telemetryService.events.map(event => event.data), [{ + provider: 'copilotcli', channel: 'session', outcome: 'success', servedFromMemory: true, @@ -93,7 +116,7 @@ suite('AgentHostSessionOpenTelemetry', () => { test('accumulates retries and reports fallback creation without duplicate emission', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { const telemetryService = new TestTelemetryService(); - const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService)); + const service = createService(telemetryService); await service.withSubscription(session, async telemetry => { telemetry.setServedFromMemory(false); telemetry.restoreStarted(true); @@ -127,7 +150,7 @@ suite('AgentHostSessionOpenTelemetry', () => { test('does not attribute an in-flight SDK resume to a late subscriber', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { const telemetryService = new TestTelemetryService(); - const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService)); + const service = createService(telemetryService); const first = service.withSubscription(session, async telemetry => { telemetry.setServedFromMemory(false); await service.withSdkResume(session, () => timeout(20)); @@ -153,7 +176,7 @@ suite('AgentHostSessionOpenTelemetry', () => { test('emits one failure outcome and rethrows the subscription error', async () => { const telemetryService = new TestTelemetryService(); - const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService)); + const service = createService(telemetryService); const expectedError = new Error('Restore failed'); await assert.rejects(service.withSubscription(defaultChat, async telemetry => { @@ -173,11 +196,46 @@ suite('AgentHostSessionOpenTelemetry', () => { }]); }); - test('emits a bounded timeout and ignores non-Copilot subscriptions', async () => { + test('emits subscription telemetry for current and future providers', async () => { + const telemetryService = new TestTelemetryService(); + const providers = [CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, 'future']; + const service = createService(telemetryService, providers); + for (const provider of [CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, 'future']) { + await service.withSubscription(AgentSession.uri(provider, 'session'), async telemetry => { + telemetry.setServedFromMemory(false); + telemetry.restoreStarted(false); + telemetry.restoreCompleted(); + }); + } + const terminalResult = await service.withSubscription(URI.parse('agenthost-terminal:/terminal'), async () => 'terminal'); + const unknownResult = await service.withSubscription(AgentSession.uri('unknown', 'session'), async () => 'unknown'); + + assert.deepStrictEqual({ + terminalResult, + unknownResult, + events: telemetryService.events.map(event => ({ + name: event.name, + provider: event.data.provider, + channel: event.data.channel, + outcome: event.data.outcome, + sdkResumeOutcome: event.data.sdkResumeOutcome, + sdkResumeAttemptCount: event.data.sdkResumeAttemptCount, + })), + }, { + terminalResult: 'terminal', + unknownResult: 'unknown', + events: [ + { name: 'agentHost.sessionSubscribe', provider: 'claude', channel: 'session', outcome: 'success', sdkResumeOutcome: undefined, sdkResumeAttemptCount: undefined }, + { name: 'agentHost.sessionSubscribe', provider: 'codex', channel: 'session', outcome: 'success', sdkResumeOutcome: undefined, sdkResumeAttemptCount: undefined }, + { name: 'agentHost.sessionSubscribe', provider: 'future', channel: 'session', outcome: 'success', sdkResumeOutcome: undefined, sdkResumeAttemptCount: undefined }, + ], + }); + }); + + test('emits a bounded timeout', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { const telemetryService = new TestTelemetryService(); - const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService)); - assert.strictEqual(await service.withSubscription(AgentSession.uri('claude', 'session'), async () => 'not measured'), 'not measured'); + const service = createService(telemetryService); const resume = new DeferredPromise(); const subscription = service.withSubscription(session, async telemetry => { telemetry.setServedFromMemory(false); @@ -187,11 +245,12 @@ suite('AgentHostSessionOpenTelemetry', () => { }); await timeout(10_000); - await timeout(AgentHostCopilotSessionSubscribeTimeoutMs - 10_000); + await timeout(AgentHostSessionSubscribeTimeoutMs - 10_000); assert.deepStrictEqual(telemetryService.events, [{ - name: 'agentHost.copilotSessionSubscribe', + name: 'agentHost.sessionSubscribe', data: { + provider: 'copilotcli', channel: 'session', outcome: 'timeout', servedFromMemory: false, @@ -203,7 +262,7 @@ suite('AgentHostSessionOpenTelemetry', () => { sdkResumeDurationMs: 50_000, timeToSdkResumeCompleteMs: undefined, timeToRestoreCompleteMs: undefined, - totalDurationMs: AgentHostCopilotSessionSubscribeTimeoutMs, + totalDurationMs: AgentHostSessionSubscribeTimeoutMs, }, }]); resume.complete(); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts index 38870271b5b65a..17f9a188cedcd4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts @@ -252,6 +252,24 @@ suite('AgentHostTelemetryService', () => { }); }); + test('marks msftInternal for an internal account and clears it again on account change', () => { + const delegate = new TestTelemetryService(); + const service = disposables.add(new AgentHostTelemetryService(delegate, new TestRestrictedSink())); + const account = (isInternal: boolean) => ({ isInternal, trackingId: 'tid', userName: 'user', isVscodeTeamMember: false }); + + service.setInternalTelemetryContext(undefined); + const beforeSignIn = { ...delegate.commonProperties }; + service.setInternalTelemetryContext(account(true)); + const afterInternalAccount = { ...delegate.commonProperties }; + service.setInternalTelemetryContext(account(false)); + + assert.deepStrictEqual({ beforeSignIn, afterInternalAccount, afterExternalAccount: delegate.commonProperties }, { + beforeSignIn: {}, + afterInternalAccount: { 'common.msftInternal': true }, + afterExternalAccount: { 'common.msftInternal': false }, + }); + }); + test('uses most restrictive client telemetry level', () => { const service = disposables.add(new AgentHostTelemetryService(new TestTelemetryService())); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 2ee5b66f567c01..b6117fcdd934cd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -29,11 +29,13 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; +import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; +import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -256,15 +258,21 @@ suite('AgentSideEffects — tool call telemetry', () => { [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); - services.set(IAgentHostChatContributions, disposables.add(new AgentHostChatContributions(logService, instantiationService))); + const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); + services.set(IAgentHostChatContributions, chatContributions); services.set(IAgentHostSessionTitleController, disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService }, logService))); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => agent)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); services.set(IAgentHostTelemetryReporter, telemetryReporter); const turnTracker = disposables.add(instantiationService.createInstance(AgentHostTurnTracker)); services.set(IAgentHostTurnTracker, turnTracker); + services.set(IAgentHostToolCallTracker, disposables.add(instantiationService.createInstance(AgentHostToolCallTracker))); const localCommands = disposables.add(instantiationService.createInstance(AgentHostLocalCommands)); services.set(IAgentHostLocalCommands, localCommands); + // Blocked/unblocked tool-call telemetry is reported by + // `SessionInputNeededContribution`, so the built-in contributions must be + // registered for this graph to mirror production wiring. + disposables.add(registerBuiltInChatContributions(chatContributions)); sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, customizationEnablementService, { getAgent: () => agent, agents: agentList, diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts index 52f8022708949a..62fda375a5cfca 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts @@ -6,7 +6,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; -import { deriveToolInvokedResult, toolSourceKindFromContributor } from '../../node/agentHostToolCallTracker.js'; +import { deriveToolInvokedResult } from '../../node/agentHostToolCallTracker.js'; +import { toolSourceKindFromContributor } from '../../node/shared/toolCallContributor.js'; function result(success: boolean, code?: string): ToolCallResult { return { diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index 4713956c7518a3..e135d5c73d9cbc 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -28,6 +28,7 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; +import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -38,6 +39,7 @@ import { IAgentHostGitStateService } from '../../common/agentHostGitStateService import type { IAgentHostCustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker, TURN_ACTIVITY_NONE, TURN_HANG_THRESHOLD_MS } from '../../node/agentHostTurnTracker.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; import { IAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; @@ -223,15 +225,21 @@ suite('AgentSideEffects — turn hang telemetry', () => { [IAgentHostClientConnectionService, clientConnections], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); - services.set(IAgentHostChatContributions, disposables.add(new AgentHostChatContributions(logService, instantiationService))); + const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); + services.set(IAgentHostChatContributions, chatContributions); services.set(IAgentHostSessionTitleController, disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService }, logService))); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => agent)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); services.set(IAgentHostTelemetryReporter, telemetryReporter); const turnTracker = disposables.add(instantiationService.createInstance(AgentHostTurnTracker)); services.set(IAgentHostTurnTracker, turnTracker); + services.set(IAgentHostToolCallTracker, disposables.add(instantiationService.createInstance(AgentHostToolCallTracker))); const localCommands = disposables.add(instantiationService.createInstance(AgentHostLocalCommands)); services.set(IAgentHostLocalCommands, localCommands); + // Blocked-turn hang telemetry is reported by `SessionInputNeededContribution`, + // so the built-in contributions must be registered for this graph to mirror + // production wiring. + disposables.add(registerBuiltInChatContributions(chatContributions)); sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, customizationEnablementService, { getAgent: () => agent, agents: agentList, diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 6942962c3630a6..ebce254637842b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -36,6 +36,7 @@ import { createTestAgentHostProviderService } from './testAgentHostProviderServi import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; +import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -243,6 +244,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { services.set(IAgentHostTelemetryReporter, telemetryReporter); const turnTracker = disposables.add(instantiationService.createInstance(AgentHostTurnTracker)); services.set(IAgentHostTurnTracker, turnTracker); + services.set(IAgentHostToolCallTracker, disposables.add(instantiationService.createInstance(AgentHostToolCallTracker))); const localTurns = new AgentHostLocalTurns(sessionDataService, logService); services.set(IAgentHostLocalTurns, localTurns); const localCommands = disposables.add(instantiationService.createInstance(AgentHostLocalCommands)); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 745c2117517dd7..f1185d12619d08 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -791,7 +791,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('finalizes the same turn with a non-resumable error when continuation fails immediately', async () => { + test('finalizes the same turn with another resumable error when continuation fails immediately', async () => { const { chat } = await createErroredTurn(); copilotAgent.chats.resumeTurn = async () => { throw new Error('continuation failed'); @@ -814,7 +814,7 @@ suite('AgentService (node dispatcher)', () => { state: TurnState.Error, errors: [ createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true), - createErrorResponsePart({ errorType: 'sendFailed', message: 'Error: continuation failed' }), + createErrorResponsePart({ errorType: 'sendFailed', message: 'Error: continuation failed' }, true), ], durationAtLeastInitial: true, }); @@ -4122,38 +4122,34 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('a registry mutation during an in-flight list is not served from the shared computation', async () => { + test('a caller after a registry mutation does not join an in-flight computation', async () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); registerTestAgentProvider(svc, agent); - await svc.listSessions(); - const snapshotCaptured = new DeferredPromise(); - const releaseSnapshot = new DeferredPromise(); - const inner = svc as unknown as { _listRegisteredSessions(): Promise }; - const original = inner._listRegisteredSessions; - let listCalls = 0; - inner._listRegisteredSessions = async () => { - const snapshot = await original.call(svc); - listCalls++; - if (listCalls === 1) { - snapshotCaptured.complete(); - await releaseSnapshot.p; - } - return snapshot; + const gate = new DeferredPromise(); + const inner = svc as unknown as { _computeSessions(mode: AgentHostExternalSessionsMode, epoch?: number): Promise }; + const original = inner._computeSessions; + let computations = 0; + inner._computeSessions = async (mode, epoch) => { + computations++; + await gate.p; + return original.call(svc, mode, epoch); }; - const listing = svc.listSessions(); - await snapshotCaptured.p; + const preInvalidation = svc.listSessions(); await svc.createSession({ provider: 'copilot' }); - releaseSnapshot.complete(); - const listed = await listing; + gate.complete(); + const preInvalidationCount = (await preInvalidation).length; + const postInvalidationCount = (await svc.listSessions()).length; assert.deepStrictEqual({ - listCalls, - listed: listed.length, + computations, + preInvalidation: preInvalidationCount, + postInvalidation: postInvalidationCount, }, { - listCalls: 2, - listed: 1, + computations: 2, + preInvalidation: 1, + postInvalidation: 1, }); }); @@ -7267,12 +7263,10 @@ suite('AgentService (node dispatcher)', () => { class LazyMetadataAgent extends MockAgent { ambientReads = 0; restoreReads = 0; - restoreRegistryFallback = false; override async getChatMetadata(chat: URI, context: URI | IAgentChatContext, _providerData?: string, options?: IAgentChatMetadataOptions): Promise { if (options?.activation === 'restore') { this.restoreReads++; - this.restoreRegistryFallback = options.registryFallback !== undefined; } else { this.ambientReads++; } @@ -7296,11 +7290,9 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ readsAfterAmbientListing, readsAfterRestore: { ambient: agent.ambientReads, restore: agent.restoreReads }, - restoreRegistryFallback: agent.restoreRegistryFallback, }, { readsAfterAmbientListing: { ambient: 1, restore: 0 }, readsAfterRestore: { ambient: 1, restore: 1 }, - restoreRegistryFallback: true, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 7f1bd95924eee5..44dd8400a14e61 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -51,6 +51,7 @@ import { createTestAgentHostProviderService } from './testAgentHostProviderServi import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter, type IAgentHostAskQuestionsToolInvokedEvent } from '../../node/agentHostTelemetryReporter.js'; +import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; @@ -189,6 +190,7 @@ function createTestSideEffects( services.set(IAgentHostTelemetryReporter, telemetryReporter); const turnTracker = disposables.add(instantiationService.createInstance(AgentHostTurnTracker)); services.set(IAgentHostTurnTracker, turnTracker); + services.set(IAgentHostToolCallTracker, disposables.add(instantiationService.createInstance(AgentHostToolCallTracker))); const localTurns = options.localTurns ?? new AgentHostLocalTurns(options.sessionDataService, logService); services.set(IAgentHostLocalTurns, localTurns); const localCommands = disposables.add(instantiationService.createInstance(AgentHostLocalCommands)); @@ -2740,6 +2742,57 @@ suite('AgentSideEffects', () => { }); }); + test('reports one cancelled turn end for a client cancellation even if the agent repeats it', () => { + const { sideEffects: persisting } = setupPersisting(); + setupSession(); + stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: true }); + disposables.add(persisting.registerProgressListener(agent)); + // Start through `handleAction` as `_dispatchActionNow` does, so the turn is tracked + // and a cancellation actually ends something. + const turnStarted = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } } as const; + stateManager.dispatchClientAction(defaultChatUri, turnStarted, { clientId: 'test', clientSeq: 1 }); + persisting.handleAction(defaultChatUri, turnStarted); + + const envelopes: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + const cancellation = { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 } as const; + stateManager.dispatchClientAction(defaultChatUri, cancellation, { clientId: 'test', clientSeq: 2 }); + persisting.handleAction(defaultChatUri, cancellation); + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), action: cancellation, + }); + + assert.deepStrictEqual({ + readChanges: readChangesFrom(envelopes), + isReadBitSet: (stateManager.getSessionSummary(sessionUri.toString())!.status & SessionStatus.IsRead) !== 0, + }, { + readChanges: [false], + isReadBitSet: false, + }); + }); + + test('ignores a stale client cancellation that ends no turn', () => { + const { sideEffects: persisting } = setupPersisting(); + setupSession(); + stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: true }); + disposables.add(persisting.registerProgressListener(agent)); + + const envelopes: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + // No turn ever started, so the reducer no-ops this and no turn end may be reported. + const cancellation = { type: ActionType.ChatTurnCancelled, turnId: 'never-started', duration: 0 } as const; + stateManager.dispatchClientAction(defaultChatUri, cancellation, { clientId: 'test', clientSeq: 2 }); + persisting.handleAction(defaultChatUri, cancellation); + + assert.deepStrictEqual({ + readChanges: readChangesFrom(envelopes), + isReadBitSet: (stateManager.getSessionSummary(sessionUri.toString())!.status & SessionStatus.IsRead) !== 0, + }, { + readChanges: [], + isReadBitSet: true, + }); + }); + test('marks a read session unread when a turn errors', () => { const { sideEffects: persisting } = setupPersisting(); setupSession(); @@ -7513,6 +7566,41 @@ suite('AgentSideEffects', () => { await captured.p; }); + test('send rejection reports an error turn end through end checkpoint capture', async () => { + setupSession(); + agent.sendMessageError = new Error('send failed'); + const captured = new DeferredPromise(); + const captures: { session: string; chat: string; turnId: string }[] = []; + const checkpoints: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + captureTurnCheckpoint: async (session, chat, turnId) => { + captures.push({ session: session.toString(), chat: chat.toString(), turnId }); + captured.complete(); + }, + }; + const localSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + }, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpoints); + const turnStarted = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + } as const; + stateManager.dispatchClientAction(defaultChatUri, turnStarted, { clientId: 'test', clientSeq: 1 }); + + localSideEffects.handleAction(defaultChatUri, turnStarted); + await captured.p; + + assert.deepStrictEqual(captures, [{ + session: sessionUri.toString(), + chat: defaultChatUri, + turnId: 'turn-1', + }]); + }); + test('chat truncation discards pending turn starts for that chat', async () => { setupSession(); const discarded = new DeferredPromise(); diff --git a/src/vs/platform/agentHost/test/node/capiReplayProxyPathNormalization.test.ts b/src/vs/platform/agentHost/test/node/capiReplayProxyPathNormalization.test.ts new file mode 100644 index 00000000000000..46cd7657103b4e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/capiReplayProxyPathNormalization.test.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir, userInfo } from 'os'; +import { join } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { CapiReplayProxy } from './e2e/harness/capiReplayProxy.js'; +import { anthropicMessageToSse } from './e2e/harness/capiWireCodec.js'; +import { scrubUserName } from './e2e/harness/userNameScrub.js'; + +suite('CapiReplayProxy path normalization', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('normalizes truncated harness workspaces from session titles', async () => { + const testDirectory = mkdtempSync(join(tmpdir(), 'capi-replay-path-normalization-')); + const fixturePath = join(testDirectory, 'capture.yaml'); + const userName = userInfo().username; + const recordedWorkspace = join(tmpdir(), 'ahp-server-tools-sessions-list-6Al4co'); + const truncatedWorkspace = scrubUserName(recordedWorkspace.slice(0, -2), userName); + const truncatedWorkspaceUri = scrubUserName(URI.file(recordedWorkspace).toString().slice(0, -2), userName); + const unrelatedCurrentWorkspace = join(tmpdir(), 'ahp-current-workspace-AbC12D'); + const proxy = new CapiReplayProxy({ + fixturePath, + mode: 'record', + workDir: unrelatedCurrentWorkspace, + homeDir: userInfo().homedir, + userName, + recordingModelResponse: { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + body: anthropicMessageToSse({ + content: [ + { type: 'text', text: `${join(unrelatedCurrentWorkspace, 'child.txt')}\nnext` }, + { + type: 'tool_use', + id: 'toolu_2', + name: 'Read', + input: { file_path: join(unrelatedCurrentWorkspace, 'child.txt') }, + }, + ], + stopReason: 'tool_use', + }), + }, + }); + + try { + const url = await proxy.start(); + const title = `Call list_sessions with workspace "${truncatedWorkspace}" or "${truncatedWorkspaceUri}"`; + const response = await fetch(`${url}/v1/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-opus-5', + system: 'system', + messages: [{ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'toolu_1', + content: JSON.stringify({ sessions: [{ title }] }), + }], + }], + }), + }); + assert.strictEqual(response.status, 200); + await response.text(); + await proxy.stop(); + + const fixture = readFileSync(fixturePath, 'utf8'); + assert.deepStrictEqual({ + workdirPlaceholders: fixture.match(/\$\{workdir\}/g)?.length, + hasPortableToolInput: fixture.includes('file_path: ${workdir}/child.txt'), + hasCorruptedNewline: fixture.includes('${workdir}/n'), + hasTempDirectory: fixture.includes(tmpdir()), + hasScrubbedTempDirectory: fixture.includes(scrubUserName(tmpdir(), userName)), + hasRandomSuffix: fixture.includes('6Al4'), + }, { + workdirPlaceholders: 4, + hasPortableToolInput: true, + hasCorruptedNewline: false, + hasTempDirectory: false, + hasScrubbedTempDirectory: false, + hasRandomSuffix: false, + }); + } finally { + await proxy.stop(); + rmSync(testDirectory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 2ee49077e8568f..9562a5edca9c5d 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -18,14 +18,14 @@ import { IAgentHostChangesetService } from '../../common/agentHostChangesetServi import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; -import { createChatMementoKey, createSessionMementoKey, IAgentHostChatContributions, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IAgentHostChatContributionHost, type IHydrationContext, type IIncomingRequest, type IObservedAction, type IOutgoingTurn, type IRestoredChat, type ITurnEnd, type IncomingRequestDisposition } from '../../common/agentHostChatContributionsService.js'; +import { createChatMementoKey, createSessionMementoKey, IAgentHostChatContributions, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IAgentHostChatContributionHost, type IHydrationContext, type IIncomingRequest, type IAppliedClientAction, type IDispatchedAction, type IOutgoingTurn, type IRestoredChat, type ITurnEnd, type IncomingRequestDisposition } from '../../common/agentHostChatContributionsService.js'; import { AgentHostArtifactToolsConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, type ISchema, type SchemaDefinition, type SchemaValue } from '../../common/agentHostSchema.js'; import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { ChatOriginKind } from '../../common/state/protocol/state.js'; -import { buildChatUri, buildDefaultChatUri, ChatInteractivity, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, TurnState, type ISessionGitHubState, type Message, type PendingMessage, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatInteractivity, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, TurnState, type ISessionGitHubState, type Message, type PendingMessage, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; @@ -36,6 +36,7 @@ import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentH import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; +import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; @@ -54,6 +55,7 @@ import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js' import '../../node/localCommands/localChatCommands.contribution.js'; let calls: string[] = []; +let envelopeRejectionReasons: (string | undefined)[] = []; abstract class TestContribution extends Disposable implements IAgentHostChatContribution { constructor(protected readonly _context: IAgentHostChatContributionContext, ..._services: BrandedService[]) { @@ -233,7 +235,7 @@ class ThrowingActionContribution extends TestContribution { static readonly id = 'throwingAction'; readonly order = 20; - onAction(): void { + onDidApplyClientAction(): void { throw new Error('expected'); } } @@ -242,11 +244,64 @@ class FollowingActionContribution extends TestContribution { static readonly id = 'followingAction'; readonly order = 21; - onAction(): void { + onDidApplyClientAction(): void { calls.push('followingAction'); } } +class OrderedFirstEnvelopeContribution extends TestContribution { + static readonly id = 'orderedFirstEnvelope'; + readonly order = 10; + + onDidDispatchAction(): void { + calls.push('first'); + } +} + +class OrderedSecondEnvelopeContribution extends TestContribution { + static readonly id = 'orderedSecondEnvelope'; + readonly order = 0; + + onDidDispatchAction(): void { + calls.push('second'); + } +} + +class OrderedThirdEnvelopeContribution extends TestContribution { + static readonly id = 'orderedThirdEnvelope'; + readonly order = 10; + + onDidDispatchAction(): void { + calls.push('third'); + } +} + +class ThrowingEnvelopeContribution extends TestContribution { + static readonly id = 'throwingEnvelope'; + readonly order = 20; + + onDidDispatchAction(): void { + throw new Error('expected'); + } +} + +class FollowingEnvelopeContribution extends TestContribution { + static readonly id = 'followingEnvelope'; + readonly order = 21; + + onDidDispatchAction(): void { + calls.push('followingEnvelope'); + } +} + +class RejectionReasonEnvelopeContribution extends TestContribution { + static readonly id = 'rejectionReasonEnvelope'; + + onDidDispatchAction(envelope: IDispatchedAction): void { + envelopeRejectionReasons.push(envelope.rejectionReason); + } +} + class OrderedFirstOutgoingTurnContribution extends TestContribution { static readonly id = 'orderedFirstOutgoingTurn'; readonly order = 10; @@ -780,6 +835,7 @@ function createBuiltInContributions(disposables: ReturnType agent = undefined }; } -function observedAction(channel: string, session: string, action: IObservedAction['action'], clientId = 'client'): IObservedAction { +function appliedClientAction(channel: string, session: string, action: IAppliedClientAction['action'], clientId = 'client'): IAppliedClientAction { return { channel, session, @@ -850,6 +907,15 @@ function observedAction(channel: string, session: string, action: IObservedActio }; } +function dispatchedAction(channel: string, session: string, action: IDispatchedAction['action'], rejectionReason?: string): IDispatchedAction { + return { + channel, + session, + action, + rejectionReason, + }; +} + function queuedMessage(id: string, text: string): { type: ActionType.ChatPendingMessageSet; kind: PendingMessageKind.Queued; id: string; message: Message } { return { type: ActionType.ChatPendingMessageSet, kind: PendingMessageKind.Queued, id, message: { text, origin: { kind: MessageKind.User } } }; } @@ -900,6 +966,7 @@ suite('AgentHostChatContributions', () => { setup(() => { calls = []; + envelopeRejectionReasons = []; FirstMementoContribution.context = undefined; SecondMementoContribution.context = undefined; }); @@ -1034,16 +1101,16 @@ suite('AgentHostChatContributions', () => { }); const first = queuedMessage('first', 'first'); const second = queuedMessage('second', 'second'); - const reordered: IObservedAction['action'] = { type: ActionType.ChatQueuedMessagesReordered, order: ['second', 'first'] }; - const removed: IObservedAction['action'] = { type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Queued, id: 'first' }; + const reordered: IAppliedClientAction['action'] = { type: ActionType.ChatQueuedMessagesReordered, order: ['second', 'first'] }; + const removed: IAppliedClientAction['action'] = { type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Queued, id: 'first' }; queue.stateManager.dispatchServerAction(queue.chat, first); - queue.service.action(observedAction(queue.chat, queue.session, first, 'first-client')); + queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, first, 'first-client')); queue.stateManager.dispatchServerAction(queue.chat, second); - queue.service.action(observedAction(queue.chat, queue.session, second, 'second-client')); + queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, second, 'second-client')); queue.stateManager.dispatchServerAction(queue.chat, reordered); - queue.service.action(observedAction(queue.chat, queue.session, reordered, 'reorder-client')); + queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, reordered, 'reorder-client')); queue.stateManager.dispatchServerAction(queue.chat, removed); - queue.service.action(observedAction(queue.chat, queue.session, removed, 'remove-client')); + queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, removed, 'remove-client')); queue.stateManager.dispatchServerAction(queue.chat, { type: ActionType.ChatTurnComplete, turnId: 'active-turn', duration: 1 }); queue.service.turnEnd({ session: queue.session, channel: queue.chat, turnId: 'active-turn', reason: { kind: 'localCommand' } }); @@ -1072,7 +1139,7 @@ suite('AgentHostChatContributions', () => { }); const queued = queuedMessage('queued', 'queued'); queue.stateManager.dispatchServerAction(queue.chat, queued); - queue.service.action(observedAction(queue.chat, queue.session, queued)); + queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, queued)); const admittedWhileFailed = queue.admitted.map(admission => admission.message.text); queue.stateManager.dispatchServerAction(queue.chat, { type: ActionType.ChatTurnResume, turnId: 'resumable-turn' }); @@ -1098,7 +1165,7 @@ suite('AgentHostChatContributions', () => { }); const action = queuedMessage('queued', 'queued'); queue.stateManager.dispatchServerAction(queue.chat, action); - queue.service.action(observedAction(queue.chat, queue.session, action, 'original-client')); + queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, action, 'original-client')); queue.service.disposeChatState(queue.chat); queue.stateManager.dispatchServerAction(queue.chat, { type: ActionType.ChatTurnComplete, turnId: 'active-turn', duration: 1 }); queue.service.turnEnd({ session: queue.session, channel: queue.chat, turnId: 'active-turn', reason: { kind: 'localCommand' } }); @@ -1126,7 +1193,7 @@ suite('AgentHostChatContributions', () => { queue.clearAgent(); const action = queuedMessage('queued', 'queued'); queue.stateManager.dispatchServerAction(queue.chat, action); - queue.service.action(observedAction(queue.chat, queue.session, action)); + queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, action)); assert.deepStrictEqual({ actions, errorTypes }, { actions: [ActionType.ChatPendingMessageSet, ActionType.ChatTurnStarted, ActionType.ChatError], @@ -1138,7 +1205,7 @@ suite('AgentHostChatContributions', () => { const queue = createQueueDrainContributions(disposables); const action = queuedMessage('rename', '/rename Suggested title'); queue.stateManager.dispatchServerAction(queue.chat, action); - queue.service.action(observedAction(queue.chat, queue.session, action)); + queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, action)); await Promise.resolve(); await Promise.resolve(); @@ -1155,7 +1222,7 @@ suite('AgentHostChatContributions', () => { const queue = createQueueDrainContributions(disposables); const action = queuedMessage('queued', 'queued'); queue.stateManager.dispatchServerAction(queue.chat, action); - queue.service.action(observedAction(queue.chat, queue.session, action)); + queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, action)); assert.ok(queue.telemetryService.events.some(event => event.eventName === 'agentHost.userMessageSent' @@ -1170,6 +1237,13 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual(calls, ['second', 'first', 'third']); }); + test('runs envelope contributions in order while preserving registration order for ties', () => { + const contributions = disposables.add(createContributions(disposables, OrderedFirstEnvelopeContribution, OrderedSecondEnvelopeContribution, OrderedThirdEnvelopeContribution)); + contributions.didDispatchAction(dispatchedAction('agent-host-chat://test', 'agent-host-session://test', { type: ActionType.ChatQueuedMessagesReordered, order: [] })); + + assert.deepStrictEqual(calls, ['second', 'first', 'third']); + }); + test('dispatches outgoing turns in contribution order while preserving registration order for ties', async () => { const contributions = disposables.add(createContributions(disposables, OrderedFirstOutgoingTurnContribution, OrderedSecondOutgoingTurnContribution, OrderedThirdOutgoingTurnContribution)); await contributions.outgoingTurn(outgoingTurn('ordered')); @@ -1205,6 +1279,58 @@ suite('AgentHostChatContributions', () => { }); }); + test('does not resurface a read session when an admission-rejected turn ends', () => { + const contributions = createBuiltInContributions(disposables); + const readChanges: boolean[] = []; + disposables.add(contributions.stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.SessionIsReadChanged) { + readChanges.push(envelope.action.isRead); + } + })); + + contributions.service.turnEnd(turnEnd('rejected', { + kind: 'rejected', + error: { errorType: 'noAgent', message: 'No agent found for session' }, + })); + const rejectedReadChanges = [...readChanges]; + contributions.service.turnEnd(turnEnd('error', { + kind: 'error', + error: { errorType: 'requestFailed', message: 'failed' }, + resumable: false, + })); + + assert.deepStrictEqual({ + rejectedReadChanges, + errorReadChanges: readChanges, + }, { + rejectedReadChanges: [], + errorReadChanges: [false], + }); + }); + + test('skips all built-in turn-end contributions for rejected requests', () => { + const observed: string[] = []; + const contributions = createBuiltInContributions(disposables, observed); + contributions.service.turnEnd(turnEnd('rejected', { + kind: 'rejected', + error: { errorType: 'noAgent', message: 'No agent found for session' }, + })); + + assert.deepStrictEqual({ + checkpointAndChangeset: observed.includes('checkpointAndChangeset'), + queueDrain: observed.includes('queueDrain'), + githubReferences: observed.includes('githubReferences'), + sessionTitle: observed.includes('sessionTitle'), + markUnread: observed.includes('markUnread'), + }, { + checkpointAndChangeset: false, + queueDrain: false, + githubReferences: false, + sessionTitle: false, + markUnread: false, + }); + }); + test('drains the queue but skips other turn-end contributions for local commands', () => { const observed: string[] = []; const contributions = createBuiltInContributions(disposables, observed); @@ -1261,7 +1387,7 @@ suite('AgentHostChatContributions', () => { const titles = createSessionTitleContributions(disposables); const action = { type: ActionType.SessionTitleChanged, title: 'Renamed peer' } as const; titles.stateManager.dispatchServerAction(titles.peerChat, action); - titles.service.action(observedAction(titles.peerChat, titles.session, action)); + titles.service.didApplyClientAction(appliedClientAction(titles.peerChat, titles.session, action)); assert.deepStrictEqual({ chatTitle: titles.stateManager.getChatState(titles.peerChat)?.title, @@ -1286,7 +1412,7 @@ suite('AgentHostChatContributions', () => { } })); titles.stateManager.dispatchServerAction(titles.defaultChat, action); - titles.service.action(observedAction(titles.defaultChat, titles.session, action)); + titles.service.didApplyClientAction(appliedClientAction(titles.defaultChat, titles.session, action)); assert.deepStrictEqual({ channels, @@ -1316,7 +1442,7 @@ suite('AgentHostChatContributions', () => { const titles = createSessionTitleContributions(disposables); const action = { type: ActionType.SessionTitleChanged, title: 'Renamed session' } as const; titles.stateManager.dispatchServerAction(titles.session, action); - titles.service.action(observedAction(titles.session, titles.session, action)); + titles.service.didApplyClientAction(appliedClientAction(titles.session, titles.session, action)); assert.deepStrictEqual({ sessionTitle: titles.stateManager.getSessionState(titles.session)?.title, @@ -1400,11 +1526,64 @@ suite('AgentHostChatContributions', () => { test('isolates a throwing action contribution', () => { const contributions = disposables.add(createContributions(disposables, ThrowingActionContribution, FollowingActionContribution)); - contributions.action(observedAction('agent-host-chat://test', 'agent-host-session://test', { type: ActionType.ChatQueuedMessagesReordered, order: [] })); + contributions.didApplyClientAction(appliedClientAction('agent-host-chat://test', 'agent-host-session://test', { type: ActionType.ChatQueuedMessagesReordered, order: [] })); assert.deepStrictEqual(calls, ['followingAction']); }); + test('isolates a throwing envelope contribution', () => { + const contributions = disposables.add(createContributions(disposables, ThrowingEnvelopeContribution, FollowingEnvelopeContribution)); + contributions.didDispatchAction(dispatchedAction('agent-host-chat://test', 'agent-host-session://test', { type: ActionType.ChatQueuedMessagesReordered, order: [] })); + + assert.deepStrictEqual(calls, ['followingEnvelope']); + }); + + test('passes envelope rejection reasons to contributions', () => { + const contributions = disposables.add(createContributions(disposables, RejectionReasonEnvelopeContribution)); + const action = { type: ActionType.ChatQueuedMessagesReordered, order: [] as string[] } as const; + contributions.didDispatchAction(dispatchedAction('agent-host-chat://test', 'agent-host-session://test', action, 'rejected')); + contributions.didDispatchAction(dispatchedAction('agent-host-chat://test', 'agent-host-session://test', action)); + + assert.deepStrictEqual(envelopeRejectionReasons, ['rejected', undefined]); + }); + + test('skips rejected session flags while persisting config values', async () => { + const contributions = createBuiltInContributions(disposables); + const config = { mode: 'plan', autoApprove: 'default' }; + contributions.stateManager.setSessionConfig(contributions.session, { + schema: { type: 'object', properties: {} }, + values: config, + }); + contributions.service.didDispatchAction(dispatchedAction(contributions.session, contributions.session, { type: ActionType.SessionIsReadChanged, isRead: false }, 'rejected')); + contributions.service.didDispatchAction(dispatchedAction(contributions.session, contributions.session, { type: ActionType.SessionIsArchivedChanged, isArchived: true }, 'rejected')); + contributions.service.didDispatchAction(dispatchedAction(contributions.session, contributions.session, { type: ActionType.SessionConfigChanged, config: { mode: 'interactive' } }, 'rejected')); + await Promise.resolve(); + + assert.deepStrictEqual({ + isRead: await contributions.database.getMetadata(AH_META_IS_READ_DB_KEY), + isArchived: await contributions.database.getMetadata(AH_META_IS_ARCHIVED_DB_KEY), + configValues: await contributions.database.getMetadata('configValues'), + }, { + isRead: undefined, + isArchived: undefined, + configValues: JSON.stringify(config), + }); + }); + + test('persists turn usage from envelopes but skips subagent chats', async () => { + const contributions = createBuiltInContributions(disposables); + const usage = { inputTokens: 10, outputTokens: 5 }; + const chat = buildDefaultChatUri(contributions.session); + const subagentChat = buildSubagentChatUri(contributions.session, 'tool-call'); + contributions.service.didDispatchAction(dispatchedAction(chat, contributions.session, { type: ActionType.ChatUsage, turnId: 'parent-turn', usage })); + contributions.service.didDispatchAction(dispatchedAction(subagentChat, contributions.session, { type: ActionType.ChatUsage, turnId: 'subagent-turn', usage })); + await Promise.resolve(); + + assert.deepStrictEqual([...(await contributions.database.getTurnUsages()).entries()], [ + ['parent-turn', JSON.stringify(usage)], + ]); + }); + test('isolates a throwing outgoing-turn contribution', async () => { const contributions = disposables.add(createContributions(disposables, ThrowingOutgoingTurnObserverContribution, FollowingOutgoingTurnObserverContribution)); await contributions.outgoingTurn(outgoingTurn('failure')); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 5347137a6f951e..cc380595963168 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -523,7 +523,7 @@ class RoundTripQuery implements AsyncGenerator { if (!startup?.onElicitation) { throw new Error('integration test: elicitation marker but Options.onElicitation not wired'); } - const result = await startup.onElicitation(item.request, { signal: new AbortController().signal }); + const result = await startup.onElicitation(item.request, { signal: new AbortController().signal, requestId: 'integration-elicitation' }); this._sdk.elicitationResults.push(result); continue; } diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index df8cef3a737215..5a97f830943854 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -7635,7 +7635,7 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { const promise = onElicitation( { serverName: 'test-mcp', message: 'Pick a side', mode: 'form', requestedSchema: { type: 'object', properties: { side: { type: 'string' } } } }, - { signal: new AbortController().signal }, + { signal: new AbortController().signal, requestId: 'elicitation-accept' }, ); await tick(); @@ -7646,10 +7646,12 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { }); assert.deepStrictEqual({ + id: inputRequest.id, message: inputRequest.message, questions: inputRequest.questions?.map(q => ({ id: q.id, kind: q.kind } as const)), result: await promise, }, { + id: 'elicitation-accept', message: 'Pick a side', questions: [{ id: 'side', kind: 'text' }], result: { action: 'accept', content: { side: 'left' } }, @@ -7661,7 +7663,7 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { const promise = onElicitation( { serverName: 'm', message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: { side: { type: 'string' } } } }, - { signal: new AbortController().signal }, + { signal: new AbortController().signal, requestId: 'elicitation-decline' }, ); await tick(); ctx.agent.respondToUserInputRequest(inputRequests.at(-1)!.id, ChatInputResponseKind.Decline); @@ -7675,7 +7677,7 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { const controller = new AbortController(); const promise = onElicitation( { serverName: 'm', message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: { side: { type: 'string' } } } }, - { signal: controller.signal }, + { signal: controller.signal, requestId: 'elicitation-abort' }, ); await tick(); assert.ok(inputRequests.at(-1), 'the elicitation parked as a ChatInputRequested action'); @@ -7689,7 +7691,7 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { const promise = onElicitation( { serverName: 'm', message: 'Authorize', mode: 'url', url: 'https://example.com/auth' }, - { signal: new AbortController().signal }, + { signal: new AbortController().signal, requestId: 'elicitation-url' }, ); await tick(); @@ -7716,7 +7718,7 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { controller.abort(); const result = await onElicitation( { serverName: 'm', message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: { side: { type: 'string' } } } }, - { signal: controller.signal }, + { signal: controller.signal, requestId: 'elicitation-pre-aborted' }, ); assert.deepStrictEqual({ result, parked: inputRequests.length }, { result: { action: 'cancel' }, parked: 0 }); @@ -7727,7 +7729,7 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { const result = await onElicitation( { serverName: 'm', message: 'Authorize', mode: 'url' }, - { signal: new AbortController().signal }, + { signal: new AbortController().signal, requestId: 'elicitation-missing-url' }, ); assert.deepStrictEqual({ result, parked: inputRequests.length }, { result: { action: 'cancel' }, parked: 0 }); @@ -7738,7 +7740,7 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { const result = await onElicitation( { serverName: 'm', message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } }, - { signal: new AbortController().signal }, + { signal: new AbortController().signal, requestId: 'elicitation-empty-form' }, ); assert.deepStrictEqual({ result, parked: inputRequests.length }, { result: { action: 'cancel' }, parked: 0 }); diff --git a/src/vs/platform/agentHost/test/node/claudeElicitation.test.ts b/src/vs/platform/agentHost/test/node/claudeElicitation.test.ts index 62b30bcc076faf..c5535c19e5b61a 100644 --- a/src/vs/platform/agentHost/test/node/claudeElicitation.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeElicitation.test.ts @@ -252,7 +252,7 @@ suite('claudeElicitation', () => { { getSession: () => undefined }, 'missing-session', { serverName: 'srv', message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: { side: { type: 'string' } } } }, - { signal: new AbortController().signal }, + { signal: new AbortController().signal, requestId: 'missing-elicitation' }, ); assert.deepStrictEqual(result, { action: 'cancel' }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index af1cf1070cfb76..b3941122477a7f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -34,7 +34,7 @@ import type { IByokLmBridgeConnection, IByokLmModelInfo } from '../../common/age import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService, NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; -import { AgentHostSessionOpenTelemetry, IAgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js'; +import { IAgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js'; import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey } from '../../common/copilotCliConfig.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js'; @@ -441,7 +441,7 @@ interface ITestCopilotClient extends Pick>[number] & { readonly clientName?: string }; +type TestCopilotSessionMetadata = Awaited>[number] & { readonly clientName?: string; readonly name?: string }; interface ITestCopilotSessionOptions { readonly clientName?: string; @@ -917,7 +917,17 @@ function createTestAgentContext(disposables: Pick, optio const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); services.set(ICopilotApiService, copilotApiService); services.set(ITelemetryService, telemetryService); - services.set(IAgentHostSessionOpenTelemetry, disposables.add(new AgentHostSessionOpenTelemetry(telemetryService))); + services.set(IAgentHostSessionOpenTelemetry, { + _serviceBrand: undefined, + withSubscription: async (_resource, operation) => operation({ + servedFromMemory: undefined, + setServedFromMemory: () => { }, + restoreStarted: () => { }, + restoreCompleted: () => { }, + }), + withSdkResume: async (_session, operation) => operation(), + sdkResumeFallbackCreated: () => { }, + }); if (options?.environmentServiceRegistration !== 'none') { const environmentService = { _serviceBrand: undefined, @@ -1393,6 +1403,39 @@ suite('CopilotAgent', () => { } }); + test('pushes the account internal signal to telemetry on authentication', async () => { + const copilotApiService = new TestCopilotApiService(); + copilotApiService.restrictedTelemetryContexts.set('internal-token', { + restrictedTelemetryEnabled: false, // internal is independent of the restricted opt-in + trackingId: 'internal-tid', + telemetryEndpoint: undefined, + isInternal: true, + userName: 'octocat', + isVscodeTeamMember: true, + }); + const telemetryService = disposables.add(new class extends AgentHostTelemetryService { + readonly internalContexts: (IAgentHostInternalTelemetryContext | undefined)[] = []; + override setInternalTelemetryContext(context: IAgentHostInternalTelemetryContext | undefined): void { + this.internalContexts.push(context); + super.setInternalTelemetryContext(context); + } + }(NullTelemetryService)); + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), copilotApiService, telemetryService }) as TestableCopilotAgent; + try { + await agent.authenticate('https://api.github.com', 'internal-token'); + for (let i = 0; i < 100 && telemetryService.internalContexts.length < 2; i++) { + await Promise.resolve(); + } + + assert.deepStrictEqual(telemetryService.internalContexts, [ + undefined, // cleared until `/copilot_internal/user` resolves + { isInternal: true, trackingId: 'internal-tid', userName: 'octocat', isVscodeTeamMember: true }, + ]); + } finally { + await disposeAgent(agent); + } + }); + test('advertises Copilot as its display name', async () => { const agent = createTestAgent(disposables); try { @@ -5468,43 +5511,6 @@ suite('CopilotAgent', () => { } }); - test('getChatMetadata restores registered sessions from host metadata without an SDK lookup', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const session = AgentSession.uri('copilotcli', 'target'); - const workingDirectory = URI.file('/workspace'); - const db = sessionDataService.openDatabase(session); - await db.object.setMetadata('copilot.workingDirectory', workingDirectory.toString()); - db.dispose(); - - const client = new TestCopilotClient([sdkSession('target')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); - try { - const chat = defaultChatUri(session); - const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session), undefined, { - activation: 'restore', - registryFallback: { startTime: 10, modifiedTime: 20 }, - }); - - assert.deepStrictEqual({ - metadata: metadata && { - ...withoutUndefinedProperties(metadata), - workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()), - }, - getSessionMetadataCalls: client.getSessionMetadataCalls, - }, { - metadata: { - chat, - startTime: 10, - modifiedTime: 20, - workingDirectories: [workingDirectory.toString()], - }, - getSessionMetadataCalls: [], - }); - } finally { - await disposeAgent(agent); - } - }); - test('getChatMetadata returns a provider-native session without a database', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const session = AgentSession.uri('copilotcli', 'external'); @@ -11190,7 +11196,7 @@ suite('CopilotAgent', () => { const session = AgentSession.uri('copilotcli', 's1'); const dbRef = sessionDataService.openDatabase(session); try { - await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([URI.file(workingDirectory).toString()])); + await dbRef.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); await dbRef.object.setMetadata('copilot.agent', JSON.stringify({ uri: 'file:///old-client/data.md' })); } finally { dbRef.dispose(); @@ -11207,13 +11213,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); await internals._resumeSession('s1'); - assert.deepStrictEqual({ - resumeAgents, - getSessionMetadataCalls: client.getSessionMetadataCalls, - }, { - resumeAgents: [undefined], - getSessionMetadataCalls: [], - }); + assert.deepStrictEqual(resumeAgents, [undefined]); } finally { await fs.rm(workingDirectory, { recursive: true, force: true }); await disposeAgent(agent); @@ -12011,6 +12011,7 @@ suite('CopilotAgent', () => { const sessionId = 'legacy-titled'; const session = AgentSession.uri('copilotcli', sessionId); const sessionDataService = disposables.add(new TestSessionDataService()); + // No SDK `name`: the staged custom title is the adopted title (it beats the summary). const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); try { @@ -12034,6 +12035,66 @@ suite('CopilotAgent', () => { } }); + test('prefers the legacy session name over the staged custom title on adoption', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-cwd-`); + const sessionId = 'legacy-named'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + // name must win over both the staged custom title and the summary. + const client = new TestCopilotClient([{ ...sdkSession(sessionId, workingDirectory), name: 'Telemetry analysis for Agents window' }]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', customTitle: 'Staged VS Code Title' }); + + const adopted = await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const title = await db?.object.getMetadata('customTitle'); + db?.dispose(); + + assert.deepStrictEqual( + { adopted, title }, + { adopted: { adopted: true, eligible: true, reason: 'adopted' }, title: 'Telemetry analysis for Agents window' }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('falls back to the SDK summary as the adopted title when there is no custom title or name', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-cwd-`); + const sessionId = 'legacy-summary-only'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + // summary is the last title source when there is no custom title or name. + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId); + + const adopted = await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const title = await db?.object.getMetadata('customTitle'); + db?.dispose(); + + assert.deepStrictEqual( + { adopted, title }, + { adopted: { adopted: true, eligible: true, reason: 'adopted' }, title: `SDK ${sessionId}` }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + test('marks the adopted session read so it is not surfaced as unread on open', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-cwd-`); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 6a2e8e8e28c531..fb2f47119a59e0 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -1464,6 +1464,7 @@ suite('CopilotAgentSession', () => { errorType: 'executionInterrupted', message: 'The agent was interrupted before this request finished.', }, + resumable: true, }); }); @@ -5704,7 +5705,7 @@ Use the attached image as context. }); }); - test('the development $error-ui path emits an error even with attachments', async () => { + test('the development $error-ui path emits a resumable error even with attachments', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); await session.send('$error-ui', [{ @@ -5730,6 +5731,7 @@ Use the attached image as context. errorType: 'developmentRecoverableError', message: 'Injected recoverable development error (1/1).', }, + resumable: true, }, }], }); diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index ba91a6be0f827e..a9efd95c28bc09 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -276,21 +276,6 @@ A user can contribute lifecycle hooks through a client-pushed Copilot plugin to --grep "plugin .* hook|failing plugin hook|non-JSON plugin hook" ``` -### File-tool denial mutates the workspace during Linux replay - -- Test: `declining a file creation tool prevents the mutation and completes the turn`. -- Scope: Claude and Copilot on Linux. -- Expected: declining the `Write` tool prevents `denied.txt` from being created and the replayed turn completes. -- Observed: the turn completes, but `denied.txt` exists on Linux. For Copilot, the host auto-approves the in-workspace write before the synthetic denial reaches the permission request; both providers pass on macOS. -- Gate: the Claude and Copilot variants are disabled on Linux through `fileToolDenialReplayUnstableOnLinux`. -- Reproduce on Linux: - - ```bash - ./scripts/test-integration.sh --run \ - src/vs/platform/agentHost/test/node/e2e/providers/{claude,copilot}AgentHostE2E.integrationTest.ts \ - --grep "declining a file creation tool" - ``` - ### Client-pushed plugin MCP coverage is provider-scoped - Tests: the `client plugin …` and `plugin MCP …` scenarios in `mcpPluginSuite.ts`. @@ -791,25 +776,18 @@ Use the affected provider command with `--grep ""` and tempora --grep "accepted steering followed by abort" ``` -### Retryable Copilot errors are temporarily disabled +### Mid-turn host shutdown recovery is record-only -Copilot errors currently end a turn without offering an in-place retry. The retry protocol remains implemented, but the host intentionally omits the `resumable` marker from live, restored, and repeated errors until the feature is re-enabled. +A user can lose the Agent Host process while a model response is still streaming. Reopening the session should restore the unfinished request as a resumable error, and retrying should continue that same turn without adding another user message. -- Tests: - - `resumes a failed turn in place` - - `resumes the same turn after repeated failures` - - `restores and resumes a turn interrupted by host shutdown` -- Scope: Copilot on all platforms and execution modes. -- Expected when enabled: a failed turn is marked resumable, and retrying continues the same turn without adding another user message. An unfinished request restored after host shutdown has the same behavior. -- Observed: Copilot errors intentionally omit the `resumable` marker, so clients cannot request an in-place retry. -- Gate: all three scenarios are unconditionally skipped. The host-shutdown scenario additionally requires direct `AGENT_HOST_REPLAY_RECORD=1` mode because replay has no active streaming window to terminate. -- Run after removing the temporary gate: +- Test: `restores and resumes a turn interrupted by host shutdown`. +- Scope: deterministic replay for Copilot. +- Expected: the host dies after streaming starts but before any terminal turn action; restoration synthesizes a resumable `executionInterrupted` error, and a zero-message continuation completes the same turn. +- Observed: replay serves the full recorded response immediately, leaving no active streaming window in which to kill the host before turn completion. +- Gate: direct `AGENT_HOST_REPLAY_RECORD=1` mode only. +- Run: ```bash - ./scripts/test-integration.sh --run \ - src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \ - --grep "resumes a failed turn in place|resumes the same turn after repeated failures" - AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \ src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \ --grep "restores and resumes a turn interrupted by host shutdown" diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-counts-lines-in-a-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-counts-lines-in-a-file.yaml index 38d34e1d28434a..079b74914eca8f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-counts-lines-in-a-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-counts-lines-in-a-file.yaml @@ -2,13 +2,15 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Count the lines in lines.txt and reply with the number only. Use your file tools; do not run a shell command. response: content: + - type: text + text: I'll read the file to count its lines. - type: tool_use id: toolcall_0 name: Read @@ -16,14 +18,15 @@ exchanges: file_path: ${workdir}/lines.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Count the lines in lines.txt and reply with the number only. Use your file tools; do not run a shell command. - role: assistant content: - - type: thinking + - type: text + text: I'll read the file to count its lines. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-creates-a-file-in-a-new-nested-directory.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-creates-a-file-in-a-new-nested-directory.yaml index 1e974d970414cd..d7ea971a7b7dcf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-creates-a-file-in-a-new-nested-directory.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-creates-a-file-in-a-new-nested-directory.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -14,22 +14,21 @@ exchanges: name: Bash input: command: node -e "const fs=require('fs');fs.mkdirSync('output',{recursive:true});fs.writeFileSync('output/report.txt','NESTED_CREATED')" - description: Create output dir and write report file + description: Create output/report.txt via node stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: 'Run exactly this shell command, with no modifications: `node -e "const fs=require(''fs'');fs.mkdirSync(''output'',{recursive:true});fs.writeFileSync(''output/report.txt'',''NESTED_CREATED'')"`. Then reply with exactly "created".' - role: assistant content: - - type: thinking - type: tool_use name: Bash input: command: node -e "const fs=require('fs');fs.mkdirSync('output',{recursive:true});fs.writeFileSync('output/report.txt','NESTED_CREATED')" - description: Create output dir and write report file + description: Create output/report.txt via node - role: user content: - type: tool_result diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-creates-a-new-text-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-creates-a-new-text-file.yaml index 0a5c6e63e0f0fc..22c563def15ce4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-creates-a-new-text-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-creates-a-new-text-file.yaml @@ -2,15 +2,13 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Create result.txt containing exactly CREATED_VALUE. response: content: - - type: text - text: I'll create that file. - type: tool_use id: toolcall_0 name: Write @@ -19,15 +17,13 @@ exchanges: content: CREATED_VALUE stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Create result.txt containing exactly CREATED_VALUE. - role: assistant content: - - type: text - text: I'll create that file. - type: tool_use name: Write input: @@ -39,5 +35,5 @@ exchanges: tool_use_id: toolcall_0 content: 'File created successfully at: ${workdir}/result.txt (file state is current in your context — no need to Read it back)' response: - content: Created `result.txt` containing exactly `CREATED_VALUE`. + content: Created `result.txt` containing `CREATED_VALUE`. stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-declining-a-file-creation-tool-prevents-the-mutation-and-completes-the-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-declining-a-file-creation-tool-prevents-the-mutation-and-completes-the-turn.yaml index b1d5ba21f482b2..faeb5fd4e37881 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-declining-a-file-creation-tool-prevents-the-mutation-and-completes-the-turn.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-declining-a-file-creation-tool-prevents-the-mutation-and-completes-the-turn.yaml @@ -6,7 +6,7 @@ exchanges: system: ${system} messages: - role: user - content: Create denied.txt containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied". + content: Create denied.lock containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied". response: content: - type: text @@ -15,7 +15,7 @@ exchanges: id: toolcall_0 name: Write input: - file_path: ${workdir}/denied.txt + file_path: ${workdir}/denied.lock content: DENIED_CONTENT stopReason: tool_use - request: @@ -23,7 +23,7 @@ exchanges: system: ${system} messages: - role: user - content: Create denied.txt containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied". + content: Create denied.lock containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied". - role: assistant content: - type: text @@ -31,7 +31,7 @@ exchanges: - type: tool_use name: Write input: - file_path: ${workdir}/denied.txt + file_path: ${workdir}/denied.lock content: DENIED_CONTENT - role: user content: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-embedded-text-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-embedded-text-attachment-reaches-the-provider-request.yaml index 4e293cb91a2790..fdea7381eb0ae5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-embedded-text-attachment-reaches-the-provider-request.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-embedded-text-attachment-reaches-the-provider-request.yaml @@ -15,7 +15,7 @@ exchanges: id: toolcall_0 name: Read input: - file_path: ${homedir}/user-data/agentSessionData/${uuid_0}/attachments/${uuid_1}/embedded.txt + file_path: ${homedir}\user-data\agentSessionData\${uuid_0}\attachments\${uuid_1}\embedded.txt stopReason: tool_use - request: model: claude-opus-5 @@ -30,7 +30,7 @@ exchanges: - type: tool_use name: Read input: - file_path: ${homedir}/user-data/agentSessionData/${uuid_0}/attachments/${uuid_1}/embedded.txt + file_path: ${homedir}\user-data\agentSessionData\${uuid_0}\attachments\${uuid_1}\embedded.txt - role: user content: - type: tool_result diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-resource-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-resource-attachment-reaches-the-provider-request.yaml index 06b7c38996734f..437f145d44c998 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-resource-attachment-reaches-the-provider-request.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-resource-attachment-reaches-the-provider-request.yaml @@ -9,8 +9,6 @@ exchanges: content: Read the attached resource and reply with only its exact contents. response: content: - - type: text - text: I'll read the file. - type: tool_use id: toolcall_0 name: Read @@ -25,8 +23,6 @@ exchanges: content: Read the attached resource and reply with only its exact contents. - role: assistant content: - - type: text - text: I'll read the file. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-simple-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-simple-attachment-reaches-the-provider-request.yaml index 5c626089452283..d642d762be901f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-simple-attachment-reaches-the-provider-request.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-default-chat-simple-attachment-reaches-the-provider-request.yaml @@ -8,9 +8,9 @@ exchanges: - role: user content: - type: text - text: Reply with only the value from the attachment. + text: Reply with only the value provided directly in the attachment. Do not inspect the workspace or use tools. - type: text text: ATTACHMENT_SIMPLE_VALUE response: - content: I don't see an attachment in this conversation — only the literal text `ATTACHMENT_SIMPLE_VALUE`. Could you re-send it? + content: ATTACHMENT_SIMPLE_VALUE stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-deletes-a-workspace-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-deletes-a-workspace-file.yaml index 1adbec18d0424e..54006c118ca411 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-deletes-a-workspace-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-deletes-a-workspace-file.yaml @@ -2,11 +2,11 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' response: content: - type: tool_use @@ -14,14 +14,14 @@ exchanges: name: Bash input: command: node -e "require('fs').unlinkSync('delete-me.txt')" - description: Delete delete-me.txt file + description: Delete delete-me.txt via node stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' - role: assistant content: - type: thinking @@ -29,7 +29,7 @@ exchanges: name: Bash input: command: node -e "require('fs').unlinkSync('delete-me.txt')" - description: Delete delete-me.txt file + description: Delete delete-me.txt via node - role: user content: - type: tool_result diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml index e1505614be7b18..b7d00297dd7f7a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-edits-an-existing-text-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-edits-an-existing-text-file.yaml index c7b00474a8145d..b4093235a117d6 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-edits-an-existing-text-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-edits-an-existing-text-file.yaml @@ -2,13 +2,15 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: Replace the complete contents of edit.txt with AFTER_VALUE. Use your file tools; do not run a shell command. + content: Replace the complete contents of edit.txt with exactly AFTER_VALUE and no trailing newline. Use your file tools; do not run a shell command. response: content: + - type: text + text: I'll read the file first, then replace its contents. - type: tool_use id: toolcall_0 name: Read @@ -16,14 +18,15 @@ exchanges: file_path: ${workdir}/edit.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: Replace the complete contents of edit.txt with AFTER_VALUE. Use your file tools; do not run a shell command. + content: Replace the complete contents of edit.txt with exactly AFTER_VALUE and no trailing newline. Use your file tools; do not run a shell command. - role: assistant content: - - type: thinking + - type: text + text: I'll read the file first, then replace its contents. - type: tool_use name: Read input: @@ -43,14 +46,15 @@ exchanges: content: AFTER_VALUE stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: Replace the complete contents of edit.txt with AFTER_VALUE. Use your file tools; do not run a shell command. + content: Replace the complete contents of edit.txt with exactly AFTER_VALUE and no trailing newline. Use your file tools; do not run a shell command. - role: assistant content: - - type: thinking + - type: text + text: I'll read the file first, then replace its contents. - type: tool_use name: Read input: @@ -62,7 +66,6 @@ exchanges: content: "1\tBEFORE_VALUE" - role: assistant content: - - type: thinking - type: tool_use name: Write input: @@ -74,5 +77,5 @@ exchanges: tool_use_id: toolcall_1 content: The file ${workdir}/edit.txt has been updated successfully. (file state is current in your context — no need to Read it back) response: - content: Done. `edit.txt` now contains `AFTER_VALUE` (replacing the previous `BEFORE_VALUE`). + content: '`edit.txt` now contains exactly `AFTER_VALUE` with no trailing newline.' stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-file-edit-before-and-after-content-can-be-read-from-session-storage.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-file-edit-before-and-after-content-can-be-read-from-session-storage.yaml index df3dcd0c5f5012..270455a2212a29 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-file-edit-before-and-after-content-can-be-read-from-session-storage.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-file-edit-before-and-after-content-can-be-read-from-session-storage.yaml @@ -9,8 +9,6 @@ exchanges: content: Replace the complete contents of stored-edit.txt with AFTER_STORED_VALUE using your file edit tool; do not run a shell command. Then reply exactly "done". response: content: - - type: text - text: I'll read the file first, then replace its contents. - type: tool_use id: toolcall_0 name: Read @@ -25,8 +23,6 @@ exchanges: content: Replace the complete contents of stored-edit.txt with AFTER_STORED_VALUE using your file edit tool; do not run a shell command. Then reply exactly "done". - role: assistant content: - - type: text - text: I'll read the file first, then replace its contents. - type: tool_use name: Read input: @@ -54,8 +50,6 @@ exchanges: content: Replace the complete contents of stored-edit.txt with AFTER_STORED_VALUE using your file edit tool; do not run a shell command. Then reply exactly "done". - role: assistant content: - - type: text - text: I'll read the file first, then replace its contents. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-fresh-peer-chat-does-not-inherit-default-chat-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-fresh-peer-chat-does-not-inherit-default-chat-context.yaml index 9e682053127d04..ec11838ff8b891 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-fresh-peer-chat-does-not-inherit-default-chat-context.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-fresh-peer-chat-does-not-inherit-default-chat-context.yaml @@ -2,19 +2,16 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word DEFAULTSECRET. Reply exactly "ready". response: - content: |- - I'll remember the code word DEFAULTSECRET. - - ready + content: ready stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-handles-a-missing-file-without-a-session-error.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-handles-a-missing-file-without-a-session-error.yaml index 2e1b2ffca20953..0902ebe93b4351 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-handles-a-missing-file-without-a-session-error.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-handles-a-missing-file-without-a-session-error.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -10,7 +10,7 @@ exchanges: response: content: - type: text - text: I'll read missing.txt. + text: I'll check for the file. - type: tool_use id: toolcall_0 name: Read @@ -18,7 +18,7 @@ exchanges: file_path: ${workdir}/missing.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -26,7 +26,7 @@ exchanges: - role: assistant content: - type: text - text: I'll read missing.txt. + text: I'll check for the file. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-inspects-git-status.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-inspects-git-status.yaml index 3593c5d1fef762..57532acc4cfabb 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-inspects-git-status.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-inspects-git-status.yaml @@ -14,7 +14,7 @@ exchanges: name: Bash input: command: git status --short - description: Check git status + description: Show git status stopReason: tool_use - request: model: claude-opus-5 @@ -28,7 +28,7 @@ exchanges: name: Bash input: command: git status --short - description: Check git status + description: Show git status - role: user content: - type: tool_result @@ -38,6 +38,6 @@ exchanges: ?? untracked.txt response: content: |- - Modified: tracked.txt - Untracked: untracked.txt + Modified: `tracked.txt` + Untracked: `untracked.txt` stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-lists-workspace-entries.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-lists-workspace-entries.yaml index d2084a6ba34c7e..688914caf4f7a3 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-lists-workspace-entries.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-lists-workspace-entries.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -14,22 +14,21 @@ exchanges: name: Bash input: command: node -e "console.log(require('fs').readdirSync('.').join(' '))" - description: List files in current directory via node + description: List files via node stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: 'Run exactly this shell command, with no modifications: `node -e "console.log(require(''fs'').readdirSync(''.'').join('' ''))"`. Then reply with the filenames it printed and nothing else.' - role: assistant content: - - type: thinking - type: tool_use name: Bash input: command: node -e "console.log(require('fs').readdirSync('.').join(' '))" - description: List files in current directory via node + description: List files via node - role: user content: - type: tool_result diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-completes-a-simple-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-completes-a-simple-turn.yaml index 81970d9a89a307..be380e35692b65 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-completes-a-simple-turn.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-completes-a-simple-turn.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-a-nested-directory.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-a-nested-directory.yaml index 296330347d470f..8b1fd95af39553 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-a-nested-directory.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-a-nested-directory.yaml @@ -2,37 +2,33 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: 'Run exactly this shell command, with no modifications: `node -e "const fs=require(''fs'');fs.mkdirSync(''peer-output'',{recursive:true});fs.writeFileSync(''peer-output/report.txt'',''PEER_NESTED'')"`. Then reply with exactly "created".' response: content: - - type: text - text: I'll run that command exactly as specified. - type: tool_use id: toolcall_0 name: Bash input: command: node -e "const fs=require('fs');fs.mkdirSync('peer-output',{recursive:true});fs.writeFileSync('peer-output/report.txt','PEER_NESTED')" - description: Create peer-output directory and report file + description: Create peer-output report file stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: 'Run exactly this shell command, with no modifications: `node -e "const fs=require(''fs'');fs.mkdirSync(''peer-output'',{recursive:true});fs.writeFileSync(''peer-output/report.txt'',''PEER_NESTED'')"`. Then reply with exactly "created".' - role: assistant content: - - type: text - text: I'll run that command exactly as specified. - type: tool_use name: Bash input: command: node -e "const fs=require('fs');fs.mkdirSync('peer-output',{recursive:true});fs.writeFileSync('peer-output/report.txt','PEER_NESTED')" - description: Create peer-output directory and report file + description: Create peer-output report file - role: user content: - type: tool_result diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-the-parent-workspace.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-the-parent-workspace.yaml index f40650f4f9c8c7..f78e143b65ab4d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-the-parent-workspace.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-the-parent-workspace.yaml @@ -2,15 +2,13 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Create the file at ${workdir}/peer-created.txt containing exactly PEER_CREATED. response: content: - - type: text - text: I'll create that file. - type: tool_use id: toolcall_0 name: Write @@ -19,15 +17,13 @@ exchanges: content: PEER_CREATED stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Create the file at ${workdir}/peer-created.txt containing exactly PEER_CREATED. - role: assistant content: - - type: text - text: I'll create that file. - type: tool_use name: Write input: @@ -39,5 +35,5 @@ exchanges: tool_use_id: toolcall_0 content: 'File created successfully at: ${workdir}/peer-created.txt (file state is current in your context — no need to Read it back)' response: - content: Done — created `peer-created.txt` containing exactly `PEER_CREATED`. + content: Created `peer-created.txt` containing `PEER_CREATED`. stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-edits-an-existing-workspace-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-edits-an-existing-workspace-file.yaml index 6bcb5b85a59f78..16d9ec1e7a377e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-edits-an-existing-workspace-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-edits-an-existing-workspace-file.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -10,46 +10,15 @@ exchanges: response: content: - type: text - text: I'll replace the contents of that file. + text: I'll read the file first, then replace its contents. - type: tool_use id: toolcall_0 - name: Write - input: - file_path: ${workdir}/peer-edit.txt - content: AFTER_PEER - stopReason: tool_use - - request: - model: claude-opus-4.8 - system: ${system} - messages: - - role: user - content: Replace the complete contents of ${workdir}/peer-edit.txt with AFTER_PEER. Use your file tools; do not run a shell command. - - role: assistant - content: - - type: text - text: I'll replace the contents of that file. - - type: tool_use - name: Write - input: - file_path: ${workdir}/peer-edit.txt - content: AFTER_PEER - - role: user - content: - - type: tool_result - tool_use_id: toolcall_0 - content: File has not been read yet. Read it first before writing to it. - response: - content: - - type: text - text: I need to read the file first. - - type: tool_use - id: toolcall_1 name: Read input: file_path: ${workdir}/peer-edit.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -57,21 +26,7 @@ exchanges: - role: assistant content: - type: text - text: I'll replace the contents of that file. - - type: tool_use - name: Write - input: - file_path: ${workdir}/peer-edit.txt - content: AFTER_PEER - - role: user - content: - - type: tool_result - tool_use_id: toolcall_0 - content: File has not been read yet. Read it first before writing to it. - - role: assistant - content: - - type: text - text: I need to read the file first. + text: I'll read the file first, then replace its contents. - type: tool_use name: Read input: @@ -79,19 +34,20 @@ exchanges: - role: user content: - type: tool_result - tool_use_id: toolcall_1 + tool_use_id: toolcall_0 content: "1\tBEFORE_PEER" response: content: - type: tool_use - id: toolcall_2 + id: toolcall_1 name: Write input: - content: AFTER_PEER file_path: ${workdir}/peer-edit.txt + content: | + AFTER_PEER stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -99,21 +55,7 @@ exchanges: - role: assistant content: - type: text - text: I'll replace the contents of that file. - - type: tool_use - name: Write - input: - file_path: ${workdir}/peer-edit.txt - content: AFTER_PEER - - role: user - content: - - type: tool_result - tool_use_id: toolcall_0 - content: File has not been read yet. Read it first before writing to it. - - role: assistant - content: - - type: text - text: I need to read the file first. + text: I'll read the file first, then replace its contents. - type: tool_use name: Read input: @@ -121,7 +63,7 @@ exchanges: - role: user content: - type: tool_result - tool_use_id: toolcall_1 + tool_use_id: toolcall_0 content: "1\tBEFORE_PEER" - role: assistant content: @@ -129,12 +71,13 @@ exchanges: name: Write input: file_path: ${workdir}/peer-edit.txt - content: AFTER_PEER + content: | + AFTER_PEER - role: user content: - type: tool_result - tool_use_id: toolcall_2 + tool_use_id: toolcall_1 content: The file ${workdir}/peer-edit.txt has been updated successfully. (file state is current in your context — no need to Read it back) response: - content: Done. The file now contains `AFTER_PEER`. + content: '`peer-edit.txt` now contains `AFTER_PEER` (was `BEFORE_PEER`).' stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml index 468ec2769ae51d..dd7d9df9793971 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml @@ -2,15 +2,13 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Try to read ${workdir}/peer-missing.txt. If it does not exist, reply exactly "missing". Use your file tools; do not run a shell command. response: content: - - type: text - text: I'll read the file. - type: tool_use id: toolcall_0 name: Read @@ -18,15 +16,13 @@ exchanges: file_path: ${workdir}/peer-missing.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Try to read ${workdir}/peer-missing.txt. If it does not exist, reply exactly "missing". Use your file tools; do not run a shell command. - role: assistant content: - - type: text - text: I'll read the file. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-a-nested-directory.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-a-nested-directory.yaml index a988e137223fac..6f2f950b5ad3c9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-a-nested-directory.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-a-nested-directory.yaml @@ -2,15 +2,13 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read the file at ${workdir}/nested/peer.txt and reply with its exact contents only. response: content: - - type: text - text: I'll read the file for you. - type: tool_use id: toolcall_0 name: Read @@ -18,15 +16,13 @@ exchanges: file_path: ${workdir}/nested/peer.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read the file at ${workdir}/nested/peer.txt and reply with its exact contents only. - role: assistant content: - - type: text - text: I'll read the file for you. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-the-parent-workspace.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-the-parent-workspace.yaml index 3acb4d71d071bc..65d68729ec0c40 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-the-parent-workspace.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-the-parent-workspace.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -16,14 +16,13 @@ exchanges: file_path: ${workdir}/peer-note.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read the file at ${workdir}/peer-note.txt and reply with its exact contents only. - role: assistant content: - - type: thinking - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-filename-containing-spaces.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-filename-containing-spaces.yaml index 264b9f0290211c..a00b815d088f95 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-filename-containing-spaces.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-filename-containing-spaces.yaml @@ -2,15 +2,13 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read the file at ${workdir}/peer file.txt and reply with its exact contents only. response: content: - - type: text - text: I'll read the file for you. - type: tool_use id: toolcall_0 name: Read @@ -18,15 +16,13 @@ exchanges: file_path: ${workdir}/peer file.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read the file at ${workdir}/peer file.txt and reply with its exact contents only. - role: assistant content: - - type: text - text: I'll read the file for you. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-retains-context-across-consecutive-turns.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-retains-context-across-consecutive-turns.yaml index 5c8182bca15aca..8dd74ebc83ae25 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-retains-context-across-consecutive-turns.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-retains-context-across-consecutive-turns.yaml @@ -2,28 +2,22 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word PEAR. Reply exactly "ready". response: - content: |- - I'll remember the code word PEAR. - - ready + content: ready stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word PEAR. Reply exactly "ready". - role: assistant - content: |- - I'll remember the code word PEAR. - - ready + content: ready - role: user content: What code word did I ask you to remember? Reply with only the code word. response: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-multiple-simple-attachments-reach-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-multiple-simple-attachments-reach-the-provider-request.yaml index bf226e496da938..e9310a36131623 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-multiple-simple-attachments-reach-the-provider-request.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-multiple-simple-attachments-reach-the-provider-request.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml index b4577a78775da9..54847fcf585b77 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -11,7 +11,7 @@ exchanges: content: ready stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-attachment-reaches-the-provider-request.yaml index 2fec9bc2bf3631..5904148b5b2a5f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-attachment-reaches-the-provider-request.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-attachment-reaches-the-provider-request.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-selection-attachment-includes-its-line-reference.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-selection-attachment-includes-its-line-reference.yaml index acd49a1083b80b..7ef3bea6959f4e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-selection-attachment-includes-its-line-reference.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-selection-attachment-includes-its-line-reference.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-reaches-the-provider-request.yaml index e9b3cb79eed905..298e0b2a7e7570 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-reaches-the-provider-request.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-reaches-the-provider-request.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml index 2fec9bc2bf3631..5904148b5b2a5f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-provider-input-request-cancellation-returns-to-the-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-provider-input-request-cancellation-returns-to-the-turn.yaml index e14adb80d548bc..92efe26182f433 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-provider-input-request-cancellation-returns-to-the-turn.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-provider-input-request-cancellation-returns-to-the-turn.yaml @@ -10,14 +10,14 @@ exchanges: response: content: - type: text - text: I'll ask the question as requested. + text: I'll ask the question now. - type: tool_use id: toolcall_0 name: AskUserQuestion input: questions: - question: Continue? - header: Continue? + header: Continue options: - label: 'Yes' description: Proceed. @@ -34,13 +34,13 @@ exchanges: - role: assistant content: - type: text - text: I'll ask the question as requested. + text: I'll ask the question now. - type: tool_use name: AskUserQuestion input: questions: - question: Continue? - header: Continue? + header: Continue options: - label: 'Yes' description: Proceed. diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-provider-multi-select-input-is-answered-through-ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-provider-multi-select-input-is-answered-through-ahp.yaml index a434b91282fb49..838d7f5ce01b12 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-provider-multi-select-input-is-answered-through-ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-provider-multi-select-input-is-answered-through-ahp.yaml @@ -21,9 +21,9 @@ exchanges: multiSelect: true options: - label: Red - description: The color red. + description: Select red. - label: Blue - description: The color blue. + description: Select blue. stopReason: tool_use - request: model: claude-opus-5 @@ -44,9 +44,9 @@ exchanges: multiSelect: true options: - label: Red - description: The color red. + description: Select red. - label: Blue - description: The color blue. + description: Select blue. - role: user content: - type: tool_result diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-file-from-a-nested-directory.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-file-from-a-nested-directory.yaml index 3c3bd24d07e299..943406228816e0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-file-from-a-nested-directory.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-file-from-a-nested-directory.yaml @@ -2,15 +2,13 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read nested/value.txt and reply with its exact contents only. Use your file tools; do not run a shell command. response: content: - - type: text - text: I'll read the file for you. - type: tool_use id: toolcall_0 name: Read @@ -18,15 +16,13 @@ exchanges: file_path: ${workdir}/nested/value.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read nested/value.txt and reply with its exact contents only. Use your file tools; do not run a shell command. - role: assistant content: - - type: text - text: I'll read the file for you. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-filename-containing-spaces.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-filename-containing-spaces.yaml index d05d193cb4c444..310ee7e0870831 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-filename-containing-spaces.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-filename-containing-spaces.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -10,7 +10,7 @@ exchanges: response: content: - type: text - text: I'll read that file for you. + text: I'll read that file. - type: tool_use id: toolcall_0 name: Read @@ -18,7 +18,7 @@ exchanges: file_path: ${workdir}/file with spaces.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -26,7 +26,7 @@ exchanges: - role: assistant content: - type: text - text: I'll read that file for you. + text: I'll read that file. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-value-from-json.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-value-from-json.yaml index 698edf604312f5..4e2692086ca1c0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-value-from-json.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-a-value-from-json.yaml @@ -2,15 +2,13 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read config.json and reply with the numeric value of "answer" only. response: content: - - type: text - text: I'll read the config file. - type: tool_use id: toolcall_0 name: Read @@ -18,15 +16,13 @@ exchanges: file_path: ${workdir}/config.json stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read config.json and reply with the numeric value of "answer" only. - role: assistant content: - - type: text - text: I'll read the config file. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-an-existing-text-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-an-existing-text-file.yaml index 2a4f97818d75e6..43ef96ff2a7a3f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-an-existing-text-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-reads-an-existing-text-file.yaml @@ -2,15 +2,13 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read note.txt and reply with its exact contents only. response: content: - - type: text - text: I'll read the file. - type: tool_use id: toolcall_0 name: Read @@ -18,15 +16,13 @@ exchanges: file_path: ${workdir}/note.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Read note.txt and reply with its exact contents only. - role: assistant content: - - type: text - text: I'll read the file. - type: tool_use name: Read input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-recreated-peer-chat-starts-with-fresh-provider-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-recreated-peer-chat-starts-with-fresh-provider-context.yaml index 444dd611cb5335..fae325c5b346b3 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-recreated-peer-chat-starts-with-fresh-provider-context.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-recreated-peer-chat-starts-with-fresh-provider-context.yaml @@ -2,19 +2,16 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word OLD_PEER. Reply exactly "ready". response: - content: |- - I'll remember the code word OLD_PEER. - - ready + content: ready stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-renames-a-workspace-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-renames-a-workspace-file.yaml index 15f9c3e049f5d0..7e5ba1d5bb20c5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-renames-a-workspace-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-renames-a-workspace-file.yaml @@ -2,11 +2,11 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' response: content: - type: tool_use @@ -17,14 +17,13 @@ exchanges: description: Rename before.txt to after.txt stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' - role: assistant content: - - type: thinking - type: tool_use name: Bash input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-retains-context-across-consecutive-turns.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-retains-context-across-consecutive-turns.yaml index 83b4ac4a177211..ce5f5e52d4c3aa 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-retains-context-across-consecutive-turns.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-retains-context-across-consecutive-turns.yaml @@ -2,28 +2,22 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word ORCHID. Reply exactly "ready". response: - content: |- - I'll remember the code word ORCHID. - - ready + content: ready stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word ORCHID. Reply exactly "ready". - role: assistant - content: |- - I'll remember the code word ORCHID. - - ready + content: ready - role: user content: What code word did I ask you to remember? Reply with only the code word. response: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-runs-a-deterministic-shell-command.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-runs-a-deterministic-shell-command.yaml index 04ad6b5922cc38..76437c1e69911e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-runs-a-deterministic-shell-command.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-runs-a-deterministic-shell-command.yaml @@ -2,37 +2,33 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: 'Run exactly this shell command, with no modifications: `echo SHELL_VALUE_73`. Then reply with that exact value only.' response: content: - - type: text - text: I'll run that command. - type: tool_use id: toolcall_0 name: Bash input: command: echo SHELL_VALUE_73 - description: Echo shell value + description: Echo a test value stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: 'Run exactly this shell command, with no modifications: `echo SHELL_VALUE_73`. Then reply with that exact value only.' - role: assistant content: - - type: text - text: I'll run that command. - type: tool_use name: Bash input: command: echo SHELL_VALUE_73 - description: Echo shell value + description: Echo a test value - role: user content: - type: tool_result diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-sends-a-simple-message-and-receives-a-response.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-sends-a-simple-message-and-receives-a-response.yaml index 3ff374bd4a1495..954acd58c05785 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-sends-a-simple-message-and-receives-a-response.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-sends-a-simple-message-and-receives-a-response.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-addcomment-converts-a-one-based-input-range-to-the-zero-based-annotations-range.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-addcomment-converts-a-one-based-input-range-to-the-zero-based-annotations-range.yaml index 117816d6c73e20..9abe85513fd144 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-addcomment-converts-a-one-based-input-range-to-the-zero-based-annotations-range.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-addcomment-converts-a-one-based-input-range-to-the-zero-based-annotations-range.yaml @@ -9,8 +9,6 @@ exchanges: content: Call addComment exactly once for untitled://server-tools/reviewed.ts with range startLineNumber 1, startColumn 7, endLineNumber 1, endColumn 13 and text "rename this", then reply exactly "added". response: content: - - type: text - text: I'll add the comment as specified. - type: tool_use id: toolcall_0 name: mcp__host__addComment @@ -31,8 +29,6 @@ exchanges: content: Call addComment exactly once for untitled://server-tools/reviewed.ts with range startLineNumber 1, startColumn 7, endLineNumber 1, endColumn 13 and text "rename this", then reply exactly "added". - role: assistant content: - - type: text - text: I'll add the comment as specified. - type: tool_use name: mcp__host__addComment input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-delete-session-refuses-to-delete-the-invoking-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-delete-session-refuses-to-delete-the-invoking-session.yaml index 0cf475ba520273..541f3f0b29a952 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-delete-session-refuses-to-delete-the-invoking-session.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-delete-session-refuses-to-delete-the-invoking-session.yaml @@ -10,7 +10,7 @@ exchanges: response: content: - type: text - text: I'll make that call. + text: I'll call it as instructed. - type: tool_use id: toolcall_0 name: mcp__host__delete_session @@ -26,7 +26,7 @@ exchanges: - role: assistant content: - type: text - text: I'll make that call. + text: I'll call it as instructed. - type: tool_use name: mcp__host__delete_session input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-deletecomments-removes-accepted-feedback-without-touching-hidden-feedback.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-deletecomments-removes-accepted-feedback-without-touching-hidden-feedback.yaml index 38454f8a263609..c96396f9bfd31d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-deletecomments-removes-accepted-feedback-without-touching-hidden-feedback.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-deletecomments-removes-accepted-feedback-without-touching-hidden-feedback.yaml @@ -9,8 +9,6 @@ exchanges: content: Call deleteComments exactly once with commentIds ["delete-me"], then reply exactly "deleted". response: content: - - type: text - text: I'll delete that comment. - type: tool_use id: toolcall_0 name: mcp__host__deleteComments @@ -26,8 +24,6 @@ exchanges: content: Call deleteComments exactly once with commentIds ["delete-me"], then reply exactly "deleted". - role: assistant content: - - type: text - text: I'll delete that comment. - type: tool_use name: mcp__host__deleteComments input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-current-session-returns-the-invoking-session-metadata-and-open-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-current-session-returns-the-invoking-session-metadata-and-open-link.yaml index 7cc66ca641e918..ba36f9e0ea3f76 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-current-session-returns-the-invoking-session-metadata-and-open-link.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-current-session-returns-the-invoking-session-metadata-and-open-link.yaml @@ -29,7 +29,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","title":"Call get_current_session exactly once, then reply exactly \"current\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-02T00:59:56.191Z","modifiedAt":"2026-08-02T00:59:55.849Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}' + content: '{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","title":"Call get_current_session exactly once, then reply exactly \"current\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T00:05:14.745Z","modifiedAt":"2026-08-28T00:05:10.397Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}' response: content: current stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-can-include-an-archived-session-on-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-can-include-an-archived-session-on-request.yaml index 48c9ffa70bdc7b..1d8480bb9d5e31 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-can-include-an-archived-session-on-request.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-can-include-an-archived-session-on-request.yaml @@ -24,6 +24,7 @@ exchanges: input: status: - archived + includeArchived: true stopReason: tool_use - request: model: claude-opus-5 @@ -38,11 +39,12 @@ exchanges: input: status: - archived + includeArchived: true - role: user content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Reply exactly \"ARCHIVED_TARGET_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-02T02:19:57.889Z","modifiedAt":"2026-08-02T02:19:59.465Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Reply exactly \"ARCHIVED_TARGET_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T00:09:07.054Z","modifiedAt":"2026-08-28T00:09:08.220Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: listed stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdafter-accepts-current-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdafter-accepts-current-sessions.yaml index 0bc68a89f32d84..e97c4f76a388f6 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdafter-accepts-current-sessions.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdafter-accepts-current-sessions.yaml @@ -33,7 +33,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\" and createdAfter \"2000-01-01T00:00:00Z\", then reply exa","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-02T04:56:45.593Z","modifiedAt":"2026-08-02T04:56:44.589Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\" and createdAfter \"2000-01-01T00:00:00Z\", then reply exactly \"filter","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T16:29:37.276Z","modifiedAt":"2026-08-28T16:29:32.980Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: filtered stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml index 23f093f1a7cf62..314a1543eb99ba 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml @@ -31,7 +31,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with createdBefore \"2999-01-01T00:00:00Z\", then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-16T00:05:45.643Z","modifiedAt":"2026-08-16T00:05:45.208Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with createdBefore \"2999-01-01T00:00:00Z\", then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T00:11:48.111Z","modifiedAt":"2026-08-28T00:11:43.839Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: filtered stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-direct-lookup-accepts-an-open-session-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-direct-lookup-accepts-an-open-session-link.yaml index c668e448dbce67..d0530e2de4be47 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-direct-lookup-accepts-an-open-session-link.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-direct-lookup-accepts-an-open-session-link.yaml @@ -31,7 +31,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with session \"agent-host-session://claude/${uuid_0}\", then reply exactly \"found\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-05T22:45:39.182Z","modifiedAt":"2026-08-05T22:45:38.161Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with session \"agent-host-session://claude/${uuid_0}\", then reply exactly \"found\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T00:08:19.491Z","modifiedAt":"2026-08-28T00:08:15.225Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: found stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-hides-archived-sessions-by-default.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-hides-archived-sessions-by-default.yaml index 69cab21d83a2b4..7e7aae71c88e8b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-hides-archived-sessions-by-default.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-hides-archived-sessions-by-default.yaml @@ -18,8 +18,6 @@ exchanges: content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". response: content: - - type: text - text: I'll list the sessions for that workspace. - type: tool_use id: toolcall_0 name: mcp__host__list_sessions @@ -34,8 +32,6 @@ exchanges: content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". - role: assistant content: - - type: text - text: I'll list the sessions for that workspace. - type: tool_use name: mcp__host__list_sessions input: @@ -44,7 +40,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\", then reply exactly \"listed\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:03:36.971Z","modifiedAt":"2026-08-15T23:03:36.554Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\", then reply exactly \"listed\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T16:29:00.243Z","modifiedAt":"2026-08-28T16:28:56.247Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: listed stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml index d2e04d522a968a..cb4c0e3a174371 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml @@ -42,7 +42,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\" and includeArchived true, then reply exactly \"listed","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:11:26.581Z","modifiedAt":"2026-08-15T23:11:26.168Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"claude:/${uuid_1}","title":"Reply exactly \"ARCHIVED_INCLUDED_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:11:24.281Z","modifiedAt":"2026-08-15T23:11:26.162Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\" and includeArchived true, then reply exactly \"listed\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T16:29:22.412Z","modifiedAt":"2026-08-28T16:29:18.412Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"claude:/${uuid_1}","openLink":"agent-host-session://claude/${uuid_1}","title":"Reply exactly \"ARCHIVED_INCLUDED_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T16:29:15.053Z","modifiedAt":"2026-08-28T16:29:14.201Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: listed stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-returns-live-session-metadata.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-returns-live-session-metadata.yaml index 7deb5093045ca9..0518564f0c945e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-returns-live-session-metadata.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-returns-live-session-metadata.yaml @@ -9,8 +9,6 @@ exchanges: content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". response: content: - - type: text - text: I'll call it once as requested. - type: tool_use id: toolcall_0 name: mcp__host__list_sessions @@ -25,8 +23,6 @@ exchanges: content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". - role: assistant content: - - type: text - text: I'll call it once as requested. - type: tool_use name: mcp__host__list_sessions input: @@ -35,7 +31,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\", then reply exactly \"listed\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-02T02:19:40.274Z","modifiedAt":"2026-08-02T02:19:39.927Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\", then reply exactly \"listed\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T16:28:16.841Z","modifiedAt":"2026-08-28T16:28:12.592Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: listed stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml index 96709bea7195e9..8b01747df63f09 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml @@ -44,7 +44,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with status [\"inProgress\", \"archived\"], then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:39:43.777Z","modifiedAt":"2026-08-15T23:39:43.354Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"claude:/${uuid_1}","title":"Reply exactly \"COMBINED_TARGET_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:39:41.314Z","modifiedAt":"2026-08-15T23:39:43.346Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with status [\"inProgress\", \"archived\"], then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T00:10:37.432Z","modifiedAt":"2026-08-28T00:10:33.383Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"claude:/${uuid_1}","title":"Reply exactly \"COMBINED_TARGET_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T00:10:29.702Z","modifiedAt":"2026-08-28T00:10:29.126Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: filtered stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-finds-the-invoking-in-progress-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-finds-the-invoking-in-progress-session.yaml index 9867d8316c13e7..1f88b07c5a83a8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-finds-the-invoking-in-progress-session.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-finds-the-invoking-in-progress-session.yaml @@ -33,7 +33,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with status [\"inProgress\"], then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-02T04:39:56.119Z","modifiedAt":"2026-08-02T04:39:55.034Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with status [\"inProgress\"], then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T00:10:15.680Z","modifiedAt":"2026-08-28T00:10:11.451Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: filtered stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-unread-filter-returns-the-invoking-unread-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-unread-filter-returns-the-invoking-unread-session.yaml index 8cbaf782082c06..b54ed05f07e14f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-unread-filter-returns-the-invoking-unread-session.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-unread-filter-returns-the-invoking-unread-session.yaml @@ -31,7 +31,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with unread true, then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-02T04:40:00.654Z","modifiedAt":"2026-08-02T04:40:00.301Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with unread true, then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-28T00:10:51.664Z","modifiedAt":"2026-08-28T00:10:47.331Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: filtered stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-workspace-filter-excludes-sessions-in-other-folders.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-workspace-filter-excludes-sessions-in-other-folders.yaml index 56ed4f81a8dcf7..35d8e64f3b848c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-workspace-filter-excludes-sessions-in-other-folders.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-workspace-filter-excludes-sessions-in-other-folders.yaml @@ -18,8 +18,6 @@ exchanges: content: Call list_sessions exactly once with workspace "${workdir}/other", then reply exactly "filtered". response: content: - - type: text - text: I'll call it once with that workspace filter. - type: tool_use id: toolcall_0 name: mcp__host__list_sessions @@ -34,8 +32,6 @@ exchanges: content: Call list_sessions exactly once with workspace "${workdir}/other", then reply exactly "filtered". - role: assistant content: - - type: text - text: I'll call it once with that workspace filter. - type: tool_use name: mcp__host__list_sessions input: @@ -44,7 +40,7 @@ exchanges: content: - type: tool_result tool_use_id: toolcall_0 - content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Reply exactly \"WORKSPACE_TARGET_READY\".","status":"idle","workingDirectory":"file://${workdir}/other","unread":true,"createdAt":"2026-08-02T02:19:48.953Z","modifiedAt":"2026-08-02T02:19:51.211Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + content: '{"sessions":[{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","title":"Reply exactly \"WORKSPACE_TARGET_READY\".","status":"idle","workingDirectory":"file://${workdir}/other","unread":true,"createdAt":"2026-08-28T16:28:31.637Z","modifiedAt":"2026-08-28T16:28:30.279Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' response: content: filtered stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-listcomments-returns-accepted-feedback-and-reports-hidden-review-feedback.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-listcomments-returns-accepted-feedback-and-reports-hidden-review-feedback.yaml index ad1f8bee097e80..bfc40d87239b3f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-listcomments-returns-accepted-feedback-and-reports-hidden-review-feedback.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-listcomments-returns-accepted-feedback-and-reports-hidden-review-feedback.yaml @@ -47,9 +47,13 @@ exchanges: }, "text": "visible", "kind": "codeReview", + "author": "agent", "resolved": false, "replies": [ - "reply" + { + "author": "user", + "text": "reply" + } ] } ], diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-resolvecomments-can-reopen-resolved-feedback.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-resolvecomments-can-reopen-resolved-feedback.yaml index 509e75c6dfc79e..2b162413d92c18 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-resolvecomments-can-reopen-resolved-feedback.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-resolvecomments-can-reopen-resolved-feedback.yaml @@ -9,8 +9,6 @@ exchanges: content: Call resolveComments exactly once with commentIds ["reopen-me"] and resolved false, then reply exactly "reopened". response: content: - - type: text - text: I'll resolve—unresolve, rather—that comment. - type: tool_use id: toolcall_0 name: mcp__host__resolveComments @@ -27,8 +25,6 @@ exchanges: content: Call resolveComments exactly once with commentIds ["reopen-me"] and resolved false, then reply exactly "reopened". - role: assistant content: - - type: text - text: I'll resolve—unresolve, rather—that comment. - type: tool_use name: mcp__host__resolveComments input: @@ -58,6 +54,7 @@ exchanges: }, "text": "reopen", "kind": "codeReview", + "author": "agent", "resolved": false } ] diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-resolvecomments-marks-accepted-feedback-resolved.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-resolvecomments-marks-accepted-feedback-resolved.yaml index c869e95fbd975f..987a2132a58d24 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-resolvecomments-marks-accepted-feedback-resolved.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-resolvecomments-marks-accepted-feedback-resolved.yaml @@ -17,6 +17,7 @@ exchanges: input: commentIds: - resolve-me + resolved: true stopReason: tool_use - request: model: claude-opus-5 @@ -33,6 +34,7 @@ exchanges: input: commentIds: - resolve-me + resolved: true - role: user content: - type: tool_result @@ -56,6 +58,7 @@ exchanges: }, "text": "resolve", "kind": "codeReview", + "author": "agent", "resolved": true } ] diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-send-message-starts-a-turn-in-another-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-send-message-starts-a-turn-in-another-session.yaml index 8612d5ceb94590..ddbd758d923edb 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-send-message-starts-a-turn-in-another-session.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-send-message-starts-a-turn-in-another-session.yaml @@ -18,8 +18,6 @@ exchanges: content: Call send_message exactly once with session "claude:/${uuid_0}" and message "/rename Target Via Send", then reply exactly "sent". response: content: - - type: text - text: I'll send that message. - type: tool_use id: toolcall_0 name: mcp__host__send_message @@ -35,8 +33,6 @@ exchanges: content: Call send_message exactly once with session "claude:/${uuid_0}" and message "/rename Target Via Send", then reply exactly "sent". - role: assistant content: - - type: text - text: I'll send that message. - type: tool_use name: mcp__host__send_message input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-viewunreviewedcomments-returns-selected-feedback-and-clears-pending-reveal-state.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-viewunreviewedcomments-returns-selected-feedback-and-clears-pending-reveal-state.yaml index e8ceee474cfc39..046751d5a01776 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-viewunreviewedcomments-returns-selected-feedback-and-clears-pending-reveal-state.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-viewunreviewedcomments-returns-selected-feedback-and-clears-pending-reveal-state.yaml @@ -47,6 +47,7 @@ exchanges: }, "text": "revealed", "kind": "prReview", + "author": "prReviewer", "resolved": false } ] diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-session-changeset-aggregates-provider-edits-from-default-and-peer-chats.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-session-changeset-aggregates-provider-edits-from-default-and-peer-chats.yaml index d3dd4c903e60fd..7ae56a31c52d5a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-session-changeset-aggregates-provider-edits-from-default-and-peer-chats.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-session-changeset-aggregates-provider-edits-from-default-and-peer-chats.yaml @@ -14,8 +14,7 @@ exchanges: name: Write input: file_path: ${workdir}/default-provider.txt - content: | - DEFAULT_PROVIDER + content: DEFAULT_PROVIDER stopReason: tool_use - request: model: claude-opus-5 @@ -29,8 +28,7 @@ exchanges: name: Write input: file_path: ${workdir}/default-provider.txt - content: | - DEFAULT_PROVIDER + content: DEFAULT_PROVIDER - role: user content: - type: tool_result @@ -47,8 +45,6 @@ exchanges: content: Create peer-provider.txt containing exactly PEER_PROVIDER using your file creation tool; do not run a shell command. Then reply exactly "created". response: content: - - type: text - text: I'll create the file with the Write tool. - type: tool_use id: toolcall_1 name: Write @@ -64,8 +60,6 @@ exchanges: content: Create peer-provider.txt containing exactly PEER_PROVIDER using your file creation tool; do not run a shell command. Then reply exactly "created". - role: assistant content: - - type: text - text: I'll create the file with the Write tool. - type: tool_use name: Write input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml index 6f472918e9fe0d..65396139e670fb 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml @@ -2,28 +2,22 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready". response: - content: |- - I'll remember the token SIDECHAT42. - - ready + content: ready stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready". - role: assistant - content: |- - I'll remember the token SIDECHAT42. - - ready + content: ready - role: user content: |- diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-streams-rich-file-creation-progress-without-exposing-partial-input.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-streams-rich-file-creation-progress-without-exposing-partial-input.yaml index 30ced22d7cc96e..16a5367827d862 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-streams-rich-file-creation-progress-without-exposing-partial-input.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-streams-rich-file-creation-progress-without-exposing-partial-input.yaml @@ -2,7 +2,7 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -14,8 +14,6 @@ exchanges: Use your file creation tool; do not run a shell command. Then reply exactly "done". response: content: - - type: text - text: I'll create the file. - type: tool_use id: toolcall_0 name: Write @@ -27,7 +25,7 @@ exchanges: STREAM_GAMMA stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user @@ -39,8 +37,6 @@ exchanges: Use your file creation tool; do not run a shell command. Then reply exactly "done". - role: assistant content: - - type: text - text: I'll create the file. - type: tool_use name: Write input: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-subagent-tool-calls-are-routed-to-the-subagent-session-not-flat-in-the-parent.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-subagent-tool-calls-are-routed-to-the-subagent-session-not-flat-in-the-parent.yaml index a24461185e8ed4..bbaf61105db4b3 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-subagent-tool-calls-are-routed-to-the-subagent-session-not-flat-in-the-parent.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-subagent-tool-calls-are-routed-to-the-subagent-session-not-flat-in-the-parent.yaml @@ -2,96 +2,107 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: Use the `Task` tool to spawn a subagent to list the files in the current working directory. The subagent should call a single read-only tool (e.g. `view` or shell with `ls`) to enumerate the directory. Do not enumerate the directory yourself — delegate to the subagent. + content: Use the `Task` tool to spawn a subagent to list the files in the current working directory. The subagent should call a single read-only file-listing tool (e.g. `Glob` or `view`) to enumerate the directory; do not run a shell command. Do not enumerate the directory yourself — delegate to the subagent. response: content: - type: text - text: I'll delegate this to a subagent using the Explore agent, which is read-only. + text: I'll delegate the directory listing to a subagent. - type: tool_use id: toolcall_0 name: Agent input: description: List files in working directory - prompt: List the files and directories in the current working directory (${workdir}). Call a single read-only tool exactly once — run `ls -la` in the shell (or use a view/list tool) to enumerate the directory. Then report back the list of entries you found. Do not call any other tools. + prompt: |- + List the files in the current working directory: ${workdir} + + Use a single read-only file-listing tool call — the Glob tool with pattern "*" and path set to that directory. Do NOT run any shell/Bash commands. + + Report back the exact list of file and directory names you found (or state that the directory is empty). subagent_type: Explore run_in_background: false stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: List the files and directories in the current working directory (${workdir}). Call a single read-only tool exactly once — run `ls -la` in the shell (or use a view/list tool) to enumerate the directory. Then report back the list of entries you found. Do not call any other tools. + content: |- + List the files in the current working directory: ${workdir} + + Use a single read-only file-listing tool call — the Glob tool with pattern "*" and path set to that directory. Do NOT run any shell/Bash commands. + + Report back the exact list of file and directory names you found (or state that the directory is empty). response: content: - type: text - text: I'll run `ls -la` to enumerate the directory. + text: I'll list the directory contents with a single Glob call. - type: tool_use id: toolcall_1 - name: Bash + name: Glob input: - command: ls -la ${workdir} - description: List all entries in the working directory + pattern: '*' + path: ${workdir} stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: List the files and directories in the current working directory (${workdir}). Call a single read-only tool exactly once — run `ls -la` in the shell (or use a view/list tool) to enumerate the directory. Then report back the list of entries you found. Do not call any other tools. + content: |- + List the files in the current working directory: ${workdir} + + Use a single read-only file-listing tool call — the Glob tool with pattern "*" and path set to that directory. Do NOT run any shell/Bash commands. + + Report back the exact list of file and directory names you found (or state that the directory is empty). - role: assistant content: - type: text - text: I'll run `ls -la` to enumerate the directory. + text: I'll list the directory contents with a single Glob call. - type: tool_use - name: Bash + name: Glob input: - command: ls -la ${workdir} - description: List all entries in the working directory + pattern: '*' + path: ${workdir} - role: user content: - type: tool_result tool_use_id: toolcall_1 content: |- - total 16 - drwx------ 4 ${user} staff 128 ${timestamp} . - drwx------@ 3522 ${user} staff 112704 ${timestamp} .. - -rw-r--r-- 1 ${user} staff 5 ${timestamp} file-a.txt - -rw-r--r-- 1 ${user} staff 4 ${timestamp} file-b.txt + file-a.txt + file-b.txt response: content: |- - Here are the entries in `${workdir}`: + The directory contains 2 entries, both files (no subdirectories): - Directories: - - `.` (current directory) - - `..` (parent directory) + - `${workdir}\file-a.txt` + - `${workdir}\file-b.txt` - Files: - - `file-a.txt` (5 bytes) - - `file-b.txt` (4 bytes) - - The directory contains two regular files (`file-a.txt` and `file-b.txt`) and no subdirectories. + Exact names as returned: `file-a.txt`, `file-b.txt` stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: Use the `Task` tool to spawn a subagent to list the files in the current working directory. The subagent should call a single read-only tool (e.g. `view` or shell with `ls`) to enumerate the directory. Do not enumerate the directory yourself — delegate to the subagent. + content: Use the `Task` tool to spawn a subagent to list the files in the current working directory. The subagent should call a single read-only file-listing tool (e.g. `Glob` or `view`) to enumerate the directory; do not run a shell command. Do not enumerate the directory yourself — delegate to the subagent. - role: assistant content: - - type: thinking - type: text - text: I'll delegate this to a subagent using the Explore agent, which is read-only. + text: I'll delegate the directory listing to a subagent. - type: tool_use name: Agent input: description: List files in working directory - prompt: List the files and directories in the current working directory (${workdir}). Call a single read-only tool exactly once — run `ls -la` in the shell (or use a view/list tool) to enumerate the directory. Then report back the list of entries you found. Do not call any other tools. + prompt: |- + List the files in the current working directory: ${workdir} + + Use a single read-only file-listing tool call — the Glob tool with pattern "*" and path set to that directory. Do NOT run any shell/Bash commands. + + Report back the exact list of file and directory names you found (or state that the directory is empty). subagent_type: Explore run_in_background: false - role: user @@ -99,21 +110,18 @@ exchanges: - type: tool_result tool_use_id: toolcall_0 content: |- - Here are the entries in `${workdir}`: + The directory contains 2 entries, both files (no subdirectories): - Directories: - - `.` (current directory) - - `..` (parent directory) + - `${workdir}\file-a.txt` + - `${workdir}\file-b.txt` - Files: - - `file-a.txt` (5 bytes) - - `file-b.txt` (4 bytes) - - The directory contains two regular files (`file-a.txt` and `file-b.txt`) and no subdirectories. + Exact names as returned: `file-a.txt`, `file-b.txt` response: content: |- - The subagent enumerated the directory with a single `ls -la` call. The working directory contains two files and no subdirectories: + The subagent enumerated the directory with a single `Glob` call. Contents of `${workdir}`: + + - `file-a.txt` + - `file-b.txt` - - `file-a.txt` (5 bytes) - - `file-b.txt` (4 bytes) + No subdirectories. stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-keep-independent-provider-contexts.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-keep-independent-provider-contexts.yaml index ff6adcb6df4241..b152d1547fc579 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-keep-independent-provider-contexts.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-keep-independent-provider-contexts.yaml @@ -2,56 +2,44 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word ALPHA_PEER. Reply exactly "ready". response: - content: |- - I'll remember the code word ALPHA_PEER. - - ready + content: ready stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word BETA_PEER. Reply exactly "ready". response: - content: |- - I'll remember the code word BETA_PEER. - - ready + content: ready stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word ALPHA_PEER. Reply exactly "ready". - role: assistant - content: |- - I'll remember the code word ALPHA_PEER. - - ready + content: ready - role: user content: Reply exactly "first". response: content: first stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user content: Remember the code word BETA_PEER. Reply exactly "ready". - role: assistant - content: |- - I'll remember the code word BETA_PEER. - - ready + content: ready - role: user content: Reply exactly "second". response: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-write-distinct-workspace-files.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-write-distinct-workspace-files.yaml index 377843e5b090f4..7ae12b68d342ef 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-write-distinct-workspace-files.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-write-distinct-workspace-files.yaml @@ -2,15 +2,13 @@ version: 1 dialect: anthropic exchanges: - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER. + content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER with no trailing newline. Use your file tools; do not run a shell command. Do not verify it with another tool. Then reply exactly "created". response: content: - - type: text - text: I'll create that file for you. - type: tool_use id: toolcall_0 name: Write @@ -19,15 +17,13 @@ exchanges: content: FIRST_PEER stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER. + content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER with no trailing newline. Use your file tools; do not run a shell command. Do not verify it with another tool. Then reply exactly "created". - role: assistant content: - - type: text - text: I'll create that file for you. - type: tool_use name: Write input: @@ -39,18 +35,16 @@ exchanges: tool_use_id: toolcall_0 content: 'File created successfully at: ${workdir}/first-peer.txt (file state is current in your context — no need to Read it back)' response: - content: Created `first-peer.txt` containing exactly `FIRST_PEER`. + content: created stopReason: end_turn - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER. + content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER with no trailing newline. Use your file tools; do not run a shell command. Do not verify it with another tool. Then reply exactly "created". response: content: - - type: text - text: I'll create that file. - type: tool_use id: toolcall_1 name: Write @@ -59,15 +53,13 @@ exchanges: content: SECOND_PEER stopReason: tool_use - request: - model: claude-opus-4.8 + model: claude-opus-5 system: ${system} messages: - role: user - content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER. + content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER with no trailing newline. Use your file tools; do not run a shell command. Do not verify it with another tool. Then reply exactly "created". - role: assistant content: - - type: text - text: I'll create that file. - type: tool_use name: Write input: @@ -79,5 +71,5 @@ exchanges: tool_use_id: toolcall_1 content: 'File created successfully at: ${workdir}/second-peer.txt (file state is current in your context — no need to Read it back)' response: - content: Done. Created `second-peer.txt` containing exactly `SECOND_PEER`. + content: created stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-deletes-a-workspace-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-deletes-a-workspace-file.yaml index 19851f09230639..c94e8baf33f691 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/codex-deletes-a-workspace-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-deletes-a-workspace-file.yaml @@ -6,9 +6,11 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' response: content: + - type: text + text: Running that exact command now. - type: tool_use id: toolcall_0 name: exec_command @@ -21,7 +23,9 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' + - role: assistant + content: Running that exact command now. - role: assistant content: - type: tool_use @@ -34,8 +38,8 @@ exchanges: - type: tool_result tool_use_id: toolcall_0 content: |- - Chunk ID: a444ab - Wall time: 0.0000 seconds + Chunk ID: 26c829 + Wall time: 0.6549 seconds Process exited with code 0 Original token count: 0 Output: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-renames-a-workspace-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-renames-a-workspace-file.yaml index 98359508693076..50389150021871 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/codex-renames-a-workspace-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-renames-a-workspace-file.yaml @@ -6,9 +6,11 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' response: content: + - type: text + text: Running your exact command now. - type: tool_use id: toolcall_0 name: exec_command @@ -21,7 +23,9 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' + - role: assistant + content: Running your exact command now. - role: assistant content: - type: tool_use @@ -34,8 +38,8 @@ exchanges: - type: tool_result tool_use_id: toolcall_0 content: |- - Chunk ID: a620b7 - Wall time: 0.0000 seconds + Chunk ID: b8af57 + Wall time: 0.7877 seconds Process exited with code 0 Original token count: 0 Output: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-two-peer-chats-write-distinct-workspace-files.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-two-peer-chats-write-distinct-workspace-files.yaml index c85d26eb89e979..ec859a1b7f6b18 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/codex-two-peer-chats-write-distinct-workspace-files.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-two-peer-chats-write-distinct-workspace-files.yaml @@ -6,7 +6,7 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''first-peer.txt'',''FIRST_PEER'')"`. Then reply exactly "created".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''first-peer.txt'',''FIRST_PEER'')"`. Do not run any other command or tool. Then reply exactly "created".' response: content: - type: text @@ -23,7 +23,7 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''first-peer.txt'',''FIRST_PEER'')"`. Then reply exactly "created".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''first-peer.txt'',''FIRST_PEER'')"`. Do not run any other command or tool. Then reply exactly "created".' - role: assistant content: Running that exact command now. - role: assistant @@ -38,8 +38,8 @@ exchanges: - type: tool_result tool_use_id: toolcall_0 content: |- - Chunk ID: d02477 - Wall time: 0.0000 seconds + Chunk ID: 635023 + Wall time: 0.6857 seconds Process exited with code 0 Original token count: 0 Output: @@ -51,7 +51,7 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''second-peer.txt'',''SECOND_PEER'')"`. Then reply exactly "created".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''second-peer.txt'',''SECOND_PEER'')"`. Do not run any other command or tool. Then reply exactly "created".' response: content: - type: text @@ -68,7 +68,7 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''second-peer.txt'',''SECOND_PEER'')"`. Then reply exactly "created".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''second-peer.txt'',''SECOND_PEER'')"`. Do not run any other command or tool. Then reply exactly "created".' - role: assistant content: Running that exact command now. - role: assistant @@ -83,8 +83,8 @@ exchanges: - type: tool_result tool_use_id: toolcall_1 content: |- - Chunk ID: 3945ac - Wall time: 0.0000 seconds + Chunk ID: dc81eb + Wall time: 0.3845 seconds Process exited with code 0 Original token count: 0 Output: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-declining-a-file-creation-tool-prevents-the-mutation-and-completes-the-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-declining-a-file-creation-tool-prevents-the-mutation-and-completes-the-turn.yaml index 4128b9d98f7eed..555b3f4479c4b7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-declining-a-file-creation-tool-prevents-the-mutation-and-completes-the-turn.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-declining-a-file-creation-tool-prevents-the-mutation-and-completes-the-turn.yaml @@ -6,14 +6,14 @@ exchanges: system: ${system} messages: - role: user - content: Create denied.txt containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied". + content: Create denied.lock containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied". response: content: - type: tool_use id: toolcall_0 name: create input: - path: ${workdir}/denied.txt + path: ${workdir}\denied.lock file_text: DENIED_CONTENT stopReason: tool_use - request: @@ -21,13 +21,13 @@ exchanges: system: ${system} messages: - role: user - content: Create denied.txt containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied". + content: Create denied.lock containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied". - role: assistant content: - type: tool_use name: create input: - path: ${workdir}/denied.txt + path: ${workdir}\denied.lock file_text: DENIED_CONTENT - role: user content: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-default-chat-simple-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-default-chat-simple-attachment-reaches-the-provider-request.yaml index 778b6766b2744c..c02f5db2b4f980 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-default-chat-simple-attachment-reaches-the-provider-request.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-default-chat-simple-attachment-reaches-the-provider-request.yaml @@ -8,7 +8,7 @@ exchanges: - role: user content: - type: text - text: Reply with only the value from the attachment. + text: Reply with only the value provided directly in the attachment. Do not inspect the workspace or use tools. - type: text text: |- diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-deletes-a-workspace-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-deletes-a-workspace-file.yaml index 2d3dba28366d5e..53d67087059620 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-deletes-a-workspace-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-deletes-a-workspace-file.yaml @@ -6,7 +6,7 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' response: content: - type: tool_use @@ -14,21 +14,21 @@ exchanges: name: ${shell} input: command: node -e "require('fs').unlinkSync('delete-me.txt')" - description: Delete file delete-me.txt + description: Delete file using node stopReason: tool_use - request: model: claude-sonnet-5 system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' - role: assistant content: - type: tool_use - name: bash + name: powershell input: command: node -e "require('fs').unlinkSync('delete-me.txt')" - description: Delete file delete-me.txt + description: Delete file using node - role: user content: - type: tool_result diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-edits-an-existing-text-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-edits-an-existing-text-file.yaml index f1dfeef96692ee..bae087967af7fc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-edits-an-existing-text-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-edits-an-existing-text-file.yaml @@ -25,7 +25,7 @@ exchanges: - role: assistant content: - type: tool_use - name: bash + name: powershell input: command: node -e "require('fs').writeFileSync('edit.txt','AFTER_VALUE')" description: Write AFTER_VALUE to edit.txt diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-renames-a-workspace-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-renames-a-workspace-file.yaml index b0d654e8942e51..5130c806f3ac12 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-renames-a-workspace-file.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-renames-a-workspace-file.yaml @@ -6,7 +6,7 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' response: content: - type: tool_use @@ -21,12 +21,11 @@ exchanges: system: ${system} messages: - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' - role: assistant content: - - type: thinking - type: tool_use - name: bash + name: powershell input: command: node -e "require('fs').renameSync('before.txt','after.txt')" description: Rename before.txt to after.txt diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-subagent-tool-calls-are-routed-to-the-subagent-session-not-flat-in-the-parent.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-subagent-tool-calls-are-routed-to-the-subagent-session-not-flat-in-the-parent.yaml index ba309974bb895e..b484a705792210 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-subagent-tool-calls-are-routed-to-the-subagent-session-not-flat-in-the-parent.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-subagent-tool-calls-are-routed-to-the-subagent-session-not-flat-in-the-parent.yaml @@ -6,7 +6,7 @@ exchanges: system: ${system} messages: - role: user - content: Use the `task` tool to spawn a subagent to list the files in the current working directory. The subagent should call a single read-only tool (e.g. `view` or shell with `ls`) to enumerate the directory. Do not enumerate the directory yourself — delegate to the subagent. + content: Use the `task` tool to spawn a subagent to list the files in the current working directory. The subagent should call a single read-only file-listing tool (e.g. `Glob` or `view`) to enumerate the directory; do not run a shell command. Do not enumerate the directory yourself — delegate to the subagent. response: content: - type: tool_use @@ -14,23 +14,17 @@ exchanges: name: task input: name: list-cwd-files - prompt: |- - List the files in the current working directory: ${workdir} - - Call a single read-only tool (e.g. `view` on that path, or a shell `ls` command) to enumerate the directory contents, then report back the list of files/directories found. Do not make any other tool calls. agent_type: explore - description: List files in cwd mode: sync + description: List files in current working directory + prompt: Use the `view` tool with path "${workdir}" to list the files and directories in that directory. Do not run any shell command. Just call view once on that path and report back the list of files/folders it contains. stopReason: tool_use - request: model: claude-haiku-4.5 system: ${system} messages: - role: user - content: |- - List the files in the current working directory: ${workdir} - - Call a single read-only tool (e.g. `view` on that path, or a shell `ls` command) to enumerate the directory contents, then report back the list of files/directories found. Do not make any other tool calls. + content: Use the `view` tool with path "${workdir}" to list the files and directories in that directory. Do not run any shell command. Just call view once on that path and report back the list of files/folders it contains. response: content: - type: tool_use @@ -44,10 +38,7 @@ exchanges: system: ${system} messages: - role: user - content: |- - List the files in the current working directory: ${workdir} - - Call a single read-only tool (e.g. `view` on that path, or a shell `ls` command) to enumerate the directory contents, then report back the list of files/directories found. Do not make any other tool calls. + content: Use the `view` tool with path "${workdir}" to list the files and directories in that directory. Do not run any shell command. Just call view once on that path and report back the list of files/folders it contains. - role: assistant content: - type: thinking @@ -64,41 +55,40 @@ exchanges: file-b.txt response: content: |- - The directory contains the following files: - - **file-a.txt** - - **file-b.txt** + The directory `${workdir}` contains the following files: + + 1. **file-a.txt** + 2. **file-b.txt** stopReason: end_turn - request: model: claude-sonnet-5 system: ${system} messages: - role: user - content: Use the `task` tool to spawn a subagent to list the files in the current working directory. The subagent should call a single read-only tool (e.g. `view` or shell with `ls`) to enumerate the directory. Do not enumerate the directory yourself — delegate to the subagent. + content: Use the `task` tool to spawn a subagent to list the files in the current working directory. The subagent should call a single read-only file-listing tool (e.g. `Glob` or `view`) to enumerate the directory; do not run a shell command. Do not enumerate the directory yourself — delegate to the subagent. - role: assistant content: - type: tool_use name: task input: name: list-cwd-files - prompt: |- - List the files in the current working directory: ${workdir} - - Call a single read-only tool (e.g. `view` on that path, or a shell `ls` command) to enumerate the directory contents, then report back the list of files/directories found. Do not make any other tool calls. agent_type: explore - description: List files in cwd mode: sync + description: List files in current working directory + prompt: Use the `view` tool with path "${workdir}" to list the files and directories in that directory. Do not run any shell command. Just call view once on that path and report back the list of files/folders it contains. - role: user content: - type: tool_result tool_use_id: toolcall_0 content: |- - The directory contains the following files: - - **file-a.txt** - - **file-b.txt** + The directory `${workdir}` contains the following files: + + 1. **file-a.txt** + 2. **file-b.txt** response: content: |- - The subagent listed the contents of the current working directory and found two files: + The subagent found two files in the current working directory: - - **file-a.txt** - - **file-b.txt** + 1. `file-a.txt` + 2. `file-b.txt` stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-write-distinct-workspace-files.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-write-distinct-workspace-files.yaml index caa77fb0b57338..040f919e9505bf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-write-distinct-workspace-files.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-write-distinct-workspace-files.yaml @@ -6,7 +6,7 @@ exchanges: system: ${system} messages: - role: user - content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER. + content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER with no trailing newline. Use your file tools; do not run a shell command. Do not verify it with another tool. Then reply exactly "created". response: content: - type: tool_use @@ -21,7 +21,7 @@ exchanges: system: ${system} messages: - role: user - content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER. + content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER with no trailing newline. Use your file tools; do not run a shell command. Do not verify it with another tool. Then reply exactly "created". - role: assistant content: - type: tool_use @@ -35,14 +35,14 @@ exchanges: tool_use_id: toolcall_0 content: Created file ${workdir}/first-peer.txt with 10 characters response: - content: Created [first-peer.txt](${workdir}/first-peer.txt) with the content `FIRST_PEER`. + content: created stopReason: end_turn - request: model: claude-sonnet-5 system: ${system} messages: - role: user - content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER. + content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER with no trailing newline. Use your file tools; do not run a shell command. Do not verify it with another tool. Then reply exactly "created". response: content: - type: tool_use @@ -57,7 +57,7 @@ exchanges: system: ${system} messages: - role: user - content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER. + content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER with no trailing newline. Use your file tools; do not run a shell command. Do not verify it with another tool. Then reply exactly "created". - role: assistant content: - type: tool_use @@ -71,5 +71,5 @@ exchanges: tool_use_id: toolcall_1 content: Created file ${workdir}/second-peer.txt with 11 characters response: - content: Created [second-peer.txt](${workdir}/second-peer.txt) containing `SECOND_PEER`. + content: created stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index c1553046729c8e..c6cf404966b273 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -336,8 +336,6 @@ export interface IAgentHostE2EProviderConfig { readonly supportsPluginCustomizationDiscoveryE2E?: boolean; /** Provider publishes live workspace-agent file changes through customization state. */ readonly supportsWorkspaceAgentWatchE2E?: boolean; - /** Provider's denied file-creation flow mutates the workspace during replay on Linux. */ - readonly fileToolDenialReplayUnstableOnLinux?: boolean; /** * Whether the suite should be enabled. Returning false skips the suite * entirely (mirrors `suite.skip(...)`). diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts b/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts index 185dd1f2649be4..f5c3458c177d95 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts @@ -37,7 +37,10 @@ import type * as http from 'http'; import type * as https from 'https'; import { createRequire } from 'module'; import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; import { basename, dirname } from '../../../../../../base/common/path.js'; +import { isWindows } from '../../../../../../base/common/platform.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { aggregateAnthropicSse, anthropicMessageToSse, ANTHROPIC_MESSAGES_PATH, aggregateResponsesSse, responsesMessageToSse, RESPONSES_PATH, summarizeResponsesRequest, deserializeAnthropicContent, serializeAnthropicContent, summarizeAnthropicRequest, type AnthropicContentBlock, type IAnthropicMessage, type IReadableAnthropicRequest } from './capiWireCodec.js'; import { getAncillaryStub } from './capiStubs.js'; import { findPosixOnlyCommands, formatPosixCommandError, getRecordedShellCommand, type IRecordedCommand } from './posixCommandLint.js'; @@ -65,6 +68,8 @@ const COPIED_PLUGIN_DIR_PLACEHOLDER = '${plugin_copy}'; const COPIED_PLUGIN_DIR_RE = /\$\{homedir\}(?:\/|\\\\)user-data(?:\/|\\\\)agentPlugins(?:\/|\\\\)[^\/\\"]+/g; const TEMP_DIR_SUFFIX_PLACEHOLDER = '${temp}'; const TEMP_DIR_SUFFIX_RE = /(\$\{workdir\}(?:\/|\\\\)(?:ahp-(?:snapshot|perm-test|plan-test|abort|test|wt-test|subagent-test|subagent-replay|attachment-test|cd-strip-test|coverage-[a-z-]+)-|copilot-(?:cost-report|text-blob)-|read-sdk-simple))[A-Za-z0-9]{6}/g; +const TEMP_WORKSPACE_COMPONENT_PATTERN = '(?:ahp-|copilot-|read-sdk-simple)[A-Za-z0-9._-]*'; +const PATH_SEPARATOR_PATTERN = '(?:\\\\\\\\|\\\\|/)'; const UUID_PLACEHOLDER_RE = /\$\{uuid_\d+\}/g; const UUID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; const FILE_LISTING_DATE_RE = /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+(?:\d{2}:\d{2}|\d{4})\b/g; @@ -867,7 +872,7 @@ export class CapiReplayProxy { const message = aggregateAnthropicSse(exchange.response.body); if (request && message) { const content = this._normalizeMessageContent(message.content); - return { exchange: { request, response: { content: serializeAnthropicContent(content), stopReason: message.stopReason } }, dialect: 'anthropic' }; + return { exchange: { request: this._normalizeRequest(request), response: { content: serializeAnthropicContent(content), stopReason: message.stopReason } }, dialect: 'anthropic' }; } } if (exchange.method === 'POST' && exchange.path === RESPONSES_PATH) { @@ -875,7 +880,7 @@ export class CapiReplayProxy { const message = aggregateResponsesSse(exchange.response.body); if (request && message) { const content = this._normalizeMessageContent(message.content); - return { exchange: { request, response: { content: serializeAnthropicContent(content), stopReason: message.stopReason } }, dialect: 'responses' }; + return { exchange: { request: this._normalizeRequest(request), response: { content: serializeAnthropicContent(content), stopReason: message.stopReason } }, dialect: 'responses' }; } } return { exchange: { method: exchange.method, path: exchange.path, response: exchange.response } }; @@ -890,18 +895,37 @@ export class CapiReplayProxy { private _normalizeMessageContent(content: AnthropicContentBlock[]): AnthropicContentBlock[] { return content.map((block): AnthropicContentBlock => { if (block.type === 'text') { - return { type: 'text', text: this._normalize(block.text) }; + return { type: 'text', text: this._normalizeValue(block.text) as string }; } - let input = block.input; - try { - input = JSON.parse(this._normalize(JSON.stringify(block.input ?? {}))); - } catch { - // non-serializable input; keep as-is - } - return { type: 'tool_use', id: block.id, name: normalizeShellToolNameForCapture(block.name), input }; + return { type: 'tool_use', id: block.id, name: normalizeShellToolNameForCapture(block.name), input: this._normalizeValue(block.input) }; }); } + private _normalizeRequest(request: IReadableAnthropicRequest): IReadableAnthropicRequest { + return { + ...request, + system: this._normalize(request.system), + messages: request.messages.map(message => ({ + ...message, + content: this._normalizeValue(message.content), + })), + }; + } + + private _normalizeValue(value: unknown): unknown { + if (typeof value === 'string') { + const normalized = this._normalize(value); + return isSerializedJson(normalized) ? normalized : normalizePlaceholderPathSeparators(normalized); + } + if (Array.isArray(value)) { + return value.map(item => this._normalizeValue(item)); + } + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this._normalizeValue(item)])); + } + return value; + } + private _normalize(text: string): string { let result = text; if (this._workingDirectory) { @@ -912,13 +936,20 @@ export class CapiReplayProxy { // The recording work directory can disappear during teardown. } for (const workDir of [...workDirs].sort((a, b) => b.length - a.length)) { - result = replaceAll(result, escapeJsonString(workDir), WORKDIR_PLACEHOLDER); - result = replaceAll(result, workDir, WORKDIR_PLACEHOLDER); + result = replacePath(result, workDir, WORKDIR_PLACEHOLDER, this._options.userName); } } + const tempDirectories = new Set([tmpdir()]); + try { + tempDirectories.add(realpathSync.native(tmpdir())); + } catch { + // The platform temp directory can disappear during teardown. + } + for (const tempDirectory of [...tempDirectories].sort((a, b) => b.length - a.length)) { + result = replaceTemporaryWorkspacePaths(result, tempDirectory, WORKDIR_PLACEHOLDER, this._options.userName); + } if (this._options.homeDir) { - result = replaceAll(result, escapeJsonString(this._options.homeDir), HOMEDIR_PLACEHOLDER); - result = replaceAll(result, this._options.homeDir, HOMEDIR_PLACEHOLDER); + result = replacePath(result, this._options.homeDir, HOMEDIR_PLACEHOLDER, this._options.userName); } if (this._options.userName) { result = scrubUserName(result, this._options.userName); @@ -1068,6 +1099,69 @@ function replaceAll(text: string, search: string, replacement: string): string { return text.split(search).join(replacement); } +function pathVariants(search: string, replacement: string, userName: string | undefined): ReadonlyMap { + const variants = new Map([ + [search, replacement], + [escapeJsonString(search), replacement], + ]); + const fileUriReplacement = `file://${replacement}`; + for (const fileUri of [URI.file(search).toString(), URI.file(search).toString(true)]) { + variants.set(fileUri, fileUriReplacement); + variants.set(escapeJsonString(fileUri), fileUriReplacement); + } + if (isWindows) { + const forwardSlashPath = search.replaceAll('\\', '/'); + const backslashPath = search.replaceAll('/', '\\'); + variants.set(forwardSlashPath, replacement); + variants.set(escapeJsonString(forwardSlashPath), replacement); + variants.set(backslashPath, replacement); + variants.set(escapeJsonString(backslashPath), replacement); + } + if (userName) { + for (const [variant, variantReplacement] of [...variants]) { + variants.set(scrubUserName(variant, userName), variantReplacement); + } + } + return variants; +} + +function replacePath(text: string, search: string, replacement: string, userName?: string): string { + const variants = pathVariants(search, replacement, userName); + for (const [variant, variantReplacement] of [...variants].sort(([a], [b]) => b.length - a.length)) { + text = isWindows + ? text.replace(new RegExp(escapeRegExpCharacters(variant), 'gi'), variantReplacement) + : replaceAll(text, variant, variantReplacement); + } + return text; +} + +/** Normalize harness temp workspaces even when a session title contains only a truncated path. */ +function replaceTemporaryWorkspacePaths(text: string, tempDirectory: string, replacement: string, userName?: string): string { + const variants = pathVariants(tempDirectory, replacement, userName); + for (const [variant, variantReplacement] of [...variants].sort(([a], [b]) => b.length - a.length)) { + const pattern = `${escapeRegExpCharacters(variant)}${PATH_SEPARATOR_PATTERN}${TEMP_WORKSPACE_COMPONENT_PATTERN}`; + text = text.replace(new RegExp(pattern, isWindows ? 'gi' : 'g'), variantReplacement); + } + return text; +} + +function normalizePlaceholderPathSeparators(text: string): string { + return text.replace(/\$\{(?:workdir|homedir)\}(?:[\\/][^\r\n"'`]*)?/g, path => path.replaceAll('\\', '/')); +} + +function isSerializedJson(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + return false; + } + try { + JSON.parse(trimmed); + return true; + } catch { + return false; + } +} + function escapeJsonString(value: string): string { return JSON.stringify(value).slice(1, -1); } diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/userNameScrub.ts b/src/vs/platform/agentHost/test/node/e2e/harness/userNameScrub.ts index 215aa4a0940783..3da0bebd6e2fe9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/userNameScrub.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/userNameScrub.ts @@ -18,6 +18,7 @@ export const USER_NAME_PLACEHOLDER = '${user}'; */ const LISTING_OWNER_RE = /^([dlcbps-][rwxStTs-]{9}[+@.]?\s+\d+\s+)(\S+)(\s+)(\S+)/gm; const PATH_SEGMENT_END = '(?=$|[/\\\\\\s"\'`,:;!?#)\\]}>])'; +const CLAUDE_PROJECT_DIR_RE = /((?:\/|\\{1,2})\.claude(?:\/|\\{1,2})projects(?:\/|\\{1,2}))([^/\\\s"'`,:;!?)}\]>]+)/g; /** * Replaces the recording machine's account name where it identifies a *user*, @@ -36,21 +37,25 @@ const PATH_SEGMENT_END = '(?=$|[/\\\\\\s"\'`,:;!?#)\\]}>])'; * happens to be an ordinary word — exactly the cross-platform mismatch the * normalization exists to prevent. * - * Only the two positions where the name genuinely identifies a user are + * Only the three positions where the name genuinely identifies a user are * rewritten: * * - after a path separator (`/home/runner/x`, `C:\Users\runner\x`), including * the escaped `\\` form that appears inside embedded JSON; * - the owner and group columns of an `ls -l` listing, which is where the name * shows up outside a path. + * - inside Claude's flattened source path under `.claude/projects`. */ export function scrubUserName(text: string, userName: string): string { if (!userName) { return text; } const escaped = escapeRegExpCharacters(userName); + const flattenedHomeUserRe = new RegExp(`((?:^|-)(?:Users|home)-)${escaped}(?=-|$)`, 'g'); return text .replace(new RegExp(`(?<=[/\\\\])${escaped}${PATH_SEGMENT_END}`, 'g'), USER_NAME_PLACEHOLDER) + .replace(CLAUDE_PROJECT_DIR_RE, (_match, prefix: string, projectDir: string) => + `${prefix}${projectDir.replace(flattenedHomeUserRe, (_segment, homePrefix: string) => `${homePrefix}${USER_NAME_PLACEHOLDER}`)}`) .replace(LISTING_OWNER_RE, (match, prefix: string, owner: string, gap: string, group: string) => { if (owner !== userName && group !== userName) { return match; diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml index b5a6b65e2bd213..c196f04734f220 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml @@ -25,6 +25,13 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/responsePart + turnId: ${turn_0} + part: + kind: markdown + content: I'll read the file to count its lines. - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml index 31604559bd7429..9e0ecb6b97614b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml @@ -25,13 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/responsePart - turnId: ${turn_0} - part: - kind: markdown - content: I'll create that file. - channel: ${chat_0} action: type: chat/toolCallStart @@ -51,7 +44,7 @@ rounds: turnId: ${turn_0} part: kind: markdown - content: Created `result.txt` containing exactly `CREATED_VALUE`. + content: Created `result.txt` containing `CREATED_VALUE`. - channel: ${chat_0} action: type: chat/turnComplete diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml index 7ef5fccaf7c2f8..ab3a41d3f4846c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml @@ -6,7 +6,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' origin: kind: user serverToClient: @@ -15,7 +15,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' origin: kind: user - channel: ${session_0} diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml index e4c7f92f3060a6..8ced5776fed52b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml @@ -6,7 +6,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: Replace the complete contents of edit.txt with AFTER_VALUE. Use your file tools; do not run a shell command. + text: Replace the complete contents of edit.txt with exactly AFTER_VALUE and no trailing newline. Use your file tools; do not run a shell command. origin: kind: user serverToClient: @@ -15,7 +15,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: Replace the complete contents of edit.txt with AFTER_VALUE. Use your file tools; do not run a shell command. + text: Replace the complete contents of edit.txt with exactly AFTER_VALUE and no trailing newline. Use your file tools; do not run a shell command. origin: kind: user - channel: ${session_0} @@ -25,6 +25,13 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/responsePart + turnId: ${turn_0} + part: + kind: markdown + content: I'll read the file first, then replace its contents. - channel: ${chat_0} action: type: chat/toolCallStart @@ -57,7 +64,7 @@ rounds: turnId: ${turn_0} part: kind: markdown - content: Done. `edit.txt` now contains `AFTER_VALUE` (replacing the previous `BEFORE_VALUE`). + content: '`edit.txt` now contains exactly `AFTER_VALUE` with no trailing newline.' - channel: ${chat_0} action: type: chat/turnComplete diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml index d88130b6356d60..a0af75f4ea83c1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml @@ -31,7 +31,7 @@ rounds: turnId: ${turn_0} part: kind: markdown - content: I'll read missing.txt. + content: I'll check for the file. - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml index bb52e168bf9bba..e0ea37a10b1a17 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml @@ -45,8 +45,8 @@ rounds: part: kind: markdown content: |- - Modified: tracked.txt - Untracked: untracked.txt + Modified: `tracked.txt` + Untracked: `untracked.txt` - channel: ${chat_0} action: type: chat/turnComplete diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml index d37765a9ac7418..4c3fd7b3bb9420 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml @@ -25,13 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/responsePart - turnId: ${turn_0} - part: - kind: markdown - content: I'll read the file for you. - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml index 16c8eebef704ed..6f27804c4d51f2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -31,7 +31,7 @@ rounds: turnId: ${turn_0} part: kind: markdown - content: I'll read that file for you. + content: I'll read that file. - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml index 9ca190c409f8d2..0d1a7a3e3160c0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml @@ -25,13 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/responsePart - turnId: ${turn_0} - part: - kind: markdown - content: I'll read the config file. - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml index da9cf6fe5870ce..b619a2d9dd4e0b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml @@ -25,13 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/responsePart - turnId: ${turn_0} - part: - kind: markdown - content: I'll read the file. - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml index 0e2a9a5dc31f71..6fb4a178474b1e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml @@ -6,7 +6,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' origin: kind: user serverToClient: @@ -15,7 +15,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' origin: kind: user - channel: ${session_0} diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml index b743f14986fb6e..2ed3fc13e97f70 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -31,10 +31,7 @@ rounds: turnId: ${turn_0} part: kind: markdown - content: |- - I'll remember the code word ORCHID. - - ready + content: ready - channel: ${chat_0} action: type: chat/turnComplete diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml index 6b0b248f714b4f..9a6332cb96e33d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml @@ -25,13 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/responsePart - turnId: ${turn_0} - part: - kind: markdown - content: I'll run that command. - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml index bc3b4a5606a61b..5cc6d23682909b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml @@ -6,7 +6,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' origin: kind: user serverToClient: @@ -15,7 +15,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' origin: kind: user - channel: ${session_0} @@ -25,6 +25,13 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/responsePart + turnId: ${turn_0} + part: + kind: markdown + content: Running that exact command now. - channel: ${chat_0} action: type: chat/toolCallStart @@ -42,7 +49,10 @@ rounds: turnId: ${turn_0} part: kind: markdown - content: deleted + content: |- + + + deleted - channel: ${chat_0} action: type: chat/turnComplete diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml index 62bf90ebd2cacb..2dd8c2a231a7f8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml @@ -6,7 +6,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' origin: kind: user serverToClient: @@ -15,7 +15,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' origin: kind: user - channel: ${session_0} @@ -25,6 +25,13 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/responsePart + turnId: ${turn_0} + part: + kind: markdown + content: Running your exact command now. - channel: ${chat_0} action: type: chat/toolCallStart @@ -42,7 +49,10 @@ rounds: turnId: ${turn_0} part: kind: markdown - content: renamed + content: |- + + + renamed - channel: ${chat_0} action: type: chat/turnComplete diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml index ff658e37b172de..5cf3b9a638bc41 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml @@ -6,7 +6,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' origin: kind: user serverToClient: @@ -15,7 +15,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Then reply with exactly "deleted".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').unlinkSync(''delete-me.txt'')"`. Do not run any other command or tool. Then reply with exactly "deleted".' origin: kind: user - channel: ${session_0} diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml index 3ec51ec5aba985..6fae6fee08547a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml @@ -6,7 +6,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' origin: kind: user serverToClient: @@ -15,7 +15,7 @@ rounds: type: chat/turnStarted turnId: ${turn_0} message: - text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Then reply with exactly "renamed".' + text: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').renameSync(''before.txt'',''after.txt'')"`. Do not run any other command or tool. Then reply with exactly "renamed".' origin: kind: user - channel: ${session_0} diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts index 7983bc6089ff95..82dfaa41156e57 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts @@ -70,7 +70,6 @@ const CLAUDE_CONFIG: IAgentHostE2EProviderConfig = { multiSelectInputPrompt: 'Use AskUserQuestion exactly once to ask "Which colors?" with options "Red" and "Blue" and multiSelect true. After the answer, name the selected colors.', supportsRuntimeSlashCommandsE2E: true, supportsAttachmentsE2E: true, - fileToolDenialReplayUnstableOnLinux: true, supportsWorktreeIncludeFilesE2E: true, enabled: !!CLAUDE_SDK_ROOT, claudeSdkRoot: CLAUDE_SDK_ROOT, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts index 7b9ab099255269..fcf2e3c519d64c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts @@ -181,7 +181,7 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { 90_000, ); const liveErrorPart = (getActionEnvelope(liveNotification).action as ChatErrorAction).part; - assert.strictEqual(liveErrorPart.resumable, undefined); + assert.strictEqual(liveErrorPart.resumable, true); client = await lease.restart(); client.setWorkingDirectory(workingDirectory); @@ -201,12 +201,11 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { }, { state: TurnState.Error, error: liveErrorPart.error, - resumable: undefined, + resumable: true, }); }); - // Retryable errors are temporarily disabled. - test.skip('resumes a failed turn in place', async function () { + test('resumes a failed turn in place', async function () { this.timeout(180_000); const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-failed-turn-resume-')); tempDirs.push(workingDirectory); @@ -345,7 +344,7 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { } }); - test.skip('resumes the same turn after repeated failures', async function () { + test('resumes the same turn after repeated failures', async function () { this.timeout(180_000); const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-repeated-failed-turn-resume-')); tempDirs.push(workingDirectory); @@ -426,8 +425,8 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { }); }); - // Retryable errors are temporarily disabled. - test.skip('restores and resumes a turn interrupted by host shutdown', async function () { + // Replay serves the full recorded response immediately, so it has no active streaming window to terminate. + (RECORD_ONLY ? test : test.skip)('restores and resumes a turn interrupted by host shutdown', async function () { this.timeout(240_000); const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-host-shutdown-resume-')); tempDirs.push(workingDirectory); diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts index a6b25cd247d090..696490d06460df 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts @@ -30,7 +30,6 @@ export const COPILOT_CONFIG: IAgentHostE2EProviderConfig = { supportsFixedInstructionDiscoveryE2E: true, supportsPluginCustomizationDiscoveryE2E: true, supportsWorkspaceAgentWatchE2E: true, - fileToolDenialReplayUnstableOnLinux: true, // The shared suite runs by default in deterministic replay mode (tokenless, // against committed fixtures). Recording new fixtures is opt-in via // `AGENT_HOST_REPLAY_RECORD=1`. The Copilot CLI is always present (dev dep). diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts index 59781876e1ce61..bfcdae9ad2063f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts @@ -495,7 +495,7 @@ export function defineCoreTests(context: IAgentHostE2ETestContext): void { context.client, sessionUri, 'turn-simple-attachment', - 'Reply with only the value from the attachment.', + 'Reply with only the value provided directly in the attachment. Do not inspect the workspace or use tools.', attachments, 1, ); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts index 51f81e00efa304..4ea333120b02dd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts @@ -65,8 +65,7 @@ export function defineFileOperationsTests(context: IAgentHostE2ETestContext): vo } as const; if (config.streamingFileCreateToolName && config.provider !== 'codex') { - const fileToolDenialEnabled = !(context.isLinux && config.fileToolDenialReplayUnstableOnLinux); - (fileToolDenialEnabled ? test : test.skip)('declining a file creation tool prevents the mutation and completes the turn', async function () { + test('declining a file creation tool prevents the mutation and completes the turn', async function () { this.timeout(180_000); const workspace = mkdtempSync(join(tmpdir(), 'ahp-decline-create-')); tempDirs.push(workspace); @@ -77,7 +76,7 @@ export function defineFileOperationsTests(context: IAgentHostE2ETestContext): vo context.client, sessionUri, turnId, - 'Create denied.txt containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied".', + 'Create denied.lock containing exactly DENIED_CONTENT using your file creation tool. If permission is denied, reply exactly "denied".', 1, ); const started = await context.client.waitForNotification(n => @@ -96,6 +95,7 @@ export function defineFileOperationsTests(context: IAgentHostE2ETestContext): vo 90_000, ); const ready = getActionEnvelope(readyNotification).action as ChatToolCallReadyAction; + const fileCreatedBeforeDenial = existsSync(join(workspace, 'denied.lock')); context.client.dispatch({ channel: chatUri, clientSeq: 2, @@ -147,10 +147,12 @@ export function defineFileOperationsTests(context: IAgentHostE2ETestContext): vo ).map(n => (getActionEnvelope(n).action as ChatToolCallCompleteAction).result.error?.message) .filter((message): message is string => typeof message === 'string' && message.includes('permission host returned malformed payload')); assert.deepStrictEqual({ - fileCreated: existsSync(join(workspace, 'denied.txt')), + fileCreatedBeforeDenial, + fileCreated: existsSync(join(workspace, 'denied.lock')), responseEndsWithDenied: getMarkdownResponseText(context.client).trim().endsWith('denied'), malformedPermissionErrors, }, { + fileCreatedBeforeDenial: false, fileCreated: false, responseEndsWithDenied: true, malformedPermissionErrors: [], @@ -499,7 +501,7 @@ Use your file creation tool; do not run a shell command. Then reply exactly "don context.client.beginAhpSnapshotRound(); const prompt = fileOperationPrompt( context, - `Replace the complete contents of edit.txt with AFTER_VALUE.${PREFER_FILE_TOOLS}`, + `Replace the complete contents of edit.txt with exactly AFTER_VALUE and no trailing newline.${PREFER_FILE_TOOLS}`, `node -e "require('fs').writeFileSync('edit.txt','AFTER_VALUE')"`, 'Then reply exactly "done".', // Copilot searches with a POSIX-only shell command despite the file-tool instruction. @@ -588,7 +590,7 @@ Use your file creation tool; do not run a shell command. Then reply exactly "don // (`mv`, and once `xxd`/`rm`). `node` is guaranteed present since the // suite runs under it, and this quoting works in both cmd and POSIX shells. const renameCommand = `node -e "require('fs').renameSync('before.txt','after.txt')"`; - await driveTurnToCompletion(context.client, sessionUri, 'turn-rename', `Run exactly this shell command, with no modifications: \`${renameCommand}\`. Then reply with exactly "renamed".`, 1); + await driveTurnToCompletion(context.client, sessionUri, 'turn-rename', `Run exactly this shell command, with no modifications: \`${renameCommand}\`. Do not run any other command or tool. Then reply with exactly "renamed".`, 1); assert.strictEqual(existsSync(join(workspace, 'before.txt')), false); assert.strictEqual(readFileSync(join(workspace, 'after.txt'), 'utf8'), 'RENAME_VALUE'); await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); @@ -605,7 +607,7 @@ Use your file creation tool; do not run a shell command. Then reply exactly "don // Pinned rather than steered: there is no file tool for a delete, so the // provider reaches for `rm`, which cmd does not have. const deleteCommand = `node -e "require('fs').unlinkSync('delete-me.txt')"`; - await driveTurnToCompletion(context.client, sessionUri, 'turn-delete', `Run exactly this shell command, with no modifications: \`${deleteCommand}\`. Then reply with exactly "deleted".`, 1); + await driveTurnToCompletion(context.client, sessionUri, 'turn-delete', `Run exactly this shell command, with no modifications: \`${deleteCommand}\`. Do not run any other command or tool. Then reply with exactly "deleted".`, 1); assert.strictEqual(existsSync(join(workspace, 'delete-me.txt')), false); await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index 9a24c4665b6be5..a0cfaa5a4e7012 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -821,14 +821,14 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { await context.client.call('subscribe', { channel: second }); const firstPrompt = peerFileOperationPrompt( - `Create the file at ${firstFile} containing exactly FIRST_PEER.`, + `Create the file at ${firstFile} containing exactly FIRST_PEER with no trailing newline.${PREFER_FILE_TOOLS} Do not verify it with another tool. Then reply exactly "created".`, `node -e "require('fs').writeFileSync('first-peer.txt','FIRST_PEER')"`, - 'Then reply exactly "created".', + 'Do not run any other command or tool. Then reply exactly "created".', ); const secondPrompt = peerFileOperationPrompt( - `Create the file at ${secondFile} containing exactly SECOND_PEER.`, + `Create the file at ${secondFile} containing exactly SECOND_PEER with no trailing newline.${PREFER_FILE_TOOLS} Do not verify it with another tool. Then reply exactly "created".`, `node -e "require('fs').writeFileSync('second-peer.txt','SECOND_PEER')"`, - 'Then reply exactly "created".', + 'Do not run any other command or tool. Then reply exactly "created".', ); await driveTurn(first, 'first-write', firstPrompt, 1); await driveTurn(second, 'second-write', secondPrompt, 10); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts index c628fb4eef3a2b..98ae91a8690bd5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts @@ -76,7 +76,7 @@ export function defineSubagentTests(context: IAgentHostE2ETestContext): void { dispatchTurn(context.client, sessionUri, 'turn-sa', `Use the \`${config.subagentToolNames[0]}\` tool to spawn a subagent to list the files in the current working directory. ` + - 'The subagent should call a single read-only tool (e.g. `view` or shell with `ls`) to enumerate the directory. ' + + 'The subagent should call a single read-only file-listing tool (e.g. `Glob` or `view`) to enumerate the directory; do not run a shell command. ' + 'Do not enumerate the directory yourself — delegate to the subagent.', 1); @@ -103,8 +103,9 @@ export function defineSubagentTests(context: IAgentHostE2ETestContext): void { const subagentSnap = await context.client.call('subscribe', { channel: subagentChatUri }); const subagentState = subagentSnap.snapshot?.state as ChatState | undefined; const subagentFirstTurn = subagentState?.turns?.[0] ?? subagentState?.activeTurn; - assert.ok( - subagentFirstTurn?.message.text && subagentFirstTurn.message.text.includes('List the files'), + assert.match( + subagentFirstTurn?.message.text ?? '', + /\blist (?:the |its )?files\b/i, `subagent chat's opening request should render the task prompt, got: ${JSON.stringify(subagentFirstTurn?.message.text)}`, ); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index fb270ddc9be920..480a4f9f27ba65 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -146,7 +146,7 @@ suite('mapSessionEvents — history replay', () => { }); }); - test('restores an unfinished request as an error on the same turn', async () => { + test('restores an unfinished request as a resumable error on the same turn', async () => { const events: ISessionEvent[] = [ { type: 'user.message', id: 'interrupted-turn', data: { interactionId: 'm1', content: 'Keep working' } }, { type: 'assistant.turn_start', data: { turnId: 'sdk-turn' } }, @@ -171,6 +171,7 @@ suite('mapSessionEvents — history replay', () => { errorPart: { kind: ResponsePartKind.Error, error: interruptedTurnError, + resumable: true, }, }); }); @@ -232,11 +233,11 @@ suite('mapSessionEvents — history replay', () => { { kind: ResponsePartKind.Markdown, content: 'Second segment' }, { kind: ResponsePartKind.Error }, ], - resumable: undefined, + resumable: true, }); }); - test('keeps an error terminal when a later notification starts another turn', async () => { + test('keeps a resumable error terminal when a later notification starts another turn', async () => { const events: ISessionEvent[] = [ { type: 'user.message', id: 'failed-turn', timestamp: '2026-08-11T00:00:00.000Z', data: { interactionId: 'm1', content: 'Start the background agent' } }, { type: 'assistant.turn_start', timestamp: '2026-08-11T00:00:00.100Z', data: { turnId: 'sdk-turn-1' } }, @@ -273,10 +274,10 @@ suite('mapSessionEvents — history replay', () => { state: TurnState.Complete, parts: [{ kind: ResponsePartKind.Markdown, content: 'The background agent finished.' }], }]); - assert.strictEqual(getErrorResponsePart(turns[0])?.resumable, undefined); + assert.strictEqual(getErrorResponsePart(turns[0])?.resumable, true); }); - test('keeps an error as the final part when a late tool completion arrives', async () => { + test('keeps a resumable error as the final part when a late tool completion arrives', async () => { const events: ISessionEvent[] = [ { type: 'user.message', id: 'failed-turn', data: { interactionId: 'm1', content: 'Run a command' } }, { type: 'assistant.turn_start', data: { turnId: 'sdk-turn-1' } }, @@ -294,7 +295,7 @@ suite('mapSessionEvents — history replay', () => { }, { state: TurnState.Error, parts: [{ kind: ResponsePartKind.Error }], - resumable: undefined, + resumable: true, }); }); diff --git a/src/vs/platform/agentHost/test/node/userNameScrub.test.ts b/src/vs/platform/agentHost/test/node/userNameScrub.test.ts index ccff2546bc3b32..118b0a4d90fff3 100644 --- a/src/vs/platform/agentHost/test/node/userNameScrub.test.ts +++ b/src/vs/platform/agentHost/test/node/userNameScrub.test.ts @@ -58,6 +58,22 @@ suite('userNameScrub', () => { ].join('\n')); }); + test('scrubs flattened source paths in Claude project directories', () => { + assert.deepStrictEqual([ + '${homedir}\\.claude\\projects\\c--Users-runner-AppData-Local-Temp-work\\memory\\MEMORY.md', + '{"file_path":"${homedir}\\\\.claude\\\\projects\\\\c--Users-runner-AppData-Local-Temp-work\\\\memory\\\\MEMORY.md"}', + '${homedir}/.claude/projects/-home-runner-work-repo/memory/MEMORY.md', + '${homedir}/.claude/projects/-Users-runner-work-repo/memory/MEMORY.md', + '${homedir}/.claude/projects/-Users-runner/memory/MEMORY.md', + ].map(text => scrubUserName(text, 'runner')), [ + '${homedir}\\.claude\\projects\\c--Users-${user}-AppData-Local-Temp-work\\memory\\MEMORY.md', + '{"file_path":"${homedir}\\\\.claude\\\\projects\\\\c--Users-${user}-AppData-Local-Temp-work\\\\memory\\\\MEMORY.md"}', + '${homedir}/.claude/projects/-home-${user}-work-repo/memory/MEMORY.md', + '${homedir}/.claude/projects/-Users-${user}-work-repo/memory/MEMORY.md', + '${homedir}/.claude/projects/-Users-${user}/memory/MEMORY.md', + ]); + }); + test('leaves the account name alone when it is an ordinary word', () => { // The regression this exists to prevent: a plain substring replace turns // every one of these into `${user}`. @@ -70,6 +86,7 @@ suite('userNameScrub', () => { 'a runner-up value', '/tmp/runner.js', 'C:\\Users\\runner-admin\\AppData', + 'Users-runner-example outside a Claude project path', ]; assert.deepStrictEqual(prose.map(text => scrubUserName(text, 'runner')), prose); }); diff --git a/src/vs/platform/assignment/common/assignment.ts b/src/vs/platform/assignment/common/assignment.ts index 07e6fbe3649f30..4423bb56958138 100644 --- a/src/vs/platform/assignment/common/assignment.ts +++ b/src/vs/platform/assignment/common/assignment.ts @@ -258,3 +258,8 @@ export function getInternalOrg(organisations: readonly string[] | undefined): 'v const isMicrosoftInternal = organisations?.includes('microsoft') || organisations?.includes('ms-copilot') || organisations?.includes('MicrosoftCopilot'); return isVSCodeInternal ? 'vscode' : isGitHubInternal ? 'github' : isMicrosoftInternal ? 'microsoft' : undefined; } + +/** Whether the account is staff or belongs to an internal org; the machine half is `verifyMicrosoftInternalDomain`. */ +export function isInternalAccount(isStaff: boolean | undefined, organisations: readonly string[] | undefined): boolean { + return isStaff === true || getInternalOrg(organisations) !== undefined; +} diff --git a/src/vs/platform/assignment/test/common/assignment.test.ts b/src/vs/platform/assignment/test/common/assignment.test.ts new file mode 100644 index 00000000000000..7406501cf9d6da --- /dev/null +++ b/src/vs/platform/assignment/test/common/assignment.test.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { isInternalAccount } from '../../common/assignment.js'; + +suite('Assignment', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('an account is internal when it is staff or belongs to an internal organisation', () => { + assert.deepStrictEqual({ + unknown: isInternalAccount(undefined, undefined), + external: isInternalAccount(false, ['contoso']), + staffOnly: isInternalAccount(true, ['contoso']), + orgOnly: isInternalAccount(false, ['microsoft']), + vscodeOrg: isInternalAccount(undefined, ['Visual-Studio-Code']), + githubOrg: isInternalAccount(undefined, ['github']), + copilotOrg: isInternalAccount(undefined, ['ms-copilot']), + }, { + unknown: false, + external: false, + staffOnly: true, + orgOnly: true, + vscodeOrg: true, + githubOrg: true, + copilotOrg: true, + }); + }); +}); diff --git a/src/vs/platform/hover/browser/hoverWidget.ts b/src/vs/platform/hover/browser/hoverWidget.ts index 33a8a3dfaf07fa..eac557f520225b 100644 --- a/src/vs/platform/hover/browser/hoverWidget.ts +++ b/src/vs/platform/hover/browser/hoverWidget.ts @@ -616,7 +616,12 @@ export class HoverWidget extends Widget implements IHoverWidget { } } + this._hover.containerDomNode.style.maxHeight = ''; + const heightOutsideContents = this._hover.containerDomNode.offsetHeight - this._hover.contentsDomNode.offsetHeight; + const contentsMaxHeight = Math.max(0, maxHeight - heightOutsideContents); + this._hover.containerDomNode.style.maxHeight = `${maxHeight}px`; + this._hover.contentsDomNode.style.maxHeight = `${contentsMaxHeight}px`; if (this._hover.contentsDomNode.clientHeight < this._hover.contentsDomNode.scrollHeight) { // Add padding for a vertical scrollbar const extraRightPadding = `${this._hover.scrollbar.options.verticalScrollbarSize}px`; diff --git a/src/vs/platform/hover/test/browser/hoverService.test.ts b/src/vs/platform/hover/test/browser/hoverService.test.ts index 817768321c0d66..7764bb3759eda9 100644 --- a/src/vs/platform/hover/test/browser/hoverService.test.ts +++ b/src/vs/platform/hover/test/browser/hoverService.test.ts @@ -234,6 +234,66 @@ suite('HoverService', () => { hover.dispose(); }); + test('should constrain scrollable content without clipping hover actions', () => { + const hover = showHover('Scrollable hover', undefined, { + appearance: { maxHeightRatio: 0.25 }, + actions: [{ + commandId: 'test.action', + label: 'Test Action', + run: () => { } + }] + }); + const hoverWidget = asHoverWidget(hover); + const contentsDomNode = hoverWidget.domNode.querySelector('.monaco-hover-content'); + assert.ok(contentsDomNode); + const statusBarDomNode = hoverWidget.domNode.querySelector('.hover-row.status-bar'); + assert.ok(statusBarDomNode); + const overflowingContent = document.createElement('div'); + overflowingContent.style.height = `${mainWindow.innerHeight}px`; + contentsDomNode.appendChild(overflowingContent); + + hoverWidget.layout(); + const expectedMaxHeight = `${mainWindow.innerHeight * 0.25}px`; + const hoverBounds = hoverWidget.domNode.getBoundingClientRect(); + const statusBarBounds = statusBarDomNode.getBoundingClientRect(); + const heightOutsideContents = hoverWidget.domNode.offsetHeight - contentsDomNode.offsetHeight; + + assert.deepStrictEqual({ + hoverMaxHeight: hoverWidget.domNode.style.maxHeight, + contentsMaxHeight: contentsDomNode.style.maxHeight, + contentOverflows: contentsDomNode.scrollHeight > contentsDomNode.clientHeight, + statusBarIsVisible: statusBarBounds.top >= hoverBounds.top && statusBarBounds.bottom <= hoverBounds.bottom + }, { + hoverMaxHeight: expectedMaxHeight, + contentsMaxHeight: `${Math.max(0, mainWindow.innerHeight * 0.25 - heightOutsideContents)}px`, + contentOverflows: true, + statusBarIsVisible: true + }); + hover.dispose(); + }); + + test('should not make short hover content scrollable', () => { + const hover = showHover('Short hover', undefined, { + appearance: { maxHeightRatio: 0.25 }, + actions: [{ + commandId: 'test.action', + label: 'Test Action', + run: () => { } + }] + }); + const contentsDomNode = asHoverWidget(hover).domNode.querySelector('.monaco-hover-content'); + assert.ok(contentsDomNode); + + assert.deepStrictEqual({ + contentOverflows: contentsDomNode.scrollHeight > contentsDomNode.clientHeight, + scrollbarPadding: contentsDomNode.style.paddingRight + }, { + contentOverflows: false, + scrollbarPadding: '' + }); + hover.dispose(); + }); + test('should call onDidShow callback when hover is shown', () => { const target = createTarget(); let didShowCalled = false; diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 62baf1640724e8..1c62eb7cfef77d 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -41,6 +41,26 @@ export interface IToastResult { readonly actionIndex?: number; } +/** + * A count badge to render on the application icon in the dock (macOS), the + * launcher (Linux) or over the taskbar icon of a window (Windows). + */ +export interface IApplicationBadge { + + /** The number to show. A count of `0` clears the badge. */ + readonly count: number; + + /** Accessible description of the badge, used for the Windows taskbar overlay. */ + readonly description: string; + + /** + * PNG image data as a `data:` URL for the Windows taskbar overlay icon, + * which has no built-in count rendering. Ignored on other platforms, + * where the OS renders {@link count} itself. + */ + readonly iconDataURL?: string; +} + /** * A ZIP entry whose contents are inline or streamed from a local file. */ @@ -307,6 +327,11 @@ export interface ICommonNativeHostService { showItemInFolder(path: string): Promise; setRepresentedFilename(path: string, options?: INativeHostOptions): Promise; setDocumentEdited(edited: boolean, options?: INativeHostOptions): Promise; + /** + * Renders a count badge on the application icon. Passing `undefined` or a + * badge with a count of `0` clears it again. + */ + setApplicationBadge(badge: IApplicationBadge | undefined, options?: INativeHostOptions): Promise; openExternal(url: string, defaultApplication?: string): Promise; moveItemToTrash(fullPath: string): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 9af28f793ba5f4..b1fb89e25a3acb 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -27,7 +27,7 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; -import { FocusMode, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, INativeZipOptions, IOpenAgentsWindowOptions, IOSProperties, IOSProxy, IOSProxyConfig, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; +import { FocusMode, IApplicationBadge, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, INativeZipOptions, IOpenAgentsWindowOptions, IOSProperties, IOSProxy, IOSProxyConfig, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; import { IGlobalKeybindingsMainService } from '../../globalKeybindings/electron-main/globalKeybindingsMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; @@ -654,6 +654,11 @@ export class NativeHostMainService extends Disposable implements INativeHostMain window?.setDocumentEdited(edited); } + async setApplicationBadge(windowId: number | undefined, badge: IApplicationBadge | undefined, options?: INativeHostOptions): Promise { + const window = this.windowById(options?.targetWindowId, windowId); + window?.setApplicationBadge(badge); + } + async openExternal(windowId: number | undefined, url: string, defaultApplication?: string): Promise { this.environmentMainService.unsetSnapExportedVariables(); try { diff --git a/src/vs/platform/telemetry/common/1dsAppender.ts b/src/vs/platform/telemetry/common/1dsAppender.ts index 0daaabd02b529d..9298c26e970f04 100644 --- a/src/vs/platform/telemetry/common/1dsAppender.ts +++ b/src/vs/platform/telemetry/common/1dsAppender.ts @@ -22,6 +22,23 @@ export interface IAppInsightsCore { const endpointUrl = 'https://mobile.events.data.microsoft.com/OneCollector/1.0'; const endpointHealthUrl = 'https://mobile.events.data.microsoft.com/ping'; +/** Fills in the envelope fields every event needs, including the internal-only routing flag. */ +export function applyEnvelopeDefaults(envelope: ITelemetryItem, isInternalMachine: boolean | undefined): void { + // Opt the user out of 1DS data sharing + envelope['ext'] = envelope['ext'] ?? {}; + envelope['ext']['web'] = envelope['ext']['web'] ?? {}; + envelope['ext']['web']['consentDetails'] = '{"GPC_DataSharingOptIn":false}'; + + // An internal account is only known after sign-in, so check each event and not just the machine. + // `validateTelemetryData` turns the boolean common property into the measurement `1`. + const baseData = envelope['baseData'] as { measurements?: Record } | undefined; + if (isInternalMachine || baseData?.measurements?.['common.msftInternal'] === 1) { + envelope['ext']['utc'] = envelope['ext']['utc'] ?? {}; + // Sets it to be internal only based on Windows UTC flagging + envelope['ext']['utc']['flags'] = 0x0000811ECD; + } +} + async function getClient(instrumentationKey: string, addInternalFlag?: boolean, xhrOverride?: IXHROverride): Promise { // eslint-disable-next-line local/code-amd-node-module const oneDs = isWeb ? await importAMDNodeModule('@microsoft/1ds-core-js', 'bundle/ms.core.min.js') : await import('@microsoft/1ds-core-js'); @@ -57,18 +74,7 @@ async function getClient(instrumentationKey: string, addInternalFlag?: boolean, appInsightsCore.initialize(coreConfig, []); - appInsightsCore.addTelemetryInitializer((envelope) => { - // Opt the user out of 1DS data sharing - envelope['ext'] = envelope['ext'] ?? {}; - envelope['ext']['web'] = envelope['ext']['web'] ?? {}; - envelope['ext']['web']['consentDetails'] = '{"GPC_DataSharingOptIn":false}'; - - if (addInternalFlag) { - envelope['ext']['utc'] = envelope['ext']['utc'] ?? {}; - // Sets it to be internal only based on Windows UTC flagging - envelope['ext']['utc']['flags'] = 0x0000811ECD; - } - }); + appInsightsCore.addTelemetryInitializer(envelope => applyEnvelopeDefaults(envelope, addInternalFlag)); return appInsightsCore; } diff --git a/src/vs/platform/telemetry/common/telemetryService.ts b/src/vs/platform/telemetry/common/telemetryService.ts index 62a840dd92b592..f3ffc70c898b89 100644 --- a/src/vs/platform/telemetry/common/telemetryService.ts +++ b/src/vs/platform/telemetry/common/telemetryService.ts @@ -61,7 +61,6 @@ export class TelemetryService implements ITelemetryService { readonly sqmId: string; readonly devDeviceId: string; readonly firstSessionDate: string; - readonly msftInternal: boolean | undefined; private _appenders: ITelemetryAppender[]; private _commonProperties: ICommonProperties; @@ -98,7 +97,6 @@ export class TelemetryService implements ITelemetryService { this.sqmId = this._commonProperties['common.sqmId'] as string; this.devDeviceId = this._commonProperties['common.devDeviceId'] as string; this.firstSessionDate = this._commonProperties['common.firstSessionDate'] as string; - this.msftInternal = this._commonProperties['common.msftInternal'] as boolean | undefined; this._piiPaths = config.piiPaths || []; this._telemetryLevel = TelemetryLevel.USAGE; @@ -189,6 +187,11 @@ export class TelemetryService implements ITelemetryService { this._telemetryLevel = level; } + // Read live: an internal account can sign in after this service was created. + get msftInternal(): boolean | undefined { + return this._commonProperties['common.msftInternal'] as boolean | undefined; + } + get sendErrorTelemetry(): boolean { return this._sendErrorTelemetry; } diff --git a/src/vs/platform/telemetry/test/browser/1dsAppender.test.ts b/src/vs/platform/telemetry/test/browser/1dsAppender.test.ts index 29acc962b7f392..0fcadc9db0bd5e 100644 --- a/src/vs/platform/telemetry/test/browser/1dsAppender.test.ts +++ b/src/vs/platform/telemetry/test/browser/1dsAppender.test.ts @@ -6,7 +6,7 @@ import type { ITelemetryItem, ITelemetryUnloadState } from '@microsoft/1ds-core- import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { OneDataSystemWebAppender } from '../../browser/1dsAppender.js'; -import { IAppInsightsCore } from '../../common/1dsAppender.js'; +import { applyEnvelopeDefaults, IAppInsightsCore } from '../../common/1dsAppender.js'; class AppInsightsCoreMock implements IAppInsightsCore { pluginVersionString: string = 'Test Runner'; @@ -130,4 +130,25 @@ suite('AIAdapter', () => { assert.strictEqual(appInsightsMock.events[0].measurements!['nestedObj.testMeasurement'], 1); }); + test('routes an event internally when the machine or the account is internal', () => { + const flagsFor = (isInternalMachine: boolean, data?: unknown): number | undefined => { + adapter.log('testEvent', data); + const envelope = { name: 'testEvent', baseData: appInsightsMock.events.pop() } as ITelemetryItem; + applyEnvelopeDefaults(envelope, isInternalMachine); + return envelope.ext?.utc?.flags; + }; + + assert.deepStrictEqual({ + external: flagsFor(false), + internalMachine: flagsFor(true), + internalAccount: flagsFor(false, { 'common.msftInternal': true }), + externalAccount: flagsFor(false, { 'common.msftInternal': false }), + }, { + external: undefined, + internalMachine: 0x0000811ECD, + internalAccount: 0x0000811ECD, + externalAccount: undefined, + }); + }); + }); diff --git a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts index f3a563919c3957..64cca827eb5c02 100644 --- a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts +++ b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts @@ -243,6 +243,19 @@ suite('TelemetryService', () => { service.dispose(); }); + test('msftInternal reflects common properties set after construction', function () { + const service = new TelemetryService({ + appenders: [NullAppender], + }, new TestConfigurationService(), TestProductService); + + const beforeSet = service.msftInternal; + service.setCommonProperty('common.msftInternal', true); + + assert.deepStrictEqual({ beforeSet, afterSet: service.msftInternal }, { beforeSet: undefined, afterSet: true }); + + service.dispose(); + }); + test('telemetry on by default', function () { const testAppender = new TestTelemetryAppender(); const service = new TelemetryService({ appenders: [testAppender] }, new TestConfigurationService(), TestProductService); diff --git a/src/vs/platform/window/electron-main/window.ts b/src/vs/platform/window/electron-main/window.ts index 748e90019de9cb..fe369ee4da2cc4 100644 --- a/src/vs/platform/window/electron-main/window.ts +++ b/src/vs/platform/window/electron-main/window.ts @@ -9,7 +9,7 @@ import { Event } from '../../../base/common/event.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { ISerializableCommandAction } from '../../action/common/action.js'; import { NativeParsedArgs } from '../../environment/common/argv.js'; -import { FocusMode } from '../../native/common/native.js'; +import { FocusMode, IApplicationBadge } from '../../native/common/native.js'; import { IUserDataProfile } from '../../userDataProfile/common/userDataProfile.js'; import { ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { DEFAULT_AUX_WINDOW_SIZE, DEFAULT_EMPTY_WINDOW_SIZE, DEFAULT_WORKSPACE_WINDOW_SIZE, INativeWindowConfiguration } from '../common/window.js'; @@ -35,6 +35,8 @@ export interface IBaseWindow extends IDisposable { setDocumentEdited(edited: boolean): void; isDocumentEdited(): boolean; + setApplicationBadge(badge: IApplicationBadge | undefined): void; + readonly isFullScreen: boolean; toggleFullScreen(): void; diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 4a51cc55ba09b9..94aee9da97656e 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -45,7 +45,7 @@ import { ILoggerMainService } from '../../log/electron-main/loggerService.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { errorHandler } from '../../../base/common/errors.js'; -import { FocusMode } from '../../native/common/native.js'; +import { FocusMode, IApplicationBadge } from '../../native/common/native.js'; import { Color } from '../../../base/common/color.js'; export interface IWindowCreationOptions { @@ -86,27 +86,58 @@ const enum ReadyState { READY } +/** + * Owns the single, application-wide badge (`app.setBadgeCount`) so that the + * transient attention dot from {@link FocusMode.Notify} and the counts pushed + * by individual windows via {@link IBaseWindow.setApplicationBadge} cannot + * overwrite each other. A count always wins over the dot because it carries + * more information, and counts from multiple windows add up. + */ class DockBadgeManager { static readonly INSTANCE = new DockBadgeManager(); - private readonly windows = new Set(); + private readonly attention = new Set(); + private readonly counts = new Map(); acquireBadge(window: IBaseWindow): IDisposable { - this.windows.add(window.id); + this.attention.add(window.id); - electron.app.setBadgeCount(isLinux ? 1 /* only numbers supported */ : undefined /* generic dot */); + this.update(); return { dispose: () => { - this.windows.delete(window.id); + this.attention.delete(window.id); - if (this.windows.size === 0) { - electron.app.setBadgeCount(0); - } + this.update(); } }; } + + setCount(window: IBaseWindow, count: number): void { + if (count > 0) { + this.counts.set(window.id, count); + } else if (!this.counts.delete(window.id)) { + return; // window had no count to begin with + } + + this.update(); + } + + private update(): void { + let total = 0; + for (const count of this.counts.values()) { + total += count; + } + + if (total > 0) { + electron.app.setBadgeCount(total); + } else if (this.attention.size > 0) { + electron.app.setBadgeCount(isLinux ? 1 /* only numbers supported */ : undefined /* generic dot */); + } else { + electron.app.setBadgeCount(0); + } + } } export abstract class BaseWindow extends Disposable implements IBaseWindow { @@ -261,6 +292,10 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { protected readonly logService: ILogService ) { super(); + + // Release this window's share of the application wide badge, so that a + // closed or crashed window cannot leave a phantom count behind. + this._register(toDisposable(() => DockBadgeManager.INSTANCE.setCount(this, 0))); } protected applyState(state: IWindowState, hasMultipleDisplays = electron.screen.getAllDisplays().length > 0): void { @@ -342,6 +377,27 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { return !!this.documentEdited; } + setApplicationBadge(badge: IApplicationBadge | undefined): void { + const count = badge && badge.count > 0 ? badge.count : 0; + + // Windows has no application wide badge, instead an overlay is + // rendered over the taskbar icon of this window. The image is drawn + // by the renderer because the main process cannot draw. + if (isWindows) { + if (count === 0 || !badge?.iconDataURL) { + this.win?.setOverlayIcon(null, ''); + } else { + this.win?.setOverlayIcon(electron.nativeImage.createFromDataURL(badge.iconDataURL), badge.description); + } + } + + // macOS (dock) and Linux (Unity launcher) render the count themselves, + // on a badge shared by the whole application. + else { + DockBadgeManager.INSTANCE.setCount(this, count); + } + } + focus(options?: { mode: FocusMode }): void { switch (options?.mode ?? FocusMode.Transfer) { case FocusMode.Transfer: diff --git a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts index eb3bad9ad55aef..85214ff775b894 100644 --- a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts +++ b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts @@ -18,7 +18,7 @@ import { toWorkspaceFolders } from '../../../workspaces/common/workspaces.js'; import { IWorkspaceIdentifier } from '../../../workspace/common/workspace.js'; import { FileAccess } from '../../../../base/common/network.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FocusMode } from '../../../native/common/native.js'; +import { FocusMode, IApplicationBadge } from '../../../native/common/native.js'; suite('WindowsFinder', () => { @@ -71,6 +71,7 @@ suite('WindowsFinder', () => { getRepresentedFilename(): string | undefined { throw new Error('Method not implemented.'); } setDocumentEdited(edited: boolean): void { throw new Error('Method not implemented.'); } isDocumentEdited(): boolean { throw new Error('Method not implemented.'); } + setApplicationBadge(badge: IApplicationBadge | undefined): void { throw new Error('Method not implemented.'); } updateTouchBar(items: UriDto[][]): void { throw new Error('Method not implemented.'); } serializeWindowState(): IWindowState { throw new Error('Method not implemented'); } updateWindowControls(options: { height?: number | undefined; backgroundColor?: string | undefined; foregroundColor?: string | undefined; dimmed?: boolean | undefined }): void { throw new Error('Method not implemented.'); } diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index ddb2b834693cca..c4f1dca5e9a577 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -51,7 +51,7 @@ Core workbench registrations may expose Local, Copilot CLI, and Claude harnesses ### `ICustomizationMigrationService` -This shared workbench service computes the customization files that need migration for an explicit chat session. Results include file URIs for consumers and migratable-configuration metadata for migration flows that need source type and storage. The service also produces a localized, harness-specific hint summarizing available migrations for UI consumers. +This shared workbench service computes customization migrations for an explicit chat session. File migrations include source URIs and migratable-configuration metadata for flows that need source type and storage; MCP migrations report known servers' binary harness compatibility together with discovery and policy-coverage state. The service also produces a localized, harness-specific hint summarizing available file migrations for UI consumers. ### `IHarnessDescriptor` diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index a643f930bb9f38..8f74fab5063871 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -14,6 +14,7 @@ import { Menus } from './menus.js'; import { ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; +import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../platform/accessibility/common/accessibility.js'; import { TogglePanelAction } from '../../workbench/browser/parts/panel/panelActions.js'; import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, IsWindowAlwaysOnTopContext, PanelVisibleContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; @@ -73,6 +74,17 @@ class ToggleSidebarVisibilityAction extends Action2 { registerAction2(ToggleSidebarVisibilityAction); +MenuRegistry.appendMenuItem(Menus.TitleBarAccessibility, { + command: { + id: 'editor.action.toggleScreenReaderAccessibilityMode', + title: localize('screenReaderOptimizedBadge', "Screen Reader Optimized"), + tooltip: localize('disableScreenReaderOptimizedMode', "Disable Screen Reader Optimized Mode"), + }, + group: 'navigation', + order: 0, + when: ContextKeyExpr.and(CONTEXT_ACCESSIBILITY_MODE_ENABLED, IsPhoneLayoutContext.negate()) +}); + const titleBarPanelWhen = ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()); MenuRegistry.appendMenuItem(Menus.TitleBarSessionMenu, { diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index d5ab97a8281df1..2a064fe7ffc461 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -18,6 +18,7 @@ export const Menus = { TitleBarCenterRight: new MenuId('SessionsTitleBarCenterRight'), TitleBarSessionTitle: new MenuId('SessionsTitleBarSessionTitle'), TitleBarSessionMenu: new MenuId('SessionsTitleBarSessionMenu'), + TitleBarAccessibility: new MenuId('SessionsTitleBarAccessibility'), TitleBarUpdate: new MenuId('SessionsTitleBarUpdate'), BlockedSessionsHeader: new MenuId('SessionsBlockedSessionsHeader'), BlockedSessionsItem: new MenuId('SessionsBlockedSessionsItem'), @@ -41,8 +42,12 @@ export const Menus = { /** Header actions of the Automations custom view. */ CustomViewAutomations: new MenuId('SessionsCustomViewAutomations'), + /** Context menu actions for an Automation definition card. */ + AutomationCardContext: new MenuId('SessionsAutomationCardContext'), /** Unified toolbar for all session-backed Automation history rows. Actions are conditionally shown via sessionItem.status context key. */ AutomationsHistoryItem: new MenuId('SessionsAutomationsHistoryItem'), + /** Context menu for session-backed Automation history rows. */ + AutomationsHistoryItemContext: new MenuId('SessionsAutomationsHistoryItemContext'), NewSessionConfig: new MenuId('NewSessions.SessionConfigMenu'), NewSessionControl: new MenuId('NewSessions.SessionControlMenu'), diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 0d1530378fc7a3..29f805e61daa86 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -97,6 +97,8 @@ flex-shrink: 0; -webkit-app-region: no-drag; height: 100%; + position: relative; + z-index: 2500; } .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container:not(.has-no-actions) { @@ -108,6 +110,10 @@ margin-right: var(--vscode-spacing-size40); } +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-screen-reader-container:not(.has-no-actions) { + margin-right: var(--vscode-spacing-size40); +} + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-center > .titlebar-actions-container.overflowing, .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container.overflowing { display: none; diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index 8b4855c85c9a33..5e3d803ea7ba6e 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -25,6 +25,7 @@ import { Parts, IWorkbenchLayoutService } from '../../../workbench/services/layo import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { IHostService } from '../../../workbench/services/host/browser/host.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; +import { MenuWorkbenchButtonBar } from '../../../platform/actions/browser/buttonbar.js'; import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; import { safeIntl } from '../../../base/common/date.js'; @@ -268,6 +269,20 @@ export class TitlebarPart extends Part implements ITitlebarPart { toolbarOptions: { primaryGroup: () => true }, })); + const screenReaderToolBarElement = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-screen-reader-container')); + const screenReaderButtonBar = this._register(this.instantiationService.createInstance(MenuWorkbenchButtonBar, screenReaderToolBarElement, Menus.TitleBarAccessibility, { + telemetrySource: 'titlePart.accessibility', + renderSecondaryActions: false, + buttonConfigProvider: () => ({ showIcon: false, showLabel: true, isSecondary: false }), + })); + const updateScreenReaderButtonBar = () => { + screenReaderToolBarElement.classList.toggle('has-no-actions', screenReaderButtonBar.buttons.length === 0); + this.updateTitleBarToolBarOverflow(); + }; + this._register(screenReaderButtonBar.onDidChange(updateScreenReaderButtonBar)); + updateScreenReaderButtonBar(); + + this.overflowManagedToolBarElements.push(screenReaderToolBarElement); this.registerOverflowManagedToolBar(centerActionsContainer, centerActionsToolBar); this.registerOverflowManagedToolBar(centerNavContainer, centerNavToolBar); this.registerOverflowManagedToolBar(rightToolbarContainer, rightToolBar); diff --git a/src/vs/sessions/common/sessionCommands.ts b/src/vs/sessions/common/sessionCommands.ts index 725a8fc4495594..fd1ef1b11b64a9 100644 --- a/src/vs/sessions/common/sessionCommands.ts +++ b/src/vs/sessions/common/sessionCommands.ts @@ -19,6 +19,12 @@ export const RENAME_SESSION_COMMAND_ID = 'sessionsViewPane.renameSession'; /** Archives one or more sessions. Registered in `sessionsViewActions.ts`. */ export const ARCHIVE_SESSION_COMMAND_ID = 'sessionsViewPane.archiveSession'; +/** Marks one or more sessions as read. Registered in `sessionsViewActions.ts`. */ +export const MARK_SESSION_READ_COMMAND_ID = 'sessionsViewPane.markRead'; + +/** Marks one or more sessions as unread. Registered in `sessionsViewActions.ts`. */ +export const MARK_SESSION_UNREAD_COMMAND_ID = 'sessionsViewPane.markUnread'; + /** Closes a chat tab. Registered in `sessionsActions.ts`. */ export const CLOSE_CHAT_COMMAND_ID = 'sessions.chatCompositeBar.closeChat'; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachmentEntry.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachmentEntry.ts index 9dbbc7a1085e0b..6982bc3af07950 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachmentEntry.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachmentEntry.ts @@ -47,6 +47,7 @@ export function createAgentFeedbackVariableEntry(sessionResource: URI, feedbackI codeSelection: f.codeSelection, diffHunks: f.diffHunks, sourcePRReviewCommentId: f.sourcePRReviewCommentId, + sourcePullRequest: f.sourcePullRequest, replies: f.replies?.map(reply => reply.text), })), value: buildAgentFeedbackValue(feedbackItems), @@ -69,7 +70,10 @@ export function buildAgentFeedbackValue(feedbackItems: readonly IAgentFeedback[] let part = `[${fileName}:${lineRef}]`; if (item.sourcePRReviewCommentId) { - part += `\n(PR review comment, thread ID: ${item.sourcePRReviewCommentId} — resolve this thread when addressed)`; + const pullRequest = item.sourcePullRequest + ? ` on ${item.sourcePullRequest.owner}/${item.sourcePullRequest.repo}#${item.sourcePullRequest.number}` + : ''; + part += `\n(PR review comment${pullRequest}, thread ID: ${item.sourcePRReviewCommentId} — resolve this thread when addressed)`; } if (item.codeSelection) { part += `\nSelection:\n\`\`\`\n${item.codeSelection}\n\`\`\``; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidget.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidget.ts index 6cb4fc5b098e24..579c1364839752 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidget.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidget.ts @@ -755,6 +755,8 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid createAgentFeedbackContext(this._editor, this._codeEditorService, comment.resourceUri, comment.range), comment.sourceId, AgentFeedbackKind.PRReview, + undefined, + comment.sourcePullRequest, ); this._agentFeedbackService.addReply(this._sessionResource, feedback.id, replyText); this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id)); @@ -826,6 +828,8 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid createAgentFeedbackContext(this._editor, this._codeEditorService, comment.resourceUri, comment.range), comment.sourceId, AgentFeedbackKind.PRReview, + undefined, + comment.sourcePullRequest, ); this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id)); this._codeReviewService.markPRReviewCommentConverted(this._sessionResource, comment.sourceId); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackItemsBackend.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackItemsBackend.ts index a36f8fe307a134..62e78ad8eae5b4 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackItemsBackend.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackItemsBackend.ts @@ -12,7 +12,7 @@ import { IAgentSubscription } from '../../../../platform/agentHost/common/state/ import { ActionType } from '../../../../platform/agentHost/common/state/protocol/common/actions.js'; import { Annotation, AnnotationEntry, AnnotationsState, StateComponents, StringOrMarkdown } from '../../../../platform/agentHost/common/state/sessionState.js'; import { TextRange } from '../../../../platform/agentHost/common/state/protocol/common/state.js'; -import { authorForFeedbackKind, feedbackAnnotationEntryMeta, FEEDBACK_ANNOTATION_META_KEY, readFeedbackAnnotationMeta, resolveFeedbackEntryAuthor, type AgentFeedbackKindValue, type AgentFeedbackStateValue, type IFeedbackAnnotationMeta } from '../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; +import { authorForFeedbackKind, feedbackAnnotationEntryMeta, FEEDBACK_ANNOTATION_META_KEY, readFeedbackAnnotationMeta, resolveFeedbackEntryAuthor, type AgentFeedbackKindValue, type AgentFeedbackStateValue, type IFeedbackAnnotationMeta, type IFeedbackPullRequest } from '../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; import { IAgentHostSessionsProvider, isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -173,6 +173,7 @@ interface IFeedbackMetaView { readonly codeSelection?: string; readonly diffHunks?: string; readonly sourcePRReviewCommentId?: string; + readonly sourcePullRequest?: IFeedbackPullRequest; readonly pendingAgentReveal?: boolean; } @@ -218,6 +219,7 @@ function readFeedbackMeta(annotation: Annotation): IFeedbackMetaView | undefined codeSelection: base.codeSelection, diffHunks: base.diffHunks, sourcePRReviewCommentId: base.sourcePRReviewCommentId, + sourcePullRequest: base.sourcePullRequest, pendingAgentReveal: base.pendingAgentReveal, }; } @@ -263,6 +265,7 @@ function feedbackToAnnotation(feedback: IAgentFeedback, connection: IAgentConnec codeSelection: feedback.codeSelection, diffHunks: feedback.diffHunks, sourcePRReviewCommentId: feedback.sourcePRReviewCommentId, + sourcePullRequest: feedback.sourcePullRequest, pendingAgentReveal: feedback.pendingAgentReveal, }; return { @@ -301,6 +304,7 @@ function annotationToFeedback(annotation: Annotation, sessionResource: URI, conn diffHunks: meta?.diffHunks, kind: meta?.kind ?? AgentFeedbackKind.UserReview, sourcePRReviewCommentId: meta?.sourcePRReviewCommentId, + sourcePullRequest: meta?.sourcePullRequest, replies: replies.length ? replies : undefined, state: annotation.resolved ? AgentFeedbackState.Resolved : (meta?.state ?? AgentFeedbackState.Accepted), pendingAgentReveal: meta?.pendingAgentReveal, diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackModel.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackModel.ts index a4decd9fa4411c..1c4a68def176c6 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackModel.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackModel.ts @@ -5,7 +5,7 @@ import type { URI } from '../../../../base/common/uri.js'; import type { IRange } from '../../../../editor/common/core/range.js'; -import type { AgentFeedbackAuthorValue } from '../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; +import type { AgentFeedbackAuthorValue, IFeedbackPullRequest } from '../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import type { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; /** @@ -83,6 +83,8 @@ export interface IAgentFeedback { readonly kind: AgentFeedbackKind; /** When this feedback was converted from a PR review comment, the original thread ID. */ readonly sourcePRReviewCommentId?: string; + /** Pull request that originated this PR review comment. */ + readonly sourcePullRequest?: IFeedbackPullRequest; /** * Additional comment messages that belong to the same thread as this feedback, * talking about the same code region. The first {@link text} is the initial diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackPRReviewSeeder.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackPRReviewSeeder.ts index e6e585836ee94f..91105b2668169f 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackPRReviewSeeder.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackPRReviewSeeder.ts @@ -125,6 +125,11 @@ export class AgentFeedbackPRReviewSeederContribution extends Disposable implemen comment.id, AgentFeedbackKind.PRReview, AgentFeedbackState.Created, + { + owner: comment.pullRequest.owner, + repo: comment.pullRequest.repo, + number: comment.pullRequest.number, + }, ); } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackPRThreadResolver.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackPRThreadResolver.ts index 8beea7afc2440c..b4c7cd84b035b4 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackPRThreadResolver.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackPRThreadResolver.ts @@ -5,6 +5,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { IFeedbackPullRequest } from '../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; @@ -13,6 +14,7 @@ import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedbackChangeEvent, IAgen interface ISeenPRComment { readonly state: AgentFeedbackState; readonly threadId: string; + readonly pullRequest: IFeedbackPullRequest | undefined; } /** @@ -65,19 +67,19 @@ export class AgentFeedbackPRThreadResolverContribution extends Disposable implem const key = e.sessionResource.toString(); const previous = this._seenBySession.get(key) ?? new Map(); const next = new Map(); - const threadsToResolve = new Set(); + const threadsToResolve = new Map(); for (const item of e.feedbackItems) { if (item.kind !== AgentFeedbackKind.PRReview || !item.sourcePRReviewCommentId) { continue; } const threadId = item.sourcePRReviewCommentId; - next.set(item.id, { state: item.state, threadId }); + next.set(item.id, { state: item.state, threadId, pullRequest: item.sourcePullRequest }); const before = previous.get(item.id); // Resolve transition (requires a prior non-resolved observation). if (item.state === AgentFeedbackState.Resolved && before && before.state !== AgentFeedbackState.Resolved) { - threadsToResolve.add(threadId); + threadsToResolve.set(threadId, item.sourcePullRequest); } } @@ -87,7 +89,7 @@ export class AgentFeedbackPRThreadResolverContribution extends Disposable implem continue; } if (before.state === AgentFeedbackState.Submitted || before.state === AgentFeedbackState.Resolved) { - threadsToResolve.add(before.threadId); + threadsToResolve.set(before.threadId, before.pullRequest); } } @@ -102,17 +104,17 @@ export class AgentFeedbackPRThreadResolverContribution extends Disposable implem requested = new Set(); this._requestedBySession.set(key, requested); } - for (const threadId of threadsToResolve) { + for (const [threadId, pullRequest] of threadsToResolve) { if (requested.has(threadId)) { continue; } requested.add(threadId); - this._resolveThread(e.sessionResource, threadId); + this._resolveThread(e.sessionResource, threadId, pullRequest); } } - private _resolveThread(sessionResource: URI, threadId: string): void { - this._codeReviewService.resolvePRReviewThread(sessionResource, threadId) + private _resolveThread(sessionResource: URI, threadId: string, pullRequest: IFeedbackPullRequest | undefined): void { + this._codeReviewService.resolvePRReviewThread(sessionResource, threadId, pullRequest) .catch(err => this._logService.warn('[AgentFeedback] Failed to resolve PR review thread on GitHub', threadId, err)); } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 69f34871f33dec..843798f656411b 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -25,6 +25,7 @@ import { changeMatchesResource, getActiveResourceCandidates, IAgentFeedbackConte import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IChatWidget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IFeedbackPullRequest } from '../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; import { ISession, ISessionFileChange, ISessionWorkspace, SessionStatus } from '../../../services/sessions/common/session.js'; import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; @@ -108,6 +109,14 @@ export interface IAgentFeedbackSubmittedEvent { readonly replyCount: number; } +/** Controls which accepted feedback items are submitted and the request used for them. */ +export interface ISubmitFeedbackOptions { + readonly query?: string; + /** Selected feedback for Agent Host requests. Other providers submit their complete reactive attachment. */ + readonly feedbackIds?: readonly string[]; + readonly onRequestAccepted?: () => void; +} + // --- Service Interface -------------------------------------------------------- export const IAgentFeedbackService = createDecorator('agentFeedbackService'); @@ -146,7 +155,7 @@ export interface IAgentFeedbackService { * to {@link AgentFeedbackState.Accepted}) sets the initial lifecycle state * and selects which lifecycle event is fired. */ - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, kind?: AgentFeedbackKind, state?: AgentFeedbackState): IAgentFeedback; + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, kind?: AgentFeedbackKind, state?: AgentFeedbackState, sourcePullRequest?: IFeedbackPullRequest): IAgentFeedback; /** * Accept a feedback item that is currently in the @@ -265,7 +274,7 @@ export interface IAgentFeedbackService { * straight to {@link AgentFeedbackState.Resolved}. No-op when there are no * accepted items. */ - markFeedbackSubmitted(sessionResource: URI): void; + markFeedbackSubmitted(sessionResource: URI, feedbackIds?: readonly string[]): void; /** * Submit the currently accumulated accepted feedback for the session to the @@ -274,7 +283,7 @@ export interface IAgentFeedbackService { * while another request is in progress, means it was queued rather than sent. Returns whether * the feedback was submitted. */ - submitFeedback(sessionResource: URI): Promise; + submitFeedback(sessionResource: URI, options?: ISubmitFeedbackOptions): Promise; /** * Add a feedback item and then submit the feedback. Waits for the @@ -589,7 +598,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe return resourceUri.scheme !== Schemas.outputChannel; } - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, kind: AgentFeedbackKind = AgentFeedbackKind.UserReview, state: AgentFeedbackState = AgentFeedbackState.Accepted): IAgentFeedback { + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, kind: AgentFeedbackKind = AgentFeedbackKind.UserReview, state: AgentFeedbackState = AgentFeedbackState.Accepted, sourcePullRequest?: IFeedbackPullRequest): IAgentFeedback { const backend = this._backendForSession(sessionResource); // A sourcePRReviewCommentId implies the feedback originated from a PR review. @@ -606,6 +615,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe diffHunks: context?.diffHunks, kind: effectiveKind, sourcePRReviewCommentId, + sourcePullRequest, state, }; @@ -947,7 +957,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe return session ? isAgentHostProviderId(session.providerId) : false; } - async submitFeedback(sessionResource: URI): Promise { + async submitFeedback(sessionResource: URI, options?: ISubmitFeedbackOptions): Promise { if (isEqual(sessionResource, AGENT_FEEDBACK_NEW_SESSION_RESOURCE)) { if (!this.getFeedback(sessionResource).some(item => item.state === AgentFeedbackState.Accepted)) { return false; @@ -968,7 +978,9 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe // so the agent receives the comments, then remove the transient // attachment again once the request has been accepted. if (this._isAgentHostSession(sessionResource)) { - const acceptedItems = this.getFeedback(sessionResource).filter(item => item.state === AgentFeedbackState.Accepted); + const feedbackIds = options?.feedbackIds ? new Set(options.feedbackIds) : undefined; + const acceptedItems = this.getFeedback(sessionResource).filter(item => + item.state === AgentFeedbackState.Accepted && (!feedbackIds || feedbackIds.has(item.id))); const attachmentId = ATTACHMENT_ID_PREFIX + sessionResource.toString(); if (acceptedItems.length) { const annotationsResource = this._getAnnotationsBackend().getAnnotationsChannelResource(sessionResource); @@ -976,13 +988,14 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe widget.attachmentModel.addContext(createAgentFeedbackVariableEntry(sessionResource, acceptedItems, annotationsResource)); } - return this._sendActOnFeedbackRequest(widget, sessionResource, () => widget.attachmentModel.delete(attachmentId)); + return this._sendActOnFeedbackRequest(widget, sessionResource, options, () => widget.attachmentModel.delete(attachmentId)); } // For non-agent-host sessions the reactive attachment contribution also // marks submission on send; marking from the helper is idempotent and // covers sessions without that contribution. - return this._sendActOnFeedbackRequest(widget, sessionResource); + const nonAgentHostOptions = options?.feedbackIds ? { ...options, feedbackIds: undefined } : options; + return this._sendActOnFeedbackRequest(widget, sessionResource, nonAgentHostOptions); } /** @@ -993,14 +1006,15 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe * once that queued request eventually runs — the feedback items must move to * the submitted state right away. */ - private _sendActOnFeedbackRequest(widget: IChatWidget, sessionResource: URI, cleanup?: () => void): Promise { + private _sendActOnFeedbackRequest(widget: IChatWidget, sessionResource: URI, options?: ISubmitFeedbackOptions, cleanup?: () => void): Promise { const submitted = new DeferredPromise(); const cleanupOnce = cleanup && createSingleCallFunction(cleanup); - widget.acceptInput('/act-on-feedback', { + widget.acceptInput(options?.query ?? '/act-on-feedback', { onRequestAccepted: () => { cleanupOnce?.(); - this.markFeedbackSubmitted(sessionResource); + this.markFeedbackSubmitted(sessionResource, options?.feedbackIds); + options?.onRequestAccepted?.(); submitted.complete(true); } }).then(() => { @@ -1015,9 +1029,10 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe return submitted.p; } - markFeedbackSubmitted(sessionResource: URI): void { + markFeedbackSubmitted(sessionResource: URI, feedbackIds?: readonly string[]): void { const backend = this._backendForSession(sessionResource); const feedbackItems = backend.getItems(sessionResource); + const selectedFeedbackIds = feedbackIds ? new Set(feedbackIds) : undefined; // Agent-host sessions hand the feedback to an agent that resolves each // comment (via the resolveComments tool) once it has acted on it, so the @@ -1034,7 +1049,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe let replyCount = 0; const submitted: IAgentFeedback[] = []; for (const item of feedbackItems) { - if (item.state !== AgentFeedbackState.Accepted) { + if (item.state !== AgentFeedbackState.Accepted || selectedFeedbackIds && !selectedFeedbackIds.has(item.id)) { continue; } switch (item.kind) { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts index dae331a578cd74..a2febcbadcf495 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts @@ -12,6 +12,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { AGENT_FEEDBACK_NEW_SESSION_RESOURCE, AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackAddedEvent, IAgentFeedbackChangeEvent, IAgentFeedbackCommentRevealEvent, IAgentFeedbackConvertedEvent, IAgentFeedbackNavigationBearing, IAgentFeedbackReplyAddedEvent, IAgentFeedbackService, IAgentFeedbackSubmittedEvent, INavigableSessionComment } from './agentFeedbackService.js'; import { IAgentFeedbackContext } from './agentFeedbackEditorUtils.js'; import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; +import { IFeedbackPullRequest } from '../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; /** * No-op implementation of {@link IAgentFeedbackService} used on web, @@ -34,7 +35,7 @@ class NullAgentFeedbackService extends Disposable implements IAgentFeedbackServi readonly onDidAddReply = this._register(new Emitter()).event; readonly onDidSubmitFeedback = this._register(new Emitter()).event; - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, _suggestion?: ICodeReviewSuggestion, _context?: IAgentFeedbackContext, _sourcePRReviewCommentId?: string, _kind?: AgentFeedbackKind, state: AgentFeedbackState = AgentFeedbackState.Accepted): IAgentFeedback { + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, _suggestion?: ICodeReviewSuggestion, _context?: IAgentFeedbackContext, _sourcePRReviewCommentId?: string, _kind?: AgentFeedbackKind, state: AgentFeedbackState = AgentFeedbackState.Accepted, _sourcePullRequest?: IFeedbackPullRequest): IAgentFeedback { return { id: '', text, diff --git a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts index 6bfd5733729fe5..477fb60f10b959 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts @@ -7,6 +7,7 @@ import { IRange, Range } from '../../../../editor/common/core/range.js'; import { URI } from '../../../../base/common/uri.js'; import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackReply } from './agentFeedbackModel.js'; import { ICodeReviewSuggestion, IPRReviewComment, IPRReviewState, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; +import { IFeedbackPullRequest } from '../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; export const enum SessionEditorCommentSource { AgentFeedback = 'agentFeedback', @@ -24,6 +25,7 @@ export interface ISessionEditorComment { readonly range: IRange; readonly text: string; readonly suggestion?: ICodeReviewSuggestion; + readonly sourcePullRequest?: IFeedbackPullRequest; readonly canConvertToAgentFeedback: boolean; /** * Replies that belong to the same comment thread as this comment. They @@ -82,6 +84,7 @@ export function getSessionEditorComments( range: item.range, text: item.text, suggestion: item.suggestion, + sourcePullRequest: item.sourcePullRequest, canConvertToAgentFeedback: false, replies: item.replies, state: item.state, @@ -103,6 +106,11 @@ export function getSessionEditorComments( resourceUri: item.uri, range: item.range, text: item.body, + sourcePullRequest: { + owner: item.pullRequest.owner, + repo: item.pullRequest.repo, + number: item.pullRequest.number, + }, canConvertToAgentFeedback: true, }); } diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.test.ts index 9b24e9963d7e1c..885b9382142231 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.test.ts @@ -10,13 +10,16 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; import { withTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; +import { IFeedbackPullRequest } from '../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; -import { ICodeReviewService } from '../../../codeReview/browser/codeReviewService.js'; +import { ICodeReviewService, ICodeReviewSuggestion } from '../../../codeReview/browser/codeReviewService.js'; import { AgentFeedbackEditorWidget, IComposerDraftState } from '../../browser/agentFeedbackEditorWidget.js'; -import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { IAgentFeedbackContext } from '../../browser/agentFeedbackEditorUtils.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; import { ISessionEditorComment, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; suite('AgentFeedbackEditorWidget', () => { @@ -42,6 +45,7 @@ suite('AgentFeedbackEditorWidget', () => { /** Comment ids passed to `setNavigationAnchor`, in call order. */ readonly navigations: readonly string[]; readonly hiddenFeedbackIds: readonly string[]; + readonly sourcePullRequests: readonly (IFeedbackPullRequest | undefined)[]; readonly domNode: HTMLElement; /** Tears the widget down and builds a new one, as the contribution does on any feedback change. */ rebuild(): HTMLElement; @@ -50,11 +54,28 @@ suite('AgentFeedbackEditorWidget', () => { function withWidget(callback: (harness: ITestHarness) => void, testComment: ISessionEditorComment = comment): void { const navigations: string[] = []; const hiddenFeedbackIds: string[] = []; + const sourcePullRequests: (IFeedbackPullRequest | undefined)[] = []; const services = new ServiceCollection(); services.set(IAgentFeedbackService, new class extends mock() { override setNavigationAnchor(_sessionResource: URI, commentId: string): void { navigations.push(commentId); } override updateFeedback(): void { } override hideFeedbackInEditor(_sessionResource: URI, feedbackId: string): void { hiddenFeedbackIds.push(feedbackId); } + override addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, _context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, kind: AgentFeedbackKind = AgentFeedbackKind.UserReview, state: AgentFeedbackState = AgentFeedbackState.Accepted, sourcePullRequest?: IFeedbackPullRequest): IAgentFeedback { + sourcePullRequests.push(sourcePullRequest); + return { + id: 'converted', + text, + resourceUri, + range, + sessionResource, + suggestion, + kind, + sourcePRReviewCommentId, + sourcePullRequest, + state, + }; + } + override addReply(): void { } }); services.set(ICodeReviewService, new class extends mock() { }); services.set(IMarkdownRendererService, new SyncDescriptor(MarkdownRendererService)); @@ -84,7 +105,7 @@ suite('AgentFeedbackEditorWidget', () => { }; try { - callback({ navigations, hiddenFeedbackIds, domNode: createWidget(), rebuild }); + callback({ navigations, hiddenFeedbackIds, sourcePullRequests, domNode: createWidget(), rebuild }); } finally { widget?.getDomNode().remove(); store.dispose(); @@ -111,6 +132,12 @@ suite('AgentFeedbackEditorWidget', () => { target.dispatchEvent(event); } + function dispatchEnter(target: HTMLElement): void { + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + Object.defineProperty(event, 'keyCode', { get: () => 13 }); + target.dispatchEvent(event); + } + test('clicking inside the edit composer keeps it open and focused without navigating', () => { withWidget(({ domNode, navigations }) => { triggerAction(domNode, 'codicon-edit'); @@ -155,6 +182,36 @@ suite('AgentFeedbackEditorWidget', () => { hideLabel: 'Hide', hiddenFeedbackIds: [resolvedComment.sourceId], }); + + test('preserves pull request identity when editing and replying to PR comments', () => { + const sourcePullRequest = { owner: 'owner', repo: 'repo', number: 42 }; + const prComment: ISessionEditorComment = { + ...comment, + id: 'prReview:thread-1', + sourceId: 'thread-1', + source: SessionEditorCommentSource.PRReview, + kind: AgentFeedbackKind.PRReview, + canConvertToAgentFeedback: true, + sourcePullRequest, + }; + const captured: (IFeedbackPullRequest | undefined)[] = []; + withWidget(({ domNode, sourcePullRequests }) => { + triggerAction(domNode, 'codicon-edit'); + const textarea = composer(domNode)!; + type(textarea, 'Edited PR comment'); + dispatchEnter(textarea); + captured.push(...sourcePullRequests); + }, prComment); + withWidget(({ domNode, sourcePullRequests }) => { + triggerAction(domNode, 'codicon-comment-discussion'); + const textarea = composer(domNode)!; + type(textarea, 'Reply to PR comment'); + dispatchEnter(textarea); + captured.push(...sourcePullRequests); + }, prComment); + + assert.deepStrictEqual(captured, [sourcePullRequest, sourcePullRequest]); + }); }, resolvedComment); }); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackItemsBackend.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackItemsBackend.test.ts index 10eff50ec375be..b9f14a53ab2881 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackItemsBackend.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackItemsBackend.test.ts @@ -40,9 +40,11 @@ suite('AnnotationsAgentFeedbackItemsBackend', () => { entries: [{ id: 'feedback-1:0', text: 'Review this code.' }], _meta: { [FEEDBACK_ANNOTATION_META_KEY]: { - kind: 'codeReview', + kind: 'prReview', state: 'created', sessionResource: sessionResource.toString(), + sourcePRReviewCommentId: 'thread-1', + sourcePullRequest: { owner: 'owner', repo: 'repo', number: 42 }, }, }, }], @@ -94,13 +96,28 @@ suite('AnnotationsAgentFeedbackItemsBackend', () => { const feedback = backend.getItems(sessionResource)[0]; assert.ok(feedback); backend.upsert(feedback); + const encodedMeta = dispatchedActions.find(action => action.type === ActionType.AnnotationsSet)?.annotation._meta?.[FEEDBACK_ANNOTATION_META_KEY]; assert.deepStrictEqual({ decoded: feedback.resourceUri.toString(), encoded: dispatchedActions.find(action => action.type === ActionType.AnnotationsSet)?.annotation.resource, + decodedPullRequest: feedback.sourcePullRequest, + encodedMeta, }, { decoded: resourceUris.fromAgentHost(agentHostResource).toString(), encoded: agentHostResource.toString(), + decodedPullRequest: { owner: 'owner', repo: 'repo', number: 42 }, + encodedMeta: { + kind: 'prReview', + state: 'created', + sessionResource: sessionResource.toString(), + suggestion: undefined, + codeSelection: undefined, + diffHunks: undefined, + sourcePRReviewCommentId: 'thread-1', + sourcePullRequest: { owner: 'owner', repo: 'repo', number: 42 }, + pendingAgentReveal: undefined, + }, }); }); }); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackPRReviewSeeder.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackPRReviewSeeder.test.ts index bc5b9b713e7e87..1d50f4502b8040 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackPRReviewSeeder.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackPRReviewSeeder.test.ts @@ -24,6 +24,7 @@ suite('AgentFeedbackPRReviewSeederContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); const session = URI.parse('local-agent-host:/session-1'); const fileA = URI.file('/workspace/a.ts'); + const pullRequest = { owner: 'owner', repo: 'repo', number: 42, uri: URI.parse('https://github.com/owner/repo/pull/42') }; let feedbackItems: IAgentFeedback[]; let loaded: boolean; @@ -32,17 +33,17 @@ suite('AgentFeedbackPRReviewSeederContribution', () => { let activeSession: ISettableObservable; function prComment(id: string, line: number, body: string): IPRReviewComment { - return { id, uri: fileA, range: new Range(line, 1, line, 1), body, author: 'reviewer' }; + return { id, pullRequest, uri: fileA, range: new Range(line, 1, line, 1), body, author: 'reviewer' }; } function setComments(...comments: IPRReviewComment[]): void { prReviewState.set({ kind: PRReviewStateKind.Loaded, comments }, undefined); } - function mirrors(): { sourceId: string | undefined; state: AgentFeedbackState; text: string }[] { + function mirrors(): { sourceId: string | undefined; prNumber: number | undefined; state: AgentFeedbackState; text: string }[] { return feedbackItems .filter(i => i.kind === AgentFeedbackKind.PRReview) - .map(i => ({ sourceId: i.sourcePRReviewCommentId, state: i.state, text: i.text })); + .map(i => ({ sourceId: i.sourcePRReviewCommentId, prNumber: i.sourcePullRequest?.number, state: i.state, text: i.text })); } function makeActiveSession(providerId: string): IActiveSession { @@ -63,8 +64,8 @@ suite('AgentFeedbackPRReviewSeederContribution', () => { override onDidChangeFeedback = onDidChangeFeedback.event; override hasLoadedFeedback(_sessionResource: URI): boolean { return loaded; } override getFeedback(_sessionResource: URI): readonly IAgentFeedback[] { return feedbackItems; } - override addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, _suggestion?: ICodeReviewSuggestion, _context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, _kind?: AgentFeedbackKind, state: AgentFeedbackState = AgentFeedbackState.Accepted): IAgentFeedback { - const feedback: IAgentFeedback = { id: generateUuid(), text, resourceUri, range, sessionResource, kind: AgentFeedbackKind.PRReview, sourcePRReviewCommentId, state }; + override addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, _suggestion?: ICodeReviewSuggestion, _context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, _kind?: AgentFeedbackKind, state: AgentFeedbackState = AgentFeedbackState.Accepted, sourcePullRequest?: IAgentFeedback['sourcePullRequest']): IAgentFeedback { + const feedback: IAgentFeedback = { id: generateUuid(), text, resourceUri, range, sessionResource, kind: AgentFeedbackKind.PRReview, sourcePRReviewCommentId, sourcePullRequest, state }; feedbackItems.push(feedback); onDidChangeFeedback.fire({ sessionResource, feedbackItems }); return feedback; @@ -98,8 +99,8 @@ suite('AgentFeedbackPRReviewSeederContribution', () => { setComments(prComment('thread-1', 5, 'Fix this'), prComment('thread-2', 9, 'And this')); assert.deepStrictEqual(mirrors(), [ - { sourceId: 'thread-1', state: AgentFeedbackState.Created, text: 'Fix this' }, - { sourceId: 'thread-2', state: AgentFeedbackState.Created, text: 'And this' }, + { sourceId: 'thread-1', prNumber: 42, state: AgentFeedbackState.Created, text: 'Fix this' }, + { sourceId: 'thread-2', prNumber: 42, state: AgentFeedbackState.Created, text: 'And this' }, ]); }); @@ -115,7 +116,7 @@ suite('AgentFeedbackPRReviewSeederContribution', () => { setComments(prComment('thread-1', 5, 'Fix this differently')); assert.deepStrictEqual(mirrors(), [ - { sourceId: 'thread-1', state: AgentFeedbackState.Created, text: 'Fix this differently' }, + { sourceId: 'thread-1', prNumber: 42, state: AgentFeedbackState.Created, text: 'Fix this differently' }, ]); }); @@ -125,7 +126,7 @@ suite('AgentFeedbackPRReviewSeederContribution', () => { setComments(prComment('thread-1', 5, 'Fix this differently')); assert.deepStrictEqual(mirrors(), [ - { sourceId: 'thread-1', state: AgentFeedbackState.Accepted, text: 'Fix this' }, + { sourceId: 'thread-1', prNumber: 42, state: AgentFeedbackState.Accepted, text: 'Fix this' }, ]); }); @@ -143,7 +144,7 @@ suite('AgentFeedbackPRReviewSeederContribution', () => { setComments(); assert.deepStrictEqual(mirrors(), [ - { sourceId: 'thread-1', state: AgentFeedbackState.Accepted, text: 'Fix this' }, + { sourceId: 'thread-1', prNumber: 42, state: AgentFeedbackState.Accepted, text: 'Fix this' }, ]); }); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackPRThreadResolver.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackPRThreadResolver.test.ts index b64ab3f049b833..b886c2e5a8d536 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackPRThreadResolver.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackPRThreadResolver.test.ts @@ -22,7 +22,7 @@ suite('AgentFeedbackPRThreadResolverContribution', () => { const file = URI.file('/workspace/a.ts'); let onDidChangeFeedback: Emitter; - let resolvedThreads: string[]; + let resolvedThreads: { threadId: string; prNumber: number | undefined }[]; function pr(id: string, state: AgentFeedbackState, threadId: string | undefined = `thread-${id}`): IAgentFeedback { return { @@ -33,6 +33,7 @@ suite('AgentFeedbackPRThreadResolverContribution', () => { sessionResource: session, kind: AgentFeedbackKind.PRReview, sourcePRReviewCommentId: threadId, + sourcePullRequest: { owner: 'owner', repo: 'repo', number: 42 }, state, }; } @@ -49,8 +50,8 @@ suite('AgentFeedbackPRThreadResolverContribution', () => { override onDidChangeFeedback = onDidChangeFeedback.event; }; const codeReviewService = new class extends mock() { - override async resolvePRReviewThread(_sessionResource: URI, threadId: string): Promise { - resolvedThreads.push(threadId); + override async resolvePRReviewThread(_sessionResource: URI, threadId: string, pullRequest?: { readonly owner: string; readonly repo: string; readonly number: number }): Promise { + resolvedThreads.push({ threadId, prNumber: pullRequest?.number }); } }; @@ -64,14 +65,14 @@ suite('AgentFeedbackPRThreadResolverContribution', () => { fire(pr('1', AgentFeedbackState.Submitted)); fire(pr('1', AgentFeedbackState.Resolved)); - assert.deepStrictEqual(resolvedThreads, ['thread-1']); + assert.deepStrictEqual(resolvedThreads, [{ threadId: 'thread-1', prNumber: 42 }]); }); test('resolves the GitHub thread when a submitted PR comment is deleted', () => { fire(pr('1', AgentFeedbackState.Submitted)); fire(); // deleted - assert.deepStrictEqual(resolvedThreads, ['thread-1']); + assert.deepStrictEqual(resolvedThreads, [{ threadId: 'thread-1', prNumber: 42 }]); }); test('does not resolve when an already-resolved PR comment is first observed (history replay)', () => { @@ -108,6 +109,6 @@ suite('AgentFeedbackPRThreadResolverContribution', () => { fire(pr('1', AgentFeedbackState.Resolved)); fire(); // deleted after resolve - assert.deepStrictEqual(resolvedThreads, ['thread-1']); + assert.deepStrictEqual(resolvedThreads, [{ threadId: 'thread-1', prNumber: 42 }]); }); }); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts index 23fd146562b8f6..0dbaeff8cd8da8 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts @@ -862,6 +862,7 @@ suite('AgentFeedbackService - Submit (agent host)', () => { let acceptInputSent: DeferredPromise; /** Whether the widget hands the request over to the chat service. */ let acceptsRequest: boolean; + let providerId: string; /** Whether the widget has the session's chat model loaded. */ let sessionLoaded: boolean; /** Simulates the widget loading the session's chat model. */ @@ -872,6 +873,7 @@ suite('AgentFeedbackService - Submit (agent host)', () => { addedEntries = []; acceptInputSent = new DeferredPromise(); acceptsRequest = true; + providerId = LOCAL_AGENT_HOST_PROVIDER_ID; sessionLoaded = true; const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IChatEditingService, new class extends mock() { }); @@ -888,7 +890,7 @@ suite('AgentFeedbackService - Submit (agent host)', () => { override onDidDeleteSession = Event.None; override onDidChangeSessions = Event.None; override getSession(_resource: URI) { - return { providerId: LOCAL_AGENT_HOST_PROVIDER_ID, sessionId: 'session-1' } as unknown as ISession; + return { providerId, sessionId: 'session-1' } as unknown as ISession; } }); instantiationService.stub(ISessionsService, { activeSession: observableValue('activeSession', undefined) } as unknown as ISessionsService); @@ -960,6 +962,52 @@ suite('AgentFeedbackService - Submit (agent host)', () => { }); }); + test('submits only selected feedback with a custom request', async () => { + const first = service.addFeedback(session, fileA, r(10), 'Fix the PR comment'); + const second = service.addFeedback(session, fileA, r(20), 'Keep this for later'); + let accepted = 0; + + await service.submitFeedback(session, { + query: '/act-on-feedback for #42', + feedbackIds: [first.id], + onRequestAccepted: () => accepted++, + }); + + assert.deepStrictEqual({ + accepted, + request: widgetOps.find(operation => operation.startsWith('accept:')), + attachedTexts: addedEntries[0]?.feedbackItems.map(item => item.text), + states: service.getFeedback(session).map(item => ({ id: item.id, state: item.state })), + }, { + accepted: 1, + request: 'accept:/act-on-feedback for #42', + attachedTexts: ['Fix the PR comment'], + states: [ + { id: first.id, state: AgentFeedbackState.Submitted }, + { id: second.id, state: AgentFeedbackState.Accepted }, + ], + }); + }); + + test('non-agent-host submissions preserve the complete reactive feedback attachment', async () => { + providerId = 'test-provider'; + const first = service.addFeedback(session, fileA, r(10), 'First'); + const second = service.addFeedback(session, fileA, r(20), 'Second'); + + await service.submitFeedback(session, { + query: '/act-on-feedback', + feedbackIds: [first.id], + }); + + assert.deepStrictEqual(service.getFeedback(session).map(item => ({ + id: item.id, + state: item.state, + })), [ + { id: first.id, state: AgentFeedbackState.Resolved }, + { id: second.id, state: AgentFeedbackState.Resolved }, + ]); + }); + test('marks feedback as submitted once the request is queued behind an in-progress request', async () => { service.addFeedback(session, fileA, r(10), 'Please simplify'); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts index c795e336092097..83df8e96a05585 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts @@ -15,6 +15,7 @@ suite('SessionEditorComments', () => { const session = URI.parse('test://session/1'); const fileA = URI.parse('file:///a.ts'); const fileB = URI.parse('file:///b.ts'); + const pullRequest = { owner: 'owner', repo: 'repo', number: 1, uri: URI.parse('https://github.com/owner/repo/pull/1') }; ensureNoDisposablesAreLeakedInTestSuite(); @@ -22,8 +23,8 @@ suite('SessionEditorComments', () => { const prState: IPRReviewState = { kind: PRReviewStateKind.Loaded, comments: [ - { id: 'review-a', uri: fileA, range: new Range(3, 1, 3, 1), body: 'review a', author: 'reviewer' }, - { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', author: 'reviewer' }, + { id: 'review-a', pullRequest, uri: fileA, range: new Range(3, 1, 3, 1), body: 'review a', author: 'reviewer' }, + { id: 'review-b', pullRequest, uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', author: 'reviewer' }, ], }; const comments = getSessionEditorComments(session, [ @@ -43,8 +44,8 @@ suite('SessionEditorComments', () => { const prState: IPRReviewState = { kind: PRReviewStateKind.Loaded, comments: [ - { id: 'review-a', uri: fileA, range: new Range(13, 1, 13, 1), body: 'review a', author: 'reviewer' }, - { id: 'review-b', uri: fileB, range: new Range(11, 1, 11, 1), body: 'review b', author: 'reviewer' }, + { id: 'review-a', pullRequest, uri: fileA, range: new Range(13, 1, 13, 1), body: 'review a', author: 'reviewer' }, + { id: 'review-b', pullRequest, uri: fileB, range: new Range(11, 1, 11, 1), body: 'review b', author: 'reviewer' }, ], }; const comments = getSessionEditorComments(session, [ @@ -66,7 +67,7 @@ suite('SessionEditorComments', () => { const prState: IPRReviewState = { kind: PRReviewStateKind.Loaded, comments: [ - { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', author: 'reviewer' }, + { id: 'review-b', pullRequest, uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', author: 'reviewer' }, ], }; const comments = getSessionEditorComments(session, [ @@ -82,16 +83,18 @@ suite('SessionEditorComments', () => { const prState: IPRReviewState = { kind: PRReviewStateKind.Loaded, comments: [ - { id: 'pr-thread-1', uri: fileA, range: new Range(5, 1, 5, 1), body: 'Please fix this', author: 'reviewer' }, - { id: 'pr-thread-2', uri: fileB, range: new Range(1, 1, 1, 1), body: 'Looks wrong', author: 'reviewer' }, + { id: 'pr-thread-1', pullRequest, uri: fileA, range: new Range(5, 1, 5, 1), body: 'Please fix this', author: 'reviewer' }, + { id: 'pr-thread-2', pullRequest, uri: fileB, range: new Range(1, 1, 1, 1), body: 'Looks wrong', author: 'reviewer' }, ], }; const comments = getSessionEditorComments(session, [], prState); - assert.strictEqual(comments.length, 2); - assert.deepStrictEqual(comments.map(c => `${c.resourceUri.path}:${c.range.startLineNumber}:${c.source}`), [ - '/a.ts:5:prReview', - '/b.ts:1:prReview', + assert.deepStrictEqual(comments.map(c => ({ + location: `${c.resourceUri.path}:${c.range.startLineNumber}:${c.source}`, + pullRequest: c.sourcePullRequest, + })), [ + { location: '/a.ts:5:prReview', pullRequest: { owner: 'owner', repo: 'repo', number: 1 } }, + { location: '/b.ts:1:prReview', pullRequest: { owner: 'owner', repo: 'repo', number: 1 } }, ]); assert.strictEqual(comments[0].canConvertToAgentFeedback, true); }); @@ -100,7 +103,7 @@ suite('SessionEditorComments', () => { const prState: IPRReviewState = { kind: PRReviewStateKind.Loaded, comments: [ - { id: 'pr-thread-1', uri: fileA, range: new Range(7, 1, 7, 1), body: 'PR comment', author: 'reviewer' }, + { id: 'pr-thread-1', pullRequest, uri: fileA, range: new Range(7, 1, 7, 1), body: 'PR comment', author: 'reviewer' }, ], }; @@ -142,7 +145,7 @@ suite('SessionEditorComments', () => { const prState: IPRReviewState = { kind: PRReviewStateKind.Loaded, comments: [ - { id: 'pr-thread-1', uri: fileA, range: new Range(5, 1, 5, 1), body: 'Please fix this', author: 'reviewer' }, + { id: 'pr-thread-1', pullRequest, uri: fileA, range: new Range(5, 1, 5, 1), body: 'Please fix this', author: 'reviewer' }, ], }; const comments = getSessionEditorComments(session, [ @@ -156,7 +159,7 @@ suite('SessionEditorComments', () => { const prState: IPRReviewState = { kind: PRReviewStateKind.Loaded, comments: [ - { id: 'pr-thread-1', uri: fileA, range: new Range(5, 1, 5, 1), body: 'Please fix this', author: 'reviewer' }, + { id: 'pr-thread-1', pullRequest, uri: fileA, range: new Range(5, 1, 5, 1), body: 'Please fix this', author: 'reviewer' }, ], }; const comments = getSessionEditorComments(session, [ diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 7a2e660ac31215..97b3e28b16ef8d 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -82,8 +82,9 @@ export class AutomationDialogService implements IAutomationDialogService { async showAutomationDialog(options: IShowAutomationDialogOptions): Promise { const disposables = new DisposableStore(); - const initial = options.existing; - const isEdit = !!initial; + const existing = options.existing; + const initial = existing ?? options.initialValues; + const isEdit = !!existing; const initialTarget = initial?.target; const initialWorkspaceTarget = initialTarget?.kind === 'workspace' ? initialTarget : undefined; @@ -228,7 +229,7 @@ export class AutomationDialogService implements IAutomationDialogService { return undefined; } - if (isEdit && initial) { + if (existing) { const patch: IUpdateAutomationOptions = { name: state.name, prompt, @@ -239,7 +240,7 @@ export class AutomationDialogService implements IAutomationDialogService { permissionLevel: permissionLevel ?? null, enabled: state.enabled, }; - return { kind: 'update', id: initial.id, value: patch }; + return { kind: 'update', id: existing.id, value: patch }; } const create: ICreateAutomationOptions = { diff --git a/src/vs/sessions/contrib/automations/browser/automationTools.ts b/src/vs/sessions/contrib/automations/browser/automationTools.ts index 3a6a63297985cf..5719db5c811129 100644 --- a/src/vs/sessions/contrib/automations/browser/automationTools.ts +++ b/src/vs/sessions/contrib/automations/browser/automationTools.ts @@ -369,10 +369,12 @@ export class ConfigureAutomationTool implements IToolImpl { icon: Codicon.calendar, displayName: localize('automation.tool.configure.displayName', "Configure Automation"), userDescription: localize('automation.tool.configure.userDescription', "Create or update an automation"), - modelDescription: `Create or update a scheduled automation. + modelDescription: `Create or update a persistent scheduled automation. + +Create a new automation only when the user explicitly asks for an automation, or for a prompt to run on a recurring schedule. Do not infer that intent from requests merely to monitor, watch, follow, or keep something (such as a pull request) green. + +Omit "automationId" to create an automation; "name", "prompt", and "schedule.interval" are then required. If "target" is omitted, the automation targets the current Agents window session. Include "automationId" to update an existing automation, and only provide fields that should change. Call listAutomations first to obtain the stable ID and current values. -Omit "automationId" to create an automation; "name", "prompt", and "schedule.interval" are then required. If "target" is omitted, the automation targets the current Agents window session. -Include "automationId" to update an existing automation, and only provide fields that should change. Call listAutomations first to obtain the stable ID and current values. The change uses the current tool-approval policy. When approval is required, the user sees a normal tool confirmation. If the user cancels or denies the request, do not retry unless they ask you to.`, source: ToolDataSource.Internal, when: automationToolWhen, diff --git a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts index 67ee0c15605f12..faff57245539c6 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts @@ -394,6 +394,24 @@ suite('AutomationTools', () => { ]); }); + test('configureAutomation tool data requires explicit creation intent', () => { + const modelDescription = new ConfigureAutomationTool( + new FakeAutomationService(), + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ).getToolData().modelDescription ?? ''; + + assert.deepStrictEqual({ + requiresExplicitAutomationIntent: modelDescription.includes('only when the user explicitly asks for an automation'), + allowsRecurringScheduleIntent: modelDescription.includes('or for a prompt to run on a recurring schedule'), + excludesMonitoringRequests: modelDescription.includes('Do not infer that intent from requests merely to monitor, watch, follow, or keep something'), + }, { + requiresExplicitAutomationIntent: true, + allowsRecurringScheduleIntent: true, + excludesMonitoringRequests: true, + }); + }); + test('listAutomations returns stable IDs and editable fields', async () => { const automation = createAutomation(); const tool = new ListAutomationsTool(new FakeAutomationService([automation]), createConfigurationService()); diff --git a/src/vs/sessions/contrib/changes/browser/checksActions.ts b/src/vs/sessions/contrib/changes/browser/checksActions.ts index e088cab43c72f9..e38506866b97f6 100644 --- a/src/vs/sessions/contrib/changes/browser/checksActions.ts +++ b/src/vs/sessions/contrib/changes/browser/checksActions.ts @@ -88,7 +88,7 @@ export function getPullRequestUrl(coords: { owner: string; repo: string; prNumbe return `https://github.com/${coords.owner}/${coords.repo}/pull/${coords.prNumber}`; } -export function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHubCICheck; annotations: string }>, prUrl?: string): string { +export function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHubCICheck; annotations: string }>, prUrl?: string, query: string = FIX_CI_QUERY): string { const sections = failedChecks.map(({ check, annotations }) => { const parts = [ `Check: ${check.name}`, @@ -104,7 +104,7 @@ export function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHu return parts.join('\n'); }); - const lines = [FIX_CI_QUERY]; + const lines = [query]; if (prUrl) { lines.push(`Pull request: ${prUrl}`); } @@ -123,7 +123,7 @@ export function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHu * when there are no failing checks. Shared by the widget-based active-session * action and the blocked-sessions list's background fix. */ -export async function buildFixCIPrompt(ciModel: GitHubPullRequestCIModel): Promise { +export async function buildFixCIPrompt(ciModel: GitHubPullRequestCIModel, query?: string): Promise { const checks = ciModel.checks.get(); const failedChecks = getFailedChecks(checks); if (failedChecks.length === 0) { @@ -135,7 +135,7 @@ export async function buildFixCIPrompt(ciModel: GitHubPullRequestCIModel): Promi return { check, annotations }; })); - return buildFixChecksPrompt(failedCheckDetails, getPullRequestUrl(ciModel)); + return buildFixChecksPrompt(failedCheckDetails, getPullRequestUrl(ciModel), query); } /** @@ -145,8 +145,8 @@ export async function buildFixCIPrompt(ciModel: GitHubPullRequestCIModel): Promi * active-session action; the blocked-sessions list sends in the background via * {@link buildFixCIPrompt} instead. */ -export async function submitFixCIChecks(ciModel: GitHubPullRequestCIModel, chatWidget: IChatWidget): Promise { - const prompt = await buildFixCIPrompt(ciModel); +export async function submitFixCIChecks(ciModel: GitHubPullRequestCIModel, chatWidget: IChatWidget, query?: string): Promise { + const prompt = await buildFixCIPrompt(ciModel, query); if (!prompt) { return; } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 582b36fc902712..5bcc6c1fd5de63 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -602,8 +602,8 @@ align-items: center; overflow: hidden; font-size: var(--vscode-fontSize-body2, 11px); - padding: 0 4px 0 0; - border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); + padding: 0 var(--vscode-spacing-size20) 0 var(--vscode-spacing-size40); + border: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); border-radius: var(--vscode-cornerRadius-small); height: 18px; max-width: 200px; @@ -616,6 +616,30 @@ white-space: nowrap; } +.sessions-chat-attachment-content, +.sessions-chat-attachment-open { + display: inline-flex; + align-items: center; + min-width: 0; + height: 100%; + overflow: hidden; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; +} + +.sessions-chat-attachment-open { + cursor: pointer; +} + +.sessions-chat-attachment-open:focus-visible, +.sessions-chat-attachment-remove:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + /* Mirrors the workbench paste pill's `.attachment-additional-info`. */ .sessions-chat-attachment-info { opacity: 0.7; @@ -625,9 +649,9 @@ } .sessions-chat-attachment-pill .codicon { - font-size: 14px; + font-size: var(--vscode-codiconFontSize-compact); flex-shrink: 0; - padding: 0 3px; + padding: 0 var(--vscode-spacing-size20); } .sessions-chat-attachment-pill .monaco-icon-label { @@ -657,16 +681,19 @@ display: flex; align-items: center; justify-content: center; + width: var(--vscode-spacing-size160); + height: var(--vscode-spacing-size160); border: 0; + border-radius: var(--vscode-cornerRadius-xSmall); padding: 0; background: none; cursor: pointer; flex-shrink: 0; color: var(--vscode-descriptionForeground); - margin-left: 4px; + margin-left: var(--vscode-spacing-size20); } -.sessions-chat-attachment-pill:hover { +.sessions-chat-attachment-pill.openable:hover { background-color: var(--vscode-toolbar-hoverBackground); } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 47335c7925c74a..987ce309a4bd25 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -260,8 +260,8 @@ .sessions-chat-picker-slot.sessions-workspace-category-picker-slot .action-label { gap: var(--vscode-spacing-size80); - min-height: var(--vscode-spacing-size320); - padding: var(--vscode-spacing-size60) var(--vscode-spacing-size120); + min-height: var(--vscode-spacing-size240); + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size120); border: var(--vscode-strokeThickness) solid var(--vscode-commandCenter-inactiveBorder, var(--vscode-widget-border)); border-radius: var(--vscode-cornerRadius-circle); box-sizing: border-box; diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 874e430e75d778..4bdb86ee0cbdcc 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -54,6 +54,8 @@ export interface INewChatAttachments { removeAttachment(id: string): void; } +const GITHUB_CONTEXT_ID_PREFIX = 'github-context:'; + /** * Manages context attachments for the sessions new-chat widget. * @@ -132,14 +134,29 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt for (const entry of visibleAttachments) { const pill = dom.append(this._container, dom.$('.sessions-chat-attachment-pill')); const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined; + const githubContextResource = entry.id.startsWith(GITHUB_CONTEXT_ID_PREFIX) + ? URI.parse(entry.id.slice(GITHUB_CONTEXT_ID_PREFIX.length)) + : undefined; + const openResource = resource ?? githubContextResource; + const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined; + const canOpen = Boolean(imageData || openResource || isPastedTextArtifact(entry)); + let content: HTMLElement; + if (canOpen) { + const openButton = dom.append(pill, dom.$('button.sessions-chat-attachment-open')); + openButton.type = 'button'; + openButton.setAttribute('aria-label', localize('openNamedAttachment', "Open {0}", entry.name)); + content = openButton; + pill.classList.add('openable'); + } else { + content = dom.append(pill, dom.$('span.sessions-chat-attachment-content')); + } if (entry.kind === 'image') { - const icon = dom.append(pill, renderIcon(Codicon.fileMedia)); - dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); - const buffer = coerceImageBuffer(entry.value); - if (buffer) { + const icon = dom.append(content, renderIcon(Codicon.fileMedia)); + dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); + if (imageData) { // Swap the generic icon for a thumbnail once the shared helper // has decoded one, matching the workbench attachment pill. - const preview = createImageHoverContent(resource, entry.name, buffer, entry.id, undefined, undefined, (url, isThumbnail) => { + const preview = createImageHoverContent(resource, entry.name, imageData, entry.id, undefined, undefined, (url, isThumbnail) => { if (isThumbnail) { icon.replaceWith(dom.$('img.sessions-chat-attachment-image', { src: url, alt: '' })); } @@ -147,11 +164,11 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt this._renderDisposables.add(preview.disposable); } } else if (entry.id.startsWith(ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX)) { - const icon = dom.append(pill, renderIcon(Codicon.repo)); + const icon = dom.append(content, renderIcon(Codicon.repo)); icon.setAttribute('aria-hidden', 'true'); - dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); + dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); } else { - const label = this._resourceLabels.create(pill, { supportIcons: true }); + const label = this._resourceLabels.create(content, { supportIcons: true }); this._renderDisposables.add(label); if (resource) { label.setFile(resource, { @@ -162,17 +179,15 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt // Matches the workbench paste pill: a file icon for the artifact's // language, and how much text it stands in for. label.setLabel(entry.fileName, undefined, { extraClasses: ['file-icon', `${entry.language}-lang-file-icon`] }); - dom.append(pill, dom.$('span.sessions-chat-attachment-info', undefined, localize('pastedLines', "Pasted {0}", entry.pastedLines))); + dom.append(content, dom.$('span.sessions-chat-attachment-info', undefined, localize('pastedLines', "Pasted {0}", entry.pastedLines))); } else { label.setLabel(entry.name); } } // Click to open the resource or image - const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined; if (imageData) { - pill.style.cursor = 'pointer'; - this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { + this._renderDisposables.add(registerOpenEditorListeners(content, async () => { if (this.configurationService.getValue(ChatConfiguration.ImageCarouselEnabled)) { const imageResource = resource ?? URI.from({ scheme: 'data', path: entry.name }); await this.chatImageCarouselService.openCarouselAtResource(imageResource, imageData); @@ -180,26 +195,16 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt await this.openerService.open(resource, { fromUserGesture: true }); } })); - } else if (resource) { - pill.style.cursor = 'pointer'; - this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { - await this.openerService.open(resource, { fromUserGesture: true }); + } else if (openResource) { + this._renderDisposables.add(registerOpenEditorListeners(content, async () => { + await this.openerService.open(openResource, { fromUserGesture: true }); })); } else if (isPastedTextArtifact(entry)) { - pill.style.cursor = 'pointer'; - this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { + this._renderDisposables.add(registerOpenEditorListeners(content, async () => { await this.instantiationService.invokeFunction(openPastedTextArtifact, entry); })); } - // Only expose the pill itself as a focusable button when it has an open - // action; reference pills without a resource (e.g. `#session`) would - // otherwise be a focusable control that does nothing. - if (imageData || resource || isPastedTextArtifact(entry)) { - pill.tabIndex = 0; - pill.role = 'button'; - } - const removeButton = dom.append(pill, dom.$('button.sessions-chat-attachment-remove')); removeButton.type = 'button'; removeButton.title = localize('removeAttachment', "Remove"); diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 53ef56f7f3c61d..c8d50975c55621 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -708,6 +708,7 @@ export class NewChatWidget extends Disposable { ariaLabel: localize('newSessionWorkspacePicker.githubContextAriaLabel', "Attach a GitHub issue or pull request to the new session"), tooltip: localize('newSessionWorkspacePicker.githubContextTooltip', "Attach an issue or pull request as context"), icon: Codicon.add, + hideIconWhenAttached: true, group: SESSION_WORKSPACE_GROUP_GITHUB, attachesContext: true, hideWhenNoGitHubRepository: true, diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index e4a60170eaedf6..554bed5f1d7332 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -105,6 +105,7 @@ export interface IWorkspacePickerTrigger { readonly ariaLabel: string; readonly tooltip?: string; readonly icon: ThemeIcon; + readonly hideIconWhenAttached?: boolean; readonly reflectsWorkspace?: boolean; readonly group?: string; readonly attachesContext?: boolean; @@ -259,10 +260,12 @@ export class WorkspacePicker extends Disposable { syncAttachedContext(attachments: readonly IChatRequestVariableEntry[]): void { this._attachedContext = attachments; - this._syncAttachedContext(); + if (!this._syncAttachedContext()) { + this._updateTriggerLabel(); + } } - private _syncAttachedContext(): void { + private _syncAttachedContext(): boolean { const attachmentIds = new Set(this._attachedContext.map(attachment => attachment.id)); let changed = false; for (const [key, contexts] of this._contextSelections) { @@ -312,6 +315,7 @@ export class WorkspacePicker extends Disposable { this._updateTriggerLabel(); this._onDidChangeSelection.fire(); } + return changed; } get preselectionSource(): NewSessionWorkspacePreselectionSource { @@ -1340,7 +1344,7 @@ export class WorkspacePicker extends Disposable { extended.hoverContent = getStatusHover(status, provider.remoteAddress); if (provider.remoteAddress) { extended.onRemove = async () => { - await removeRemoteHost(provider, this.remoteAgentHostService); + await removeRemoteHost(provider, this.remoteAgentHostService, this.configurationService); }; } manageActions.push(action); @@ -1440,11 +1444,16 @@ export class WorkspacePicker extends Disposable { trigger.classList.toggle('selected', (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined); const icon = (reflectsWorkspace ? workspace?.icon : undefined) ?? (relatedGitHubInfo ? Codicon.repo : (isSelectedCategory && workspace ? workspace.icon : options.icon)); - if (!contents.icon) { - contents.icon = renderIcon(icon); - trigger.prepend(contents.icon); + if (options.hideIconWhenAttached === true && badgeCount > 0) { + contents.icon?.remove(); + contents.icon = undefined; + } else { + if (!contents.icon) { + contents.icon = renderIcon(icon); + trigger.prepend(contents.icon); + } + contents.icon.className = ThemeIcon.asClassName(icon); } - contents.icon.className = ThemeIcon.asClassName(icon); const label = (reflectsWorkspace ? workspace?.label : undefined) ?? (relatedGitHubInfo ? `${relatedGitHubInfo.owner}/${relatedGitHubInfo.repo}` : (isSelectedCategory && workspace ? workspace.label : options.label)); trigger.setAttribute('aria-label', badgeCount > 0 @@ -1735,9 +1744,11 @@ export class WorkspacePicker extends Disposable { let isFirstRun = true; store.add(autorun(reader => { const status = connStatus.read(reader); + const isBusy = RemoteAgentHostConnectionStatus.isConnecting(status) + || RemoteAgentHostConnectionStatus.isReconnecting(status); if (RemoteAgentHostConnectionStatus.isConnected(status)) { this._connectionStatusWatch.clear(); - } else if ((RemoteAgentHostConnectionStatus.isDisconnected(status) || RemoteAgentHostConnectionStatus.isIncompatible(status)) && !isFirstRun) { + } else if (!isBusy && !isFirstRun) { fallback(); } isFirstRun = false; diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index d6d9d9c1b434ff..ba71757ed15f4d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -39,14 +39,14 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.createdBySession', "When a session was created by another session, focus it in the Sessions list and use the Show Hover command{0}. Move focus to the Created by link, then press Enter or Space to open the creator session.", '')); content.push(localize('sessionsChat.promptOptions', "When prompt options appear above the new-session input, use Tab and Shift+Tab to move between them, then press Enter or Space to insert one. You can select a different option while the input is empty, exactly matches the inserted prompt, or only has its editable placeholder removed; other edits disable the options without hiding them. Clearing the input also clears the selected option. Use the Close action to hide the options and return focus to the input.")); content.push(localize('sessionsChat.promptTemplatePlaceholder', "When the new-session prompt contains a highlighted task placeholder, place the caret inside it and replace it{0} to type your task.", ``)); - content.push(localize('sessionsChat.feedbackComments', "When feedback comments are available for a new session, a comments banner appears above the input. You can send the comments without typing a message, or focus the Reveal button to open the first comment in its editor.")); + content.push(localize('sessionsChat.feedbackComments', "When pull requests have failing checks or unreviewed comments, one banner appears above the input. If several pull requests need attention, use the Previous Banner and Next Banner buttons to move between them. A pull request with both failing checks and comments uses split buttons: activate the main action to address both, or use its More Actions button to address or reveal only CI or comments. In-product agent review comments appear as their own carousel item.")); content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); content.push(localize('sessionsChat.devContainer', "When Dev Container Agent Host sessions are enabled, Docker is available, and the selected local folder contains a Dev Container configuration, a Dev Container checkbox appears before New Worktree. Select it to run the session on an Agent Host inside that folder's Dev Container.")); content.push(localize('sessionsChat.pullRequestSession', "In a repository section where New Session is a split button, focus New Session and press Right Arrow to reach its dropdown, then activate New Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); content.push(localize('sessionsChat.githubReferences', "Pull request and issue pills above the chat input open their GitHub item in the GitHub Pull Requests extension when it is available. Pills that represent several items open a keyboard-accessible picker.")); - content.push(localize('sessionsChat.failingChecksPullRequest', "When the active session has failing checks, use Reveal in the banner above the input to open its pull request, or use Fix Checks to ask the agent to address the failures.")); + content.push(localize('sessionsChat.failingChecksPullRequest', "When a session pull request has failing checks, use Reveal CI in its banner item to open that pull request, or use Fix CI to ask the agent to address the failures.")); content.push(localize('sessionsChat.pickFolderQuickPick', "To choose a folder from a searchable list instead, use the New Session in Folder command{0}.", '')); content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); @@ -58,7 +58,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.dictation', "When dictation is configured, dictate your message into the input{0}. Tap to start and stop, or hold to dictate only while pressed. If the speech-to-text model is still preparing, activate the dictation control again to cancel.", '')); content.push(localize('sessionsChat.voiceMode', "Start or stop Voice Mode to interact with the agent using your microphone{0}.", '')); content.push(localize('sessionsChat.micContextMenu', "To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu (for example Shift+F10).")); - content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove.")); + content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter. Openable references appear as buttons above the input; activate one to open it, or use its Remove button to detach it.")); content.push(localize('sessionsChat.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference.")); content.push(localize('sessionsChat.pasteAsText', "To paste the clipboard as plain text, without converting it to Markdown or storing it as an attachment, invoke Paste as Text{0}.", '')); content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and the chat's subagents of any status appear in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill.")); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts index ab6e6941220483..6907c228539936 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -63,6 +63,9 @@ interface IAttachmentRenderingHarness { setFile(resource: URI, options: object): void; }; }; + readonly openerService: { + open(resource: URI): Promise; + }; removeAttachment(id: string): void; } @@ -202,12 +205,13 @@ suite('NewChatInputWidget', () => { }); }); - test('renders a keyboard-reachable remove button for string context pills', () => { + test('renders GitHub context pills as openable with a keyboard-reachable remove button', async () => { const container = document.createElement('div'); const entry = toPasteVariableEntry('microsoft/vscode#332825', 'GitHub context: https://github.com/microsoft/vscode/pull/332825', { id: 'github-context:https://github.com/microsoft/vscode/pull/332825', }); let removed: string | undefined; + let opened: string | undefined; const renderDisposables = disposables.add(new DisposableStore()); updateAttachmentRendering.call({ _container: container, @@ -221,26 +225,45 @@ suite('NewChatInputWidget', () => { setFile: () => { }, }), }, + openerService: { + open: async resource => { + opened = resource.toString(); + return true; + }, + }, removeAttachment: id => removed = id, }); const removeButton = container.querySelector('.sessions-chat-attachment-remove'); const pill = container.querySelector('.sessions-chat-attachment-pill'); + const openButton = container.querySelector('.sessions-chat-attachment-open'); let bubbledKeyDown = false; pill?.addEventListener('keydown', () => bubbledKeyDown = true); removeButton?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + openButton?.click(); + await Promise.resolve(); removeButton?.click(); assert.deepStrictEqual({ + pillRole: pill?.getAttribute('role'), + pillTabIndex: pill?.tabIndex, + openTagName: openButton?.tagName, + openAriaLabel: openButton?.getAttribute('aria-label'), tagName: removeButton?.tagName, tabIndex: removeButton?.tabIndex, ariaLabel: removeButton?.getAttribute('aria-label'), bubbledKeyDown, + opened, removed, }, { + pillRole: null, + pillTabIndex: -1, + openTagName: 'BUTTON', + openAriaLabel: 'Open microsoft/vscode#332825', tagName: 'BUTTON', tabIndex: 0, ariaLabel: 'Remove microsoft/vscode#332825', bubbledKeyDown: false, + opened: 'https://github.com/microsoft/vscode/pull/332825', removed: entry.id, }); }); @@ -276,6 +299,7 @@ suite('NewChatInputWidget', () => { setFile: (_resource, _options) => pill.textContent = 'docs', }), }, + openerService: { open: async () => true }, removeAttachment: () => { }, }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index d1c116b17ca060..f7769a2a2ac12b 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -2104,6 +2104,7 @@ suite('WorkspacePicker - Category Triggers', () => { label: 'Issue/PR', ariaLabel: 'Choose an issue or pull request', icon: Codicon.add, + hideIconWhenAttached: true, group: SESSION_WORKSPACE_GROUP_GITHUB, attachesContext: true, hideWhenNoGitHubRepository: true, @@ -2149,14 +2150,48 @@ suite('WorkspacePicker - Category Triggers', () => { 'https://github.com/microsoft/vscode/issues/332805', ], triggerSnapshots: [ - { label: 'Issue/PR', icon: 'codicon codicon-add', badge: '1', ariaLabel: 'Choose an issue or pull request, 1 attached' }, - { label: 'Issue/PR', icon: 'codicon codicon-add', badge: '2', ariaLabel: 'Choose an issue or pull request, 2 attached' }, - { label: 'Issue/PR', icon: 'codicon codicon-add', badge: '1', ariaLabel: 'Choose an issue or pull request, 1 attached' }, + { label: 'Issue/PR', icon: undefined, badge: '1', ariaLabel: 'Choose an issue or pull request, 1 attached' }, + { label: 'Issue/PR', icon: undefined, badge: '2', ariaLabel: 'Choose an issue or pull request, 2 attached' }, + { label: 'Issue/PR', icon: undefined, badge: '1', ariaLabel: 'Choose an issue or pull request, 1 attached' }, { label: 'Issue/PR', icon: 'codicon codicon-add', badge: undefined, ariaLabel: 'Choose an issue or pull request' }, ], }); }); + test('updates the issue and pull request trigger when attached context is synchronized directly', () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const picker = createTestPicker(disposables, providersService); + const container = document.createElement('div'); + picker.renderCategoryTriggers(container, [{ + label: 'Issue/PR', + ariaLabel: 'Choose an issue or pull request', + icon: Codicon.add, + hideIconWhenAttached: true, + group: SESSION_WORKSPACE_GROUP_GITHUB, + attachesContext: true, + }]); + const contextTrigger = container.querySelector('.action-label'); + const getContextTriggerSnapshot = () => ({ + icon: contextTrigger?.querySelector('.codicon')?.className, + badge: contextTrigger?.querySelector('.monaco-count-badge')?.textContent, + ariaLabel: contextTrigger?.getAttribute('aria-label'), + }); + const triggerSnapshots = [getContextTriggerSnapshot()]; + + picker.syncAttachedContext([toPasteVariableEntry('microsoft/vscode#1', 'GitHub context', { + id: 'github-context:https://github.com/microsoft/vscode/pull/1', + })]); + triggerSnapshots.push(getContextTriggerSnapshot()); + picker.syncAttachedContext([]); + triggerSnapshots.push(getContextTriggerSnapshot()); + + assert.deepStrictEqual(triggerSnapshots, [ + { icon: 'codicon codicon-add', badge: undefined, ariaLabel: 'Choose an issue or pull request' }, + { icon: undefined, badge: '1', ariaLabel: 'Choose an issue or pull request, 1 attached' }, + { icon: 'codicon codicon-add', badge: undefined, ariaLabel: 'Choose an issue or pull request' }, + ]); + }); + test('selects the first repository as the primary virtual workspace', async () => { const providersService = disposables.add(new MockSessionsProvidersService()); const repositoryUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts index ba06f0610d8226..c86b62d664a9e2 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { arrayEquals } from '../../../../base/common/equals.js'; import { autorun, derivedOpts, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -11,6 +12,7 @@ import { IRange, Range } from '../../../../editor/common/core/range.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IGitHubService } from '../../github/browser/githubService.js'; +import { getGitHubPullRequestRefs, IGitHubPullRequestRef } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; // --- Types ------------------------------------------------------------------- @@ -42,6 +44,7 @@ export type IPRReviewState = export interface IPRReviewComment { readonly id: string; + readonly pullRequest: IGitHubPullRequestRef; readonly uri: URI; readonly range: IRange; readonly body: string; @@ -57,14 +60,14 @@ export interface ICodeReviewService { /** * Get the observable PR review state for a session. - * Returns unresolved review comments from the PR associated with the session. + * Returns unresolved review comments from every PR associated with the session. */ getPRReviewState(sessionResource: URI): IObservable; /** * Resolve a PR review thread on GitHub and remove it from local state. */ - resolvePRReviewThread(sessionResource: URI, threadId: string): Promise; + resolvePRReviewThread(sessionResource: URI, threadId: string, pullRequest?: Pick): Promise; /** * Mark a PR review comment as locally converted to agent feedback. @@ -90,6 +93,12 @@ interface IPRSessionReviewData { readonly state: ISettableObservable; } +interface IActivePRReviewContext { + readonly sessionResource: URI; + readonly workingDirectory: URI | undefined; + readonly pullRequests: readonly IGitHubPullRequestRef[]; +} + export class CodeReviewService extends Disposable implements ICodeReviewService { declare readonly _serviceBrand: undefined; @@ -111,39 +120,55 @@ export class CodeReviewService extends Disposable implements ICodeReviewService super(); this._registerSessionListeners(); - const activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => { - return this._sessionsService.activeSession.read(reader)?.resource; + const activeReviewContext = derivedOpts({ + owner: this, + equalsFn: (a, b) => a === b || !!a && !!b && + isEqual(a.sessionResource, b.sessionResource) && + isEqual(a.workingDirectory, b.workingDirectory) && + arrayEquals(a.pullRequests, b.pullRequests, (x, y) => + x.owner === y.owner && x.repo === y.repo && x.number === y.number) + }, reader => { + const activeSession = this._sessionsService.activeSession.read(reader); + if (!activeSession) { + return undefined; + } + const workspace = activeSession.workspace.read(reader); + const gitHubInfo = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); + return { + sessionResource: activeSession.resource, + workingDirectory: workspace?.folders[0]?.workingDirectory, + pullRequests: getGitHubPullRequestRefs(gitHubInfo), + }; }); - // Subscribe to the active session's PR review threads model and project - // review threads into per-session PR review state. The model lifetime is - // owned by `IGitHubService.activeSessionPullRequestReviewThreadsObs`; we - // only consume it here. this._register(autorun(reader => { - const activeSessionResource = activeSessionResourceObs.read(reader); - if (!activeSessionResource) { - return; + const context = activeReviewContext.read(reader); + for (const pullRequest of context?.pullRequests ?? []) { + const reviewThreadsRef = reader.store.add(this._gitHubService.createPullRequestReviewThreadsModelReference(pullRequest.owner, pullRequest.repo, pullRequest.number)); + void reviewThreadsRef.object.refresh(); + reader.store.add(reviewThreadsRef.object.startPolling()); } + })); - const reviewThreadsModel = this._gitHubService.activeSessionPullRequestReviewThreadsObs.read(reader); - if (!reviewThreadsModel) { + this._register(autorun(reader => { + const context = activeReviewContext.read(reader); + if (!context || context.pullRequests.length === 0) { return; } - const data = this._getOrCreatePRReviewData(activeSessionResource); + const data = this._getOrCreatePRReviewData(context.sessionResource); if (data.state.read(undefined).kind === PRReviewStateKind.None) { data.state.set({ kind: PRReviewStateKind.Loading }, undefined); } - const session = this._sessionsManagementService.getSession(activeSessionResource); - const workspace = session?.workspace.read(undefined); - - // Map review threads -> local state. - reader.store.add(autorun(innerReader => { - const threads = reviewThreadsModel.reviewThreads.read(innerReader); - const converted = this._convertedPRCommentsBySession.get(activeSessionResource.toString()); - const comments: IPRReviewComment[] = []; - + const converted = this._convertedPRCommentsBySession.get(context.sessionResource.toString()); + const comments: IPRReviewComment[] = []; + let hasLoadedAll = true; + for (const pullRequest of context.pullRequests) { + const reviewThreadsRef = reader.store.add(this._gitHubService.createPullRequestReviewThreadsModelReference(pullRequest.owner, pullRequest.repo, pullRequest.number)); + const reviewThreadsModel = reviewThreadsRef.object; + hasLoadedAll = reviewThreadsModel.hasLoaded.read(reader) && hasLoadedAll; + const threads = reviewThreadsModel.reviewThreads.read(reader); for (const thread of threads) { if (thread.isResolved) { continue; @@ -152,7 +177,7 @@ export class CodeReviewService extends Disposable implements ICodeReviewService if (converted?.has(threadId)) { continue; } - const baseUri = workspace?.folders[0]?.workingDirectory; + const baseUri = context.workingDirectory; if (!baseUri) { continue; } @@ -161,15 +186,19 @@ export class CodeReviewService extends Disposable implements ICodeReviewService const firstComment = thread.comments[0]; comments.push({ id: String(thread.id), + pullRequest, uri: fileUri, range: new Range(line, 1, line, 1), body: firstComment?.body ?? '', author: firstComment?.author.login ?? '', }); } - - data.state.set({ kind: PRReviewStateKind.Loaded, comments }, undefined); - })); + } + if (!hasLoadedAll) { + data.state.set({ kind: PRReviewStateKind.Loading }, undefined); + return; + } + data.state.set({ kind: PRReviewStateKind.Loaded, comments }, undefined); })); } @@ -186,11 +215,15 @@ export class CodeReviewService extends Disposable implements ICodeReviewService return this._getOrCreatePRReviewData(sessionResource).state; } - async resolvePRReviewThread(sessionResource: URI, threadId: string): Promise { + async resolvePRReviewThread(sessionResource: URI, threadId: string, pullRequest?: Pick): Promise { const session = this._sessionsManagementService.getSession(sessionResource); const gitHubInfo = session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get(); - if (gitHubInfo?.pullRequest) { - const modelRef = this._gitHubService.createPullRequestReviewThreadsModelReference(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + const state = this._prReviewBySession.get(sessionResource.toString())?.state.get(); + const source = pullRequest + ?? (state?.kind === PRReviewStateKind.Loaded ? state.comments.find(comment => comment.id === threadId)?.pullRequest : undefined) + ?? getGitHubPullRequestRefs(gitHubInfo)[0]; + if (source) { + const modelRef = this._gitHubService.createPullRequestReviewThreadsModelReference(source.owner, source.repo, source.number); try { await modelRef.object.resolveThread(threadId); } catch (err) { diff --git a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts index 1be7dd5d46c0e5..7d15fab0410835 100644 --- a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { IObservable, constObservable, derived, observableValue } from '../../../../../base/common/observable.js'; @@ -142,12 +143,15 @@ suite('CodeReviewService', () => { class MockReviewThreadsFetcher { nextThreads: IGitHubPullRequestReviewThread[] = []; + getReviewThreadsGate: DeferredPromise | undefined; getReviewThreadsCalls = 0; resolveThreadCalls: { threadId: string }[] = []; async getReviewThreads(_owner: string, _repo: string, _prNumber: number): Promise { this.getReviewThreadsCalls++; - return this.nextThreads; + const result = this.nextThreads; + await this.getReviewThreadsGate?.p; + return result; } async postReviewComment(_owner: string, _repo: string, _prNumber: number, body: string, inReplyTo: number): Promise { @@ -246,30 +250,95 @@ suite('CodeReviewService', () => { sessionsManagement.setActiveSession(sessionsManagement.getSession(session)); await tick(); - // Polling is owned by GitHubPullRequestPollingContribution; refresh - // manually here to seed the review threads model with data. - await gitHubService.getReviewThreadsModel('owner', 'repo', 1).refresh(); - await tick(); - const state = service.getPRReviewState(session).get(); assert.strictEqual(state.kind, PRReviewStateKind.Loaded); if (state.kind === PRReviewStateKind.Loaded) { assert.deepStrictEqual({ - comments: state.comments.map(comment => ({ id: comment.id, uri: comment.uri.toString(), body: comment.body, author: comment.author })), + comments: state.comments.map(comment => ({ id: comment.id, prNumber: comment.pullRequest.number, uri: comment.uri.toString(), body: comment.body, author: comment.author })), getPullRequestCalls: gitHubService.getPullRequestCalls, - getPullRequestReviewThreadsCalls: gitHubService.getPullRequestReviewThreadsCalls, legacyThreadRefreshes: gitHubService.legacyFetcher.getReviewThreadsCalls, reviewThreadRefreshes: gitHubService.reviewThreadsFetcher.getReviewThreadsCalls, }, { - comments: [{ id: 'thread-100', uri: 'file:///workspace/src/a.ts', body: 'Comment on src/a.ts', author: 'reviewer' }], + comments: [{ id: 'thread-100', prNumber: 1, uri: 'file:///workspace/src/a.ts', body: 'Comment on src/a.ts', author: 'reviewer' }], getPullRequestCalls: 0, - getPullRequestReviewThreadsCalls: 0, legacyThreadRefreshes: 0, reviewThreadRefreshes: 1, }); } }); + test('PR review state combines comments from every associated pull request', async () => { + sessionsManagement.addSession(session); + sessionsManagement.setGitHubInfo(session, { + ...makeGitHubInfo(), + pullRequests: [1, 2].map(number => ({ + owner: 'owner', + repo: 'repo', + number, + uri: URI.parse(`https://github.com/owner/repo/pull/${number}`), + })), + }); + gitHubService.getReviewThreadsFetcher('owner', 'repo', 1).nextThreads = [makePRThread('thread-100', 'src/a.ts')]; + gitHubService.getReviewThreadsFetcher('owner', 'repo', 2).nextThreads = [makePRThread('thread-200', 'src/b.ts')]; + + sessionsManagement.setActiveSession(sessionsManagement.getSession(session)); + await tick(); + + const state = service.getPRReviewState(session).get(); + assert.deepStrictEqual(state.kind === PRReviewStateKind.Loaded + ? state.comments.map(comment => ({ id: comment.id, prNumber: comment.pullRequest.number })) + : state.kind, [ + { id: 'thread-100', prNumber: 1 }, + { id: 'thread-200', prNumber: 2 }, + ]); + }); + + test('PR review state stays loading until every pull request completes its initial refresh', async () => { + sessionsManagement.addSession(session); + sessionsManagement.setGitHubInfo(session, { + ...makeGitHubInfo(), + pullRequests: [1, 2].map(number => ({ + owner: 'owner', + repo: 'repo', + number, + uri: URI.parse(`https://github.com/owner/repo/pull/${number}`), + })), + }); + const firstFetcher = gitHubService.getReviewThreadsFetcher('owner', 'repo', 1); + const secondFetcher = gitHubService.getReviewThreadsFetcher('owner', 'repo', 2); + firstFetcher.nextThreads = [makePRThread('thread-100', 'src/a.ts')]; + secondFetcher.nextThreads = [makePRThread('thread-200', 'src/b.ts')]; + firstFetcher.getReviewThreadsGate = new DeferredPromise(); + secondFetcher.getReviewThreadsGate = new DeferredPromise(); + + sessionsManagement.setActiveSession(sessionsManagement.getSession(session)); + await tick(); + const beforeRefresh = service.getPRReviewState(session).get().kind; + + firstFetcher.getReviewThreadsGate.complete(); + await tick(); + const afterFirstRefresh = service.getPRReviewState(session).get().kind; + + secondFetcher.getReviewThreadsGate.complete(); + await tick(); + const afterAllRefreshes = service.getPRReviewState(session).get(); + + assert.deepStrictEqual({ + beforeRefresh, + afterFirstRefresh, + afterAllRefreshes: afterAllRefreshes.kind === PRReviewStateKind.Loaded + ? afterAllRefreshes.comments.map(comment => ({ id: comment.id, prNumber: comment.pullRequest.number })) + : afterAllRefreshes.kind, + }, { + beforeRefresh: PRReviewStateKind.Loading, + afterFirstRefresh: PRReviewStateKind.Loading, + afterAllRefreshes: [ + { id: 'thread-100', prNumber: 1 }, + { id: 'thread-200', prNumber: 2 }, + ], + }); + }); + test('resolvePRReviewThread uses dedicated review threads model', async () => { sessionsManagement.addSession(session); sessionsManagement.setGitHubInfo(session, makeGitHubInfo()); diff --git a/src/vs/sessions/contrib/github/browser/github.contribution.ts b/src/vs/sessions/contrib/github/browser/github.contribution.ts index 6884e8655ee8ce..6f72fb5cc59300 100644 --- a/src/vs/sessions/contrib/github/browser/github.contribution.ts +++ b/src/vs/sessions/contrib/github/browser/github.contribution.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, IDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IReader } from '../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IReader, IReaderWithStore } from '../../../../base/common/observable.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { ISession } from '../../../services/sessions/common/session.js'; +import { getGitHubPullRequestRefs, ISession } from '../../../services/sessions/common/session.js'; import { ISessionsChangeEvent, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { GitHubPullRequestState } from '../common/types.js'; @@ -30,8 +30,14 @@ const TRACE_PREFIX = '[PR-ICON-TRACE]'; * other kinds are logged so the trace pinpoints *why* a non-active session's PR * icon never refreshes (its model was never kept warm). */ +interface IPullRequestIdentity { + readonly owner: string; + readonly repo: string; + readonly prNumber: number; +} + type PullRequestIdentityState = - | { readonly kind: 'ok'; readonly owner: string; readonly repo: string; readonly prNumber: number } + | { readonly kind: 'ok'; readonly pullRequests: readonly IPullRequestIdentity[] } | { readonly kind: 'archived' } | { readonly kind: 'no-workspace' } | { readonly kind: 'no-git-repository' } @@ -209,11 +215,15 @@ export class GitHubPullRequestPollingContribution extends Disposable implements } const gitHubInfo = gitRepository.gitHubInfo.read(reader); - if (!gitHubInfo?.pullRequest) { + const pullRequests = getGitHubPullRequestRefs(gitHubInfo); + if (pullRequests.length === 0) { return { kind: 'no-pull-request' }; } - return { kind: 'ok', owner: gitHubInfo.owner, repo: gitHubInfo.repo, prNumber: gitHubInfo.pullRequest.number }; + return { + kind: 'ok', + pullRequests: pullRequests.map(({ owner, repo, number: prNumber }) => ({ owner, repo, prNumber })), + }; }); return autorun(reader => { @@ -226,53 +236,60 @@ export class GitHubPullRequestPollingContribution extends Disposable implements return; } - const { owner, repo, prNumber } = identity; - this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} resolved PR identity ${owner}/${repo}#${prNumber}; acquiring model and refreshing`); + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} resolved ${identity.pullRequests.length} PR identities; acquiring models and refreshing`); + for (const pullRequest of identity.pullRequests) { + this._pollPullRequest(session, pullRequest, reader); + } + }); + } - const modelRef = reader.store.add(this._gitHubService.createPullRequestModelReference(owner, repo, prNumber)); - const model = modelRef.object; + private _pollPullRequest(session: ISession, identity: IPullRequestIdentity, reader: IReaderWithStore): void { + const { owner, repo, prNumber } = identity; + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} polling ${owner}/${repo}#${prNumber}`); - // Fetch once so we learn the PR state and can render the icon — even for - // a merged PR that won't keep polling. - model.refresh(); + const modelRef = reader.store.add(this._gitHubService.createPullRequestModelReference(owner, repo, prNumber)); + const model = modelRef.object; - // Gate the repeating poll loop on a stable boolean so poll results (which - // update `pullRequest`) don't toggle the loop on every refresh. - const shouldPollObs = derived(this, pollReader => { - const prDetails = model.pullRequest.read(pollReader); - const isMerged = prDetails?.state === GitHubPullRequestState.Merged; - return !isMerged || this._isActiveSession(session, pollReader); - }); - reader.store.add(autorun(pollReader => { - if (!shouldPollObs.read(pollReader)) { - this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} PR ${owner}/${repo}#${prNumber} is merged and not active; not polling`); - return; - } + // Fetch once so we learn the PR state and can render the title and icon — + // even for a merged PR that won't keep polling. + model.refresh(); - this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} starting PR polling for ${owner}/${repo}#${prNumber}`); - pollReader.store.add(model.startPolling()); - })); - - // Poll CI checks and review threads so the session's PR icon can reflect - // failing checks / unresolved comments even when the session is not active. - // Only open, non-draft PRs need this (merged/closed/draft don't surface it). - reader.store.add(autorun(statusReader => { - const prDetails = model.pullRequest.read(statusReader); - if (!prDetails || prDetails.isDraft || prDetails.state !== GitHubPullRequestState.Open) { - return; - } + // Gate the repeating poll loop on a stable boolean so poll results (which + // update `pullRequest`) don't toggle the loop on every refresh. + const shouldPollObs = derived(this, pollReader => { + const prDetails = model.pullRequest.read(pollReader); + const isMerged = prDetails?.state === GitHubPullRequestState.Merged; + return !isMerged || this._isActiveSession(session, pollReader); + }); + reader.store.add(autorun(pollReader => { + if (!shouldPollObs.read(pollReader)) { + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} PR ${owner}/${repo}#${prNumber} is merged and not active; not polling`); + return; + } - this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} starting CI + review-thread polling for ${owner}/${repo}#${prNumber}@${prDetails.headSha}`); + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} starting PR polling for ${owner}/${repo}#${prNumber}`); + pollReader.store.add(model.startPolling()); + })); - const ciModelRef = statusReader.store.add(this._gitHubService.createPullRequestCIModelReference(owner, repo, prNumber, prDetails.headSha)); - ciModelRef.object.refresh(); - statusReader.store.add(ciModelRef.object.startPolling()); + // Poll CI checks and review threads so the session's PR icon can reflect + // failing checks / unresolved comments even when the session is not active. + // Only open, non-draft PRs need this (merged/closed/draft don't surface it). + reader.store.add(autorun(statusReader => { + const prDetails = model.pullRequest.read(statusReader); + if (!prDetails || prDetails.isDraft || prDetails.state !== GitHubPullRequestState.Open) { + return; + } - const reviewThreadsModelRef = statusReader.store.add(this._gitHubService.createPullRequestReviewThreadsModelReference(owner, repo, prNumber)); - reviewThreadsModelRef.object.refresh(); - statusReader.store.add(reviewThreadsModelRef.object.startPolling()); - })); - }); + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} starting CI + review-thread polling for ${owner}/${repo}#${prNumber}@${prDetails.headSha}`); + + const ciModelRef = statusReader.store.add(this._gitHubService.createPullRequestCIModelReference(owner, repo, prNumber, prDetails.headSha)); + ciModelRef.object.refresh(); + statusReader.store.add(ciModelRef.object.startPolling()); + + const reviewThreadsModelRef = statusReader.store.add(this._gitHubService.createPullRequestReviewThreadsModelReference(owner, repo, prNumber)); + reviewThreadsModelRef.object.refresh(); + statusReader.store.add(reviewThreadsModelRef.object.startPolling()); + })); } private _isActiveSession(session: ISession, reader: IReader): boolean { diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts index 9f99a68f6819e7..91b989f6b36eed 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts @@ -5,7 +5,7 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { Disposable, IDisposable, ReferenceCollection, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { IObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IGitHubPRComment, IGitHubPullRequestReviewThread } from '../../common/types.js'; import { GitHubApiClient } from '../githubApiClient.js'; @@ -46,6 +46,8 @@ export class GitHubPullRequestReviewThreadsModel extends Disposable { private readonly _reviewThreads = observableValue(this, []); readonly reviewThreads: IObservable = this._reviewThreads; + private readonly _hasLoaded = observableValue(this, false); + readonly hasLoaded: IObservable = this._hasLoaded; private _refreshPromise: Promise | undefined = undefined; @@ -90,7 +92,10 @@ export class GitHubPullRequestReviewThreadsModel extends Disposable { this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Refreshing review threads for ${this.owner}/${this.repo}#${this.prNumber}`); try { const data = await this._fetcher.getReviewThreads(this.owner, this.repo, this.prNumber); - this._reviewThreads.set(data, undefined); + transaction(tx => { + this._reviewThreads.set(data, tx); + this._hasLoaded.set(true, tx); + }); const unresolved = data.filter(thread => !thread.isResolved).length; this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Refreshed review threads for ${this.owner}/${this.repo}#${this.prNumber}: ${data.length} thread(s), ${unresolved} unresolved`); } catch (err) { diff --git a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts index 9a7966fc55e436..a075d75a0d4c08 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts @@ -34,7 +34,7 @@ import { SessionHasPullRequestContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { IGitHubPullRequestRef, ISession } from '../../../services/sessions/common/session.js'; +import { getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IGitHubPullRequestRef, ISession } from '../../../services/sessions/common/session.js'; import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest, IPullRequestIconStatus, OPEN_PULL_REQUEST_ACTION_ID } from '../common/types.js'; import { IGitHubService } from './githubService.js'; import { GitHubReferenceList, IGitHubReferenceListEntry } from './githubReferenceList.js'; @@ -143,21 +143,7 @@ registerAction2(OpenPullRequestAction); function getSessionPullRequest(session: ISession | undefined): IGitHubPullRequestRef | undefined { const gitHubInfo = session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get(); - const pullRequestRef = gitHubInfo?.pullRequests?.[0]; - if (pullRequestRef) { - return pullRequestRef; - } - if (!gitHubInfo?.pullRequest) { - return undefined; - } - - return { - owner: gitHubInfo.owner, - repo: gitHubInfo.repo, - number: gitHubInfo.pullRequest.number, - uri: gitHubInfo.pullRequest.uri, - icon: gitHubInfo.pullRequest.icon, - }; + return getGitHubPullRequestRefs(gitHubInfo)[0]; } class CopyPullRequestUrlAction extends Action2 { @@ -228,19 +214,7 @@ export class OpenPullRequestActionViewItem extends ChatPillActionViewItem { const session = sessionContext.session.read(reader); const workspace = session?.workspace.read(reader); const gitHubInfo = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); - if (!gitHubInfo) { - return []; - } - if (gitHubInfo.pullRequests?.length) { - return gitHubInfo.pullRequests; - } - return gitHubInfo.pullRequest ? [{ - owner: gitHubInfo.owner, - repo: gitHubInfo.repo, - number: gitHubInfo.pullRequest.number, - uri: gitHubInfo.pullRequest.uri, - icon: gitHubInfo.pullRequest.icon, - }] : []; + return getGitHubPullRequestRefs(gitHubInfo); }); this._pullRequestIdentitiesObs = derivedOpts({ @@ -327,7 +301,7 @@ export class OpenPullRequestActionViewItem extends ChatPillActionViewItem { } protected override getIconElement(): HTMLElement | undefined { - const icon = this._pullRequestsObs.get()[0]?.icon ?? Codicon.gitPullRequest; + const icon = getHighestPriorityPullRequestIcon(this._pullRequestsObs.get().map(pullRequest => pullRequest.icon)) ?? Codicon.gitPullRequest; const iconElement = $(`span.chat-pill-icon${ThemeIcon.asCSSSelector(icon)}`, { 'aria-hidden': 'true' }); if (icon.color) { // Inline `!important` wins over `button.css`'s `.monaco-text-button .codicon diff --git a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts index b42d879cb28a02..20237b9753485a 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts @@ -5,7 +5,7 @@ import { IReaderWithStore } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IGitHubInfo } from '../../../services/sessions/common/session.js'; +import { IGitHubPullRequestRef } from '../../../services/sessions/common/session.js'; import { computePullRequestIcon, GitHubCIOverallStatus, GitHubPullRequestState, IGitHubPullRequest, IPullRequestIconStatus } from '../common/types.js'; import { IGitHubService } from './githubService.js'; import { IPullRequestIconCache } from './pullRequestIconCache.js'; @@ -40,23 +40,19 @@ export function computeLivePullRequestIcon(reader: IReaderWithStore, gitHubServi return computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state, status); } -/** - * Computes a session PR icon from the shared live model, with persistent and provider-reported fallbacks while it loads. - */ -export function computeSessionPullRequestIcon(reader: IReaderWithStore, gitHubService: IGitHubService, iconCache: IPullRequestIconCache, gitHubInfo: IGitHubInfo): ThemeIcon | undefined { - const pullRequest = gitHubInfo.pullRequest; - if (!pullRequest) { - return undefined; - } - +/** Computes the live title and icon used to present a pull request reference. */ +export function computePullRequestRefPresentation(reader: IReaderWithStore, gitHubService: IGitHubService, iconCache: IPullRequestIconCache, pullRequest: IGitHubPullRequestRef): Pick & { readonly icon: ThemeIcon } { const prLink = pullRequest.uri.toString(); - const prModelRef = reader.store.add(gitHubService.createPullRequestModelReference(gitHubInfo.owner, gitHubInfo.repo, pullRequest.number)); + const prModelRef = reader.store.add(gitHubService.createPullRequestModelReference(pullRequest.owner, pullRequest.repo, pullRequest.number)); const livePullRequest = prModelRef.object.pullRequest.read(reader); if (!livePullRequest) { - return iconCache.get(prLink) ?? pullRequest.icon ?? computePullRequestIcon(GitHubPullRequestState.Open); + return { + icon: iconCache.get(prLink) ?? pullRequest.icon ?? computePullRequestIcon(GitHubPullRequestState.Open), + title: pullRequest.title, + }; } - const icon = computeLivePullRequestIcon(reader, gitHubService, gitHubInfo.owner, gitHubInfo.repo, livePullRequest); + const icon = computeLivePullRequestIcon(reader, gitHubService, pullRequest.owner, pullRequest.repo, livePullRequest); iconCache.set(prLink, icon); - return icon; + return { icon, title: livePullRequest.title }; } diff --git a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts index 27ce297fa47d14..e7fe20f977f92f 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts @@ -224,6 +224,27 @@ suite('GitHubPullRequestPollingContribution', () => { assert.strictEqual(existingSession.isArchived.get(), false); }); + test('polls every pull request associated with a session', () => { + const gitHubInfo = makeGitHubInfo(2); + const session = sessionsManagementService.addSession('session', { + ...gitHubInfo, + pullRequests: [1, 2].map(number => ({ + owner: 'owner', + repo: 'repo', + number, + uri: URI.parse(`https://github.com/owner/repo/pull/${number}`), + })), + }); + + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); + + assert.deepStrictEqual(gitHubService.snapshot(), { + 'owner/repo/1': { startPollingCalls: 1, stopPollingCalls: 0, disposeCalls: 0 }, + 'owner/repo/2': { startPollingCalls: 1, stopPollingCalls: 0, disposeCalls: 0 }, + }); + assert.strictEqual(session.isArchived.get(), false); + }); + test('rebinds polling when a session is replaced under the same session id', () => { const provisionalSession = sessionsManagementService.addSession('session', makeGitHubInfo(1)); store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); 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 c11acf58a18a38..74201e53f9c101 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts @@ -20,7 +20,8 @@ import { GitHubApiClient } from '../../browser/githubApiClient.js'; import { GitHubPRFetcher } from '../../browser/fetchers/githubPRFetcher.js'; import { GitHubPRCIFetcher } from '../../browser/fetchers/githubPRCIFetcher.js'; import { GitHubRepositoryFetcher } from '../../browser/fetchers/githubRepositoryFetcher.js'; -import { GitHubCIOverallStatus, GitHubCheckConclusion, GitHubCheckStatus, GitHubIssueState, GitHubPullRequestState, IGitHubCICheck, IGitHubPRComment, IGitHubPullRequestReview, IGitHubPullRequest, IGitHubRepository, IGitHubPullRequestReviewThread } from '../../common/types.js'; +import { computePullRequestIcon, GitHubCIOverallStatus, GitHubCheckConclusion, GitHubCheckStatus, GitHubIssueState, GitHubPullRequestState, IGitHubCICheck, IGitHubPRComment, IGitHubPullRequestReview, IGitHubPullRequest, IGitHubRepository, IGitHubPullRequestReviewThread } from '../../common/types.js'; +import { getHighestPriorityPullRequestIcon } from '../../../../services/sessions/common/session.js'; //#region Mock Fetchers @@ -109,6 +110,27 @@ class MockCIFetcher { //#endregion +suite('Pull Request Icons', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses the highest-priority icon across pull requests', () => { + const closed = computePullRequestIcon(GitHubPullRequestState.Closed); + const merged = computePullRequestIcon(GitHubPullRequestState.Merged); + const draft = computePullRequestIcon('draft'); + const open = computePullRequestIcon(GitHubPullRequestState.Open); + const comments = computePullRequestIcon(GitHubPullRequestState.Open, { hasUnresolvedComments: true }); + const failing = computePullRequestIcon(GitHubPullRequestState.Open, { hasFailingChecks: true }); + + assert.deepStrictEqual([ + getHighestPriorityPullRequestIcon([closed, merged]), + getHighestPriorityPullRequestIcon([draft, merged, closed]), + getHighestPriorityPullRequestIcon([open, draft]), + getHighestPriorityPullRequestIcon([comments, open]), + getHighestPriorityPullRequestIcon([comments, failing, open]), + ], [merged, draft, open, comments, failing]); + }); +}); + suite('GitHubRepositoryModel', () => { const store = new DisposableStore(); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 00f1d77f2e50fa..e31007aa6415c2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -52,12 +52,12 @@ import { getRegisteredLanguageModels, resolveConfiguredModel, resolveModelIdenti import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionChatCustomization, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionChatCustomization, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { dedupeLinks, getPresentedArtifacts, linkKey, partitionSessionArtifacts } from './agentHostSessionArtifacts.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; -import { computeSessionPullRequestIcon } from '../../../github/browser/pullRequestIconStatus.js'; +import { computePullRequestRefPresentation } from '../../../github/browser/pullRequestIconStatus.js'; import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js'; import { parseGitHubPullRequestUrl } from '../../../github/common/utils.js'; import { mapProtocolStatus } from './agentHostDiffs.js'; @@ -310,9 +310,11 @@ function isGitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | undefine x.repo === y.repo && x.number === y.number && isEqual(x.uri, y.uri) && - x.icon?.id === y.icon?.id) && + x.icon?.id === y.icon?.id && + x.title === y.title) && a.pullRequest?.number === b.pullRequest?.number && a.pullRequest?.icon?.id === b.pullRequest?.icon?.id && + a.pullRequest?.title === b.pullRequest?.title && a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && a.pullRequest?.headRefOid === b.pullRequest?.headRefOid && arrayEquals(a.issues ?? [], b.issues ?? [], (x, y) => x.owner === y.owner && x.repo === y.repo && x.number === y.number); @@ -976,21 +978,19 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return baseGitHubInfo; } - const icon = computeSessionPullRequestIcon(reader, this._gitHubService, this._pullRequestIconCache, baseGitHubInfo); + const pullRequests = getGitHubPullRequestRefs(baseGitHubInfo).map(pullRequest => ({ + ...pullRequest, + ...computePullRequestRefPresentation(reader, this._gitHubService, this._pullRequestIconCache, pullRequest) + })); + const icon = pullRequests[0].icon; + const title = pullRequests[0].title; return { ...baseGitHubInfo, - // Only the main pull request is polled live; the rest fall back to - // their last-known icon so the hover can still show their state. - pullRequests: baseGitHubInfo.pullRequests?.map((pullRequest, index) => index === 0 ? { - ...pullRequest, - icon - } : { - ...pullRequest, - icon: this._pullRequestIconCache.get(pullRequest.uri.toString()) ?? pullRequest.icon - }), + pullRequests: baseGitHubInfo.pullRequests ? pullRequests : undefined, pullRequest: { ...baseGitHubInfo.pullRequest, - icon + icon, + title, } }; }); @@ -1000,7 +1000,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { if (sourceControlState?.latestOutcome === SessionSourceControlOutcome.Merge) { return { ...Codicon.gitMerge, color: themeColorFromId('charts.purple') }; } - return this.gitHubInfo.read(reader)?.pullRequest?.icon; + const gitHubInfo = this.gitHubInfo.read(reader); + return getHighestPriorityPullRequestIcon(getGitHubPullRequestRefs(gitHubInfo).map(pullRequest => pullRequest.icon)); }); const initialWorkspace = this._computeWorkspace(); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index a49074ff94e3cd..fa491d3846fbe3 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -6354,7 +6354,15 @@ suite('LocalAgentHostSessionsProvider', () => { pullRequest.set(makePullRequest(), undefined); - assert.strictEqual(updateCount, 1); + assert.deepStrictEqual({ + updateCount, + mainTitle: gitHubInfo.get()?.pullRequest?.title, + historyTitles: gitHubInfo.get()?.pullRequests?.map(ref => ref.title), + }, { + updateCount: 1, + mainTitle: 'PR', + historyTitles: ['PR'], + }); })); test.skip('keeps a resolved PR number sticky across gitHubInfo recomputes (no re-lookup / icon flap)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -6485,7 +6493,7 @@ suite('LocalAgentHostSessionsProvider', () => { { number: 41, uri: 'https://github.com/owner/repo/pull/41', - icon: undefined, + icon: computePullRequestIcon(GitHubPullRequestState.Open), }, ] }); @@ -6594,7 +6602,7 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); - test('promotes GitHub pull request and issue artifacts into GitHub info', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('promotes PR artifacts and current-branch discovery but keeps PR references out of GitHub info', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const gitHubService = new class extends mock() { private readonly _model = { pullRequest: constObservable(undefined) } as unknown as GitHubPullRequestModel; override createPullRequestModelReference = () => new ImmortalReference(this._model); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 53ebe078797846..717055c5a497a8 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -48,7 +48,7 @@ import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessi import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; -import { computeSessionPullRequestIcon } from '../../../github/browser/pullRequestIconStatus.js'; +import { computePullRequestRefPresentation } from '../../../github/browser/pullRequestIconStatus.js'; import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js'; import { structuralEquals } from '../../../../../base/common/equals.js'; import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; @@ -1014,11 +1014,19 @@ class AgentSessionAdapter implements ICopilotChatSession { if (pullRequest.uri.authority.toLowerCase() !== 'github.com') { return info; } + const presentation = computePullRequestRefPresentation(reader, this._gitHubService, this._pullRequestIconCache, { + owner: info.owner, + repo: info.repo, + number: pullRequest.number, + uri: pullRequest.uri, + icon: pullRequest.icon, + title: pullRequest.title, + }); return { ...info, pullRequest: { ...pullRequest, - icon: computeSessionPullRequestIcon(reader, this._gitHubService, this._pullRequestIconCache, info) + ...presentation, } }; }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 5be0779a5ef421..32a15488166697 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -973,11 +973,13 @@ suite('CopilotChatSessionsProvider', () => { number: beforeLiveUpdate.number, uri: beforeLiveUpdate.uri.toString(), icon: beforeLiveUpdate.icon, + title: beforeLiveUpdate.title, }, afterLiveUpdate: afterLiveUpdate && { number: afterLiveUpdate.number, uri: afterLiveUpdate.uri.toString(), icon: afterLiveUpdate.icon, + title: afterLiveUpdate.title, }, lookupCalls: gitHubService.lookupCalls, cachedIcon: iconCache.get('https://github.com/owner/repo/pull/42'), @@ -988,11 +990,13 @@ suite('CopilotChatSessionsProvider', () => { number: 42, uri: 'https://github.com/owner/repo/pull/42', icon: computePullRequestIcon(GitHubPullRequestState.Open), + title: undefined, }, afterLiveUpdate: { number: 42, uri: 'https://github.com/owner/repo/pull/42', icon: computePullRequestIcon(GitHubPullRequestState.Merged), + title: 'Cloud PR', }, lookupCalls: 1, cachedIcon: computePullRequestIcon(GitHubPullRequestState.Merged), diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts index a50c1faef98571..13d6932de17a7c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts @@ -10,9 +10,10 @@ import { agentsWindowAgentHostClientInfo } from '../../../../../platform/agentHo import { AgentHostClientConnectionKind } from '../../../../../platform/agentHost/common/agentHostTelemetry.js'; import { IRemoteAgentHostLocationPreferenceService } from '../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ReconnectingTransport, type IEstablishedTransport } from '../../../../../platform/agentHost/common/reconnectingTransport.js'; import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import type { IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; +import { NonReconnectableTransportError, type IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; import { TunnelAgentHostConnector, parseTunnelInfo, @@ -23,6 +24,7 @@ import { } from '../../../../../platform/agentHost/common/tunnelAgentHostConnector.js'; import { isTunnelGatewaySelectionRejectedError, + isTunnelNotFoundError, TUNNEL_ADDRESS_PREFIX, TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION, TUNNEL_LAUNCHER_LABEL, @@ -231,9 +233,57 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel if (!result) { return; } - const transport = new BrowserTunnelConnectionTransport(result.connectionId, this._connector, this._logService); + let useSeedConnection = true; + const establish = async (): Promise => { + if (useSeedConnection) { + useSeedConnection = false; + // The initial relay is already owned by the transport established for this managed connection. + return { transport: new BrowserTunnelConnectionTransport(result.connectionId, this._connector, this._logService) }; + } + + const authForReconnect = await this._getTokenForProvider(auth.provider, true); + if (!authForReconnect) { + throw new NonReconnectableTransportError('No cached authentication available to reconnect the tunnel.'); + } + + try { + const reconnected = await connectThroughTunnelGateway( + this._connector, + this._resolveGatewaySelection, + this._locationPreferenceService, + this._dialogService, + this._productService.nameShort, + authForReconnect, + tunnel, + false, + ); + if (!reconnected) { + throw new NonReconnectableTransportError('Tunnel agent host selection requires user interaction.'); + } + try { + return { + transport: new BrowserTunnelConnectionTransport(reconnected.connectionId, this._connector, this._logService), + close: () => this._connector.disconnect(reconnected.connectionId), + }; + } catch (error) { + await this._connector.disconnect(reconnected.connectionId); + throw error; + } + } catch (error) { + if (isTunnelNotFoundError(error)) { + throw new NonReconnectableTransportError(error.message); + } + throw error; + } + }; + const transportFactory = () => new ReconnectingTransport( + establish, + this._logService, + LOG_PREFIX, + AgentHostClientConnectionKind.DevTunnel, + ); const protocolClient = this._instantiationService.createInstance( - AgentHostProtocolClient, result.address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, + AgentHostProtocolClient, result.address, transportFactory, { clientInfo: agentsWindowAgentHostClientInfo }, ); let status: RemoteAgentHostConnectionStatus = RemoteAgentHostConnectionStatus.connected; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts index 9fb0a8c27bd40a..6832d81a9cc839 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts @@ -151,7 +151,7 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa // Mission Control mints the client id and binds the relay lane to it, so the AHP identity // must match or the host rejects requests on that lane. const protocolClient = this._instantiationService.createInstance( - AgentHostProtocolClient, address, transportFactory, undefined, clientToken.client_id, editorWindowAgentHostClientInfo, + AgentHostProtocolClient, address, transportFactory, { clientId: clientToken.client_id, clientInfo: editorWindowAgentHostClientInfo }, ); let status: RemoteAgentHostConnectionStatus = RemoteAgentHostConnectionStatus.connected; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts index 6c3469313f5619..46186e44f24a82 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts @@ -68,7 +68,7 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont connect(workspaceUri: URI, token: CancellationToken): Promise { const key = getComparisonKey(workspaceUri); const active = this._activeConnections.get(key); - if (active && this._isConnected(active.address)) { + if (active && this._isConnectedOrReconnecting(active.address)) { return Promise.resolve(this._acquireConnection(key, active)); } const pending = this._pendingConnections.get(key); @@ -232,9 +232,10 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont this._providerStores.deleteAndDispose(key); } - private _isConnected(address: string): boolean { + private _isConnectedOrReconnecting(address: string): boolean { return this._remoteAgentHostService.connections.some(connection => - connection.address === address && RemoteAgentHostConnectionStatus.isConnected(connection.status) + connection.address === address + && (RemoteAgentHostConnectionStatus.isConnected(connection.status) || RemoteAgentHostConnectionStatus.isReconnecting(connection.status)) ); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/manageRemoteAgentHosts.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/manageRemoteAgentHosts.ts index 052d3a190f8de6..ebf9bdc32fd478 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/manageRemoteAgentHosts.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/manageRemoteAgentHosts.ts @@ -13,6 +13,7 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TUNNEL_ADDRESS_PREFIX } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { Menus } from '../../../../browser/menus.js'; @@ -49,6 +50,7 @@ registerAction2(class extends Action2 { const quickInputService = accessor.get(IQuickInputService); const sessionsProvidersService = accessor.get(ISessionsProvidersService); const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const configurationService = accessor.get(IConfigurationService); const menuService = accessor.get(IMenuService); const contextKeyService = accessor.get(IContextKeyService); const commandService = accessor.get(ICommandService); @@ -144,7 +146,7 @@ registerAction2(class extends Action2 { store.add(picker.onDidTriggerItemButton(async e => { if (e.item.kind === 'remote' && e.button === removeButton) { - await removeRemoteHost(e.item.provider, remoteAgentHostService); + await removeRemoteHost(e.item.provider, remoteAgentHostService, configurationService); // onDidChangeProviders will refresh } })); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/managedReconnectAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/managedReconnectAgentHostContribution.ts index b9234aa8fc8dd4..c8db25ed0eb011 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/managedReconnectAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/managedReconnectAgentHostContribution.ts @@ -7,6 +7,7 @@ import { disposableTimeout } from '../../../../../base/common/async.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { hasExhaustedReconnectAttempts, type IRemoteAgentHostReconnectPolicy } from '../../../../../platform/agentHost/common/reconnectPolicy.js'; import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -68,8 +69,8 @@ export interface IManagedReconnectAttemptOptions { readonly address: string; /** Whether the attempt was triggered by an explicit user action. */ readonly userInitiated: boolean; - /** Consecutive failures before pausing auto-reconnect. */ - readonly maxAttempts: number; + /** Policy controlling automatic reconnect attempts. */ + readonly reconnectPolicy: IRemoteAgentHostReconnectPolicy; /** Whether the given error should pause (rather than retry) auto-reconnect. */ readonly shouldPause: (err: unknown) => boolean; /** @@ -182,7 +183,7 @@ export abstract class ManagedReconnectAgentHostContribution extends Disposable { /** * Shared retry-loop body for managed-reconnect entries. Handles - * `connecting`/`disconnected`/`incompatible` provider status, cached-session + * `connecting`/`reconnecting`/`disconnected`/`incompatible` provider status, cached-session * unpublishing on failure, pause-on-cancel, and pause-after-max-attempts. * Type-specific behaviour is provided via {@link IManagedReconnectAttemptOptions}. */ @@ -190,6 +191,15 @@ export abstract class ManagedReconnectAgentHostContribution extends Disposable { // Wrap the body so we can store our own promise in `_pendingReconnects` // for concurrent on-demand callers to join. const runPromise = (async () => { + const live = this._remoteAgentHostService.connections.find(connection => connection.address === opts.address); + if (!opts.userInitiated && RemoteAgentHostConnectionStatus.isConnecting(live?.status)) { + return; + } + if (!opts.userInitiated && RemoteAgentHostConnectionStatus.isReconnecting(live?.status)) { + // The protocol client is preserving its state while it reconnects; don't replace it. + this._reconnectStates.get(opts.key)?.cancelTimer(); + return; + } const state = this._getOrCreateReconnectState(opts.key); const attempt = state.attempts; const provider = this._providerInstances.get(opts.address); @@ -246,7 +256,7 @@ export abstract class ManagedReconnectAgentHostContribution extends Disposable { // reconnect was in flight — re-resolve to be safe. const liveState = this._getOrCreateReconnectState(opts.key); liveState.attempts = attempt + 1; - if (liveState.attempts >= opts.maxAttempts) { + if (hasExhaustedReconnectAttempts(opts.reconnectPolicy, liveState.attempts)) { this._logService.info(`[RemoteAgentHost] Pausing ${opts.kind} auto-reconnect for ${opts.key} after ${liveState.attempts} consecutive failures`); liveState.paused = true; liveState.pausedAt = Date.now(); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 0ad92775abc831..0d5110d609ba0c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -87,7 +87,9 @@ async function waitForRemoteAgentHostActivation(accessor: ServicesAccessor, sess } const connectionInfo = remoteAgentHostService.connections.find(connection => connection.address === address); - if (connectionInfo && !RemoteAgentHostConnectionStatus.isConnecting(connectionInfo.status)) { + if (connectionInfo + && !RemoteAgentHostConnectionStatus.isConnecting(connectionInfo.status) + && !RemoteAgentHostConnectionStatus.isReconnecting(connectionInfo.status)) { return false; } @@ -113,44 +115,21 @@ function getAddressForSessionType(sessionType: string, remoteAgentHostService: I return authority ? authorities.get(authority) : undefined; } -/** Initial auto-reconnect delay after a failed SSH reconnect attempt. */ -const SSH_RECONNECT_INITIAL_DELAY = 1000; -/** Maximum auto-reconnect backoff delay for SSH. */ -const SSH_RECONNECT_MAX_DELAY = 30_000; /** - * Consecutive SSH reconnect failures before pausing auto-reconnect. We resume - * when the user changes config, when {@link _reconcile} is otherwise triggered - * (e.g. a new connection arrives), or when {@link _resumeSSHReconnects} is - * explicitly invoked. This bounds noise from a permanently-dead host while - * still being responsive to "the network just came back". - */ -const SSH_RECONNECT_MAX_ATTEMPTS = 10; -/** - * After this much wall-clock time, a paused auto-reconnect is automatically - * resumed by the periodic reconcile. Covers the case where reconnect attempts - * all failed quickly (e.g. network not ready right after sleep), exhausted the - * attempt budget, and no other trigger (config change, connection event) fired - * to give them a fresh chance. - */ -const SSH_RECONNECT_PAUSE_AUTO_RESUME_MS = 5 * 60 * 1000; // 5 minutes -/** - * How often the periodic reconcile backstop runs. This fires {@link _reconcile} - * even when no event arrives, so a broken event chain doesn't leave SSH hosts - * disconnected indefinitely. + * How often the periodic provider reconciliation backstop runs. */ const SSH_RECONNECT_PERIODIC_INTERVAL_MS = 60_000; // 1 minute /** - * Per-host SSH auto-reconnect state. Owned by {@link RemoteAgentHostContribution._sshReconnectStates} - * which disposes the entry — and therefore the pending timer — when the host - * is no longer configured or when the contribution itself is disposed. + * Per-host SSH reconnect state used to preserve user-requested pause and + * resume behavior. Owned by {@link RemoteAgentHostContribution._sshReconnectStates}. */ export class SSHReconnectState extends Disposable { private readonly _timer = this._register(new MutableDisposable()); /** Consecutive failed reconnect attempts. */ attempts = 0; - /** True after we've given up auto-reconnecting until something resumes us. */ + /** True after a reconnect was paused until something resumes it. */ paused = false; /** Wall-clock timestamp when {@link paused} was last set to true. */ pausedAt = 0; @@ -213,9 +192,8 @@ export function sshConnectionKey(connection: IRemoteAgentHostSSHConnection): str * Order matters: `removeRemoteAgentHost` MUST run before the SSH tunnel * teardown. `sshService.disconnect()` fires `onDidCloseConnection` * synchronously, which the renderer translates into `onDidChangeConnections` - * and the contribution's `_reconcile` → `_reconnectSSHEntries`. If the entry - * is still in configured storage at that point, the auto-reconnect path - * immediately reconnects the host we just told it to disconnect. + * and the contribution's reconciliation. If the entry is still in configured + * storage at that point, it can be surfaced again before teardown completes. * * `removeRemoteAgentHost` itself runs the entry's transport disposable * (which calls `_mainService.disconnect(connectionId)`), so the underlying @@ -272,13 +250,11 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc /** * In-flight reconnect attempts keyed by host id (`sshConfigHost` for SSH, * `distro` for WSL). Stores the {@link _attemptManagedReconnect} promise - * so concurrent on-demand callers (e.g. a user click on "Select..." while - * the periodic poll is already reconnecting) join the existing attempt - * rather than racing it. + * so concurrent user requests join the existing attempt rather than racing it. */ private readonly _pendingSSHReconnects = new Map>(); - /** Per-host SSH auto-reconnect state (timer + attempts + paused). */ + /** Per-host SSH reconnect state (timer + attempts + paused). */ private readonly _sshReconnectStates = this._register(new DisposableMap()); constructor( @@ -304,8 +280,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Reconcile providers when configured entries change this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId) || e.affectsConfiguration(RemoteAgentHostAutoConnectSettingId)) { - // User changed config — give paused auto-reconnect a fresh chance. + if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { + // User changed config — reset any paused on-demand state. this._resumeSSHReconnects(); this._reconcile(); } @@ -313,8 +289,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Reconcile when connections change (added/removed/reconnected) this._register(this._remoteAgentHostService.onDidChangeConnections(() => { - // New/removed connection — paused auto-reconnect may have been - // caused by a transient outage that's now resolved. + // New/removed connection gives paused on-demand state a fresh start. this._resumeSSHReconnects(); this._reconcile(); })); @@ -329,9 +304,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Initial setup for configured entries and connected remotes this._reconcile(); - // Periodic backstop: even if the event-driven chain breaks (e.g. IPC - // delivery fails after a sleep/wake cycle), this ensures we retry SSH - // reconnects and reconcile providers at most once per minute. + // Periodic backstop: reconcile provider state even if the event-driven + // chain breaks after a sleep/wake cycle. this._register(new IntervalTimer()).cancelAndSet( () => { this._logService.trace('[RemoteAgentHost] Periodic reconcile (backstop)'); @@ -344,7 +318,6 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc private _reconcile(): void { this._reconcileProviders(); this._reconcileConnections(); - this._reconnectSSHEntries(); // Ensure every live connection is wired to its provider. This covers // the case where a provider was recreated (e.g. name change) while a @@ -364,8 +337,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc if (connectionInfo) { // Service has an entry for this address — its status is // authoritative (including the `incompatible` set by the - // WebSocket connect failure path, and the `connecting` - // status of a fresh reconnect attempt after an upgrade). + // WebSocket connect failure path, and the `connecting` or + // `reconnecting` status of a fresh reconnect attempt). provider.setConnectionStatus(connectionInfo.status); } else if (!RemoteAgentHostConnectionStatus.isIncompatible(provider.connectionStatus.get())) { // No service entry. Preserve incompatible state set by @@ -433,78 +406,6 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._providerStores.set(address, store); } - /** - * Re-establish SSH connections for configured entries that have an - * sshConfigHost but no active connection. Schedules retries with - * exponential backoff on failure so a transient outage doesn't leave - * the host stuck "disconnected" until the next config / connection - * change. Auto-reconnect pauses after {@link SSH_RECONNECT_MAX_ATTEMPTS} - * consecutive failures and resumes when {@link _reconcile} runs again - * (config change, connection event) or {@link _resumeSSHReconnects} is - * called. - */ - private _reconnectSSHEntries(): void { - if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { - this._sshReconnectStates.clearAndDisposeAll(); - return; - } - - const autoConnect = this._configurationService.getValue(RemoteAgentHostAutoConnectSettingId); - const entries = this._remoteAgentHostService.configuredEntries; - const stillConfigured = new Set(); - for (const entry of entries) { - if (entry.connection.type !== RemoteAgentHostEntryType.SSH || !entry.connection.sshConfigHost) { - continue; - } - const sshConfigHost = entry.connection.sshConfigHost; - stillConfigured.add(sshConfigHost); - const address = getEntryAddress(entry); - // Skip if already connected: clear any retry state. - const hasConnection = this._remoteAgentHostService.connections.some( - c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status) - ); - if (hasConnection) { - this._sshReconnectStates.deleteAndDispose(sshConfigHost); - continue; - } - if (this._pendingSSHReconnects.has(sshConfigHost)) { - this._logService.trace(`[RemoteAgentHost] SSH reconnect for ${sshConfigHost}: reconnect already in progress, skipping`); - continue; - } - const state = this._sshReconnectStates.get(sshConfigHost); - if (state?.hasPendingTimer) { - this._logService.trace(`[RemoteAgentHost] SSH reconnect for ${sshConfigHost}: retry timer already scheduled, skipping`); - continue; - } - if (state?.paused) { - if (state.requiresUserInitiatedResume) { - this._logService.trace(`[RemoteAgentHost] SSH reconnect for ${sshConfigHost}: waiting for a user-initiated reconnect`); - continue; - } - const pausedMs = Date.now() - state.pausedAt; - if (pausedMs < SSH_RECONNECT_PAUSE_AUTO_RESUME_MS) { - this._logService.trace(`[RemoteAgentHost] SSH reconnect for ${sshConfigHost}: paused (${Math.round(pausedMs / 1000)}s ago), skipping`); - continue; - } - // Pause duration exceeded — give it another chance automatically. - this._logService.info(`[RemoteAgentHost] SSH reconnect for ${sshConfigHost}: auto-resuming after ${Math.round(pausedMs / 1000)}s pause`); - state.resetForResume(); - } - if (!autoConnect) { - this._logService.trace(`[RemoteAgentHost] SSH reconnect for ${sshConfigHost}: auto-connect disabled, skipping`); - continue; - } - void this._attemptSSHReconnect(sshConfigHost, entry.name, address); - } - - // Drop retry state for hosts that are no longer configured. - for (const host of [...this._sshReconnectStates.keys()]) { - if (!stillConfigured.has(host)) { - this._sshReconnectStates.deleteAndDispose(host); - } - } - } - private async _connectSSHOnDemand(connection: IRemoteAgentHostSSHConnection, name: string, address: string): Promise { const sshConfigHost = connection.sshConfigHost; if (!sshConfigHost) { @@ -561,45 +462,14 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc key: sshConfigHost, address, userInitiated: !!options.userInitiated, - maxAttempts: SSH_RECONNECT_MAX_ATTEMPTS, shouldPause: shouldPauseSSHReconnectAfterFailure, pending: this._pendingSSHReconnects, states: this._sshReconnectStates, getOrCreateState: key => this._getOrCreateSSHReconnectState(key), - // Thread userInitiated through to the actual reconnect() call, not - // just the local bookkeeping above: a silent/background attempt - // (the default here — options.userInitiated is only set `true` - // by the on-demand connect path) must never open the - // endpoint-selection picker and must never silently attach to an - // `editor`-owned endpoint, per the SSH service's selection policy. - doConnect: () => this._sshService.reconnect(sshConfigHost, name, !!options.userInitiated).then(() => undefined), - schedule: state => this._scheduleSSHReconnect(sshConfigHost, name, address, state as SSHReconnectState), - }); - } - - private _scheduleSSHReconnect(sshConfigHost: string, name: string, address: string, state: SSHReconnectState): void { - const delay = Math.min(SSH_RECONNECT_INITIAL_DELAY * Math.pow(2, state.attempts - 1), SSH_RECONNECT_MAX_DELAY); - this._logService.info(`[RemoteAgentHost] Scheduling SSH reconnect for ${sshConfigHost} in ${delay}ms (attempt ${state.attempts + 1}/${SSH_RECONNECT_MAX_ATTEMPTS})`); - state.scheduleRetry(delay, () => { - // Re-check eligibility — config might have changed, or a manual - // connect might have succeeded while we were waiting. - if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { - this._sshReconnectStates.deleteAndDispose(sshConfigHost); - return; - } - const autoConnect = this._configurationService.getValue(RemoteAgentHostAutoConnectSettingId); - if (!autoConnect) { - return; - } - const live = this._remoteAgentHostService.connections.find(c => c.address === address); - if (live && RemoteAgentHostConnectionStatus.isConnected(live.status)) { - this._sshReconnectStates.deleteAndDispose(sshConfigHost); - return; - } - if (this._pendingSSHReconnects.has(sshConfigHost)) { - return; - } - void this._attemptSSHReconnect(sshConfigHost, name, address); + doConnect: async () => { + this._remoteAgentHostService.reconnect(address, !!options.userInitiated); + await this._remoteAgentHostService.waitForConnection(address); + }, }); } @@ -613,9 +483,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } /** - * Resume SSH auto-reconnect for any paused hosts. Called by the reconcile - * path so that a fresh trigger (config change, new connection event) gives - * paused hosts another chance. + * Reset paused SSH reconnect state after a fresh external trigger. */ private _resumeSSHReconnects(): void { let resumed = 0; @@ -625,14 +493,14 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } } if (resumed > 0) { - this._logService.info(`[RemoteAgentHost] Resuming SSH auto-reconnect for ${resumed} paused host(s)`); + this._logService.info(`[RemoteAgentHost] Reset SSH reconnect state for ${resumed} paused host(s)`); } } /** * Shared retry-loop body for SSH managed-reconnect entries. * - * Handles `connecting`/`disconnected`/`incompatible` provider status, + * Handles `connecting`/`reconnecting`/`disconnected`/`incompatible` provider status, * cached-session unpublishing on failure, pause-on-cancel, and * pause-after-max-attempts. An optional pre-check can bail out without * incrementing the attempt counter (returns `{ skip: true }`). @@ -642,19 +510,26 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc readonly key: string; readonly address: string; readonly userInitiated: boolean; - readonly maxAttempts: number; readonly shouldPause: (err: unknown) => boolean; readonly pending: Map>; readonly states: DisposableMap; readonly getOrCreateState: (key: string) => SSHReconnectState; readonly preCheck?: (userInitiated: boolean) => Promise<{ readonly skip: boolean; readonly reason?: string } | undefined>; readonly doConnect: () => Promise; - readonly schedule: (state: SSHReconnectState) => void; }): Promise { // Wrap the body so we can store our own promise in `opts.pending` for // concurrent on-demand callers to join. The inner IIFE keeps the // existing control flow intact; only the bookkeeping moves out. const runPromise = (async () => { + const live = this._remoteAgentHostService.connections.find(connection => connection.address === opts.address); + if (!opts.userInitiated && RemoteAgentHostConnectionStatus.isConnecting(live?.status)) { + return; + } + if (!opts.userInitiated && RemoteAgentHostConnectionStatus.isReconnecting(live?.status)) { + // The protocol client is preserving its state while it reconnects; don't replace it. + this._sshReconnectStates.get(opts.key)?.cancelTimer(); + return; + } const state = opts.getOrCreateState(opts.key); const attempt = state.attempts; const provider = this._providerInstances.get(opts.address); @@ -688,14 +563,13 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc const enabled = this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); const pause = opts.shouldPause(err); const incompatible = RemoteAgentHostConnectionStatus.fromConnectError(err, [PROTOCOL_VERSION]); - const willRetry = enabled && !opts.userInitiated && !pause && !incompatible && attempt + 1 < opts.maxAttempts; logSSHConnectAttempt(this._telemetryService, { operation: 'reconnect', userInitiated: opts.userInitiated, attempt: attempt + 1, durationMs: stopwatch.elapsed(), success: false, - willRetry, + willRetry: false, errorCategory: categorizeSSHConnectError(err), }); if (!enabled) { @@ -707,7 +581,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } if (pause) { const requiresUserInitiatedResume = isSSHHostKeyDeniedError(err); - this._logService.info(`[RemoteAgentHost] Pausing ${opts.kind} auto-reconnect for ${opts.key} after ${requiresUserInitiatedResume ? 'host key denial' : 'user cancellation'}`); + this._logService.info(`[RemoteAgentHost] Pausing ${opts.kind} reconnect for ${opts.key} after ${requiresUserInitiatedResume ? 'host key denial' : 'user cancellation'}`); provider?.unpublishCachedSessions(); const liveState = opts.getOrCreateState(opts.key); liveState.paused = true; @@ -731,20 +605,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // were showing so the UI doesn't list stale entries for a // host we cannot currently reach. provider?.unpublishCachedSessions(); - // State may have been cleared (e.g. host removed) while the - // reconnect was in flight — re-resolve to be safe. - const liveState = opts.getOrCreateState(opts.key); - liveState.attempts = attempt + 1; - if (liveState.attempts >= opts.maxAttempts) { - this._logService.info(`[RemoteAgentHost] Pausing ${opts.kind} auto-reconnect for ${opts.key} after ${liveState.attempts} consecutive failures`); - liveState.paused = true; - liveState.pausedAt = Date.now(); - return; - } - if (opts.userInitiated) { - return; - } - opts.schedule(liveState); + return; } })(); opts.pending.set(opts.key, runPromise); @@ -1144,7 +1005,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis }, [RemoteAgentHostAutoConnectSettingId]: { type: 'boolean', - description: nls.localize('chat.remoteAgentHosts.autoConnect', "Automatically connect to online dev tunnel and SSH-configured remote agent hosts on startup. When disabled, cached sessions are still shown but connections are established only on demand."), + description: nls.localize('chat.remoteAgentHosts.autoConnect', "Automatically connect to online dev tunnel and WSL remote agent hosts on startup. When disabled, cached sessions are still shown but connections are established only on demand."), default: true, scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts index ff45b47b34bab9..931ec5f2cdbae4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -25,7 +25,8 @@ import { ITunnelHostService } from '../../../../../workbench/contrib/chat/common import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { addWebSocketRemoteAgentHostEntry, IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ISSHRemoteAgentHostService, isSSHHostKeyDeniedError, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHResolvedConfig } from '../../../../../platform/agentHost/common/sshRemoteAgentHost.js'; import { isTunnelHosted, ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { IWSLRemoteAgentHostService, WSL_INSTALL_DOCS_URL, type IWSLDistro } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; @@ -74,6 +75,7 @@ registerAction2(class extends Action2 { const remoteAgentHostService = accessor.get(IRemoteAgentHostService); const quickInputService = accessor.get(IQuickInputService); const notificationService = accessor.get(INotificationService); + const configurationService = accessor.get(IConfigurationService); // Prompt for address const address = await quickInputService.input({ @@ -117,7 +119,7 @@ registerAction2(class extends Action2 { // Connect try { - await remoteAgentHostService.addRemoteAgentHost({ + await addWebSocketRemoteAgentHostEntry(configurationService, { name: name.trim(), connectionToken: parsed.parsed.connectionToken, connection: { @@ -125,6 +127,7 @@ registerAction2(class extends Action2 { address: parsed.parsed.address, }, }); + await remoteAgentHostService.waitForConnection(parsed.parsed.address); } catch { notificationService.error(localize('addRemoteFailed', "Failed to connect to remote agent host {0}.", parsed.parsed.address)); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 188d479a312673..0065159eb1b85d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -19,7 +19,7 @@ import { AGENT_HOST_SCHEME, agentHostAuthority, type AgentHostUriMapper, fromAge import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; -import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, removeWebSocketRemoteAgentHostEntry, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -354,6 +354,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid await this._disconnectOnDemand(); return; } + await removeWebSocketRemoteAgentHostEntry(this._configurationService, this.remoteAddress); await this._remoteAgentHostService.removeRemoteAgentHost(this.remoteAddress); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts index 5ea37b5db22541..56da56d077efa5 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts @@ -10,7 +10,8 @@ import { timeout } from '../../../../../base/common/async.js'; import { autorun } from '../../../../../base/common/observable.js'; import { toAction } from '../../../../../base/common/actions.js'; import Severity from '../../../../../base/common/severity.js'; -import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, removeWebSocketRemoteAgentHostEntry, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TUNNEL_ADDRESS_PREFIX } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { IRemoteAgentHostLocationPreferenceService } from '../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; import { promptRemoteAgentHostLocationPreference } from '../../../../../platform/agentHost/common/remoteAgentHostLocationPreferenceDialog.js'; @@ -19,6 +20,8 @@ import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.j import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { ISSHRemoteAgentHostService } from '../../../../../platform/agentHost/common/sshRemoteAgentHost.js'; import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { INotificationService, Severity as NotificationSeverity } from '../../../../../platform/notification/common/notification.js'; @@ -32,16 +35,19 @@ export async function reconnectRemoteHost(provider: IAgentHostSessionsProvider, } } -export async function removeRemoteHost(provider: IAgentHostSessionsProvider, remoteAgentHostService: IRemoteAgentHostService): Promise { +export async function removeRemoteHost(provider: IAgentHostSessionsProvider, remoteAgentHostService: IRemoteAgentHostService, configurationService: IConfigurationService): Promise { if (provider.disconnect) { await provider.disconnect(); } else if (provider.remoteAddress) { + await removeWebSocketRemoteAgentHostEntry(configurationService, provider.remoteAddress); await remoteAgentHostService.removeRemoteAgentHost(provider.remoteAddress); } } export function hasUpgradeReconnectStarted(status: RemoteAgentHostConnectionStatus): boolean { - return RemoteAgentHostConnectionStatus.isConnecting(status) || RemoteAgentHostConnectionStatus.isConnected(status); + return RemoteAgentHostConnectionStatus.isConnecting(status) + || RemoteAgentHostConnectionStatus.isReconnecting(status) + || RemoteAgentHostConnectionStatus.isConnected(status); } /** @@ -206,6 +212,8 @@ export function getStatusLabel(status: RemoteAgentHostConnectionStatus): string return localize('workspacePicker.statusOnline', "Online"); case 'connecting': return localize('workspacePicker.statusConnecting', "Connecting"); + case 'reconnecting': + return localize('workspacePicker.statusReconnecting', "Reconnecting"); case 'disconnected': return localize('workspacePicker.statusOffline', "Offline"); case 'incompatible': @@ -223,6 +231,10 @@ export function getStatusHover(status: RemoteAgentHostConnectionStatus, address? return address ? localize('workspacePicker.hoverConnectingAddr', "Attempting to connect to remote agent host...\n\nAddress: {0}", address) : localize('workspacePicker.hoverConnecting', "Attempting to connect to remote agent host..."); + case 'reconnecting': + return address + ? localize('workspacePicker.hoverReconnectingAddr', "The remote agent host connection dropped and is being restored automatically.\n\nAddress: {0}", address) + : localize('workspacePicker.hoverReconnecting', "The remote agent host connection dropped and is being restored automatically."); case 'disconnected': return address ? localize('workspacePicker.hoverDisconnectedAddr', "Remote agent host is disconnected.\n\nAddress: {0}", address) @@ -271,6 +283,23 @@ export function supportsRemoteAgentHostLocationPreference(preferenceKey: string, return preferenceKey.startsWith(SSH_ADDRESS_PREFIX) || preferenceKey.startsWith(TUNNEL_ADDRESS_PREFIX); } +/** + * Whether a host's connection details are authored in the user's SSH config + * file rather than in VS Code settings. + * + * True only for SSH hosts identified by an `~/.ssh/config` alias: + * `computeSSHConnectionKey` keys those as `ssh:`, while an SSH host + * connected with explicit credentials keys as `user@host:port` and has no + * file to open. Other kinds keep their entries in settings or in runtime-only + * storage. Always `false` on web, where SSH hosts cannot exist. + */ +export function usesSSHConfigFile(preferenceKey: string, isWebPlatform: boolean = isWeb): boolean { + if (isWebPlatform) { + return false; + } + return preferenceKey.startsWith(SSH_ADDRESS_PREFIX); +} + export type RemoteOptionPickItem = IQuickPickItem & { id: string }; export interface IBuildRemoteHostOptionItemsOptions { @@ -307,8 +336,12 @@ export function buildRemoteHostOptionItems(options: IBuildRemoteHostOptionItemsO items.push( { label: '$(trash) ' + localize('workspacePicker.removeRemote', "Remove Remote"), id: 'remove' }, { label: '$(copy) ' + localize('workspacePicker.copyAddress', "Copy Address"), id: 'copy' }, - { label: '$(settings-gear) ' + localize('workspacePicker.openSettings', "Open Settings"), id: 'settings' }, ); + // An SSH host aliased in `~/.ssh/config` is authored there, not in + // settings, so point at the file that actually defines it. + items.push(usesSSHConfigFile(options.preferenceKey ?? options.address, options.isWebPlatform ?? isWeb) + ? { label: '$(settings-gear) ' + localize('workspacePicker.openSSHConfig', "Open SSH Configuration"), id: 'sshConfig' } + : { label: '$(settings-gear) ' + localize('workspacePicker.openSettings', "Open Settings"), id: 'settings' }); if (supportsRemoteAgentHostLocationPreference(options.preferenceKey ?? options.address, options.isWebPlatform ?? isWeb)) { items.push({ label: '$(server-process) ' + localize('workspacePicker.changeLocationPreference', "Change Preferred Agent Location"), id: 'locationPreference' }); } @@ -413,6 +446,9 @@ export async function showRemoteHostOptions(accessor: ServicesAccessor, provider const dialogService = accessor.get(IDialogService); const notificationService = accessor.get(INotificationService); const progressService = accessor.get(IProgressService); + const configurationService = accessor.get(IConfigurationService); + const sshService = accessor.get(ISSHRemoteAgentHostService); + const editorService = accessor.get(IEditorService); // IRemoteAgentHostLocationPreferenceService is only registered on // desktop (see sessions.desktop.main.ts) - the web tunnel service does // not honor a preference at all, so avoid resolving an unregistered @@ -485,7 +521,7 @@ export async function showRemoteHostOptions(accessor: ServicesAccessor, provider await reconnectRemoteHost(provider, remoteAgentHostService); break; case 'remove': - await removeRemoteHost(provider, remoteAgentHostService); + await removeRemoteHost(provider, remoteAgentHostService, configurationService); break; case 'copy': await clipboardService.writeText(address); @@ -493,6 +529,14 @@ export async function showRemoteHostOptions(accessor: ServicesAccessor, provider case 'settings': await preferencesService.openSettings({ query: 'chat.remoteAgentHosts' }); break; + case 'sshConfig': { + // `ensureUserSSHConfig` creates `~/.ssh/config` with the right + // permissions when it does not exist yet, so this is safe for a + // host resolved through a system-wide config. + const configResource = await sshService.ensureUserSSHConfig(); + await editorService.openEditor({ resource: configResource, options: { pinned: true } }); + break; + } case 'locationPreference': // Only reachable when buildRemoteHostOptionItems() offered the // item, i.e. never on web - locationPreferenceService is diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index 5f4f585f21e74a..24978011ed89d1 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -7,7 +7,8 @@ import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../ import { isWeb } from '../../../../../base/common/platform.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import * as nls from '../../../../../nls.js'; -import { IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, getEntryTypeConfig } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { computeReconnectDelay, hasExhaustedReconnectAttempts } from '../../../../../platform/agentHost/common/reconnectPolicy.js'; import { isTunnelHosted, ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -28,17 +29,6 @@ import { watchForIncompatibleNotifications } from './remoteHostOptions.js'; /** Minimum interval between silent status checks (5 minutes). */ const STATUS_CHECK_INTERVAL = 5 * 60 * 1000; -/** Initial auto-reconnect delay after an unexpected tunnel disconnect. */ -const RECONNECT_INITIAL_DELAY = 1000; -/** Maximum auto-reconnect backoff delay. */ -const RECONNECT_MAX_DELAY = 30_000; -/** - * Consecutive failures before pausing auto-reconnect. We resume immediately - * when the window regains focus, so this is - * mostly a guard against a permanently dead tunnel. - */ -const RECONNECT_MAX_ATTEMPTS = 10; - /** Minimum gap between event-triggered reconnect resumes. */ const RESUME_RATE_LIMIT_MS = 10_000; @@ -264,8 +254,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc if (connectionInfo) { // Service has an entry — its status is authoritative // (including incompatible from the WebSocket connect - // failure path, and connecting/connected from a fresh - // reconnect after an upgrade). + // failure path, and connecting/reconnecting/connected from a + // fresh reconnect after an upgrade). provider.setConnectionStatus(connectionInfo.status); continue; } @@ -301,8 +291,10 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc provider.setConnection(connection, connectionInfo.defaultDirectory); this._wiredAddresses.add(address); } - } else if (this._wiredAddresses.has(address) && !RemoteAgentHostConnectionStatus.isConnecting(connectionInfo?.status)) { - // Keep the provider live while a replacement transport is connecting. + } else if (this._wiredAddresses.has(address) + && !RemoteAgentHostConnectionStatus.isConnecting(connectionInfo?.status) + && !RemoteAgentHostConnectionStatus.isReconnecting(connectionInfo?.status)) { + // Keep the provider live while a replacement transport is connecting or reconnecting. this._wiredAddresses.delete(address); provider.clearConnection(); } @@ -334,6 +326,13 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._logService.info(`[TunnelAgentHost] Skipping background connect for user-disconnected tunnel ${address}`); return Promise.resolve(); } + const live = this._remoteAgentHostService.connections.find(connection => connection.address === address); + if (!options.userInitiated && RemoteAgentHostConnectionStatus.isConnecting(live?.status)) { + return Promise.resolve(); + } + if (!options.userInitiated && RemoteAgentHostConnectionStatus.isReconnecting(live?.status)) { + return Promise.resolve(); + } if (options.userInitiated) { this._tunnelService.clearAutoConnectSuppression(tunnelId); // Clear any sticky `incompatible` state so this attempt can @@ -545,23 +544,31 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._clearReconnectBackoff(address); return; } + if (live && RemoteAgentHostConnectionStatus.isConnecting(live.status)) { + return; + } + if (live && RemoteAgentHostConnectionStatus.isReconnecting(live.status)) { + // The protocol client is preserving its state while it reconnects; don't replace it. + return; + } // Cancel any existing timer — we're rescheduling. this._cancelReconnect(address); const attempt = this._reconnectAttempts.get(address) ?? 0; - if (attempt >= RECONNECT_MAX_ATTEMPTS) { + const reconnectPolicy = getEntryTypeConfig(RemoteAgentHostEntryType.Tunnel).reconnect; + if (hasExhaustedReconnectAttempts(reconnectPolicy, attempt)) { this._pauseReconnect(address, 'maxAttemptsReached'); return; } const delay = immediate ? 0 - : Math.min(RECONNECT_INITIAL_DELAY * Math.pow(2, attempt), RECONNECT_MAX_DELAY); + : computeReconnectDelay(reconnectPolicy, attempt + 1); this._logService.info( - `[TunnelAgentHost] Scheduling reconnect for ${address} in ${delay}ms (attempt ${attempt + 1}/${RECONNECT_MAX_ATTEMPTS})` + `[TunnelAgentHost] Scheduling reconnect for ${address} in ${delay}ms (attempt ${attempt + 1}/${reconnectPolicy.maxAttempts})` ); const timer = setTimeout(() => { @@ -579,6 +586,13 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._clearReconnectBackoff(address); return; } + if (live && RemoteAgentHostConnectionStatus.isConnecting(live.status)) { + return; + } + if (live && RemoteAgentHostConnectionStatus.isReconnecting(live.status)) { + // The protocol client is preserving its state while it reconnects; don't replace it. + return; + } this._reconnectAttempts.set(address, attempt + 1); this._connectTunnel(address, { userInitiated: false }).catch(() => { /* _connectTunnel already re-schedules on failure */ }); @@ -771,6 +785,10 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc continue; } const live = this._remoteAgentHostService.connections.find(connection => connection.address === address); + if (live && RemoteAgentHostConnectionStatus.isReconnecting(live.status)) { + // The protocol client is preserving its state while it reconnects; don't replace it. + continue; + } if (!live || !RemoteAgentHostConnectionStatus.isConnected(live.status)) { resumableAddresses.push(address); } @@ -891,8 +909,9 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc const onlineTunnelMap = new Map(onlineTunnels.map(t => [t.tunnelId, t])); for (const [address, provider] of this._providerInstances) { // Skip tunnels that already have an active relay connection + // A reconnecting protocol client is already restoring this relay. const hasConnection = this._remoteAgentHostService.connections.some( - c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status) + c => c.address === address && (RemoteAgentHostConnectionStatus.isConnected(c.status) || RemoteAgentHostConnectionStatus.isReconnecting(c.status)) ); if (hasConnection) { continue; @@ -933,8 +952,9 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc if (this._reconnectPauseReasons.has(address)) { continue; } + // A reconnecting protocol client is already restoring this relay. const alreadyConnected = this._remoteAgentHostService.connections.some( - c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status) + c => c.address === address && (RemoteAgentHostConnectionStatus.isConnected(c.status) || RemoteAgentHostConnectionStatus.isReconnecting(c.status)) ); if (!alreadyConnected) { const mode = this._tunnelService.getAutoConnectMode(tunnel); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts index d5370a60da3308..a53db54d3f795a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts @@ -8,10 +8,11 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind } from '../../../../../platform/agentHost/common/agentHostTelemetry.js'; +import { ReconnectingTransport, type IEstablishedTransport } from '../../../../../platform/agentHost/common/reconnectingTransport.js'; +import { NonReconnectableTransportError, type IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; import { deriveConnectionToken } from '../../../../../platform/agentHost/common/tunnelAgentHostConnector.js'; import { RemoteAgentHostEntryType, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; -import type { IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; import type { ProtocolMessage, AhpServerNotification, JsonRpcResponse } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../../../../platform/agentHost/common/transportConstants.js'; import { @@ -19,6 +20,7 @@ import { TUNNEL_ADDRESS_PREFIX, TUNNEL_MIN_PROTOCOL_VERSION, TunnelTags, + isTunnelNotFoundError, type ICachedTunnel, type ITunnelInfo, type TunnelAutoConnectMode, @@ -157,10 +159,46 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen // Derive connection token from tunnel ID (same convention as CLI and desktop) const connectionToken = await deriveConnectionToken(tunnelId); - const transport = new TunnelConnectionTransport(connection, this._logService); const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; + let useSeedConnection = true; + const establish = async (): Promise => { + if (useSeedConnection) { + useSeedConnection = false; + // The initial connection is already owned by the transport established for this managed connection. + return { transport: new TunnelConnectionTransport(connection, this._logService) }; + } + + const discoveryProvider = this._discoveryProvider; + if (!discoveryProvider) { + throw new NonReconnectableTransportError('No tunnel discovery provider is available to reconnect.'); + } + + try { + const reconnected = await discoveryProvider.connect(tunnelId, clusterId); + try { + return { + transport: new TunnelConnectionTransport(reconnected, this._logService), + close: async () => reconnected.close(), + }; + } catch (error) { + reconnected.close(); + throw error; + } + } catch (error) { + if (isTunnelNotFoundError(error)) { + throw new NonReconnectableTransportError(error.message); + } + throw error; + } + }; + const transportFactory = () => new ReconnectingTransport( + establish, + this._logService, + LOG_PREFIX, + AgentHostClientConnectionKind.DevTunnel, + ); const protocolClient = this._instantiationService.createInstance( - AgentHostProtocolClient, address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, + AgentHostProtocolClient, address, transportFactory, { clientInfo: agentsWindowAgentHostClientInfo }, ); // Keep an incompatible handshake from tearing down the relay: the diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts index 9c82561ddb51a5..730fd5ade527cc 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts @@ -6,7 +6,8 @@ import { IntervalTimer } from '../../../../../base/common/async.js'; import { isCancellationError } from '../../../../../base/common/errors.js'; import { isWindows } from '../../../../../base/common/platform.js'; -import { IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, getEntryTypeConfig } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { computeReconnectDelay } from '../../../../../platform/agentHost/common/reconnectPolicy.js'; import { IWSLRemoteAgentHostService, WSL_ADDRESS_PREFIX } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -16,12 +17,6 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ManagedReconnectAgentHostContribution, ManagedReconnectState } from './managedReconnectAgentHostContribution.js'; -/** Initial auto-reconnect delay after a failed WSL reconnect attempt. */ -const WSL_RECONNECT_INITIAL_DELAY = 1000; -/** Maximum auto-reconnect backoff delay for WSL. */ -const WSL_RECONNECT_MAX_DELAY = 30_000; -/** Consecutive WSL reconnect failures before pausing auto-reconnect. */ -const WSL_RECONNECT_MAX_ATTEMPTS = 10; /** After this much wall-clock time, a paused auto-reconnect is auto-resumed. */ const WSL_RECONNECT_PAUSE_AUTO_RESUME_MS = 5 * 60 * 1000; /** @@ -153,7 +148,7 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut if (connectionInfo) { // Service has an entry for this address — its status is // authoritative (including `incompatible` from the WebSocket - // connect failure path and `connecting` from a fresh reconnect). + // connect failure path and `connecting` or `reconnecting`). provider.setConnectionStatus(connectionInfo.status); } else if (this._pendingReconnects.has(this._distroForAddress(address))) { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connecting); @@ -213,13 +208,19 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut if (!running.has(entry.distro)) { continue; } - const hasConnection = this._remoteAgentHostService.connections.some( - c => c.address === entry.address && RemoteAgentHostConnectionStatus.isConnected(c.status) - ); - if (hasConnection) { + const connection = this._remoteAgentHostService.connections.find(c => c.address === entry.address); + if (connection && RemoteAgentHostConnectionStatus.isConnected(connection.status)) { this._reconnectStates.deleteAndDispose(entry.distro); continue; } + if (connection && RemoteAgentHostConnectionStatus.isConnecting(connection.status)) { + continue; + } + if (connection && RemoteAgentHostConnectionStatus.isReconnecting(connection.status)) { + // The protocol client is preserving its state while it reconnects; don't replace it. + this._reconnectStates.get(entry.distro)?.cancelTimer(); + continue; + } if (this._pendingReconnects.has(entry.distro)) { this._logService.trace(`[WSLAgentHost] WSL reconnect for ${entry.distro}: reconnect already in progress, skipping`); continue; @@ -259,7 +260,7 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut key: distro, address, userInitiated: !!options.userInitiated, - maxAttempts: WSL_RECONNECT_MAX_ATTEMPTS, + reconnectPolicy: getEntryTypeConfig(RemoteAgentHostEntryType.WSL).reconnect, shouldPause: shouldPauseWSLReconnectAfterFailure, // WSL-specific gate: never auto-boot a stopped distro. The gate is // skipped on user-initiated attempts (the user explicitly clicked @@ -288,8 +289,9 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut } private _scheduleWSLReconnect(distro: string, name: string, address: string, state: ManagedReconnectState): void { - const delay = Math.min(WSL_RECONNECT_INITIAL_DELAY * Math.pow(2, state.attempts - 1), WSL_RECONNECT_MAX_DELAY); - this._logService.info(`[WSLAgentHost] Scheduling WSL reconnect for ${distro} in ${delay}ms (attempt ${state.attempts + 1}/${WSL_RECONNECT_MAX_ATTEMPTS})`); + const reconnectPolicy = getEntryTypeConfig(RemoteAgentHostEntryType.WSL).reconnect; + const delay = computeReconnectDelay(reconnectPolicy, state.attempts); + this._logService.info(`[WSLAgentHost] Scheduling WSL reconnect for ${distro} in ${delay}ms (attempt ${state.attempts + 1}/${reconnectPolicy.maxAttempts})`); state.scheduleRetry(delay, () => { if (!this._enabled) { this._reconnectStates.deleteAndDispose(distro); @@ -303,6 +305,13 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut this._reconnectStates.deleteAndDispose(distro); return; } + if (live && RemoteAgentHostConnectionStatus.isConnecting(live.status)) { + return; + } + if (live && RemoteAgentHostConnectionStatus.isReconnecting(live.status)) { + // The protocol client is preserving its state while it reconnects; don't replace it. + return; + } if (this._pendingReconnects.has(distro)) { return; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts index 5d96e7a3e2da28..5bbde6d625fa81 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts @@ -19,8 +19,9 @@ import { AgentHostClientConnectionKind } from '../../../../../platform/agentHost import { AgentHostAhpJsonlLoggingSettingId } from '../../../../../platform/agentHost/common/agentService.js'; import { AhpJsonlLogger } from '../../../../../platform/agentHost/common/ahpJsonlLogger.js'; import { DEV_CONTAINER_AGENT_HOST_CHANNEL, IDevContainerAgentHostMainService } from '../../../../../platform/agentHost/common/devContainerAgentHost.js'; -import { RelayTransport } from '../../../../../platform/agentHost/common/relayTransport.js'; -import { RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ReconnectingRelayTransport, type IRelayConnectionHandle } from '../../../../../platform/agentHost/common/relayTransport.js'; +import { getEntryTypeConfig, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { NonReconnectableTransportError } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ISharedProcessService } from '../../../../../platform/ipc/electron-browser/services.js'; @@ -67,6 +68,7 @@ export async function isDevContainerWorkspaceAvailable( class DevContainerOutputWriter extends Disposable { private readonly _channelId: string; + private readonly _connectionIds = new Set(); constructor( mainService: IDevContainerAgentHostMainService, @@ -75,6 +77,7 @@ class DevContainerOutputWriter extends Disposable { private readonly _outputService: IOutputService, ) { super(); + this._connectionIds.add(connectionId); const sha = new StringSHA1(); sha.update(getComparisonKey(workspaceUri)); this._channelId = `devContainer.${sha.digest()}`; @@ -91,12 +94,20 @@ class DevContainerOutputWriter extends Disposable { this._append(localize('devContainerOutputStarting', "\n--- Starting Dev Container for {0} ---\n", workspaceUri.fsPath)); this._register(mainService.onDidOutput(output => { - if (output.connectionId === connectionId) { + if (this._connectionIds.has(output.connectionId)) { this._append(output.data); } })); } + addConnection(connectionId: string): void { + this._connectionIds.add(connectionId); + } + + removeConnection(connectionId: string): void { + this._connectionIds.delete(connectionId); + } + private _append(value: string): void { this._outputService.getChannel(this._channelId)?.append(value); } @@ -129,6 +140,8 @@ class DevContainerAgentHostConnector implements IDevContainerAgentHostConnector throw new Error(localize('devContainerAgentHost.localWorkspaceRequired', "Dev Container Agent Hosts require a local file workspace.")); } const connectionId = generateUuid(); + const workspaceFolder = workspaceUri.fsPath; + const name = `${basename(workspaceUri)} Dev Container`; const outputWriter = new DevContainerOutputWriter(this._mainService, connectionId, workspaceUri, this._outputService); const cancellationListener = token.onCancellationRequested(() => { void this._mainService.disconnect(connectionId).catch(error => { @@ -139,36 +152,79 @@ class DevContainerAgentHostConnector implements IDevContainerAgentHostConnector try { const result = await this._mainService.connect({ connectionId, - workspaceFolder: workspaceUri.fsPath, - name: `${basename(workspaceUri)} Dev Container`, + workspaceFolder, + name, }); if (token.isCancellationRequested) { throw new CancellationError(); } - const logger = this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId) - ? this._instantiationService.createInstance(AhpJsonlLogger, { - logsHome: this._environmentService.logsHome, - connectionId, - transport: 'devcontainer', - }) - : undefined; - const transport = this._instantiationService.createInstance( - RelayTransport, - connectionId, - this._mainService, - logger, - this._logService, - '[DevContainerRelayTransport]', - AgentHostClientConnectionKind.DevContainer, - ); + let seed = true; + const establish = async (): Promise => { + if (seed) { + seed = false; + // The initial relay is owned by the connection cancellation and teardown path below. + return { connectionId }; + } + + try { + ensureDevContainerAgentHostsEnabled(this._configurationService); + } catch (error) { + throw new NonReconnectableTransportError(error instanceof Error ? error.message : String(error)); + } + if (!await this._fileService.exists(workspaceUri)) { + throw new NonReconnectableTransportError('Dev Container workspace folder no longer exists.'); + } + + const reconnectConnectionId = generateUuid(); + outputWriter.addConnection(reconnectConnectionId); + try { + await this._mainService.connect({ + connectionId: reconnectConnectionId, + workspaceFolder, + name, + }); + return { + connectionId: reconnectConnectionId, + close: async () => { + outputWriter.removeConnection(reconnectConnectionId); + await this._mainService.disconnect(reconnectConnectionId); + }, + }; + } catch (error) { + outputWriter.removeConnection(reconnectConnectionId); + if (error instanceof CancellationError) { + throw new NonReconnectableTransportError('Dev Container Agent Host connection was cancelled.'); + } + throw error; + } + }; + const transportFactory = () => { + // Post-reconnect logs use the original channel id because the new id is assigned asynchronously by `establish`. + const createLogger = () => this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId) + ? this._instantiationService.createInstance(AhpJsonlLogger, { + logsHome: this._environmentService.logsHome, + connectionId, + transport: 'devcontainer', + }) + : undefined; + return new ReconnectingRelayTransport( + establish, + this._mainService, + createLogger, + this._logService, + '[DevContainerRelayTransport]', + AgentHostClientConnectionKind.DevContainer, + ); + }; protocolClient = this._instantiationService.createInstance( AgentHostProtocolClient, result.address, - transport, - undefined, - undefined, - agentsWindowAgentHostClientInfo, + transportFactory, + { + clientInfo: agentsWindowAgentHostClientInfo, + reconnectPolicy: getEntryTypeConfig(RemoteAgentHostEntryType.DevContainer).reconnect, + }, ); await protocolClient.connect(); if (token.isCancellationRequested) { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts index d9ca6d4d318d33..cc4092cf54fc73 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { hasKey } from '../../../../../base/common/types.js'; import { ProxyChannel } from '../../../../../base/parts/ipc/common/ipc.js'; import { localize } from '../../../../../nls.js'; @@ -23,6 +23,7 @@ import { IRemoteAgentHostLocationPreferenceService } from '../../../../../platfo import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { isTunnelGatewaySelectionRejectedError, + isTunnelNotFoundError, ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, TUNNEL_AGENT_HOST_CHANNEL, @@ -38,6 +39,7 @@ import { type TunnelAutoConnectMode, } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { AhpJsonlLogger } from '../../../../../platform/agentHost/common/ahpJsonlLogger.js'; +import { AgentHostClientConnectionKind } from '../../../../../platform/agentHost/common/agentHostTelemetry.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../../../../../platform/agentHost/common/agentService.js'; import { resolveGatewaySelection, @@ -46,7 +48,8 @@ import { } from '../../../../../platform/agentHost/common/tunnelGatewaySelection.js'; import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; -import { TunnelRelayTransport } from '../../../../../platform/agentHost/electron-browser/tunnelRelayTransport.js'; +import { ReconnectingRelayTransport, type IRelayConnectionHandle } from '../../../../../platform/agentHost/common/relayTransport.js'; +import { NonReconnectableTransportError } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; export { type IGatewaySelectionRequest, @@ -206,13 +209,28 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo let protocolClient: AgentHostProtocolClient; try { const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); - const logger = ahpLoggingEnabled ? this._instantiationService.createInstance( - AhpJsonlLogger, - { logsHome: this._environmentService.logsHome, connectionId: result.connectionId, transport: 'tunnel' }, - ) : undefined; - const transport = new TunnelRelayTransport(result.connectionId, this._mainService, logger); + let useSeedConnection = true; + const establish = async (): Promise => { + if (useSeedConnection) { + useSeedConnection = false; + // The initial relay belongs to the managed connection's transport disposable. + return { connectionId: result.connectionId }; + } + return this._establishBackgroundRelay(tunnel, auth.provider); + }; + const transportFactory = () => new ReconnectingRelayTransport( + establish, + this._mainService, + () => ahpLoggingEnabled ? this._instantiationService.createInstance( + AhpJsonlLogger, + { logsHome: this._environmentService.logsHome, connectionId: result.connectionId, transport: 'tunnel' }, + ) : undefined, + this._logService, + LOG_PREFIX, + AgentHostClientConnectionKind.DevTunnel, + ); protocolClient = this._instantiationService.createInstance( - AgentHostProtocolClient, result.address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, + AgentHostProtocolClient, result.address, transportFactory, { clientInfo: agentsWindowAgentHostClientInfo }, ); } catch (err) { this._logService.error(`${LOG_PREFIX} Connection setup failed`, err); @@ -244,6 +262,9 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo this.cacheTunnel(tunnel, auth.provider); + const transportDisposable = toDisposable(() => { + this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); + }); try { await this._remoteAgentHostService.addManagedConnection({ name: result.name, @@ -255,11 +276,11 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo label: tunnel.name, authProvider: auth.provider, }, - }, protocolClient, undefined, status); + }, protocolClient, transportDisposable, status); } catch (err) { this._logService.error(`${LOG_PREFIX} addManagedConnection failed`, err); protocolClient.dispose(); - this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); + transportDisposable.dispose(); throw err; } @@ -270,6 +291,46 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo this._notifyIfTunnelFailover(result, options, editorFallback); } + private async _establishBackgroundRelay(tunnel: ITunnelInfo, authProvider: 'github' | 'microsoft'): Promise { + // Resolve a current cached token per attempt; reconnects must never prompt. + const auth = await this._getTokenForProvider(authProvider, true); + if (!auth) { + throw new NonReconnectableTransportError('No cached authentication available to reconnect the tunnel.'); + } + + try { + const session = await this._mainService.prepareSelection(auth.token, auth.provider, tunnel.tunnelId, tunnel.clusterId); + let result: ITunnelConnectResult; + if (session) { + const selection = await resolveGatewaySelection(this._locationPreferenceService, this._dialogService, { + hostKey: `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`, + hostLabel: tunnel.name, + productName: this._productService.nameShort, + inventory: session.inventory, + userInitiated: false, + }); + if (!selection) { + await this._mainService.cancelSelection(session.selectionId); + throw new NonReconnectableTransportError('Tunnel agent host selection requires user interaction.'); + } + result = (await this._completeSelectionWithFallback(auth, tunnel, session, selection)).result; + } else { + result = await this._mainService.connect(auth.token, auth.provider, tunnel.tunnelId, tunnel.clusterId); + } + + const connectionId = result.connectionId; + return { + connectionId, + close: () => this._mainService.disconnect(connectionId), + }; + } catch (err) { + if (isTunnelNotFoundError(err)) { + throw new NonReconnectableTransportError(err.message); + } + throw err; + } + } + /** * Send `selection` over the prepared gateway session and, if the gateway * *rejects* it, transparently retry once using a fresh inventory. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteHostOptions.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteHostOptions.test.ts index 8e7c673e7d1927..f4c193f2633f70 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteHostOptions.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteHostOptions.test.ts @@ -18,6 +18,7 @@ import { getStatusLabel, hasUpgradeReconnectStarted, supportsRemoteAgentHostLocationPreference, + usesSSHConfigFile, } from '../../browser/remoteHostOptions.js'; suite('remoteHostOptions', () => { @@ -26,6 +27,7 @@ suite('remoteHostOptions', () => { test('getStatusLabel covers every connection status variant', () => { assert.ok(getStatusLabel(RemoteAgentHostConnectionStatus.connected).length > 0); assert.ok(getStatusLabel(RemoteAgentHostConnectionStatus.connecting).length > 0); + assert.ok(getStatusLabel(RemoteAgentHostConnectionStatus.reconnecting).length > 0); assert.ok(getStatusLabel(RemoteAgentHostConnectionStatus.disconnected).length > 0); const incompatibleLabel = getStatusLabel( @@ -60,6 +62,7 @@ suite('remoteHostOptions', () => { assert.strictEqual(hasUpgradeReconnectStarted(RemoteAgentHostConnectionStatus.disconnected), false); assert.strictEqual(hasUpgradeReconnectStarted(RemoteAgentHostConnectionStatus.incompatible('reason', ['0.1.0'])), false); assert.strictEqual(hasUpgradeReconnectStarted(RemoteAgentHostConnectionStatus.connecting), true); + assert.strictEqual(hasUpgradeReconnectStarted(RemoteAgentHostConnectionStatus.reconnecting), true); assert.strictEqual(hasUpgradeReconnectStarted(RemoteAgentHostConnectionStatus.connected), true); }); @@ -129,7 +132,46 @@ suite('remoteHostOptions', () => { // Always-present items remain regardless of preference support. assert.ok(items.some(item => item.id === 'remove')); assert.ok(items.some(item => item.id === 'copy')); - assert.ok(items.some(item => item.id === 'settings')); + // An aliased SSH host is authored in ~/.ssh/config, not settings. + assert.ok(items.some(item => item.id === 'sshConfig')); + }); + + test('offers the SSH config file for an aliased SSH host, and settings for every other host', () => { + const idsFor = (options: Parameters[0]) => + buildRemoteHostOptionItems(options).map(item => item.id); + + assert.deepStrictEqual({ + aliasedSSH: idsFor({ address: 'localhost:4321', preferenceKey: 'ssh:my-host-alias', isConnected: true, isWebPlatform: false }).filter(id => id === 'sshConfig' || id === 'settings'), + // Explicit-credential SSH keys as user@host:port, so there is no file to open. + credentialSSH: idsFor({ address: 'me@host.example:22', isConnected: true, isWebPlatform: false }).filter(id => id === 'sshConfig' || id === 'settings'), + webSocket: idsFor({ address: 'host1:8080', isConnected: true, isWebPlatform: false }).filter(id => id === 'sshConfig' || id === 'settings'), + tunnel: idsFor({ address: 'tunnel:some-tunnel-id', isConnected: true, isWebPlatform: false }).filter(id => id === 'sshConfig' || id === 'settings'), + }, { + aliasedSSH: ['sshConfig'], + credentialSSH: ['settings'], + webSocket: ['settings'], + tunnel: ['settings'], + }); + }); + }); + + suite('usesSSHConfigFile', () => { + test('only an ssh: aliased key on desktop resolves to the SSH config file', () => { + assert.deepStrictEqual({ + aliasedDesktop: usesSSHConfigFile('ssh:my-host-alias', false), + aliasedWeb: usesSSHConfigFile('ssh:my-host-alias', true), + credentialKey: usesSSHConfigFile('me@host.example:22', false), + forwardedAddress: usesSSHConfigFile('localhost:4321', false), + tunnel: usesSSHConfigFile('tunnel:some-tunnel-id', false), + empty: usesSSHConfigFile('', false), + }, { + aliasedDesktop: true, + aliasedWeb: false, + credentialKey: false, + forwardedAddress: false, + tunnel: false, + empty: false, + }); }); }); diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css index 01d0188ce73efb..84e1862f237c56 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css @@ -10,8 +10,9 @@ gap: 4px; } -/* Collapse the host entirely when there is nothing to show so it adds no spacing. */ -.session-input-banners:not(:has(.session-input-banner)) { +/* Collapse empty hosts and widgets entirely so they add no spacing. */ +.session-input-banners.empty, +.session-input-banner.empty { display: none; } @@ -55,6 +56,34 @@ color: var(--vscode-charts-orange); } +.session-input-banner .session-input-banner-navigation { + display: flex; + align-items: center; + flex-shrink: 0; + gap: var(--vscode-spacing-size20); +} + +.session-input-banner .session-input-banner-navigation-button { + width: 16px; + height: 20px; + min-width: 0; + padding: 0; + color: var(--vscode-icon-foreground); + background: transparent; +} + +.session-input-banner .session-input-banner-position { + min-width: 24px; + text-align: center; + color: var(--vscode-descriptionForeground); +} + +.session-input-banner .session-input-banner-reference { + flex-shrink: 0; + font-weight: var(--vscode-fontWeight-semiBold); + color: var(--vscode-foreground); +} + /* Text — single line, ellipsized when it does not fit next to the actions. */ .session-input-banner .session-input-banner-text { flex: 1 1 auto; @@ -71,11 +100,13 @@ display: flex; align-items: center; gap: 4px; - flex-shrink: 0; + flex: 0 1 auto; + min-width: 0; margin-left: auto; } -.session-input-banner .session-input-banner-action { +.session-input-banner .session-input-banner-action:not(.monaco-button-dropdown), +.session-input-banner .session-input-banner-action.monaco-button-dropdown > .monaco-button { font-size: var(--vscode-fontSize-label2); padding: 0 8px; min-width: unset; @@ -84,6 +115,33 @@ white-space: nowrap; } +.session-input-banner .session-input-banner-action { + flex: 0 1 auto; + min-width: 0; +} + +.session-input-banner .session-input-banner-action.monaco-button-dropdown { + align-items: stretch; + height: 20px; +} + +.session-input-banner .session-input-banner-action.monaco-button-dropdown > .monaco-button, +.session-input-banner .session-input-banner-action.monaco-button-dropdown > .monaco-button-dropdown-separator { + box-sizing: border-box; + height: 100%; + min-height: 0; +} + +.session-input-banner .session-input-banner-action.monaco-button-dropdown > .monaco-button-dropdown-separator { + padding-block: 0; +} + +.session-input-banner .session-input-banner-action.monaco-button-dropdown > .monaco-button:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + /* Outlined ghost style for secondary action buttons (e.g. "Reveal"). */ .session-input-banner .session-input-banner-action.secondary { border: var(--vscode-strokeThickness) solid var(--vscode-button-secondaryBackground, var(--vscode-button-border, var(--vscode-contrastBorder))); @@ -97,6 +155,18 @@ opacity: 1; } +.session-input-banner .session-input-banner-action.monaco-button-dropdown > .monaco-button.secondary, +.session-input-banner .session-input-banner-action.monaco-button-dropdown > .monaco-button-dropdown-separator { + background: transparent; + color: var(--vscode-foreground); + opacity: 0.8; +} + +.session-input-banner .session-input-banner-action.monaco-button-dropdown > .monaco-button.secondary:hover { + background: var(--vscode-toolbar-hoverBackground); + opacity: 1; +} + /* Dismiss (x) button. */ .session-input-banner .session-input-banner-dismiss { display: flex; diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts index 71959233c446d4..ac741a9e03eb58 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts @@ -6,13 +6,16 @@ import './media/sessionInputBanners.css'; import * as dom from '../../../../base/browser/dom.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; +import { Button, ButtonWithDropdown, IButton, IButtonOptions } from '../../../../base/browser/ui/button/button.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { disposableTimeout } from '../../../../base/common/async.js'; +import { toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; -import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import type { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; @@ -24,9 +27,12 @@ import { chartsOrange } from '../../../../platform/theme/common/colors/chartsCol const SHOW_WORKING_DELAY_MS = 1_000; export interface ISessionInputBannerAction { + readonly id?: string; readonly label: string; /** Renders the action with the prominent button colors. */ readonly primary?: boolean; + /** Alternative actions offered from a split-button dropdown. */ + readonly dropdownActions?: readonly ISessionInputBannerAction[]; /** * Waits until the action can run. The primary button is disabled immediately * and the banner shows progress when this takes longer than one second. @@ -36,6 +42,8 @@ export interface ISessionInputBannerAction { } export interface ISessionInputBanner { + /** Stable identity used to preserve the selected carousel item across updates. */ + readonly id?: string; readonly icon: ThemeIcon; /** Use the orange accent (border + icon) reserved for CI failures. */ readonly accent: boolean; @@ -43,10 +51,20 @@ export interface ISessionInputBanner { readonly text: string; readonly ariaLabel: string; readonly actions: readonly ISessionInputBannerAction[]; + readonly reference?: { + readonly label: string; + readonly hover: string; + }; readonly dismissTooltip?: string; + readonly focusAfterDismiss?: () => void; dismiss?(): void; } +type BannerFocus = + | { readonly kind: 'previous' | 'next'; readonly focusFallback: (() => void) | undefined } + | { readonly kind: 'dismiss'; readonly focusAfterDismiss: (() => void) | undefined } + | { readonly kind: 'action'; readonly index: number; readonly dropdown: boolean; readonly focusFallback: (() => void) | undefined }; + /** * A single, self-contained banner card rendered directly above the chat input. * Shows a leading icon, an ellipsized line of text, a floating right-aligned @@ -57,71 +75,247 @@ export class SessionInputBannerWidget extends Disposable { readonly domNode: HTMLElement; - private readonly _buttons: Array<{ readonly button: Button; readonly primary: boolean }> = []; + private readonly _buttons: Array<{ readonly button: IButton; readonly primary: boolean }> = []; private readonly _showWorkingAnimation = this._register(new MutableDisposable()); + private readonly _content = this._register(new MutableDisposable()); private _runningPrimaryAction = false; private _disposed = false; + private _working = false; + private _banners: readonly ISessionInputBanner[] = []; + private _activeIndex = 0; + private _previousButton: IButton | undefined; + private _nextButton: IButton | undefined; + private _dismissButton: HTMLButtonElement | undefined; constructor( - banner: ISessionInputBanner, + banner: ISessionInputBanner | readonly ISessionInputBanner[], @IHoverService private readonly hoverService: IHoverService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { super(); this.domNode = dom.$('.session-input-banner'); - this.domNode.classList.toggle('accent-orange', banner.accent); this.domNode.setAttribute('role', 'status'); + this.setBanners(Array.isArray(banner) ? banner : [banner]); + } + + setBanners(banners: readonly ISessionInputBanner[]): void { + const focus = this._captureFocus(); + const activeId = this._banners[this._activeIndex]?.id; + this._banners = banners; + const nextActiveIndex = activeId ? banners.findIndex(banner => banner.id === activeId) : -1; + this._activeIndex = nextActiveIndex >= 0 ? nextActiveIndex : Math.min(this._activeIndex, Math.max(0, banners.length - 1)); + this._render(focus); + } + + private _render(focus: BannerFocus | undefined = this._captureFocus()): void { + const store = this._content.value = new DisposableStore(); + this._buttons.length = 0; + this._previousButton = undefined; + this._nextButton = undefined; + this._dismissButton = undefined; + dom.clearNode(this.domNode); + + const banner = this._banners[this._activeIndex]; + this.domNode.classList.toggle('empty', !banner); + if (!banner) { + this.domNode.removeAttribute('aria-label'); + this._restoreFocus(focus); + return; + } + + this.domNode.classList.toggle('accent-orange', banner.accent); this.domNode.setAttribute('aria-label', banner.ariaLabel); + if (this._banners.length > 1) { + this._renderNavigation(store); + } + + if (banner.reference) { + const reference = dom.append(this.domNode, dom.$('span.session-input-banner-reference')); + reference.textContent = banner.reference.label; + store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), reference, banner.reference.hover)); + } + const icon = dom.append(this.domNode, dom.$('.session-input-banner-icon')); + icon.setAttribute('aria-hidden', 'true'); icon.appendChild(renderIcon(banner.icon)); const textEl = dom.append(this.domNode, dom.$('span.session-input-banner-text')); textEl.textContent = banner.text; - this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), textEl, banner.text)); + store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), textEl, banner.text)); const actions = dom.append(this.domNode, dom.$('.session-input-banner-actions')); - for (const action of banner.actions) { - // Primary uses the prominent button colors; secondary renders as a - // ghost button (colors overridden to undefined so the CSS ghost - // styles apply), mirroring the chat input notification widget. - const button = this._register(new Button(actions, { - ...defaultButtonStyles, - ...(action.primary && banner.accent ? { - buttonBackground: asCssVariable(chartsOrange), - buttonHoverBackground: `color-mix(in srgb, ${asCssVariable(chartsOrange)} 88%, black)`, - buttonBorder: asCssVariable(chartsOrange), - } : {}), - ...(action.primary ? {} : { - buttonBackground: undefined, - buttonHoverBackground: undefined, - buttonForeground: undefined, - buttonSecondaryBackground: undefined, - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - }), - secondary: !action.primary, - })); + for (const [actionIndex, action] of banner.actions.entries()) { + const options = this._buttonOptions(action, banner); + const button = action.dropdownActions?.length + ? store.add(new ButtonWithDropdown(actions, { + ...options, + actions: action.dropdownActions.map((dropdownAction, dropdownIndex) => toAction({ + id: dropdownAction.id ?? `session.inputBanner.${actionIndex}.${dropdownIndex}`, + label: dropdownAction.label, + run: () => this._runAction(dropdownAction), + })), + addPrimaryActionToDropdown: false, + contextMenuProvider: this.contextMenuService, + })) + : store.add(new Button(actions, options)); button.element.classList.add('session-input-banner-action'); button.label = action.label; - button.element.ariaLabel = `${banner.ariaLabel} ${action.label}`; + button.setTitle(action.label); + button.setAriaLabel(`${banner.ariaLabel} ${action.label}`); + if (button instanceof ButtonWithDropdown) { + button.dropdownButton.setAriaLabel(localize('sessionInputBanner.moreActionsFor', "More Actions for {0}", action.label)); + } this._buttons.push({ button, primary: !!action.primary }); - this._register(button.onDidClick(() => { void this._runAction(action).catch(onUnexpectedError); })); + store.add(button.onDidClick(() => { void this._runAction(action).catch(onUnexpectedError); })); } if (banner.dismiss && banner.dismissTooltip) { const dismiss = dom.append(this.domNode, dom.$('button.session-input-banner-dismiss')) as HTMLButtonElement; + this._dismissButton = dismiss; dismiss.type = 'button'; dismiss.setAttribute('aria-label', banner.dismissTooltip); dismiss.appendChild(renderIcon(Codicon.close)); - this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), dismiss, banner.dismissTooltip)); - this._register(dom.addDisposableListener(dismiss, dom.EventType.CLICK, e => { + store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), dismiss, banner.dismissTooltip)); + store.add(dom.addDisposableListener(dismiss, dom.EventType.CLICK, e => { dom.EventHelper.stop(e, true); banner.dismiss?.(); })); } + + this._setPrimaryButtonsEnabled(!this._working && !this._runningPrimaryAction); + this._restoreFocus(focus); + } + + private _captureFocus(): BannerFocus | undefined { + const activeElement = dom.getActiveElement(); + const focusFallback = this._banners[this._activeIndex]?.focusAfterDismiss; + if (this._previousButton?.element === activeElement) { + return { kind: 'previous', focusFallback }; + } + if (this._nextButton?.element === activeElement) { + return { kind: 'next', focusFallback }; + } + if (this._dismissButton === activeElement) { + return { kind: 'dismiss', focusAfterDismiss: this._banners[this._activeIndex]?.focusAfterDismiss }; + } + for (let index = 0; index < this._buttons.length; index++) { + const button = this._buttons[index].button; + if (button instanceof ButtonWithDropdown) { + if (button.primaryButton.element === activeElement) { + return { kind: 'action', index, dropdown: false, focusFallback }; + } + if (button.dropdownButton.element === activeElement) { + return { kind: 'action', index, dropdown: true, focusFallback }; + } + } else if (button.element === activeElement) { + return { kind: 'action', index, dropdown: false, focusFallback }; + } + } + return undefined; + } + + private _restoreFocus(focus: BannerFocus | undefined): void { + if (!focus) { + return; + } + switch (focus.kind) { + case 'previous': + if (this._previousButton ?? this._buttons[0]?.button) { + (this._previousButton ?? this._buttons[0].button).focus(); + } else { + focus.focusFallback?.(); + } + break; + case 'next': + if (this._nextButton ?? this._buttons[0]?.button) { + (this._nextButton ?? this._buttons[0].button).focus(); + } else { + focus.focusFallback?.(); + } + break; + case 'dismiss': + if (this._dismissButton) { + this._dismissButton.focus(); + } else { + focus.focusAfterDismiss?.(); + } + break; + case 'action': { + const button = this._buttons[focus.index]?.button; + if (button instanceof ButtonWithDropdown && focus.dropdown) { + button.dropdownButton.focus(); + } else if (button) { + button?.focus(); + } else { + focus.focusFallback?.(); + } + break; + } + } + } + + private _renderNavigation(store: DisposableStore): void { + const navigation = dom.append(this.domNode, dom.$('.session-input-banner-navigation')); + const previous = store.add(new Button(navigation, { + ...this._navigationButtonOptions(localize('sessionInputBanner.previous', "Previous Banner")), + })); + previous.element.classList.add('session-input-banner-navigation-button', 'previous'); + previous.icon = Codicon.chevronLeft; + this._previousButton = previous; + store.add(previous.onDidClick(() => this._move(-1, 'previous'))); + + const position = dom.append(navigation, dom.$('span.session-input-banner-position')); + position.textContent = localize('sessionInputBanner.position', "{0}/{1}", this._activeIndex + 1, this._banners.length); + + const next = store.add(new Button(navigation, { + ...this._navigationButtonOptions(localize('sessionInputBanner.next', "Next Banner")), + })); + next.element.classList.add('session-input-banner-navigation-button', 'next'); + next.icon = Codicon.chevronRight; + this._nextButton = next; + store.add(next.onDidClick(() => this._move(1, 'next'))); + } + + private _navigationButtonOptions(ariaLabel: string): IButtonOptions { + return { + ...defaultButtonStyles, + ariaLabel, + supportIcons: true, + buttonBackground: undefined, + buttonHoverBackground: undefined, + buttonForeground: undefined, + buttonBorder: undefined, + }; + } + + private _move(delta: number, focusClass: 'previous' | 'next'): void { + this._activeIndex = (this._activeIndex + delta + this._banners.length) % this._banners.length; + this._render(); + (focusClass === 'previous' ? this._previousButton : this._nextButton)?.focus(); + } + + private _buttonOptions(action: ISessionInputBannerAction, banner: ISessionInputBanner): IButtonOptions { + return { + ...defaultButtonStyles, + ...(action.primary && banner.accent ? { + buttonBackground: asCssVariable(chartsOrange), + buttonHoverBackground: `color-mix(in srgb, ${asCssVariable(chartsOrange)} 88%, black)`, + buttonBorder: asCssVariable(chartsOrange), + } : {}), + ...(action.primary ? {} : { + buttonBackground: undefined, + buttonHoverBackground: undefined, + buttonForeground: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + }), + secondary: !action.primary, + }; } private async _runAction(action: ISessionInputBannerAction): Promise { @@ -135,6 +329,9 @@ export class SessionInputBannerWidget extends Disposable { this._runningPrimaryAction = true; this._setPrimaryButtonsEnabled(false); + const banner = this._banners[this._activeIndex]; + const bannerIdentity = banner?.id ?? banner; + const actionIdentity = action.id ?? action.label; try { if (action.waitUntilReady && !await this._waitUntilReady(action.waitUntilReady)) { return; @@ -142,7 +339,8 @@ export class SessionInputBannerWidget extends Disposable { // Readiness can resolve after the banner was replaced or torn down // (e.g. the comments it acted on disappeared), and running then would // act on state this banner no longer represents. - if (this._disposed) { + const currentBanner = this._banners[this._activeIndex]; + if (this._disposed || (currentBanner?.id ?? currentBanner) !== bannerIdentity || !this._hasAction(currentBanner, actionIdentity)) { return; } await action.run(); @@ -152,6 +350,12 @@ export class SessionInputBannerWidget extends Disposable { } } + private _hasAction(banner: ISessionInputBanner | undefined, actionIdentity: string): boolean { + return banner?.actions.some(action => + (action.id ?? action.label) === actionIdentity || + action.dropdownActions?.some(dropdownAction => (dropdownAction.id ?? dropdownAction.label) === actionIdentity)) === true; + } + private async _waitUntilReady(waitUntilReady: () => Promise): Promise { this.domNode.setAttribute('aria-busy', 'true'); this._showWorkingAnimation.value = disposableTimeout(() => this.domNode.classList.add('working'), SHOW_WORKING_DELAY_MS); @@ -165,6 +369,7 @@ export class SessionInputBannerWidget extends Disposable { } setWorking(working: boolean): void { + this._working = working; this.domNode.classList.toggle('working', working); this.domNode.setAttribute('aria-busy', String(working)); this._setPrimaryButtonsEnabled(!working); diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts index 8b3e630a4aeb27..a2357b3218fc6a 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts @@ -5,92 +5,95 @@ import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; +import { Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun, derived, IObservable, ISettableObservable, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; +import { getFailedChecks, buildFixCIPrompt, submitFixCIChecks } from '../../changes/browser/checksActions.js'; import { whenChatWidgetForSession } from '../../chat/browser/chatWidgetUtils.js'; -import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { SessionStatus } from '../../../services/sessions/common/session.js'; +import type { ISessionChatPillsDebugData } from '../../chat/browser/sessionChatInputToolbarDebug.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPullRequestCIModel.js'; -import { GitHubCheckStatus, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; -import { getFailedChecks, submitFixCIChecks } from '../../changes/browser/checksActions.js'; -import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; -import type { ISessionChatPillsDebugData } from '../../chat/browser/sessionChatInputToolbarDebug.js'; -import { ISessionInputBanner, SessionInputBannerWidget } from './sessionInputBannerWidget.js'; - -/** Persisted set of session ids whose CI banner the user dismissed. */ -const STORAGE_KEY_CI_DISMISSED = 'sessions.inputBanners.ci.dismissed'; -/** Persisted set of session ids whose comments banner the user dismissed. */ -const STORAGE_KEY_COMMENTS_DISMISSED = 'sessions.inputBanners.comments.dismissed'; +import { GitHubCheckStatus, GitHubPullRequestState, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { getGitHubPullRequestRefs, IGitHubPullRequestRef, SessionStatus } from '../../../services/sessions/common/session.js'; +import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionInputBanner, ISessionInputBannerAction, SessionInputBannerWidget } from './sessionInputBannerWidget.js'; -/** - * Feedback kinds that originate from a review the user triages (a pull request - * review or an in-product code review), matching the comments surfaced to the - * agent via the `viewUnreviewedComments` tool. - */ -const REVIEWABLE_KINDS: ReadonlySet = new Set([AgentFeedbackKind.PRReview, AgentFeedbackKind.AgentReview]); +const STORAGE_KEY_DISMISSED = 'sessions.inputBanners.dismissedItems'; +const LEGACY_STORAGE_KEY_CI_DISMISSED = 'sessions.inputBanners.ci.dismissed'; +const LEGACY_STORAGE_KEY_COMMENTS_DISMISSED = 'sessions.inputBanners.comments.dismissed'; -interface ICIBannerState { +interface IBaseBannerState { + readonly id: string; readonly sessionId: string; readonly sessionResource: URI; + readonly commentIds: readonly string[]; + readonly firstCommentId: string | undefined; + readonly debug?: true; +} + +interface IPRBannerState extends IBaseBannerState { + readonly kind: 'pullRequest'; + readonly pullRequest: IGitHubPullRequestRef; + readonly title: string | undefined; readonly failed: number; - /** Number of checks that have completed (succeeded or failed). */ readonly completed: number; - /** Number of checks still running or queued. */ readonly pending: number; - readonly ciModel?: GitHubPullRequestCIModel; - readonly debug?: true; + readonly multiplePullRequests: boolean; } -interface ICommentsBannerState { - readonly sessionId: string; - readonly sessionResource: URI; - readonly count: number; - /** Whether all counted comments are PR reviews, all are agent reviews, or mixed. */ - readonly kind: 'pr' | 'agent' | 'mixed'; - readonly firstCommentId: string; - readonly debug?: true; +interface IAgentCommentsBannerState extends IBaseBannerState { + readonly kind: 'agentComments'; +} + +type BannerState = IPRBannerState | IAgentCommentsBannerState; + +function pullRequestKey(pullRequest: Pick): string { + return `${pullRequest.owner.toLowerCase()}/${pullRequest.repo.toLowerCase()}#${pullRequest.number}`; +} + +function pullRequestBannerId(sessionId: string, pullRequest: IGitHubPullRequestRef): string { + return `${sessionId}:pullRequest:${pullRequestKey(pullRequest)}`; +} + +function agentCommentsBannerId(sessionId: string): string { + return `${sessionId}:agentComments`; +} + +function feedbackForPullRequest(feedback: IAgentFeedback, pullRequest: IGitHubPullRequestRef, firstPullRequest: IGitHubPullRequestRef | undefined): boolean { + if (feedback.kind !== AgentFeedbackKind.PRReview) { + return false; + } + const source = feedback.sourcePullRequest ?? firstPullRequest; + return !!source && pullRequestKey(source) === pullRequestKey(pullRequest); } /** - * Hosts the banners that render directly above the active session's chat input: - * a CI failures banner and a created-comments banner. Each banner can be - * permanently dismissed per session. - * - * The host is owned by the session's chat view and only shows content while - * that view is the active session (driven via {@link setActive}); the CI model - * and feedback are read for the active session. + * Hosts one actionable banner directly above the active session's chat input. + * Multiple pull requests and agent-review comments are presented as carousel + * items within that single banner. */ export class SessionInputBanners extends Disposable { readonly domNode: HTMLElement; - private readonly _ciSlot: HTMLElement; - private readonly _commentsSlot: HTMLElement; - - private readonly _ciContent = this._register(new MutableDisposable()); - private readonly _commentsContent = this._register(new MutableDisposable()); - private readonly _active = observableValue(this, false); private readonly _debugData = observableValue(this, undefined); + private readonly _dismissed = observableValue>(this, new Set()); + private readonly _legacyCIDismissed = observableValue>(this, new Set()); + private readonly _legacyCommentsDismissed = observableValue>(this, new Set()); + private readonly _feedbackChanged: IObservable; + private readonly _widget: SessionInputBannerWidget; - private readonly _ciDismissed = observableValue>(this, new Set()); - private readonly _commentsDismissed = observableValue>(this, new Set()); - - private _feedbackChanged!: IObservable; - - /** - * The session whose banners should be shown, or undefined when inactive or - * while the session/chat is still in progress. Banners only surface once the - * session has completed so they don't distract from a running agent. - */ private readonly _session = derived(this, reader => { if (!this._active.read(reader)) { return undefined; @@ -102,97 +105,155 @@ export class SessionInputBanners extends Disposable { return session; }); - private readonly _ciState: IObservable = derived(this, reader => { - const debugData = this._debugData.read(reader); - if (debugData) { - return debugData.ciFailed > 0 - ? { sessionId: 'debug', sessionResource: URI.from({ scheme: 'session-chat-pills-debug', path: '/ci' }), failed: debugData.ciFailed, completed: debugData.ciFailed, pending: debugData.ciPending, debug: true } - : undefined; - } + private readonly _agentMergeEnabled = derived(this, reader => { const session = this._session.read(reader); - if (!session || this._ciDismissed.read(reader).has(session.sessionId)) { - return undefined; - } - const ciModel = this.gitHubService.activeSessionPullRequestCIObs.read(reader); - if (!ciModel) { - return undefined; - } - // Once the user has requested a CI fix for the current PR head commit, - // hide the entire banner until a new commit lands on the PR. - if (ciModel.fixRequested.read(reader)) { - return undefined; - } - const checks = ciModel.checks.read(reader); - const failed = getFailedChecks(checks).length; - if (failed === 0) { - return undefined; + const provider = session && this.sessionsProvidersService.getProvider(session.providerId); + if (!session || !provider || !isAgentHostProvider(provider)) { + return false; } - const completed = checks.filter(check => check.status === GitHubCheckStatus.Completed).length; - const pending = checks.length - completed; - return { sessionId: session.sessionId, sessionResource: session.resource, failed, completed, pending, ciModel }; + observableSignalFromEvent(reader.store, Event.filter(provider.onDidChangeSessionConfig, sessionId => sessionId === session.sessionId)).read(reader); + return provider.getAgentMergeSessionState(session.sessionId)?.enabled === true; }); - private readonly _commentsState: IObservable = derived(this, reader => { + private readonly _states: IObservable = derived(this, reader => { const debugData = this._debugData.read(reader); if (debugData) { - const count = debugData.prFeedback + debugData.agentFeedback; - if (count === 0) { - return undefined; - } - const kind = debugData.prFeedback > 0 && debugData.agentFeedback > 0 - ? 'mixed' - : debugData.prFeedback > 0 ? 'pr' : 'agent'; - return { sessionId: 'debug', sessionResource: URI.from({ scheme: 'session-chat-pills-debug', path: '/feedback' }), count, kind, firstCommentId: 'debug', debug: true }; + return this._debugStates(debugData); } + const session = this._session.read(reader); - if (!session || this._commentsDismissed.read(reader).has(session.sessionId)) { - return undefined; + if (!session) { + return []; } + this._feedbackChanged.read(reader); - const created = this.feedbackService.getFeedback(session.resource) - .filter(item => item.state === AgentFeedbackState.Created && REVIEWABLE_KINDS.has(item.kind)); - if (created.length === 0) { - return undefined; + const createdFeedback = this.feedbackService.getFeedback(session.resource) + .filter(item => item.state === AgentFeedbackState.Created); + const gitHubInfo = session.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader); + const pullRequests = getGitHubPullRequestRefs(gitHubInfo); + const firstPullRequest = pullRequests[0]; + const dismissed = this._dismissed.read(reader); + const legacyCIDismissed = this._legacyCIDismissed.read(reader).has(session.sessionId); + const legacyCommentsDismissed = this._legacyCommentsDismissed.read(reader).has(session.sessionId); + const states: BannerState[] = []; + + if (!this._agentMergeEnabled.read(reader)) { + for (const pullRequest of pullRequests) { + const id = pullRequestBannerId(session.sessionId, pullRequest); + if (dismissed.has(id)) { + continue; + } + + const comments = legacyCommentsDismissed + ? [] + : createdFeedback.filter(item => feedbackForPullRequest(item, pullRequest, firstPullRequest)); + const prModelRef = reader.store.add(this.gitHubService.createPullRequestModelReference(pullRequest.owner, pullRequest.repo, pullRequest.number)); + const livePullRequest = prModelRef.object.pullRequest.read(reader); + let failed = 0; + let completed = 0; + let pending = 0; + if (!legacyCIDismissed && livePullRequest && !livePullRequest.isDraft && livePullRequest.state === GitHubPullRequestState.Open) { + const ciModelRef = reader.store.add(this.gitHubService.createPullRequestCIModelReference(pullRequest.owner, pullRequest.repo, pullRequest.number, livePullRequest.headSha)); + const ciModel = ciModelRef.object; + if (!ciModel.fixRequested.read(reader)) { + const checks = ciModel.checks.read(reader); + failed = getFailedChecks(checks).length; + completed = checks.filter(check => check.status === GitHubCheckStatus.Completed).length; + pending = checks.length - completed; + } + } + + if (failed === 0 && comments.length === 0) { + continue; + } + + states.push({ + id, + kind: 'pullRequest', + sessionId: session.sessionId, + sessionResource: session.resource, + pullRequest, + title: livePullRequest?.title ?? pullRequest.title, + failed, + completed, + pending, + commentIds: comments.map(comment => comment.id), + firstCommentId: comments[0]?.id, + multiplePullRequests: pullRequests.length > 1, + }); + } } - const allPR = created.every(item => item.kind === AgentFeedbackKind.PRReview); - const allAgent = created.every(item => item.kind === AgentFeedbackKind.AgentReview); - const kind = allPR ? 'pr' : allAgent ? 'agent' : 'mixed'; - return { sessionId: session.sessionId, sessionResource: session.resource, count: created.length, kind, firstCommentId: created[0].id }; + + const agentComments = legacyCommentsDismissed + ? [] + : createdFeedback.filter(item => item.kind === AgentFeedbackKind.AgentReview); + const agentCommentsId = agentCommentsBannerId(session.sessionId); + if (agentComments.length > 0 && !dismissed.has(agentCommentsId)) { + states.push({ + id: agentCommentsId, + kind: 'agentComments', + sessionId: session.sessionId, + sessionResource: session.resource, + commentIds: agentComments.map(comment => comment.id), + firstCommentId: agentComments[0].id, + }); + } + + return states; }); constructor( @ISessionsService private readonly sessionsService: ISessionsService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IGitHubService private readonly gitHubService: IGitHubService, @IAgentFeedbackService private readonly feedbackService: IAgentFeedbackService, @ICommandService private readonly commandService: ICommandService, @IStorageService private readonly storageService: IStorageService, - @IInstantiationService private readonly instantiationService: IInstantiationService, + @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, ) { super(); - this.domNode = dom.$('.session-input-banners'); - this._ciSlot = dom.append(this.domNode, dom.$('.session-input-banner-slot')); - this._commentsSlot = dom.append(this.domNode, dom.$('.session-input-banner-slot')); - + this.domNode = dom.$('.session-input-banners.empty'); + this._widget = this._register(instantiationService.createInstance(SessionInputBannerWidget, [])); + this.domNode.appendChild(this._widget.domNode); this._feedbackChanged = observableSignalFromEvent(this, this.feedbackService.onDidChangeFeedback); - // Load persisted dismissal state and keep it in sync with other windows/profiles. - this._ciDismissed.set(this._readDismissed(STORAGE_KEY_CI_DISMISSED), undefined); - this._commentsDismissed.set(this._readDismissed(STORAGE_KEY_COMMENTS_DISMISSED), undefined); - this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, STORAGE_KEY_CI_DISMISSED, this._store)(() => { - this._ciDismissed.set(this._readDismissed(STORAGE_KEY_CI_DISMISSED), undefined); - })); - this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, STORAGE_KEY_COMMENTS_DISMISSED, this._store)(() => { - this._commentsDismissed.set(this._readDismissed(STORAGE_KEY_COMMENTS_DISMISSED), undefined); + this._loadDismissedState(); + this._registerStorageListener(STORAGE_KEY_DISMISSED, this._dismissed); + this._registerStorageListener(LEGACY_STORAGE_KEY_CI_DISMISSED, this._legacyCIDismissed); + this._registerStorageListener(LEGACY_STORAGE_KEY_COMMENTS_DISMISSED, this._legacyCommentsDismissed); + + this._register(autorun(reader => { + const states = this._states.read(reader); + const banners = states.map((state, index) => this._toBanner(state, index, states.length)); + this.domNode.classList.toggle('empty', banners.length === 0); + this._widget.setBanners(banners); })); - this._register(autorun(reader => this._renderCIBanner(this._ciState.read(reader)))); - this._register(autorun(reader => this._renderCommentsBanner(this._commentsState.read(reader)))); + this._register(autorun(reader => { + const session = this._session.read(reader); + if (!session || this._agentMergeEnabled.read(reader)) { + return; + } + const gitHubInfo = session.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader); + for (const pullRequest of getGitHubPullRequestRefs(gitHubInfo)) { + const prModelRef = reader.store.add(this.gitHubService.createPullRequestModelReference(pullRequest.owner, pullRequest.repo, pullRequest.number)); + const prModel = prModelRef.object; + void prModel.refresh(); + reader.store.add(prModel.startPolling()); + const livePullRequest = prModel.pullRequest.read(reader); + if (!livePullRequest || livePullRequest.isDraft || livePullRequest.state !== GitHubPullRequestState.Open) { + continue; + } + const ciModelRef = reader.store.add(this.gitHubService.createPullRequestCIModelReference(pullRequest.owner, pullRequest.repo, pullRequest.number, livePullRequest.headSha)); + void ciModelRef.object.refresh(); + reader.store.add(ciModelRef.object.startPolling()); + } + })); } - /** Marks whether the owning chat view is the active session. */ setActive(active: boolean): void { this._active.set(active, undefined); } @@ -201,111 +262,239 @@ export class SessionInputBanners extends Disposable { this._debugData.set(data, undefined); } - private _renderCIBanner(state: ICIBannerState | undefined): void { - const store = this._ciContent.value = new DisposableStore(); - dom.clearNode(this._ciSlot); - if (!state) { - return; + private _debugStates(data: ISessionChatPillsDebugData): readonly BannerState[] { + const states: BannerState[] = []; + if (data.ciFailed > 0 || data.prFeedback > 0) { + states.push({ + id: 'debug:pullRequest', + kind: 'pullRequest', + sessionId: 'debug', + sessionResource: URI.from({ scheme: 'session-chat-pills-debug', path: '/pull-request' }), + pullRequest: { + owner: 'microsoft', + repo: 'vscode', + number: 123, + uri: URI.parse('https://github.com/microsoft/vscode/pull/123'), + }, + title: localize('inputBanner.examplePullRequest', "Example Pull Request"), + failed: data.ciFailed, + completed: data.ciFailed, + pending: data.ciPending, + commentIds: Array.from({ length: data.prFeedback }, (_, index) => `debug-pr-${index}`), + firstCommentId: data.prFeedback > 0 ? 'debug-pr-0' : undefined, + multiplePullRequests: false, + debug: true, + }); } + if (data.agentFeedback > 0) { + states.push({ + id: 'debug:agentComments', + kind: 'agentComments', + sessionId: 'debug', + sessionResource: URI.from({ scheme: 'session-chat-pills-debug', path: '/agent-comments' }), + commentIds: Array.from({ length: data.agentFeedback }, (_, index) => `debug-agent-${index}`), + firstCommentId: 'debug-agent-0', + debug: true, + }); + } + return states; + } + + private _toBanner(state: BannerState, index: number, total: number): ISessionInputBanner { + const compact = total > 1; + const text = state.kind === 'pullRequest' + ? this._pullRequestText(state, compact) + : this._commentsText('agent', state.commentIds.length); + const reference = state.kind === 'pullRequest' + ? { + label: `#${state.pullRequest.number}`, + hover: state.title + ? localize('inputBanner.pullRequestHoverWithTitle', "Pull Request #{0}: {1}", state.pullRequest.number, state.title) + : localize('inputBanner.pullRequestHover', "Pull Request #{0}", state.pullRequest.number), + } + : compact ? { + label: localize('inputBanner.agentReview', "Agent Review"), + hover: localize('inputBanner.agentReviewHover', "In-product agent review comments"), + } : undefined; + const showReference = compact || state.kind === 'pullRequest' && state.multiplePullRequests; + const position = compact + ? localize('inputBanner.positionAria', "Item {0} of {1}", index + 1, total) + : undefined; + const ariaLabel = [position, showReference ? reference?.label : undefined, text].filter(Boolean).join(', '); + + return { + id: state.id, + icon: state.kind === 'pullRequest' && state.failed > 0 ? Codicon.warning : Codicon.commentDiscussion, + accent: state.kind === 'pullRequest' && state.failed > 0, + text, + ariaLabel, + reference: showReference ? reference : undefined, + dismissTooltip: localize('inputBanner.dismiss', "Hide this item for this session"), + actions: state.kind === 'pullRequest' ? this._pullRequestActions(state) : this._agentCommentActions(state), + focusAfterDismiss: () => this.chatWidgetService.getWidgetBySessionResource(state.sessionResource)?.focusInput(), + dismiss: () => { if (!state.debug) { this._dismiss(state.id); } }, + }; + } + private _pullRequestText(state: IPRBannerState, compact: boolean): string { + const checks = state.failed > 0 ? this._checksText(state, compact) : undefined; + const comments = state.commentIds.length > 0 ? this._commentsText('pr', state.commentIds.length) : undefined; + return checks && comments + ? localize('inputBanner.combinedText', "{0} | {1}", checks, comments) + : checks ?? comments ?? ''; + } + + private _checksText(state: IPRBannerState, compact: boolean): string { + if (compact) { + return state.failed === 1 + ? localize('inputBanner.oneCheckFailing', "1 Check Failing") + : localize('inputBanner.checksFailing', "{0} Checks Failing", state.failed); + } const failedText = state.completed === 1 - ? localize('ci.oneCheckFailed', "1 check failed") - : localize('ci.checksFailed', "{0} out of {1} checks failed", state.failed, state.completed); - const text = state.pending > 0 - ? localize('ci.checksFailedPending', "{0}, {1} pending", failedText, state.pending) + ? localize('inputBanner.oneCheckFailed', "1 check failed") + : localize('inputBanner.checksFailed', "{0} out of {1} checks failed", state.failed, state.completed); + return state.pending > 0 + ? localize('inputBanner.checksFailedPending', "{0}, {1} pending", failedText, state.pending) : failedText; - - this._renderBanner(this._ciSlot, store, { - icon: Codicon.warning, - accent: true, - text, - ariaLabel: text, - dismissTooltip: localize('ci.dismiss', "Hide for this session"), - actions: [ - { - label: localize('ci.fixChecks', "Fix Checks"), - primary: true, - waitUntilReady: () => state.debug ? Promise.resolve(true) : this._waitForChatModel(state.sessionResource), - run: () => state.debug ? undefined : this._fixChecks(state), - }, - { - label: localize('ci.revealChecks', "Reveal"), - run: () => { if (!state.debug) { void this._executeCommand(OPEN_PULL_REQUEST_ACTION_ID); } }, - }, - ], - dismiss: () => { if (!state.debug) { this._dismiss(STORAGE_KEY_CI_DISMISSED, this._ciDismissed, state.sessionId); } }, - }); } - private _renderCommentsBanner(state: ICommentsBannerState | undefined): void { - const store = this._commentsContent.value = new DisposableStore(); - dom.clearNode(this._commentsSlot); - if (!state) { - return; + private _commentsText(kind: 'pr' | 'agent', count: number): string { + if (kind === 'pr') { + return count === 1 + ? localize('inputBanner.onePRComment', "1 PR Comment") + : localize('inputBanner.prComments', "{0} PR Comments", count); } + return count === 1 + ? localize('inputBanner.oneAgentComment', "1 Agent Comment") + : localize('inputBanner.agentComments', "{0} Agent Comments", count); + } - const text = this._commentsBannerText(state.kind, state.count); + private _pullRequestActions(state: IPRBannerState): readonly ISessionInputBannerAction[] { + const hasCI = state.failed > 0; + const hasComments = state.commentIds.length > 0; + const fixCI: ISessionInputBannerAction = { + id: 'fixCI', + label: localize('inputBanner.fixCI', "Fix CI"), + primary: true, + waitUntilReady: () => state.debug ? Promise.resolve(true) : this._waitForChatModel(state.sessionResource), + run: () => state.debug ? undefined : this._fixChecks(state), + }; + const addressComments: ISessionInputBannerAction = { + id: 'addressComments', + label: localize('inputBanner.addressComments', "Address Comments"), + primary: true, + waitUntilReady: () => state.debug ? Promise.resolve(true) : this._waitForChatModel(state.sessionResource), + run: () => state.debug ? undefined : this._addressComments(state, this._queryFor(state, '/act-on-feedback')), + }; + const primary = hasCI && hasComments ? { + id: 'fixCIAndAddressComments', + label: localize('inputBanner.fixCIAndAddressComments', "Fix CI & Address Comments"), + primary: true, + dropdownActions: [fixCI, addressComments], + waitUntilReady: () => state.debug ? Promise.resolve(true) : this._waitForChatModel(state.sessionResource), + run: () => state.debug ? undefined : this._fixCIAndAddressComments(state), + } satisfies ISessionInputBannerAction : hasCI ? fixCI : addressComments; + + const revealCI: ISessionInputBannerAction = { + id: 'revealCI', + label: localize('inputBanner.revealCI', "Reveal CI"), + run: () => { if (!state.debug) { void this._revealPullRequest(state.pullRequest); } }, + }; + const revealComments: ISessionInputBannerAction = { + id: 'revealComments', + label: localize('inputBanner.revealComments', "Reveal Comments"), + run: () => { if (!state.debug && state.firstCommentId) { this._revealComment(state.sessionResource, state.firstCommentId); } }, + }; + const reveal = hasCI && hasComments ? { + ...revealCI, + dropdownActions: [revealComments], + } : hasCI ? revealCI : revealComments; + + return [primary, reveal]; + } - this._renderBanner(this._commentsSlot, store, { - icon: Codicon.commentDiscussion, - accent: false, - text, - ariaLabel: text, - dismissTooltip: localize('comments.dismiss', "Hide for this session"), - actions: [ - { - label: localize('comments.address', "Address Comments"), - primary: true, - waitUntilReady: () => state.debug ? Promise.resolve(true) : this._waitForChatModel(state.sessionResource), - run: () => state.debug ? undefined : this._addressComments(state.sessionResource).catch(err => this.logService.error('[SessionInputBanners] Failed to address comments', err)), - }, - { - label: localize('comments.reveal', "Reveal"), - run: () => { if (!state.debug) { this._revealComment(state.sessionResource, state.firstCommentId); } }, - }, - ], - dismiss: () => { if (!state.debug) { this._dismiss(STORAGE_KEY_COMMENTS_DISMISSED, this._commentsDismissed, state.sessionId); } }, - }); + private _agentCommentActions(state: IAgentCommentsBannerState): readonly ISessionInputBannerAction[] { + return [{ + id: 'addressComments', + label: localize('inputBanner.addressComments', "Address Comments"), + primary: true, + waitUntilReady: () => state.debug ? Promise.resolve(true) : this._waitForChatModel(state.sessionResource), + run: () => state.debug ? undefined : this._addressComments(state, '/act-on-feedback'), + }, { + id: 'revealComments', + label: localize('inputBanner.revealComments', "Reveal Comments"), + run: () => { if (!state.debug && state.firstCommentId) { this._revealComment(state.sessionResource, state.firstCommentId); } }, + }]; } - private _renderBanner(container: HTMLElement, store: DisposableStore, banner: ISessionInputBanner): void { - const widget = store.add(this.instantiationService.createInstance(SessionInputBannerWidget, banner)); - container.appendChild(widget.domNode); + private _queryFor(state: IPRBannerState, query: string): string { + return state.multiplePullRequests ? `${query} for #${state.pullRequest.number}` : query; } - private _commentsBannerText(kind: 'pr' | 'agent' | 'mixed', count: number): string { - switch (kind) { - case 'pr': - return count === 1 - ? localize('comments.pr.one', "1 PR comment") - : localize('comments.pr.many', "{0} PR comments", count); - case 'agent': - return count === 1 - ? localize('comments.agent.one', "1 agent comment") - : localize('comments.agent.many', "{0} agent comments", count); - case 'mixed': - return count === 1 - ? localize('comments.one', "1 comment") - : localize('comments.many', "{0} comments", count); + private async _fixChecks(state: IPRBannerState): Promise { + const widget = this.chatWidgetService.getWidgetBySessionResource(state.sessionResource); + if (!widget) { + this.logService.error('[SessionInputBanners] Cannot fix CI checks: chat model is unavailable', state.sessionResource.toString(), state.pullRequest.number); + return; } + await this._withCurrentCIModel(state, ciModel => submitFixCIChecks(ciModel, widget, this._queryFor(state, '/fix-ci'))); } - private async _executeCommand(commandId: string): Promise { + private async _fixCIAndAddressComments(state: IPRBannerState): Promise { + await this._withCurrentCIModel(state, async ciModel => { + this._acceptComments(state); + const prompt = await buildFixCIPrompt(ciModel, this._queryFor(state, '/fix-ci and /act-on-feedback')); + if (!prompt) { + this.logService.warn('[SessionInputBanners] CI failures disappeared before the combined request was submitted', state.pullRequest.number); + await this._submitComments(state, this._queryFor(state, '/act-on-feedback')); + return; + } + const submitted = await this.feedbackService.submitFeedback(state.sessionResource, { + query: prompt, + feedbackIds: state.commentIds, + onRequestAccepted: () => ciModel.markFixRequested(), + }); + if (!submitted) { + this.logService.error('[SessionInputBanners] Failed to submit combined CI and comments request', state.sessionResource.toString(), state.pullRequest.number); + } + }); + } + + private async _withCurrentCIModel(state: IPRBannerState, task: (ciModel: GitHubPullRequestCIModel) => Promise): Promise { + const store = new DisposableStore(); try { - await this.commandService.executeCommand(commandId); - } catch (err) { - this.logService.error('[SessionInputBanners] command failed', commandId, err); + const pullRequestRef = store.add(this.gitHubService.createPullRequestModelReference(state.pullRequest.owner, state.pullRequest.repo, state.pullRequest.number)); + const pullRequest = pullRequestRef.object.pullRequest.get(); + if (!pullRequest) { + this.logService.error('[SessionInputBanners] Cannot act on CI: pull request model is unavailable', state.sessionResource.toString(), state.pullRequest.number); + return; + } + const ciModelRef = store.add(this.gitHubService.createPullRequestCIModelReference(state.pullRequest.owner, state.pullRequest.repo, state.pullRequest.number, pullRequest.headSha)); + await task(ciModelRef.object); + } finally { + store.dispose(); } } - private async _fixChecks(state: ICIBannerState): Promise { - const widget = this.chatWidgetService.getWidgetBySessionResource(state.sessionResource); - if (!widget || !state.ciModel) { - this.logService.error('[SessionInputBanners] Cannot fix CI checks: chat model is not loaded for session', state.sessionResource.toString()); - return; + private async _addressComments(state: IBaseBannerState, query: string): Promise { + this._acceptComments(state); + await this._submitComments(state, query); + } + + private _acceptComments(state: IBaseBannerState): void { + for (const commentId of state.commentIds) { + this.feedbackService.acceptFeedback(state.sessionResource, commentId); } + } - await submitFixCIChecks(state.ciModel, widget); + private async _submitComments(state: IBaseBannerState, query: string): Promise { + const submitted = await this.feedbackService.submitFeedback(state.sessionResource, { + query, + feedbackIds: state.commentIds, + }); + if (!submitted) { + this.logService.error('[SessionInputBanners] Failed to submit comments', state.sessionResource.toString()); + } } private async _waitForChatModel(sessionResource: URI): Promise { @@ -313,26 +502,15 @@ export class SessionInputBanners extends Disposable { if (widget) { return true; } - this.logService.error('[SessionInputBanners] Chat model did not load for session', sessionResource.toString()); return false; } - private async _addressComments(sessionResource: URI): Promise { - // Accept the reviewable comments surfaced in the banner so they become - // attachable feedback, then submit them to the agent. This mirrors the - // agent feedback editor overlay's Submit button: rather than sending a - // bare `/act-on-feedback` command, the accepted feedback items are - // attached to the request so the agent receives the comments. - const created = this.feedbackService.getFeedback(sessionResource) - .filter(item => item.state === AgentFeedbackState.Created && REVIEWABLE_KINDS.has(item.kind)); - for (const item of created) { - this.feedbackService.acceptFeedback(sessionResource, item.id); - } - - const submitted = await this.feedbackService.submitFeedback(sessionResource); - if (!submitted) { - this.logService.error('[SessionInputBanners] Failed to submit feedback for session', sessionResource.toString()); + private async _revealPullRequest(pullRequest: IGitHubPullRequestRef): Promise { + try { + await this.commandService.executeCommand(OPEN_PULL_REQUEST_ACTION_ID, { pullRequest }); + } catch (err) { + this.logService.error('[SessionInputBanners] Failed to reveal pull request', pullRequest.number, err); } } @@ -340,11 +518,23 @@ export class SessionInputBanners extends Disposable { this.feedbackService.revealFeedback(sessionResource, commentId).catch(err => this.logService.error('[SessionInputBanners] Failed to reveal comment', err)); } - private _dismiss(storageKey: string, observable: ISettableObservable>, sessionId: string): void { - const next = new Set(observable.get()); - next.add(sessionId); - this.storageService.store(storageKey, JSON.stringify([...next]), StorageScope.PROFILE, StorageTarget.USER); - observable.set(next, undefined); + private _dismiss(id: string): void { + const next = new Set(this._dismissed.get()); + next.add(id); + this.storageService.store(STORAGE_KEY_DISMISSED, JSON.stringify([...next]), StorageScope.PROFILE, StorageTarget.USER); + this._dismissed.set(next, undefined); + } + + private _loadDismissedState(): void { + this._dismissed.set(this._readDismissed(STORAGE_KEY_DISMISSED), undefined); + this._legacyCIDismissed.set(this._readDismissed(LEGACY_STORAGE_KEY_CI_DISMISSED), undefined); + this._legacyCommentsDismissed.set(this._readDismissed(LEGACY_STORAGE_KEY_COMMENTS_DISMISSED), undefined); + } + + private _registerStorageListener(storageKey: string, target: ISettableObservable>): void { + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, storageKey, this._store)(() => { + target.set(this._readDismissed(storageKey), undefined); + })); } private _readDismissed(storageKey: string): ReadonlySet { @@ -353,7 +543,7 @@ export class SessionInputBanners extends Disposable { return new Set(); } try { - const parsed = JSON.parse(raw); + const parsed: unknown = JSON.parse(raw); return Array.isArray(parsed) ? new Set(parsed.filter((id): id is string => typeof id === 'string')) : new Set(); } catch { return new Set(); diff --git a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBannerWidget.test.ts b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBannerWidget.test.ts index 118786a05d8309..282bf51f079230 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBannerWidget.test.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBannerWidget.test.ts @@ -7,9 +7,11 @@ import assert from 'assert'; import type { IManagedHover } from '../../../../../base/browser/ui/hover/hover.js'; import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { ISessionInputBanner, SessionInputBannerWidget } from '../../browser/sessionInputBannerWidget.js'; @@ -45,7 +47,8 @@ suite('SessionInputBannerWidget', () => { const hoverService = upcastPartial({ setupManagedHover: () => upcastPartial({ dispose() { } }), }); - const widget = disposables.add(new SessionInputBannerWidget(banner, hoverService)); + const contextMenuService = upcastPartial({ showContextMenu() { } }); + const widget = disposables.add(new SessionInputBannerWidget(banner, hoverService, contextMenuService)); const [primaryButton, secondaryButton] = widget.domNode.querySelectorAll('.session-input-banner-action'); primaryButton.click(); @@ -108,7 +111,8 @@ suite('SessionInputBannerWidget', () => { const hoverService = upcastPartial({ setupManagedHover: () => upcastPartial({ dispose() { } }), }); - const widget = new SessionInputBannerWidget(banner, hoverService); + const contextMenuService = upcastPartial({ showContextMenu() { } }); + const widget = new SessionInputBannerWidget(banner, hoverService, contextMenuService); const primaryButton = widget.domNode.querySelector('.session-input-banner-action')!; primaryButton.click(); @@ -122,4 +126,197 @@ suite('SessionInputBannerWidget', () => { assert.strictEqual(primaryRuns, 0); })); + + test('does not run a primary action whose banner is replaced while readiness is pending', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const ready = new DeferredPromise(); + let primaryRuns = 0; + let inputFocusCount = 0; + const hoverService = upcastPartial({ + setupManagedHover: () => upcastPartial({ dispose() { } }), + }); + const contextMenuService = upcastPartial({ showContextMenu() { } }); + const widget = disposables.add(new SessionInputBannerWidget({ + id: 'old', + icon: Codicon.warning, + accent: true, + text: '2 Checks Failing', + ariaLabel: '2 Checks Failing', + actions: [{ + label: 'Fix CI', + primary: true, + waitUntilReady: () => ready.p, + run: () => { primaryRuns++; }, + }], + focusAfterDismiss: () => inputFocusCount++, + }, hoverService, contextMenuService)); + + document.body.appendChild(widget.domNode); + disposables.add(toDisposable(() => widget.domNode.remove())); + const primaryButton = widget.domNode.querySelector('.session-input-banner-action'); + primaryButton?.focus(); + primaryButton?.click(); + await timeout(0); + widget.setBanners([{ + id: 'new', + icon: Codicon.commentDiscussion, + accent: false, + text: '1 PR Comment', + ariaLabel: '1 PR Comment', + actions: [], + }]); + ready.complete(true); + await timeout(0); + + assert.deepStrictEqual({ primaryRuns, inputFocusCount }, { primaryRuns: 0, inputFocusCount: 1 }); + })); + + test('runs a pending primary action after the same banner is refreshed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const ready = new DeferredPromise(); + let primaryRuns = 0; + const hoverService = upcastPartial({ + setupManagedHover: () => upcastPartial({ dispose() { } }), + }); + const contextMenuService = upcastPartial({ showContextMenu() { } }); + const banner = (text: string): ISessionInputBanner => ({ + id: 'same', + icon: Codicon.warning, + accent: true, + text, + ariaLabel: text, + actions: [{ + id: 'fixCI', + label: 'Fix CI', + primary: true, + waitUntilReady: () => ready.p, + run: () => { primaryRuns++; }, + }], + }); + const widget = disposables.add(new SessionInputBannerWidget(banner('2 Checks Failing'), hoverService, contextMenuService)); + + widget.domNode.querySelector('.session-input-banner-action')?.click(); + await timeout(0); + widget.setBanners([banner('3 Checks Failing')]); + ready.complete(true); + await timeout(0); + + assert.strictEqual(primaryRuns, 1); + })); + + test('navigates carousel items and preserves one banner surface', () => { + const hoverService = upcastPartial({ + setupManagedHover: () => upcastPartial({ dispose() { } }), + }); + const contextMenuService = upcastPartial({ showContextMenu() { } }); + const widget = disposables.add(new SessionInputBannerWidget([{ + id: 'pr-1', + icon: Codicon.warning, + accent: true, + text: '2 Checks Failing', + ariaLabel: 'Item 1 of 2, #101, 2 Checks Failing', + reference: { label: '#101', hover: 'Pull Request #101: First' }, + actions: [], + }, { + id: 'pr-2', + icon: Codicon.commentDiscussion, + accent: false, + text: '3 PR Comments', + ariaLabel: 'Item 2 of 2, #102, 3 PR Comments', + reference: { label: '#102', hover: 'Pull Request #102: Second' }, + actions: [], + }], hoverService, contextMenuService)); + + widget.domNode.querySelector('.session-input-banner-navigation-button.next')?.click(); + + assert.deepStrictEqual({ + rootIsSingleBanner: widget.domNode.matches('.session-input-banner'), + position: widget.domNode.querySelector('.session-input-banner-position')?.textContent, + reference: widget.domNode.querySelector('.session-input-banner-reference')?.textContent, + text: widget.domNode.querySelector('.session-input-banner-text')?.textContent, + accent: widget.domNode.classList.contains('accent-orange'), + }, { + rootIsSingleBanner: true, + position: '2/2', + reference: '#102', + text: '3 PR Comments', + accent: false, + }); + }); + + test('preserves dismiss focus across updates and returns to input after the final item', () => { + const hoverService = upcastPartial({ + setupManagedHover: () => upcastPartial({ dispose() { } }), + }); + const contextMenuService = upcastPartial({ showContextMenu() { } }); + let inputFocusCount = 0; + const banner = (id: string): ISessionInputBanner => ({ + id, + icon: Codicon.commentDiscussion, + accent: false, + text: '1 PR Comment', + ariaLabel: '1 PR Comment', + actions: [], + dismissTooltip: 'Hide this item', + dismiss() { }, + focusAfterDismiss: () => inputFocusCount++, + }); + const widget = disposables.add(new SessionInputBannerWidget([banner('first'), banner('second')], hoverService, contextMenuService)); + document.body.appendChild(widget.domNode); + disposables.add(toDisposable(() => widget.domNode.remove())); + const dismiss = widget.domNode.querySelector('.session-input-banner-dismiss'); + dismiss?.focus(); + + widget.setBanners([banner('second')]); + const focusPreserved = document.activeElement === widget.domNode.querySelector('.session-input-banner-dismiss'); + widget.setBanners([]); + + assert.deepStrictEqual({ focusPreserved, inputFocusCount }, { focusPreserved: true, inputFocusCount: 1 }); + }); + + test('renders combined work and reveal actions as split buttons', () => { + const hoverService = upcastPartial({ + setupManagedHover: () => upcastPartial({ dispose() { } }), + }); + let dropdownLabels: readonly string[] = []; + const contextMenuService = upcastPartial({ + showContextMenu(delegate) { + dropdownLabels = (delegate.getActions?.() ?? []).map(action => action.label); + } + }); + const widget = disposables.add(new SessionInputBannerWidget({ + icon: Codicon.warning, + accent: true, + text: '2 Checks Failing | 3 PR Comments', + ariaLabel: '#101, 2 Checks Failing, 3 PR Comments', + actions: [{ + label: 'Fix CI & Address Comments', + primary: true, + dropdownActions: [ + { label: 'Fix CI', primary: true, run() { } }, + { label: 'Address Comments', primary: true, run() { } }, + ], + run() { }, + }, { + label: 'Reveal CI', + dropdownActions: [{ label: 'Reveal Comments', run() { } }], + run() { }, + }], + }, hoverService, contextMenuService)); + + const dropdownButtons = widget.domNode.querySelectorAll('.monaco-dropdown-button'); + dropdownButtons[0].click(); + const workDropdownLabels = dropdownLabels; + dropdownButtons[1].click(); + + assert.deepStrictEqual({ + splitButtonCount: widget.domNode.querySelectorAll('.monaco-button-dropdown').length, + primaryLabels: [...widget.domNode.querySelectorAll('.monaco-button-dropdown > .monaco-button:first-child')].map(element => element.textContent), + workDropdownLabels, + revealDropdownLabels: dropdownLabels, + }, { + splitButtonCount: 2, + primaryLabels: ['Fix CI & Address Comments', 'Reveal CI'], + workDropdownLabels: ['Fix CI', 'Address Comments'], + revealDropdownLabels: ['Reveal Comments'], + }); + }); }); diff --git a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts index 3fc85c22585aa8..ed210f272a32be 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { ISessionInputBanner, SessionInputBannerWidget } from '../../browser/sessionInputBannerWidget.js'; export default defineThemedFixtureGroup({ path: 'sessions/inputBanners/' }, { @@ -40,7 +40,21 @@ export default defineThemedFixtureGroup({ path: 'sessions/inputBanners/' }, { Both: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (context) => renderBanners(context, [ciBanner(1, 4, 0), commentsBanner(1, 'mixed')]), + render: (context) => renderBanners(context, [combinedPRBanner(42, 1, 3)]), + }), + + MultiplePullRequests: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanners(context, [ + combinedPRBanner(42, 2, 3), + { ...commentsBanner(4, 'pr'), id: 'pr-43', text: '4 PR Comments', ariaLabel: '#43, 4 PR Comments', reference: { label: '#43', hover: 'Pull Request #43: Improve session rendering' } }, + { ...ciBanner(1, 2, 0), id: 'pr-44', text: '1 Check Failing', ariaLabel: '#44, 1 Check Failing', reference: { label: '#44', hover: 'Pull Request #44: Fix accessibility labels' } }, + ]), + }), + + CombinedNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderBanners(context, [combinedPRBanner(42, 2, 3)], 360), }), LongTextEllipsis: defineComponentFixture({ @@ -59,15 +73,15 @@ function ciBanner(failed: number, completed: number, pending: number): ISessionI ariaLabel: text, dismissTooltip: 'Hide for this session', actions: [ - { label: 'Fix Checks', primary: true, run: () => console.log('Fix Checks') }, - { label: 'Reveal', run: () => console.log('Open Pull Request') }, + { label: 'Fix CI', primary: true, run: () => console.log('Fix CI') }, + { label: 'Reveal CI', run: () => console.log('Open Pull Request') }, ], dismiss: () => console.log('Dismiss CI banner'), }; } function commentsBanner(count: number, kind: 'pr' | 'agent' | 'mixed'): ISessionInputBanner { - const noun = kind === 'pr' ? 'PR comment' : kind === 'agent' ? 'agent comment' : 'comment'; + const noun = kind === 'pr' ? 'PR Comment' : kind === 'agent' ? 'Agent Comment' : 'Comment'; const text = count === 1 ? `1 ${noun}` : `${count} ${noun}s`; return { icon: Codicon.commentDiscussion, @@ -77,12 +91,37 @@ function commentsBanner(count: number, kind: 'pr' | 'agent' | 'mixed'): ISession dismissTooltip: 'Hide for this session', actions: [ { label: 'Address Comments', primary: true, run: () => console.log('Address Comments') }, - { label: 'Reveal', run: () => console.log('Reveal Comments') }, + { label: 'Reveal Comments', run: () => console.log('Reveal Comments') }, ], dismiss: () => console.log('Dismiss comments banner'), }; } +function combinedPRBanner(number: number, failed: number, comments: number): ISessionInputBanner { + const fixCI = { label: 'Fix CI', primary: true, run: () => console.log('Fix CI') }; + const addressComments = { label: 'Address Comments', primary: true, run: () => console.log('Address Comments') }; + return { + id: `pr-${number}`, + icon: Codicon.warning, + accent: true, + text: `${failed} Checks Failing | ${comments} PR Comments`, + ariaLabel: `#${number}, ${failed} Checks Failing, ${comments} PR Comments`, + reference: { label: `#${number}`, hover: `Pull Request #${number}: Add multi-PR banner support` }, + dismissTooltip: 'Hide this item for this session', + actions: [{ + label: 'Fix CI & Address Comments', + primary: true, + dropdownActions: [fixCI, addressComments], + run: () => console.log('Fix CI and Address Comments'), + }, { + label: 'Reveal CI', + dropdownActions: [{ label: 'Reveal Comments', run: () => console.log('Reveal Comments') }], + run: () => console.log('Reveal CI'), + }], + dismiss: () => console.log('Dismiss PR banner'), + }; +} + function renderBanners({ container, disposableStore, theme }: ComponentFixtureContext, banners: readonly ISessionInputBanner[], width = 480, working = false): void { container.style.width = `${width}px`; container.style.display = 'flex'; @@ -91,11 +130,12 @@ function renderBanners({ container, disposableStore, theme }: ComponentFixtureCo container.style.padding = '8px'; container.style.backgroundColor = 'var(--vscode-editorWidget-background)'; - const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: registerWorkbenchServices, + }); - for (const banner of banners) { - const widget = disposableStore.add(instantiationService.createInstance(SessionInputBannerWidget, banner)); - widget.setWorking(working); - container.appendChild(widget.domNode); - } + const widget = disposableStore.add(instantiationService.createInstance(SessionInputBannerWidget, banners)); + widget.setWorking(working); + container.appendChild(widget.domNode); } diff --git a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.test.ts b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.test.ts new file mode 100644 index 00000000000000..0f3dba8feb4e33 --- /dev/null +++ b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.test.ts @@ -0,0 +1,311 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { IManagedHover } from '../../../../../base/browser/ui/hover/hover.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event, Emitter } from '../../../../../base/common/event.js'; +import { Disposable, ImmortalReference, IDisposable, IReference } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { IGitHubPullRequestRef, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService, ISubmitFeedbackOptions } from '../../../agentFeedback/browser/agentFeedbackService.js'; +import { IGitHubService } from '../../../github/browser/githubService.js'; +import { GitHubPullRequestCIModel } from '../../../github/browser/models/githubPullRequestCIModel.js'; +import { GitHubPullRequestModel } from '../../../github/browser/models/githubPullRequestModel.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubPullRequestState, IGitHubCICheck, IGitHubPullRequest } from '../../../github/common/types.js'; +import { SessionInputBanners } from '../../browser/sessionInputBanners.js'; + +suite('SessionInputBanners', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('groups actionable PRs, scopes the combined request, and dismisses one carousel item', async () => { + const sessionResource = URI.parse('local-agent-host:/session-1'); + const pullRequests = [pullRequest(42), pullRequest(41)]; + const session = new class extends mock() { + override readonly sessionId = 'session-1'; + override readonly resource = sessionResource; + override readonly providerId = LOCAL_AGENT_HOST_PROVIDER_ID; + override readonly status = observableValue('status', SessionStatus.Completed); + override readonly workspace = observableValue('workspace', { + uri: URI.file('/workspace'), + label: 'workspace', + icon: Codicon.folder, + folders: [{ + root: URI.file('/workspace'), + workingDirectory: URI.file('/workspace'), + name: 'workspace', + description: undefined, + gitRepository: { + uri: URI.file('/workspace'), + workTreeUri: undefined, + baseBranchName: undefined, + gitHubInfo: observableValue('gitHubInfo', { + owner: 'owner', + repo: 'repo', + pullRequests, + pullRequest: pullRequests[0], + }), + }, + }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }); + }(); + const sessionsService = new class extends mock() { + override readonly activeSession = observableValue('activeSession', session); + }(); + let agentMergeEnabled = false; + const onDidChangeSessionConfig = store.add(new Emitter()); + const agentHostProvider = new class extends mock() { + override readonly id = LOCAL_AGENT_HOST_PROVIDER_ID; + override readonly onDidChangeSessionConfig = onDidChangeSessionConfig.event; + override getAgentMergeSessionState() { return { enabled: agentMergeEnabled }; } + }(); + const sessionsProvidersService = new class extends mock() { + override getProvider(): T | undefined { + return agentHostProvider as unknown as T; + } + }(); + + const prModels = new Map([ + [42, pullRequestModel(42, 'Newest pull request')], + [41, pullRequestModel(41, 'Older pull request')], + ]); + const ciModels = new Map([ + [42, ciModel([failedCheck(1), failedCheck(2)])], + [41, ciModel([])], + ]); + const gitHubService = new class extends mock() { + override createPullRequestModelReference(_owner: string, _repo: string, prNumber: number): IReference { + return new ImmortalReference(prModels.get(prNumber)!); + } + override createPullRequestCIModelReference(_owner: string, _repo: string, prNumber: number): IReference { + return new ImmortalReference(ciModels.get(prNumber)!); + } + }(); + + let feedbackItems: IAgentFeedback[] = [ + { ...prFeedback('pr-42-a', sessionResource, 42), sourcePullRequest: undefined }, + prFeedback('pr-42-b', sessionResource, 42), + prFeedback('pr-41', sessionResource, 41), + agentFeedback('agent', sessionResource), + ]; + const onDidChangeFeedback = store.add(new Emitter<{ sessionResource: URI; feedbackItems: readonly IAgentFeedback[] }>()); + let submitted: ISubmitFeedbackOptions | undefined; + const feedbackService = new class extends mock() { + override readonly onDidChangeFeedback = onDidChangeFeedback.event; + override getFeedback(): readonly IAgentFeedback[] { return feedbackItems; } + override acceptFeedback(_sessionResource: URI, feedbackId: string): void { + feedbackItems = feedbackItems.map(item => item.id === feedbackId ? { ...item, state: AgentFeedbackState.Accepted } : item); + onDidChangeFeedback.fire({ sessionResource, feedbackItems }); + } + override async submitFeedback(_sessionResource: URI, options?: ISubmitFeedbackOptions): Promise { + submitted = options; + options?.onRequestAccepted?.(); + return true; + } + override revealFeedback(): Promise { return Promise.resolve(); } + }(); + + const chatWidget = upcastPartial({}); + const chatWidgetService = new class extends mock() { + override readonly onDidAddWidget = Event.None; + override getWidgetBySessionResource(): IChatWidget { return chatWidget; } + }(); + const storageService = store.add(new TestStorageService()); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IHoverService, upcastPartial({ + setupManagedHover: () => upcastPartial({ dispose() { } }), + })); + instantiationService.stub(IContextMenuService, upcastPartial({ showContextMenu() { } })); + + const banners = store.add(new SessionInputBanners( + sessionsService, + sessionsProvidersService, + gitHubService, + feedbackService, + new class extends mock() { }(), + storageService, + instantiationService, + new NullLogService(), + chatWidgetService, + )); + banners.setActive(true); + + assert.deepStrictEqual(currentBanner(banners), { + position: '1/3', + reference: '#42', + text: '2 Checks Failing | 2 PR Comments', + splitButtons: 2, + }); + + agentMergeEnabled = true; + onDidChangeSessionConfig.fire(session.sessionId); + assert.deepStrictEqual(currentBanner(banners), { + position: undefined, + reference: undefined, + text: '1 Agent Comment', + splitButtons: 0, + }); + agentMergeEnabled = false; + onDidChangeSessionConfig.fire(session.sessionId); + + banners.domNode.querySelector('.session-input-banner-navigation-button.next')?.click(); + assert.deepStrictEqual(currentBanner(banners), { + position: '1/3', + reference: '#42', + text: '2 Checks Failing | 2 PR Comments', + splitButtons: 2, + }); + banners.domNode.querySelector('.session-input-banner-navigation-button.next')?.click(); + assert.deepStrictEqual(currentBanner(banners), { + position: '2/3', + reference: '#41', + text: '1 PR Comment', + splitButtons: 0, + }); + banners.domNode.querySelector('.session-input-banner-navigation-button.next')?.click(); + assert.deepStrictEqual(currentBanner(banners), { + position: '3/3', + reference: 'Agent Review', + text: '1 Agent Comment', + splitButtons: 0, + }); + banners.domNode.querySelector('.session-input-banner-navigation-button.next')?.click(); + + banners.domNode.querySelector('.session-input-banner-action.monaco-button-dropdown > .monaco-button')?.click(); + await timeout(0); + + assert.deepStrictEqual({ + query: submitted?.query?.split('\n')[0], + feedbackIds: submitted?.feedbackIds, + fixRequested: ciModels.get(42)?.fixRequested.get(), + current: currentBanner(banners), + }, { + query: '/fix-ci and /act-on-feedback for #42', + feedbackIds: ['pr-42-a', 'pr-42-b'], + fixRequested: true, + current: { + position: '1/2', + reference: '#41', + text: '1 PR Comment', + splitButtons: 0, + }, + }); + + banners.domNode.querySelector('.session-input-banner-dismiss')?.click(); + assert.deepStrictEqual({ + current: currentBanner(banners), + dismissed: JSON.parse(storageService.get('sessions.inputBanners.dismissedItems', StorageScope.PROFILE) ?? '[]'), + }, { + current: { + position: undefined, + reference: undefined, + text: '1 Agent Comment', + splitButtons: 0, + }, + dismissed: ['session-1:pullRequest:owner/repo#41'], + }); + }); +}); + +function pullRequest(number: number): IGitHubPullRequestRef { + return { + owner: 'owner', + repo: 'repo', + number, + uri: URI.parse(`https://github.com/owner/repo/pull/${number}`), + }; +} + +function pullRequestModel(number: number, title: string): GitHubPullRequestModel { + const pullRequest = upcastPartial({ + number, + title, + state: GitHubPullRequestState.Open, + isDraft: false, + headSha: `sha-${number}`, + }); + return new class extends mock() { + override readonly pullRequest = observableValue('pullRequest', pullRequest); + override refresh(): Promise { return Promise.resolve(); } + override startPolling(): IDisposable { return Disposable.None; } + }(); +} + +function ciModel(checks: readonly IGitHubCICheck[]): GitHubPullRequestCIModel { + return new class extends mock() { + override readonly checks = observableValue('checks', checks); + override readonly fixRequested = observableValue('fixRequested', false); + override refresh(): Promise { return Promise.resolve(); } + override startPolling(): IDisposable { return Disposable.None; } + override getCheckRunAnnotations(): Promise { return Promise.resolve('failure details'); } + override markFixRequested(): void { this.fixRequested.set(true, undefined); } + }(); +} + +function failedCheck(id: number): IGitHubCICheck { + return { + id, + name: `Check ${id}`, + status: GitHubCheckStatus.Completed, + conclusion: GitHubCheckConclusion.Failure, + startedAt: undefined, + completedAt: undefined, + detailsUrl: undefined, + }; +} + +function prFeedback(id: string, sessionResource: URI, number: number): IAgentFeedback { + return { + id, + text: id, + resourceUri: URI.file(`/workspace/${id}.ts`), + range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, + sessionResource, + kind: AgentFeedbackKind.PRReview, + sourcePRReviewCommentId: `thread-${id}`, + sourcePullRequest: { owner: 'owner', repo: 'repo', number }, + state: AgentFeedbackState.Created, + }; +} + +function agentFeedback(id: string, sessionResource: URI): IAgentFeedback { + return { + id, + text: id, + resourceUri: URI.file(`/workspace/${id}.ts`), + range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, + sessionResource, + kind: AgentFeedbackKind.AgentReview, + state: AgentFeedbackState.Created, + }; +} + +function currentBanner(banners: SessionInputBanners): { position: string | undefined; reference: string | undefined; text: string | undefined; splitButtons: number } { + return { + position: banners.domNode.querySelector('.session-input-banner-position')?.textContent ?? undefined, + reference: banners.domNode.querySelector('.session-input-banner-reference')?.textContent ?? undefined, + text: banners.domNode.querySelector('.session-input-banner-text')?.textContent ?? undefined, + splitButtons: banners.domNode.querySelectorAll('.monaco-button-dropdown').length, + }; +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index ad7253ab392937..87c0c7e5abfe7b 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -32,7 +32,7 @@ import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsPro import { CLOSE_CHAT_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { ChatOriginKind, getChatCapabilities, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js'; import { $, append, EventHelper, ModifierKeyEmitter, reset } from '../../../../base/browser/dom.js'; @@ -99,7 +99,8 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { const isRead = session.isRead.read(reader); const isArchived = session.isArchived.read(reader); const workspace = session.workspace.read(reader); - const pullRequestIcon = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest?.icon; + const gitHubInfo = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); + const pullRequestIcon = getHighestPriorityPullRequestIcon(getGitHubPullRequestRefs(gitHubInfo).map(pullRequest => pullRequest.icon)); const completedStateIcon = session.completedStateIcon?.read(reader) ?? pullRequestIcon; const icon = sessionsListModelService.getStatusIcon(status, isRead, isArchived, completedStateIcon); diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts index 82256a6bc5e185..120bb9eea6d1da 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts @@ -28,8 +28,8 @@ class AutomationsCustomViewAccessibilityHelp implements IAccessibleViewImplement const restoreFocus = createFocusRestorer(layoutService); const content = [ localize('automationsCustomView.help.overview', "You are in the Automations view. It contains automation cards followed by run history."), - localize('automationsCustomView.help.cards', "Tab to a card's Edit control and action buttons. Use Left Arrow and Right Arrow to move between Run now and Delete. Press Enter or Space to activate a control. Edit, or clicking anywhere else on the card, opens the automation dialog. Run now starts a session immediately. Delete asks for confirmation."), - localize('automationsCustomView.help.history', "Run history is grouped by date. While a run is waiting for its session, a lightweight row shows the automation name with a Working... description. Once the session is available, use Up Arrow and Down Arrow to navigate the Sessions list, Enter to open, and Tab to reach Stop, the configured Archive or Mark as Done action, or Delete when available. Delete permanently deletes the session and removes it from run history after confirmation."), + localize('automationsCustomView.help.cards', "Tab to a card's Edit control and action buttons. Use Left Arrow and Right Arrow to move between Run now and Delete. Press Enter or Space to activate a control. Edit, or clicking anywhere else on the card, opens the automation dialog. Open a card's context menu{0} (for example Shift+F10). Duplicate opens a prefilled New automation dialog, Disable prevents scheduled runs, and Delete asks for confirmation. Run now starts a session immediately.", ''), + localize('automationsCustomView.help.history', "Run history is grouped by date. While a run is waiting for its session, a lightweight row shows the automation name with a Working... description. Once the session is available, use Up Arrow and Down Arrow to navigate the Sessions list, Enter to open, and Tab to reach Stop, the configured Archive or Mark as Done action, or Delete when available. Open a row's context menu, for example with Shift+F10, to rename it, change its active or read state, or delete it. Delete permanently deletes the session and removes it from run history after confirmation."), localize('automationsCustomView.help.read', "Completed and failed runs that have not been opened are announced as unread. Use Mark all as read to clear all available unread runs."), localize('automationsCustomView.help.accessibleView', "Use Open Accessible View to read the current automations and run history as text."), ].join('\n'); diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index 316fdef0690859..97c5702e970d0b 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -6,6 +6,7 @@ import '../media/automationsCards.css'; import './automationsAccessibility.js'; import * as DOM from '../../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Button, ButtonBar, IButton } from '../../../../../base/browser/ui/button/button.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -31,7 +32,8 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Gesture, GestureEvent, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -45,16 +47,19 @@ import { SyncDescriptor } from '../../../../../platform/instantiation/common/des import { Menus } from '../../../../browser/menus.js'; import { Action2, MenuItemAction, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSettingId, getChatSessionArchiveActionPresentation, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; import { BaseActionViewItem, IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../../base/common/actions.js'; -import { AutomationsCustomViewFocusContext, AutomationsHasItemsContext, SessionSupportsDeleteContext } from '../../../../common/contextkeys.js'; -import { ARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { AutomationsCustomViewFocusContext, AutomationsHasItemsContext, SessionIsArchivedContext, SessionIsReadContext, SessionSupportsDeleteContext, SessionSupportsRenameContext } from '../../../../common/contextkeys.js'; import { SessionsFlatList, SessionItemStatusContext } from './sessionsList.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; +import { ARCHIVE_SESSION_COMMAND_ID, MARK_SESSION_READ_COMMAND_ID, MARK_SESSION_UNREAD_COMMAND_ID, RENAME_SESSION_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; const $ = DOM.$; const STOP_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.stopRunSession'; const DELETE_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.deleteRunSession'; +const AutomationCardCanDeleteContext = new RawContextKey('sessionsAutomationCardCanDelete', false); +const AutomationCardCanDisableContext = new RawContextKey('sessionsAutomationCardCanDisable', false); interface IAutomationCardEntry { readonly element: HTMLElement; @@ -63,6 +68,8 @@ interface IAutomationCardEntry { readonly actions: HTMLElement; readonly runButton: IButton; readonly deleteButton: IButton; + readonly canDeleteContext: IContextKey; + readonly canDisableContext: IContextKey; readonly nameText: HTMLElement; readonly scheduleEl: HTMLElement; readonly folderEl: HTMLElement; @@ -194,6 +201,8 @@ class AutomationCardsSection extends Disposable { @ILogService private readonly logService: ILogService, @IDialogService private readonly dialogService: IDialogService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { super(); this.container = DOM.append(parent, $('.automations-cards-grid')); @@ -259,7 +268,24 @@ class AutomationCardsSection extends Disposable { const wrapper = $('.automations-card-wrapper'); const card = DOM.append(wrapper, $('.automations-card')); card.setAttribute('role', 'group'); + const cardContextKeyService = disposables.add(this.contextKeyService.createScoped(card)); + const canDeleteContext = AutomationCardCanDeleteContext.bindTo(cardContextKeyService); + const canDisableContext = AutomationCardCanDisableContext.bindTo(cardContextKeyService); disposables.add(Gesture.addTarget(card)); + disposables.add(DOM.addDisposableListener(card, DOM.EventType.CONTEXT_MENU, (event: MouseEvent) => { + const currentAutomation = this.latestAutomations.get(automation.id); + if (!currentAutomation) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.contextMenuService.showContextMenu({ + menuId: Menus.AutomationCardContext, + menuActionOptions: { shouldForwardArgs: true, arg: currentAutomation }, + getAnchor: () => DOM.isMouseEvent(event) ? new StandardMouseEvent(DOM.getWindow(card), event) : card, + contextKeyService: cardContextKeyService, + }); + })); const main = DOM.append(card, $('button.automations-card-main', { type: 'button', @@ -331,6 +357,8 @@ class AutomationCardsSection extends Disposable { actions, runButton: runBtn, deleteButton: deleteBtn, + canDeleteContext, + canDisableContext, nameText: nameTextEl, scheduleEl, folderEl, @@ -347,6 +375,8 @@ class AutomationCardsSection extends Disposable { card.main.disabled = this.automationService.canUpdateAutomation?.(automation.id) === false; card.runButton.enabled = this.automationService.canRunAutomation?.(automation.id) !== false; card.deleteButton.enabled = this.automationService.canDeleteAutomation?.(automation.id) !== false; + card.canDeleteContext.set(this.automationService.canDeleteAutomation?.(automation.id) !== false); + card.canDisableContext.set(automation.enabled && this.automationService.canUpdateAutomation?.(automation.id) !== false); const schedule = formatSchedule(automation); const scheduleChanged = !previous || formatSchedule(previous) !== schedule; const nameChanged = !previous || previous.name !== automation.name; @@ -486,30 +516,7 @@ class AutomationCardsSection extends Disposable { } private async confirmDelete(automation: IAutomationDescriptor): Promise { - if (!await this.ensureEnabled()) { - return; - } - const confirmed = await this.dialogService.confirm({ - message: localize('confirmDeleteAutomation', "Delete automation \"{0}\"?", automation.name), - detail: localize('confirmDeleteDetail', "This will permanently delete the automation and its run history."), - primaryButton: localize('delete', "Delete"), - }); - if (!confirmed.confirmed) { - return; - } - if (!await this.ensureEnabled()) { - return; - } - try { - await this.automationService.deleteAutomation(automation.id, () => this.throwIfDisabled()); - status(localize('automationDeletedStatus', "Deleted automation {0}", automation.name)); - } catch (err) { - this.logService.error('[AutomationsCards] Failed to delete automation', err); - await this.dialogService.error( - localize('automationDeleteFailed', "Failed to delete automation."), - getErrorMessage(err), - ); - } + await confirmAndDeleteAutomation(automation, this.automationService, this.configurationService, this.dialogService, this.logService); } private isEnabled(): boolean { @@ -738,10 +745,12 @@ class AutomationHistorySection extends Disposable { alwaysConsumeMouseWheel: false, useCompactQuickChatRows: false, toolbarMenuId: Menus.AutomationsHistoryItem, + contextMenuId: Menus.AutomationsHistoryItemContext, markSessionReadOnOpen: false, approvalModel: this.approvalModel, onSessionOpen: resource => void this.openRunSession(resource), - onToolbarAction: (action, session) => this.handleSessionToolbarAction(action, session, entry.runsBySession), + onToolbarAction: (action, session) => this.handleSessionAction(action, session, entry.runsBySession), + onContextMenuAction: (action, session) => this.handleSessionAction(action, session, entry.runsBySession), })); disposables.add(list.onDidChangeContentHeight(() => this.layoutSessionList(entry.listContainer, list))); entry.list = list; @@ -848,7 +857,7 @@ class AutomationHistorySection extends Disposable { list.layout(height, width); } - private async handleSessionToolbarAction(action: IAction, session: ISession, runsBySession: ReadonlyMap): Promise { + private async handleSessionAction(action: IAction, session: ISession, runsBySession: ReadonlyMap): Promise { const run = runsBySession.get(session.resource.toString()); if (!run) { return false; @@ -1086,6 +1095,49 @@ async function showAutomationsDisabled(dialogService: IDialogService): Promise { + if (automationService.canDeleteAutomation?.(automation.id) === false) { + return; + } + const isEnabled = () => configurationService.getValue(CHAT_AUTOMATIONS_ENABLED_SETTING) === true; + if (!isEnabled()) { + await showAutomationsDisabled(dialogService); + return; + } + const confirmed = await dialogService.confirm({ + message: localize('confirmDeleteAutomation', "Delete automation \"{0}\"?", automation.name), + detail: localize('confirmDeleteDetail', "This will permanently delete the automation and its run history."), + primaryButton: localize('delete', "Delete"), + }); + if (!confirmed.confirmed) { + return; + } + if (!isEnabled()) { + await showAutomationsDisabled(dialogService); + return; + } + try { + await automationService.deleteAutomation(automation.id, () => { + if (!isEnabled()) { + throw new Error(localize('automationsDisabledBeforeDelete', "Automations were disabled before the automation could be deleted.")); + } + }); + status(localize('automationDeletedStatus', "Deleted automation {0}", automation.name)); + } catch (error) { + logService.error('[AutomationsCards] Failed to delete automation', error); + await dialogService.error( + localize('automationDeleteFailed', "Failed to delete automation."), + getErrorMessage(error), + ); + } +} + //#endregion //#region AutomationsView (Custom View) @@ -1129,16 +1181,26 @@ export class AutomationsCustomView extends AbstractCustomView { export class AutomationsCustomViewContribution extends Disposable { static readonly ID = 'sessions.contrib.automationsCustomView'; + private readonly historyItemActions = this._register(new MutableDisposable()); constructor( @ICustomViewService customViewService: ICustomViewService, @IActionViewItemService actionViewItemService: IActionViewItemService, @IContextKeyService contextKeyService: IContextKeyService, @IAutomationService automationService: IAutomationService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); - this._register(registerAutomationHistoryItemActions()); + const registerHistoryItemActions = () => { + this.historyItemActions.value = registerAutomationHistoryItemActions(getChatSessionArchiveActionWording(configurationService)); + }; + registerHistoryItemActions(); + this._register(configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(ChatSessionArchiveActionWordingSettingId)) { + registerHistoryItemActions(); + } + })); const hasItemsContext = AutomationsHasItemsContext.bindTo(contextKeyService); this._register(autorun(reader => { @@ -1172,7 +1234,8 @@ export class AutomationsCustomViewContribution extends Disposable { } } -function registerAutomationHistoryItemActions(): IDisposable { +function registerAutomationHistoryItemActions(archiveWording: ChatSessionArchiveActionWording): IDisposable { + const archivePresentation = getChatSessionArchiveActionPresentation(archiveWording); return combinedDisposable( MenuRegistry.appendMenuItem(Menus.AutomationsHistoryItem, { command: { @@ -1203,6 +1266,66 @@ function registerAutomationHistoryItemActions(): IDisposable { ), ), }), + MenuRegistry.appendMenuItem(Menus.AutomationsHistoryItemContext, { + command: { + id: MARK_SESSION_READ_COMMAND_ID, + title: localize('markAutomationRunSessionReadAction', "Mark as Read"), + }, + group: '0_read', + order: 1, + when: ContextKeyExpr.and(SessionIsReadContext.negate(), SessionIsArchivedContext.negate()), + }), + MenuRegistry.appendMenuItem(Menus.AutomationsHistoryItemContext, { + command: { + id: MARK_SESSION_UNREAD_COMMAND_ID, + title: localize('markAutomationRunSessionUnreadAction', "Mark as Unread"), + }, + group: '0_read', + order: 1, + when: ContextKeyExpr.and(SessionIsReadContext, SessionIsArchivedContext.negate()), + }), + MenuRegistry.appendMenuItem(Menus.AutomationsHistoryItemContext, { + command: { + id: RENAME_SESSION_COMMAND_ID, + title: localize('renameAutomationRunSessionAction', "Rename..."), + }, + group: '1_edit', + order: 1, + when: SessionSupportsRenameContext, + }), + MenuRegistry.appendMenuItem(Menus.AutomationsHistoryItemContext, { + command: { + id: ARCHIVE_SESSION_COMMAND_ID, + title: archivePresentation.archive.title, + }, + group: '1_edit', + order: 2, + when: SessionIsArchivedContext.negate(), + }), + MenuRegistry.appendMenuItem(Menus.AutomationsHistoryItemContext, { + command: { + id: UNARCHIVE_SESSION_COMMAND_ID, + title: archivePresentation.unarchive.title, + }, + group: '1_edit', + order: 2, + when: SessionIsArchivedContext, + }), + MenuRegistry.appendMenuItem(Menus.AutomationsHistoryItemContext, { + command: { + id: DELETE_AUTOMATION_RUN_SESSION_COMMAND_ID, + title: localize('deleteAutomationRunSessionContextAction', "Delete"), + }, + group: '2_delete', + order: 1, + when: ContextKeyExpr.and( + SessionSupportsDeleteContext, + ContextKeyExpr.or( + SessionItemStatusContext.isEqualTo(SessionStatus.Completed), + SessionItemStatusContext.isEqualTo(SessionStatus.Error), + ), + ), + }), ); } @@ -1288,4 +1411,143 @@ registerAction2(class NewAutomationAction extends Action2 { } }); +registerAction2(class DuplicateAutomationAction extends Action2 { + constructor() { + super({ + id: 'sessions.automations.duplicate', + title: localize2('duplicateAutomation', "Duplicate"), + precondition: ChatAutomationsEnabledContext, + menu: [{ id: Menus.AutomationCardContext, group: 'navigation', order: 1, when: ChatAutomationsEnabledContext }], + }); + } + + override async run(accessor: ServicesAccessor, automation: IAutomationDescriptor): Promise { + const automationDialogService = accessor.get(IAutomationDialogService); + const automationService = accessor.get(IAutomationService); + const configurationService = accessor.get(IConfigurationService); + const dialogService = accessor.get(IDialogService); + const logService = accessor.get(ILogService); + const isEnabled = () => configurationService.getValue(CHAT_AUTOMATIONS_ENABLED_SETTING) === true; + if (!isEnabled()) { + await showAutomationsDisabled(dialogService); + return; + } + + try { + const name = getDuplicateAutomationName(automation.name, automationService.automations.get()); + const result = await automationDialogService.showAutomationDialog({ + initialValues: { + name, + prompt: automation.prompt, + schedule: automation.schedule, + target: automation.target, + modelId: automation.modelId, + mode: automation.mode, + permissionLevel: automation.permissionLevel, + enabled: automation.enabled, + }, + }); + if (!result || result.kind !== 'create') { + return; + } + if (!isEnabled()) { + await showAutomationsDisabled(dialogService); + return; + } + const duplicate = await automationService.createAutomation(result.value, () => { + if (!isEnabled()) { + throw new Error(localize('automationsDisabledBeforeDuplicate', "Automations were disabled before the duplicate could be saved.")); + } + }); + status(localize('automationDuplicatedStatus', "Created duplicate automation {0}", duplicate.name)); + } catch (error) { + logService.error('[Automations] Failed to duplicate automation', error); + await dialogService.error( + localize('automationDuplicateFailed', "Failed to duplicate automation."), + getErrorMessage(error), + ); + } + } +}); + +registerAction2(class DeleteAutomationAction extends Action2 { + constructor() { + super({ + id: 'sessions.automations.delete', + title: localize2('deleteAutomationContextMenu', "Delete"), + precondition: ContextKeyExpr.and(ChatAutomationsEnabledContext, AutomationCardCanDeleteContext), + menu: [{ id: Menus.AutomationCardContext, group: 'navigation', order: 3, when: ChatAutomationsEnabledContext }], + }); + } + + override async run(accessor: ServicesAccessor, automation: IAutomationDescriptor): Promise { + await confirmAndDeleteAutomation( + automation, + accessor.get(IAutomationService), + accessor.get(IConfigurationService), + accessor.get(IDialogService), + accessor.get(ILogService), + ); + } +}); + +registerAction2(class DisableAutomationAction extends Action2 { + constructor() { + super({ + id: 'sessions.automations.disable', + title: localize2('disableAutomationContextMenu', "Disable"), + precondition: ContextKeyExpr.and(ChatAutomationsEnabledContext, AutomationCardCanDisableContext), + menu: [{ id: Menus.AutomationCardContext, group: 'navigation', order: 2, when: ChatAutomationsEnabledContext }], + }); + } + + override async run(accessor: ServicesAccessor, automation: IAutomationDescriptor): Promise { + const automationService = accessor.get(IAutomationService); + if (!automation.enabled || automationService.canUpdateAutomation?.(automation.id) === false) { + return; + } + const configurationService = accessor.get(IConfigurationService); + const dialogService = accessor.get(IDialogService); + const logService = accessor.get(ILogService); + const isEnabled = () => configurationService.getValue(CHAT_AUTOMATIONS_ENABLED_SETTING) === true; + if (!isEnabled()) { + await showAutomationsDisabled(dialogService); + return; + } + try { + const result = await automationService.updateAutomationIfUnchanged(automation.id, { enabled: false }, automation, () => { + if (!isEnabled()) { + throw new Error(localize('automationsDisabledBeforeDisable', "Automations were disabled before the automation could be updated.")); + } + }); + if (result.kind === 'conflict') { + throw new Error(result.current + ? localize('automationChangedDuringDisable', "This automation changed before it could be disabled. Try again.") + : localize('automationDeletedDuringDisable', "This automation was deleted before it could be disabled.")); + } + status(localize('automationDisabledStatus', "Disabled automation {0}", automation.name)); + } catch (error) { + logService.error('[Automations] Failed to disable automation', error); + await dialogService.error( + localize('automationDisableFailed', "Failed to disable automation."), + getErrorMessage(error), + ); + } + } +}); + +function getDuplicateAutomationName(name: string, automations: readonly IAutomationDescriptor[]): string { + const existingNames = new Set(automations.map(automation => automation.name)); + const copyName = localize('automationCopyName', "{0} Copy", name); + if (!existingNames.has(copyName)) { + return copyName; + } + for (let index = 2; ; index++) { + const indexedCopyName = localize('automationIndexedCopyName', "{0} Copy {1}", name, index); + if (!existingNames.has(indexedCopyName)) { + return indexedCopyName; + } + } +} + //#endregion diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 5dbbb4f8ef0150..249d8182a18e89 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -48,7 +48,7 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSettingId, getChatSessionArchivedSectionLabel, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; -import { ChatInteractivity, ChatOriginKind, getChatCapabilities, getSessionStatusMessage, getSessionWorkspaceKind, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionWorkspace, SessionStatus, SessionWorkspaceKind } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, getSessionStatusMessage, getSessionWorkspaceKind, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionWorkspace, SessionStatus, SessionWorkspaceKind } from '../../../../services/sessions/common/session.js'; import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; @@ -970,7 +970,8 @@ class SessionItemRenderer implements ITreeRenderer pullRequest.icon)); // The status icon widget snaps on row recycling and cross-fades real state changes. template.statusIcon.setStatus(sessionStatus, isRead, isArchived, completedStateIcon, element.resource); @@ -4449,6 +4450,10 @@ export interface ISessionsFlatListOptions { readonly toolbarMenuId?: MenuId; /** Allows focused list surfaces to handle actions from their custom toolbar menu. */ readonly onToolbarAction?: (action: IAction, session: ISession) => boolean | Promise; + /** Optional context menu shown for each session row. */ + readonly contextMenuId?: MenuId; + /** Allows focused list surfaces to handle actions from their custom context menu. */ + readonly onContextMenuAction?: (action: IAction, session: ISession) => boolean | Promise; /** Whether opening a row immediately marks its session as read. Defaults to `true`. */ readonly markSessionReadOnOpen?: boolean; /** @@ -4490,9 +4495,12 @@ export class SessionsFlatList extends Disposable { @ISessionsListModelService private readonly _sessionsListModelService: ISessionsListModelService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IInstantiationService instantiationService: IInstantiationService, - @IContextKeyService contextKeyService: IContextKeyService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, @IHoverService hoverService: IHoverService, + @IMenuService private readonly menuService: IMenuService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IKeybindingService private readonly keybindingService: IKeybindingService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IVoicePlaybackService voicePlaybackService: IVoicePlaybackService, @IAgentHostConnectionsService agentHostConnectionsService: IAgentHostConnectionsService, @@ -4584,6 +4592,9 @@ export class SessionsFlatList extends Disposable { const preserveFocus = isLeftClick ? false : (e.editorOptions.preserveFocus ?? false); this.options.onSessionOpen(element.resource, preserveFocus, e.sideBySide); })); + if (this.options.contextMenuId) { + this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); + } this._register(sessionRenderer.onDidChangeItemHeight(session => { if (this.tree.hasElement(session)) { @@ -4595,6 +4606,40 @@ export class SessionsFlatList extends Disposable { this._register(sessionRenderer.onDidApproveSession(approved => this._onDidApproveSession.fire(approved))); } + private onContextMenu(e: ITreeContextMenuEvent): void { + const session = e.element; + if (!session || !isSessionItem(session) || !this.options.contextMenuId) { + return; + } + + const disposables = new DisposableStore(); + const contextKeyService = this.contextKeyService.createOverlay([ + [SessionIsArchivedContext.key, session.isArchived.get()], + [SessionIsReadContext.key, session.isRead.get()], + [SessionTypeContext.key, session.sessionType], + [SessionProviderIdContext.key, session.providerId], + [SessionSupportsRenameContext.key, session.capabilities.get().supportsRename ?? false], + [SessionSupportsDeleteContext.key, session.capabilities.get().supportsDelete ?? false], + [SessionItemStatusContext.key, session.status.get()], + ]); + const menu = disposables.add(this.menuService.createMenu(this.options.contextMenuId, contextKeyService)); + const actions = Separator.join(...menu.getActions({ shouldForwardArgs: true }).map(([, groupActions]) => groupActions)); + if (actions.length === 0) { + disposables.dispose(); + return; + } + + const actionRunner = disposables.add(new SessionItemActionRunner(selected => [selected], this.options.onContextMenuAction)); + this.contextMenuService.showContextMenu({ + getActions: () => actions, + getActionsContext: () => session, + getAnchor: () => e.anchor, + getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id, contextKeyService, true) ?? undefined, + actionRunner, + onHide: () => disposables.dispose(), + }); + } + setSessions(sessions: readonly ISession[]): void { this._sessions = sessions; this.tree.setChildren(null, sessions.map(session => ({ element: session }))); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index fd5e2f15cbb3b2..d878e9c6f118ae 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -23,7 +23,7 @@ import { IViewsService } from '../../../../../workbench/services/views/common/vi import { CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID } from '../../../../browser/workbench.js'; import { EditorsVisibleContext, EditorAreaFocusContext, FocusedViewContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { SessionsCategories } from '../../../../common/categories.js'; -import { ARCHIVE_SESSION_COMMAND_ID, RENAME_SESSION_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { ARCHIVE_SESSION_COMMAND_ID, MARK_SESSION_READ_COMMAND_ID, MARK_SESSION_UNREAD_COMMAND_ID, RENAME_SESSION_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { SessionSupportsDeleteContext, SessionSupportsRenameContext, IsNewChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext } from '../../../../common/contextkeys.js'; import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionTypeContext, SessionSectionHasNonCloudRepositoryContext, SessionGroupHasVisibleSessionsContext, SessionGroupIsEmptyContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem, NEW_SESSION_FOR_WORKSPACE_ACTION_ID } from './sessionsList.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; @@ -1104,7 +1104,7 @@ registerAction2(class DeleteSessionAction extends Action2 { registerAction2(class MarkSessionReadAction extends Action2 { constructor() { super({ - id: 'sessionsViewPane.markRead', + id: MARK_SESSION_READ_COMMAND_ID, title: localize2('markRead', "Mark as Read"), menu: [{ id: SessionItemContextMenuId, @@ -1138,7 +1138,7 @@ registerAction2(class MarkSessionReadAction extends Action2 { registerAction2(class MarkSessionUnreadAction extends Action2 { constructor() { super({ - id: 'sessionsViewPane.markUnread', + id: MARK_SESSION_UNREAD_COMMAND_ID, title: localize2('markUnread', "Mark as Unread"), menu: [{ id: SessionItemContextMenuId, diff --git a/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts new file mode 100644 index 00000000000000..b8454ccf766291 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { SESSIONS_APPLICATION_BADGE_SETTING, SessionsApplicationBadge } from './sessionsApplicationBadge.js'; + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'sessions', + properties: { + [SESSIONS_APPLICATION_BADGE_SETTING]: { + type: 'boolean', + tags: ['preview'], + description: localize('sessions.showApplicationBadge', "Controls whether the application icon shows a badge with the number of unread sessions and sessions that need input. The badge appears on the dock icon on macOS, on the launcher icon on Linux and over the taskbar icon on Windows."), + default: false, + experiment: { mode: 'auto' } + }, + }, +}); + +registerWorkbenchContribution2(SessionsApplicationBadge.ID, SessionsApplicationBadge, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/electron-browser/sessionsApplicationBadge.ts b/src/vs/sessions/contrib/sessions/electron-browser/sessionsApplicationBadge.ts new file mode 100644 index 00000000000000..a7955fba5e5800 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/electron-browser/sessionsApplicationBadge.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../base/browser/window.js'; +import { Color } from '../../../../base/common/color.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { isWindows } from '../../../../base/common/platform.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IApplicationBadge, INativeHostService } from '../../../../platform/native/common/native.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND } from '../../../../workbench/common/theme.js'; +import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +export const SESSIONS_APPLICATION_BADGE_SETTING = 'sessions.showApplicationBadge'; + +/** + * Renders the number of sessions that need the user's attention — unread or + * waiting for input, archived ones excluded — as a badge on the application + * icon in the dock (macOS), the launcher (Linux) or the taskbar (Windows). + */ +export class SessionsApplicationBadge extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsApplicationBadge'; + + /** Windows renders the taskbar overlay small, so draw at 2x for HiDPI. */ + private static readonly ICON_SIZE = 32; + + /** Beyond this the label no longer fits into a 16x16 overlay. */ + private static readonly MAX_ICON_COUNT = 9; + + private _lastCount = 0; + + private readonly _enabled: IObservable; + private readonly _sessions: IObservable; + private readonly _colorTheme: IObservable; + private readonly _count: IObservable; + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @INativeHostService private readonly _nativeHostService: INativeHostService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IThemeService private readonly _themeService: IThemeService, + ) { + super(); + + this._enabled = observableConfigValue(SESSIONS_APPLICATION_BADGE_SETTING, false, this._configurationService); + + this._sessions = observableFromEvent(this, this._sessionsManagementService.onDidChangeSessions, () => this._sessionsManagementService.getSessions()); + + this._colorTheme = observableFromEvent(this, this._themeService.onDidColorThemeChange, () => this._themeService.getColorTheme()); + + this._count = derived(this, reader => { + if (!this._enabled.read(reader)) { + return 0; + } + + let count = 0; + for (const session of this._sessions.read(reader)) { + if (session.isArchived.read(reader)) { + continue; + } + + if (!session.isRead.read(reader) || session.status.read(reader) === SessionStatus.NeedsInput) { + count++; + } + } + + return count; + }); + + this._register(autorun(reader => { + const count = this._count.read(reader); + + // The Windows overlay icon is drawn here, so it has to follow the theme + this._setBadge(count, isWindows ? this._colorTheme.read(reader) : undefined); + })); + + // The badge is application wide on macOS and Linux, so it must not + // outlive this window. + this._register(toDisposable(() => this._setBadge(0, undefined))); + } + + private _setBadge(count: number, colorTheme: IColorTheme | undefined): void { + if (count === 0 && this._lastCount === 0) { + return; // nothing to clear + } + this._lastCount = count; + + let badge: IApplicationBadge | undefined; + if (count > 0) { + const description = count === 1 + ? localize('sessions.applicationBadge.single', "1 session needs your attention") + : localize('sessions.applicationBadge.multiple', "{0} sessions need your attention", count); + + // Leave `iconDataURL` out entirely rather than setting it to + // `undefined`: the IPC serializes the badge as JSON, which drops + // undefined valued properties, so the badge arriving in the main + // process would not match the one sent here. + const iconDataURL = colorTheme ? this._renderIcon(count, colorTheme) : undefined; + badge = iconDataURL ? { count, description, iconDataURL } : { count, description }; + } + + this._nativeHostService.setApplicationBadge(badge); + } + + private _renderIcon(count: number, colorTheme: IColorTheme): string | undefined { + const size = SessionsApplicationBadge.ICON_SIZE; + + const canvas = mainWindow.document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + + const context = canvas.getContext('2d'); + if (!context) { + return undefined; + } + + context.fillStyle = (colorTheme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND) ?? Color.blue).toString(); + context.beginPath(); + context.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); + context.fill(); + + const label = count > SessionsApplicationBadge.MAX_ICON_COUNT ? `${SessionsApplicationBadge.MAX_ICON_COUNT}+` : String(count); + context.fillStyle = (colorTheme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND) ?? Color.white).toString(); + context.font = `600 ${label.length > 1 ? 17 : 21}px "Segoe UI", sans-serif`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText(label, size / 2, size / 2 + 1); + + return canvas.toDataURL('image/png'); + } +} diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts index dc133c7489099a..9d66a4c07642c5 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { IContextMenuDelegate } from '../../../../../base/browser/contextmenu.js'; import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; import { GestureEvent, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; @@ -20,14 +21,17 @@ import { IAccessibilityService } from '../../../../../platform/accessibility/com import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuMenuDelegate, IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IConfirmation, IConfirmationResult, IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { NullHoverService } from '../../../../../platform/hover/test/browser/nullHoverService.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { MockContextKeyService, MockKeybindingService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IAutomationDescriptor, IAutomationRun, IAutomationSchedule, AutomationRunTrigger, AutomationTarget } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; @@ -48,9 +52,12 @@ import { ISessionsListModelService } from '../../../../services/sessions/browser import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { IMenuService, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { MenuService } from '../../../../../platform/actions/common/menuService.js'; +import { Menus } from '../../../../browser/menus.js'; import { ArchiveSessionAction } from '../../browser/views/sessionsViewActions.js'; +import { MARK_SESSION_READ_COMMAND_ID, MARK_SESSION_UNREAD_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { TestCommandService } from './sessionsListTestUtils.js'; const AUTOMATION_ID = 'automation-1'; @@ -113,6 +120,13 @@ class FakeAutomationService extends mock() { updateResult: IGuardedAutomationUpdateResult | undefined; updateCalls = 0; deleteRunCalls = 0; + createError: Error | undefined; + deleteError: Error | undefined; + canDelete = true; + canUpdate = true; + readonly createCalls: ICreateAutomationOptions[] = []; + readonly deleteCalls: string[] = []; + readonly guardedUpdateCalls: { id: string; patch: IUpdateAutomationOptions; expected: IAutomationDescriptor }[] = []; readonly deleteRunCompleted = new DeferredPromise(); setAutomations(value: readonly IAutomationDescriptor[]): void { @@ -133,8 +147,12 @@ class FakeAutomationService extends mock() { override async createAutomation(options: ICreateAutomationOptions, mutationGuard?: AutomationMutationGuard): Promise { mutationGuard?.(); + this.createCalls.push(options); + if (this.createError) { + throw this.createError; + } const created = automation({ - id: AUTOMATION_ID, + id: `created-automation-${this.createCalls.length}`, name: options.name, prompt: options.prompt, schedule: options.schedule, @@ -169,15 +187,29 @@ class FakeAutomationService extends mock() { return updated; } - override async updateAutomationIfUnchanged(id: string, patch: IUpdateAutomationOptions, _expected: IAutomationDescriptor, mutationGuard?: AutomationMutationGuard): Promise { + override async updateAutomationIfUnchanged(id: string, patch: IUpdateAutomationOptions, expected: IAutomationDescriptor, mutationGuard?: AutomationMutationGuard): Promise { this.updateCalls++; + this.guardedUpdateCalls.push({ id, patch, expected }); mutationGuard?.(); return this.updateResult ?? { kind: 'updated', automation: await this.updateAutomation(id, patch) }; } override async deleteAutomation(id: string, mutationGuard?: AutomationMutationGuard): Promise { mutationGuard?.(); + this.deleteCalls.push(id); + if (this.deleteError) { + throw this.deleteError; + } this.setAutomations(this.automationValue.get().filter(item => item.id !== id)); + this.setRuns(this.runValue.get().filter(run => run.automationId !== id)); + } + + override canDeleteAutomation(): boolean { + return this.canDelete; + } + + override canUpdateAutomation(): boolean { + return this.canUpdate; } override async recordRunStart(): Promise { @@ -195,6 +227,25 @@ class FakeAutomationService extends mock() { } } +class TestContextMenuService extends mock() { + override readonly onDidShowContextMenu = Event.None; + override readonly onDidHideContextMenu = Event.None; + delegate: IContextMenuDelegate | undefined; + menuDelegate: IContextMenuMenuDelegate | undefined; + + override showContextMenu(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): void { + if (this.isMenuDelegate(delegate)) { + this.menuDelegate = delegate; + } else { + this.delegate = delegate; + } + } + + private isMenuDelegate(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): delegate is IContextMenuMenuDelegate { + return (delegate).menuId instanceof MenuId; + } +} + class FakeAutomationDialogService extends mock() { result: IAutomationDialogResult | undefined; error: Error | undefined; @@ -245,6 +296,15 @@ class FakeDialogService extends mock() { } } +class TestKeybindingService extends MockKeybindingService { + readonly lookupCalls: { commandId: string; context: IContextKeyService | undefined; enforceContextCheck: boolean | undefined }[] = []; + + override lookupKeybinding(commandId: string, context?: IContextKeyService, enforceContextCheck?: boolean) { + this.lookupCalls.push({ commandId, context, enforceContextCheck }); + return undefined; + } +} + class FakeRunner extends mock() { whenDispatched: Promise = Promise.resolve({ kind: 'notStarted', reason: 'targetUnavailable' }); runCalls = 0; @@ -289,7 +349,7 @@ class FakeSessionsManagementService extends mock() i readonly secondIsRead = observableValue(this, false); readonly isArchived = observableValue(this, false); readonly sessionStatus = observableValue(this, SessionStatus.Completed); - readonly capabilities = observableValue(this, { supportsMultipleChats: false, supportsDelete: true }); + readonly capabilities = observableValue(this, { supportsMultipleChats: false, supportsDelete: true, supportsRename: true }); readonly session = upcastPartial({ resource: SESSION_RESOURCE, sessionId: 'test/session-1', @@ -434,7 +494,11 @@ class FakeSessionsManagementService extends mock() i } setSupportsDelete(supportsDelete: boolean): void { - this.capabilities.set({ supportsMultipleChats: false, supportsDelete }, undefined); + this.capabilities.set({ supportsMultipleChats: false, supportsDelete, supportsRename: true }, undefined); + } + + setCapabilities(supportsDelete: boolean, supportsRename: boolean): void { + this.capabilities.set({ supportsMultipleChats: false, supportsDelete, supportsRename }, undefined); } addSession(resource: URI, title: string): void { @@ -479,30 +543,38 @@ suite('AutomationsCardsWidget', () => { return !!button && button.style.display !== 'none'; } - function setup() { + function setup(archiveWording: 'archive' | 'done' = 'archive') { const automationService = new FakeAutomationService(); const automationDialogService = new FakeAutomationDialogService(); + const contextMenuService = new TestContextMenuService(); const dialogService = new FakeDialogService(); const runner = new FakeRunner(); const sessionsManagementService = disposables.add(new FakeSessionsManagementService()); const sessionsService = new FakeSessionsService(() => sessionsManagementService.markRead(sessionsManagementService.session)); - const configurationService = new TestConfigurationService({ chat: { automations: { enabled: true } } }); + const configurationService = new TestConfigurationService({ chat: { automations: { enabled: true }, experimental: { sessionArchiveActionWording: archiveWording } } }); const logService = new TestLogService(); + const commandService = new TestCommandService(); + const keybindingService = new TestKeybindingService(); const store = disposables.add(new DisposableStore()); store.add(toDisposable(() => ModifierKeyEmitter.disposeInstance())); const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(ICommandService, commandService); instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { override isMotionReduced(): boolean { return false; } }()); instantiationService.stub(IMenuService, store.add(instantiationService.createInstance(MenuService))); instantiationService.stub(IAutomationService, automationService); instantiationService.stub(IAutomationDialogService, automationDialogService); + instantiationService.stub(IContextMenuService, contextMenuService); instantiationService.stub(IDialogService, dialogService); instantiationService.stub(IAutomationRunner, runner); instantiationService.stub(ISessionsService, sessionsService); instantiationService.stub(ISessionsManagementService, sessionsManagementService); instantiationService.stub(IConfigurationService, configurationService); - instantiationService.stub(IContextKeyService, store.add(new ContextKeyService(configurationService))); + const contextKeyService = store.add(new ContextKeyService(configurationService)); + ChatAutomationsEnabledContext.bindTo(contextKeyService).set(true); + instantiationService.stub(IContextKeyService, contextKeyService); + instantiationService.stub(IKeybindingService, keybindingService); instantiationService.stub(IHoverService, NullHoverService); instantiationService.stub(ILogService, logService); instantiationService.stub(ISessionsListModelService, new class extends mock() { @@ -535,7 +607,11 @@ suite('AutomationsCardsWidget', () => { const widget = disposables.add(instantiationService.createInstance(AutomationsCardsWidget)); document.body.append(widget.element); disposables.add(toDisposable(() => widget.element.remove())); - return { automationService, automationDialogService, configurationService, dialogService, instantiationService, logService, runner, sessionsManagementService, sessionsService, widget }; + return { automationService, automationDialogService, commandService, configurationService, contextKeyService, contextMenuService, dialogService, instantiationService, keybindingService, logService, runner, sessionsManagementService, sessionsService, widget }; + } + + function dispatchContextMenu(target: HTMLElement): void { + target.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); } test('renders localized schedules and shared session rows', () => { @@ -778,6 +854,388 @@ suite('AutomationsCardsWidget', () => { }); }); + test('card context menu opens create mode seeded with all editable fields', async () => { + const { automationDialogService, automationService, contextKeyService, contextMenuService, instantiationService, widget } = setup(); + const source = automation({ + name: 'Daily review', + prompt: 'Review all open issues', + schedule: { interval: 'weekly', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 1 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + modelId: 'model', + mode: 'agent', + permissionLevel: 'autopilot', + enabled: false, + }); + automationService.setAutomations([source]); + automationService.setRuns([run()]); + const sourceCard = widget.element.querySelector('.automations-card'); + assert.ok(sourceCard); + + sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + const delegate = contextMenuService.menuDelegate; + assert.ok(delegate); + assert.strictEqual(delegate.menuId, Menus.AutomationCardContext); + assert.strictEqual(delegate.menuActionOptions?.arg, source); + const menuActions = instantiationService.get(IMenuService).getMenuActions( + Menus.AutomationCardContext, + delegate.contextKeyService ?? contextKeyService, + delegate.menuActionOptions, + ).flatMap(([, actions]) => actions); + assert.deepStrictEqual(menuActions.map(action => ({ id: action.id, enabled: action.enabled })), [ + { id: 'sessions.automations.duplicate', enabled: true }, + { id: 'sessions.automations.disable', enabled: false }, + { id: 'sessions.automations.delete', enabled: true }, + ]); + const command = CommandsRegistry.getCommand('sessions.automations.duplicate'); + assert.ok(command); + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + + assert.deepStrictEqual({ + dialogOptions: automationDialogService.lastOptions, + createCalls: automationService.createCalls, + automationNames: automationService.automations.get().map(item => item.name), + runCount: automationService.runs.get().length, + }, { + dialogOptions: { + initialValues: { + name: 'Daily review Copy', + prompt: 'Review all open issues', + schedule: source.schedule, + target: source.target, + modelId: 'model', + mode: 'agent', + permissionLevel: 'autopilot', + enabled: false, + }, + }, + createCalls: [], + automationNames: ['Daily review'], + runCount: 1, + }); + }); + + test('duplicate creates the automation with values submitted from the dialog', async () => { + const { automationDialogService, automationService, instantiationService } = setup(); + const source = automation({ name: 'Daily review' }); + const existingCopy = automation({ id: 'copy', name: 'Daily review Copy' }); + automationService.setAutomations([source, existingCopy]); + const submitted: ICreateAutomationOptions = { + name: 'Customized copy', + prompt: 'Customized prompt', + schedule: { interval: 'hourly', scheduleHour: 10, scheduleMinute: 15, scheduleDay: 2 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + modelId: 'custom-model', + mode: 'ask', + permissionLevel: 'default', + enabled: true, + }; + automationDialogService.result = { kind: 'create', value: submitted }; + const command = CommandsRegistry.getCommand('sessions.automations.duplicate'); + assert.ok(command); + + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + + assert.deepStrictEqual({ + initialName: automationDialogService.lastOptions?.initialValues?.name, + createCalls: automationService.createCalls, + createdNames: automationService.automations.get().map(item => item.name), + }, { + initialName: 'Daily review Copy 2', + createCalls: [submitted], + createdNames: ['Customized copy', 'Daily review', 'Daily review Copy'], + }); + }); + + test('duplicate dialog failures are logged and reported to the user', async () => { + const { automationDialogService, automationService, dialogService, instantiationService, logService } = setup(); + const source = automation(); + const error = new Error('dialog failed'); + automationDialogService.error = error; + automationService.setAutomations([source]); + const command = CommandsRegistry.getCommand('sessions.automations.duplicate'); + assert.ok(command); + + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + await dialogService.errorCalled.p; + + assert.deepStrictEqual({ + createCalls: automationService.createCalls, + loggedErrors: logService.errors, + dialogErrors: dialogService.errors, + }, { + createCalls: [], + loggedErrors: [{ + message: '[Automations] Failed to duplicate automation', + args: [error], + }], + dialogErrors: [{ + message: 'Failed to duplicate automation.', + detail: 'dialog failed', + }], + }); + }); + + test('duplicate creation failures are logged and reported to the user', async () => { + const { automationDialogService, automationService, contextKeyService, contextMenuService, dialogService, instantiationService, logService, widget } = setup(); + const source = automation(); + const error = new Error('create failed'); + automationService.createError = error; + automationDialogService.result = { + kind: 'create', + value: { + name: 'Daily review Copy', + prompt: source.prompt, + schedule: source.schedule, + target: source.target, + modelId: source.modelId, + mode: source.mode, + permissionLevel: source.permissionLevel, + enabled: source.enabled, + }, + }; + automationService.setAutomations([source]); + const sourceCard = widget.element.querySelector('.automations-card'); + assert.ok(sourceCard); + + sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + const delegate = contextMenuService.menuDelegate; + assert.ok(delegate); + const duplicateActions = instantiationService.get(IMenuService).getMenuActions( + Menus.AutomationCardContext, + delegate.contextKeyService ?? contextKeyService, + delegate.menuActionOptions, + ).flatMap(([, actions]) => actions); + assert.deepStrictEqual(duplicateActions.map(action => action.id), ['sessions.automations.duplicate', 'sessions.automations.disable', 'sessions.automations.delete']); + const command = CommandsRegistry.getCommand('sessions.automations.duplicate'); + assert.ok(command); + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + await dialogService.errorCalled.p; + + assert.deepStrictEqual({ + loggedErrors: logService.errors, + dialogErrors: dialogService.errors, + }, { + loggedErrors: [{ + message: '[Automations] Failed to duplicate automation', + args: [error], + }], + dialogErrors: [{ + message: 'Failed to duplicate automation.', + detail: 'create failed', + }], + }); + }); + + test('disable context menu action disables the automation and then becomes unavailable', async () => { + const { automationService, contextKeyService, contextMenuService, instantiationService, widget } = setup(); + const source = automation(); + automationService.setAutomations([source]); + const sourceCard = widget.element.querySelector('.automations-card'); + assert.ok(sourceCard); + const getDisableAction = () => { + sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + const delegate = contextMenuService.menuDelegate; + assert.ok(delegate); + return instantiationService.get(IMenuService).getMenuActions( + Menus.AutomationCardContext, + delegate.contextKeyService ?? contextKeyService, + delegate.menuActionOptions, + ).flatMap(([, actions]) => actions).find(action => action.id === 'sessions.automations.disable'); + }; + const command = CommandsRegistry.getCommand('sessions.automations.disable'); + assert.ok(command); + + const initiallyEnabled = getDisableAction()?.enabled; + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + const enabledAfterDisable = getDisableAction()?.enabled; + + assert.deepStrictEqual({ + initiallyEnabled, + enabledAfterDisable, + updateCalls: automationService.guardedUpdateCalls, + automationEnabled: automationService.automations.get()[0].enabled, + }, { + initiallyEnabled: true, + enabledAfterDisable: false, + updateCalls: [{ id: source.id, patch: { enabled: false }, expected: source }], + automationEnabled: false, + }); + }); + + test('disable context menu action is unavailable when updates are unsupported', async () => { + const { automationService, contextKeyService, contextMenuService, instantiationService, widget } = setup(); + automationService.canUpdate = false; + const source = automation(); + automationService.setAutomations([source]); + const sourceCard = widget.element.querySelector('.automations-card'); + assert.ok(sourceCard); + sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + const delegate = contextMenuService.menuDelegate; + assert.ok(delegate); + const disableAction = instantiationService.get(IMenuService).getMenuActions( + Menus.AutomationCardContext, + delegate.contextKeyService ?? contextKeyService, + delegate.menuActionOptions, + ).flatMap(([, actions]) => actions).find(action => action.id === 'sessions.automations.disable'); + const command = CommandsRegistry.getCommand('sessions.automations.disable'); + assert.ok(command); + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + + assert.deepStrictEqual({ + actionEnabled: disableAction?.enabled, + updateCalls: automationService.guardedUpdateCalls, + }, { + actionEnabled: false, + updateCalls: [], + }); + }); + + test('disable conflicts are logged and reported to the user', async () => { + const { automationService, dialogService, instantiationService, logService } = setup(); + const source = automation(); + automationService.setAutomations([source]); + automationService.updateResult = { kind: 'conflict', current: automation({ prompt: 'Changed' }) }; + const command = CommandsRegistry.getCommand('sessions.automations.disable'); + assert.ok(command); + + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + await dialogService.errorCalled.p; + + assert.deepStrictEqual({ + automationEnabled: automationService.automations.get()[0].enabled, + loggedErrors: logService.errors.map(entry => entry.message), + dialogErrors: dialogService.errors, + }, { + automationEnabled: true, + loggedErrors: ['[Automations] Failed to disable automation'], + dialogErrors: [{ + message: 'Failed to disable automation.', + detail: 'This automation changed before it could be disabled. Try again.', + }], + }); + }); + + test('disable reports when the automation was deleted concurrently', async () => { + const { automationService, dialogService, instantiationService } = setup(); + const source = automation(); + automationService.setAutomations([source]); + automationService.updateResult = { kind: 'conflict', current: undefined }; + const command = CommandsRegistry.getCommand('sessions.automations.disable'); + assert.ok(command); + + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + await dialogService.errorCalled.p; + + assert.deepStrictEqual(dialogService.errors, [{ + message: 'Failed to disable automation.', + detail: 'This automation was deleted before it could be disabled.', + }]); + }); + + test('delete context menu action confirms before deleting the automation and its history', async () => { + const { automationService, contextMenuService, dialogService, instantiationService, widget } = setup(); + const source = automation(); + automationService.setAutomations([source]); + automationService.setRuns([run({ automationId: source.id })]); + const sourceCard = widget.element.querySelector('.automations-card'); + assert.ok(sourceCard); + sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + const delegate = contextMenuService.menuDelegate; + assert.ok(delegate); + const command = CommandsRegistry.getCommand('sessions.automations.delete'); + assert.ok(command); + + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + dialogService.confirmResult = { confirmed: true }; + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + + assert.deepStrictEqual({ + confirmations: dialogService.confirmations, + deleteCalls: automationService.deleteCalls, + automations: automationService.automations.get(), + runs: automationService.runs.get(), + }, { + confirmations: [{ + message: 'Delete automation "Daily review"?', + detail: 'This will permanently delete the automation and its run history.', + primaryButton: 'Delete', + }, { + message: 'Delete automation "Daily review"?', + detail: 'This will permanently delete the automation and its run history.', + primaryButton: 'Delete', + }], + deleteCalls: [source.id], + automations: [], + runs: [], + }); + }); + + test('delete context menu action is disabled when deletion is unsupported', async () => { + const { automationService, contextKeyService, contextMenuService, dialogService, instantiationService, widget } = setup(); + automationService.canDelete = false; + const source = automation(); + automationService.setAutomations([source]); + const sourceCard = widget.element.querySelector('.automations-card'); + assert.ok(sourceCard); + sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + const delegate = contextMenuService.menuDelegate; + assert.ok(delegate); + const menuActions = instantiationService.get(IMenuService).getMenuActions( + Menus.AutomationCardContext, + delegate.contextKeyService ?? contextKeyService, + delegate.menuActionOptions, + ).flatMap(([, actions]) => actions); + const command = CommandsRegistry.getCommand('sessions.automations.delete'); + assert.ok(command); + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + + assert.deepStrictEqual({ + actions: menuActions.map(action => ({ id: action.id, enabled: action.enabled })), + confirmations: dialogService.confirmations.length, + deleteCalls: automationService.deleteCalls, + }, { + actions: [ + { id: 'sessions.automations.duplicate', enabled: true }, + { id: 'sessions.automations.disable', enabled: true }, + { id: 'sessions.automations.delete', enabled: false }, + ], + confirmations: 0, + deleteCalls: [], + }); + }); + + test('delete context menu failures are logged and reported to the user', async () => { + const { automationService, dialogService, instantiationService, logService } = setup(); + const source = automation(); + const error = new Error('delete failed'); + automationService.deleteError = error; + automationService.setAutomations([source]); + dialogService.confirmResult = { confirmed: true }; + const command = CommandsRegistry.getCommand('sessions.automations.delete'); + assert.ok(command); + + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + await dialogService.errorCalled.p; + + assert.deepStrictEqual({ + deleteCalls: automationService.deleteCalls, + automations: automationService.automations.get(), + loggedErrors: logService.errors, + dialogErrors: dialogService.errors, + }, { + deleteCalls: [source.id], + automations: [source], + loggedErrors: [{ + message: '[AutomationsCards] Failed to delete automation', + args: [error], + }], + dialogErrors: [{ + message: 'Failed to delete automation.', + detail: 'delete failed', + }], + }); + }); + test('automation action buttons support arrow navigation and keyboard activation', async () => { const { automationService, runner, widget } = setup(); automationService.setAutomations([automation()]); @@ -1010,6 +1468,163 @@ suite('AutomationsCardsWidget', () => { }); }); + test('history context menu shows state-specific actions and forwards the session', async () => { + const { automationService, commandService, contextMenuService, sessionsManagementService, widget } = setup(); + automationService.setAutomations([automation()]); + automationService.setRuns([run()]); + await waitForSessionActions(); + const row = widget.element.querySelector('.automations-run-session-list .session-item'); + assert.ok(row); + + dispatchContextMenu(row); + const unreadDelegate = contextMenuService.delegate; + const unreadActions = unreadDelegate?.getActions() ?? []; + const archiveLabel = unreadActions.find(action => action.id === 'sessionsViewPane.archiveSession')?.label; + const markReadAction = unreadActions.find(action => action.id === MARK_SESSION_READ_COMMAND_ID); + assert.ok(markReadAction); + await unreadDelegate?.actionRunner?.run(markReadAction, unreadDelegate.getActionsContext?.()); + unreadDelegate?.onHide?.(false); + + sessionsManagementService.setRead(true); + dispatchContextMenu(row); + const readDelegate = contextMenuService.delegate; + const readActionIds = (readDelegate?.getActions() ?? []).map(action => action.id); + readDelegate?.onHide?.(false); + + assert.deepStrictEqual({ + unreadActionIds: unreadActions.map(action => action.id), + archiveLabel, + readActionIds, + commandCalls: commandService.calls.map(call => ({ + commandId: call.commandId, + argCount: call.args.length, + hasExpectedSessions: Array.isArray(call.args[0]) && call.args[0].length === 1 && call.args[0][0] === sessionsManagementService.session, + })), + }, { + unreadActionIds: [ + MARK_SESSION_READ_COMMAND_ID, + 'vs.actions.separator', + 'sessionsViewPane.renameSession', + 'sessionsViewPane.archiveSession', + 'vs.actions.separator', + 'sessions.automations.deleteRunSession', + ], + archiveLabel: 'Archive', + readActionIds: [ + MARK_SESSION_UNREAD_COMMAND_ID, + 'vs.actions.separator', + 'sessionsViewPane.renameSession', + 'sessionsViewPane.archiveSession', + 'vs.actions.separator', + 'sessions.automations.deleteRunSession', + ], + commandCalls: [{ + commandId: MARK_SESSION_READ_COMMAND_ID, + argCount: 1, + hasExpectedSessions: true, + }], + }); + }); + + test('history context menu only shows keybindings valid for the row context', async () => { + const { automationService, contextMenuService, keybindingService, widget } = setup(); + automationService.setAutomations([automation()]); + automationService.setRuns([run()]); + await waitForSessionActions(); + const row = widget.element.querySelector('.automations-run-session-list .session-item'); + assert.ok(row); + + dispatchContextMenu(row); + const delegate = contextMenuService.delegate; + const renameAction = delegate?.getActions().find(action => action.id === 'sessionsViewPane.renameSession'); + assert.ok(renameAction); + keybindingService.lookupCalls.length = 0; + delegate?.getKeyBinding?.(renameAction); + delegate?.onHide?.(false); + + assert.deepStrictEqual(keybindingService.lookupCalls.map(call => ({ + commandId: call.commandId, + hasContext: !!call.context, + enforceContextCheck: call.enforceContextCheck, + })), [{ + commandId: 'sessionsViewPane.renameSession', + hasContext: true, + enforceContextCheck: true, + }]); + }); + + test('history context menu gates actions by session state and capabilities', async () => { + const { automationService, contextMenuService, sessionsManagementService, widget } = setup(); + sessionsManagementService.sessionStatus.set(SessionStatus.InProgress, undefined); + sessionsManagementService.setCapabilities(false, false); + sessionsManagementService.isArchived.set(true, undefined); + automationService.setAutomations([automation()]); + automationService.setRuns([run({ status: 'running' })]); + await waitForSessionActions(); + const row = widget.element.querySelector('.automations-run-session-list .session-item'); + assert.ok(row); + + dispatchContextMenu(row); + const delegate = contextMenuService.delegate; + const actionIds = (delegate?.getActions() ?? []).map(action => action.id); + delegate?.onHide?.(false); + + assert.deepStrictEqual(actionIds, [UNARCHIVE_SESSION_COMMAND_ID]); + }); + + test('history context menu follows Mark as Done archive wording', async () => { + const { automationService, contextMenuService, sessionsManagementService, widget } = setup('done'); + automationService.setAutomations([automation()]); + automationService.setRuns([run()]); + await waitForSessionActions(); + const row = widget.element.querySelector('.automations-run-session-list .session-item'); + assert.ok(row); + + dispatchContextMenu(row); + const activeActions = contextMenuService.delegate?.getActions() ?? []; + const markAsDoneLabel = activeActions.find(action => action.id === 'sessionsViewPane.archiveSession')?.label; + contextMenuService.delegate?.onHide?.(false); + + sessionsManagementService.isArchived.set(true, undefined); + dispatchContextMenu(row); + const archivedActions = contextMenuService.delegate?.getActions() ?? []; + const restoreLabel = archivedActions.find(action => action.id === UNARCHIVE_SESSION_COMMAND_ID)?.label; + contextMenuService.delegate?.onHide?.(false); + + assert.deepStrictEqual({ markAsDoneLabel, restoreLabel }, { + markAsDoneLabel: 'Mark as Done', + restoreLabel: 'Restore', + }); + }); + + test('history context menu deletion uses the run-only deletion flow', async () => { + const { automationService, contextMenuService, dialogService, sessionsManagementService, widget } = setup(); + automationService.setAutomations([automation()]); + automationService.setRuns([run()]); + dialogService.confirmResult = { confirmed: true }; + await waitForSessionActions(); + const row = widget.element.querySelector('.automations-run-session-list .session-item'); + assert.ok(row); + + dispatchContextMenu(row); + const delegate = contextMenuService.delegate; + const deleteAction = delegate?.getActions().find(action => action.id === 'sessions.automations.deleteRunSession'); + assert.ok(deleteAction); + await delegate?.actionRunner?.run(deleteAction, delegate.getActionsContext?.()); + await automationService.deleteRunCompleted.p; + delegate?.onHide?.(false); + + assert.deepStrictEqual({ + deleteSessionCalls: sessionsManagementService.deleteSessionCalls, + deleteRunCalls: automationService.deleteRunCalls, + historyItemStillVisible: !!widget.element.querySelector('.automations-run-session-list .session-item'), + }, { + deleteSessionCalls: 1, + deleteRunCalls: 1, + historyItemStillVisible: false, + }); + }); + test('stops an active run without opening its session', async () => { const { automationService, sessionsManagementService, sessionsService, widget } = setup(); sessionsManagementService.sessionStatus.set(SessionStatus.InProgress, undefined); @@ -1319,6 +1934,7 @@ suite('AutomationsCustomViewContribution — context key', () => { let restore: boolean | undefined; const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAutomationService, automationService); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(ICustomViewService, new class extends mock() { override readonly activeCustomView = constObservable(undefined); diff --git a/src/vs/sessions/contrib/sessions/test/electron-browser/sessionsApplicationBadge.test.ts b/src/vs/sessions/contrib/sessions/test/electron-browser/sessionsApplicationBadge.test.ts new file mode 100644 index 00000000000000..d4baff5c58b0e6 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/electron-browser/sessionsApplicationBadge.test.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { isWindows } from '../../../../../base/common/platform.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { BufferReader, BufferWriter, deserialize, serialize } from '../../../../../base/parts/ipc/common/ipc.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IApplicationBadge, INativeHostService } from '../../../../../platform/native/common/native.js'; +import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { SESSIONS_APPLICATION_BADGE_SETTING, SessionsApplicationBadge } from '../../electron-browser/sessionsApplicationBadge.js'; + +class TestSessionsManagementService extends mock() { + + private readonly _onDidChangeSessions = new Emitter(); + override readonly onDidChangeSessions = this._onDidChangeSessions.event; + + readonly sessions: ISession[] = []; + + override getSessions(): ISession[] { + return [...this.sessions]; + } + + change(): void { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [] }); + } + + dispose(): void { + this._onDidChangeSessions.dispose(); + } +} + +class TestNativeHostService extends mock() { + + readonly badges: (IApplicationBadge | undefined)[] = []; + + override async setApplicationBadge(badge: IApplicationBadge | undefined): Promise { + this.badges.push(badge); + } +} + +function createSession(id: string, state: { status?: SessionStatus; isRead?: boolean; isArchived?: boolean }) { + const status = observableValue(`status-${id}`, state.status ?? SessionStatus.Completed); + const isRead = observableValue(`isRead-${id}`, state.isRead ?? true); + const isArchived = observableValue(`isArchived-${id}`, state.isArchived ?? false); + + const session = new class extends mock() { + override readonly sessionId = id; + override readonly resource = URI.parse(`test:///${id}`); + override readonly status = status; + override readonly isRead = isRead; + override readonly isArchived = isArchived; + }; + + return { session, status, isRead, isArchived }; +} + +suite('SessionsApplicationBadge', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createBadge(sessions: ISession[], enabled = true) { + const management = store.add(new TestSessionsManagementService()); + management.sessions.push(...sessions); + + const nativeHost = new TestNativeHostService(); + const configuration = new TestConfigurationService({ [SESSIONS_APPLICATION_BADGE_SETTING]: enabled }); + + store.add(new SessionsApplicationBadge(management, nativeHost, configuration, new TestThemeService())); + + return { management, nativeHost, configuration }; + } + + function badgeCounts(nativeHost: TestNativeHostService): (number | undefined)[] { + return nativeHost.badges.map(badge => badge?.count); + } + + test('counts unread and needs-input sessions, ignoring archived and idle ones', () => { + const { nativeHost } = createBadge([ + createSession('unread', { isRead: false }).session, + createSession('needs-input', { status: SessionStatus.NeedsInput }).session, + createSession('unread-and-needs-input', { isRead: false, status: SessionStatus.NeedsInput }).session, + createSession('archived-unread', { isRead: false, isArchived: true }).session, + createSession('archived-needs-input', { status: SessionStatus.NeedsInput, isArchived: true }).session, + createSession('idle', {}).session, + ]); + + assert.deepStrictEqual(nativeHost.badges.map(badge => ({ + count: badge?.count, + description: badge?.description, + // Only Windows needs an image, the other platforms render the count + isPng: badge?.iconDataURL?.startsWith('data:image/png;base64,') ?? false + })), [ + { count: 3, description: '3 sessions need your attention', isPng: isWindows } + ]); + }); + + test('is off until enabled', () => { + const { nativeHost, configuration } = createBadge([createSession('unread', { isRead: false }).session], false); + + configuration.setUserConfiguration(SESSIONS_APPLICATION_BADGE_SETTING, true); + configuration.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration() { return true; } + }); + + // No badge is pushed while disabled, so a second Agents window cannot + // clear the application wide badge of the first one on startup. + assert.deepStrictEqual(nativeHost.badges.map(badge => ({ count: badge?.count, description: badge?.description })), [ + { count: 1, description: '1 session needs your attention' } + ]); + }); + + test('follows session state and session list changes', () => { + const unread = createSession('unread', { isRead: false }); + const { nativeHost, management } = createBadge([unread.session]); + + unread.isRead.set(true, undefined); + + const added = createSession('added', { status: SessionStatus.NeedsInput }); + management.sessions.push(added.session); + management.change(); + + added.isArchived.set(true, undefined); + + assert.deepStrictEqual(badgeCounts(nativeHost), [1, undefined, 1, undefined]); + }); + + test('badge survives the IPC round trip to the main process', () => { + const { nativeHost } = createBadge([createSession('unread', { isRead: false }).session]); + + // The badge crosses a `ProxyChannel` as plain JSON: a nested `VSBuffer` + // is not revived, and an `undefined` valued property is dropped + // entirely. Both would make the main process see a different badge. + const writer = new BufferWriter(); + serialize(writer, [nativeHost.badges[0]]); + const [revived] = deserialize(new BufferReader(writer.buffer)) as [IApplicationBadge]; + + assert.deepStrictEqual(revived, nativeHost.badges[0]); + }); +}); diff --git a/src/vs/sessions/services/agentHostFilter/browser/agentHostFilterService.ts b/src/vs/sessions/services/agentHostFilter/browser/agentHostFilterService.ts index d304f872eb3580..c5a22c69e3221b 100644 --- a/src/vs/sessions/services/agentHostFilter/browser/agentHostFilterService.ts +++ b/src/vs/sessions/services/agentHostFilter/browser/agentHostFilterService.ts @@ -22,6 +22,7 @@ function mapStatus(s: RemoteAgentHostConnectionStatus): AgentHostFilterConnectio switch (s.kind) { case 'connected': return AgentHostFilterConnectionStatus.Connected; case 'connecting': return AgentHostFilterConnectionStatus.Connecting; + case 'reconnecting': return AgentHostFilterConnectionStatus.Connecting; case 'disconnected': case 'incompatible': default: return AgentHostFilterConnectionStatus.Disconnected; diff --git a/src/vs/sessions/services/agentHostFilter/test/browser/agentHostFilterService.test.ts b/src/vs/sessions/services/agentHostFilter/test/browser/agentHostFilterService.test.ts index f60db2f83d2370..7bde516e6d061f 100644 --- a/src/vs/sessions/services/agentHostFilter/test/browser/agentHostFilterService.test.ts +++ b/src/vs/sessions/services/agentHostFilter/test/browser/agentHostFilterService.test.ts @@ -154,6 +154,14 @@ suite('AgentHostFilterService', () => { assert.strictEqual(events, 1); }); + test('maps reconnecting providers to connecting', () => { + const providers = new StubSessionsProvidersService(); + store.add(providers.registerProvider(new StubRemoteProvider('localhost:4321', 'Host A', RemoteAgentHostConnectionStatus.reconnecting) as unknown as ISessionsProvider)); + const service = createService(providers); + + assert.strictEqual(service.hosts[0].status, AgentHostFilterConnectionStatus.Connecting); + }); + test('setSelectedHostId fires change and restores based on platform', () => { const providers = new StubSessionsProvidersService(); store.add(providers.registerProvider(new StubRemoteProvider('localhost:4321', 'Host A') as unknown as ISessionsProvider)); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 21b961b6b53759..08b8004ed15602 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { arrayEquals } from '../../../../base/common/equals.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IObservable, IReader } from '../../../../base/common/observable.js'; @@ -305,6 +306,8 @@ export interface IGitHubInfo { readonly uri: URI; /** Icon reflecting the PR state. */ readonly icon?: ThemeIcon; + /** Pull request title, when known. */ + readonly title?: string; /** Object ID of the base ref (merge target) commit. */ readonly baseRefOid?: string; /** Object ID of the head ref (PR branch) commit. */ @@ -341,6 +344,50 @@ export interface IGitHubPullRequestRef { readonly createdByThisSession?: boolean; } +/** Returns all pull requests associated with GitHub info, including its legacy single-PR shape. */ +export function getGitHubPullRequestRefs(gitHubInfo: IGitHubInfo | undefined): readonly IGitHubPullRequestRef[] { + if (gitHubInfo?.pullRequests?.length) { + return gitHubInfo.pullRequests; + } + if (!gitHubInfo?.pullRequest) { + return []; + } + return [{ + owner: gitHubInfo.owner, + repo: gitHubInfo.repo, + number: gitHubInfo.pullRequest.number, + uri: gitHubInfo.pullRequest.uri, + icon: gitHubInfo.pullRequest.icon, + title: gitHubInfo.pullRequest.title, + }]; +} + +const pullRequestIconPriority = new Map([ + [Codicon.gitPullRequestError.id, 6], + [Codicon.gitPullRequestComment.id, 5], + [Codicon.gitPullRequest.id, 4], + [Codicon.gitPullRequestDraft.id, 3], + [Codicon.gitPullRequestDone.id, 2], + [Codicon.gitPullRequestClosed.id, 1], +]); + +/** Returns the most important status icon across a session's pull requests. */ +export function getHighestPriorityPullRequestIcon(icons: readonly (ThemeIcon | undefined)[]): ThemeIcon | undefined { + let result: ThemeIcon | undefined; + let resultPriority = -1; + for (const icon of icons) { + if (!icon) { + continue; + } + const priority = pullRequestIconPriority.get(icon.id) ?? 0; + if (priority > resultPriority) { + result = icon; + resultPriority = priority; + } + } + return result; +} + /** A GitHub issue referenced by a session. */ export interface IGitHubIssueRef { /** GitHub repository owner of the issue. */ @@ -977,6 +1024,7 @@ export function gitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | und a.pullRequest?.number === b.pullRequest?.number && isEqual(a.pullRequest?.uri, b.pullRequest?.uri) && (aIcon === bIcon || (!!aIcon && !!bIcon && ThemeIcon.isEqual(aIcon, bIcon))) && + a.pullRequest?.title === b.pullRequest?.title && a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && a.pullRequest?.headRefOid === b.pullRequest?.headRefOid; } diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 65da1a113e292f..16be5da38d42a4 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -259,6 +259,9 @@ import './contrib/providers/agentHost/electron-browser/agentHost.contribution.js // Tunnel Host (allow remote connections to local agent host) import './contrib/tunnelHost/electron-browser/tunnelHost.contribution.js'; +// Sessions (desktop only) +import './contrib/sessions/electron-browser/sessions.contribution.js'; + //#endregion export { main } from './electron-browser/sessions.main.js'; diff --git a/src/vs/sessions/test/browser/layoutActions.test.ts b/src/vs/sessions/test/browser/layoutActions.test.ts index e1d3771971210f..4aece161561ae6 100644 --- a/src/vs/sessions/test/browser/layoutActions.test.ts +++ b/src/vs/sessions/test/browser/layoutActions.test.ts @@ -11,6 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/comm import { isIMenuItem, MenuId, MenuRegistry } from '../../../platform/actions/common/actions.js'; import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; +import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../platform/accessibility/common/accessibility.js'; import { ToggleAuxiliaryBarAction } from '../../../workbench/browser/parts/auxiliarybar/auxiliaryBarActions.js'; import { PanelVisibleContext, SecondarySideBarVisibleContext } from '../../../workbench/common/contextkeys.js'; import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; @@ -44,6 +45,22 @@ suite('Sessions - Layout Actions', () => { assert.strictEqual(toggleAlwaysOnTop.group, 'navigation'); }); + test('screen reader optimized action uses a title bar toolbar menu', () => { + const item = MenuRegistry.getMenuItems(Menus.TitleBarAccessibility) + .filter(isIMenuItem) + .find(item => item.command.id === 'editor.action.toggleScreenReaderAccessibilityMode'); + + assert.deepStrictEqual({ + title: item?.command.title, + tooltip: item?.command.tooltip, + when: item?.when?.serialize(), + }, { + title: 'Screen Reader Optimized', + tooltip: 'Disable Screen Reader Optimized Mode', + when: `${CONTEXT_ACCESSIBILITY_MODE_ENABLED.key} && !sessionsIsPhoneLayout`, + }); + }); + test('bottom panel layout action replaces the terminal action in the session title bar', () => { const items = MenuRegistry.getMenuItems(Menus.TitleBarSessionMenu).filter(isIMenuItem); const panelActions = items diff --git a/src/vs/sessions/test/browser/titlebarPart.test.ts b/src/vs/sessions/test/browser/titlebarPart.test.ts index 45f0ce149320b2..3d34ba51332b2d 100644 --- a/src/vs/sessions/test/browser/titlebarPart.test.ts +++ b/src/vs/sessions/test/browser/titlebarPart.test.ts @@ -14,18 +14,19 @@ suite('Sessions - Titlebar Part', () => { const updateTitleBarToolBarOverflow = Reflect.get(TitlebarPart.prototype, 'updateTitleBarToolBarOverflow') as (this: TitlebarPart) => void; test('hides optional toolbar groups when a titlebar section overflows', () => { - let centerClientWidth = 60; - let rightClientWidth = 60; + let centerClientWidth = 100; + let rightClientWidth = 100; const root = createMeasuredElement(() => 100, () => 100); const left = createMeasuredElement(() => 20, () => 20); - const toolBars = [20, 20, 20, 20, 20].map(() => mainWindow.document.createElement('div')); + const toolBars = [20, 20, 20, 20, 20, 20].map(() => mainWindow.document.createElement('div')); + toolBars[0].classList.add('titlebar-screen-reader-container'); const center = createMeasuredElement( () => centerClientWidth, - () => 40 + visibleWidth(toolBars[0], 20) + visibleWidth(toolBars[1], 20) + () => 40 + visibleWidth(toolBars[1], 20) + visibleWidth(toolBars[2], 20) ); const right = createMeasuredElement( () => rightClientWidth, - () => 40 + visibleWidth(toolBars[2], 20) + visibleWidth(toolBars[3], 20) + visibleWidth(toolBars[4], 20) + () => 40 + visibleWidth(toolBars[0], 20) + visibleWidth(toolBars[3], 20) + visibleWidth(toolBars[4], 20) + visibleWidth(toolBars[5], 20) ); const titlebarPart = Object.create(TitlebarPart.prototype) as TitlebarPart; Reflect.set(titlebarPart, 'rootContainer', root); @@ -35,16 +36,16 @@ suite('Sessions - Titlebar Part', () => { Reflect.set(titlebarPart, 'overflowManagedToolBarElements', toolBars); updateTitleBarToolBarOverflow.call(titlebarPart); - const constrained = toolBars.map(element => element.classList.contains('overflowing')); + const prioritized = toolBars.map(element => element.classList.contains('overflowing')); - centerClientWidth = 100; - rightClientWidth = 100; + centerClientWidth = 200; + rightClientWidth = 200; updateTitleBarToolBarOverflow.call(titlebarPart); const expanded = toolBars.map(element => element.classList.contains('overflowing')); - assert.deepStrictEqual({ constrained, expanded }, { - constrained: [true, true, true, true, false], - expanded: [false, false, false, false, false], + assert.deepStrictEqual({ prioritized, expanded }, { + prioritized: [true, false, false, false, false, false], + expanded: [false, false, false, false, false, false], }); }); }); diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index d48cc5fe2aae29..c226f649f11b14 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -22,7 +22,7 @@ import { ITelemetryService } from '../../../platform/telemetry/common/telemetry. import { ISecretStorageService } from '../../../platform/secrets/common/secrets.js'; import { IWorkbenchMcpGatewayService } from '../../contrib/mcp/common/mcpGatewayService.js'; import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; -import { extensionPrefixedIdentifier, McpCollectionDefinition, McpCollectionSortOrder, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, mcpOAuthClientSecretStorageKey, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js'; +import { extensionPrefixedIdentifier, McpCollectionDefinition, McpCollectionProvenance, McpCollectionSortOrder, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, mcpOAuthClientSecretStorageKey, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js'; import { IMcpEnterpriseManagedAuthIdpConfig, mcpEnterpriseManagedAuthIdpSection } from '../../contrib/mcp/common/mcpConfiguration.js'; import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; import { IAuthenticationMcpAccessService } from '../../services/authentication/browser/authenticationMcpAccessService.js'; @@ -152,6 +152,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { const register = () => { handle.value ??= this._mcpRegistry.registerCollection({ ...collection, + provenance: McpCollectionProvenance.Extension, source: extensionId, order: McpCollectionSortOrder.Extension, resolveServerLanch: collection.canResolveLaunch ? (async def => { diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts index 60a412e395d4ed..3e7bb2ecf68b4a 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts @@ -21,7 +21,7 @@ import { isToolResultInputOutputDetails, isToolResultOutputDetails, toolContentT import { CancelChatActionId } from '../actions/chatExecuteActions.js'; import { AcceptToolConfirmationActionId } from '../actions/chatToolActions.js'; import { ChatTreeItem } from '../chat.js'; -import { getAgentMergeRequestLabel } from '../widget/chatContentParts/chatAgentMergeContentPart.js'; +import { getChatRequestText } from '../chatRequestText.js'; export const getToolConfirmationAlert = (accessor: ServicesAccessor, toolInvocation: IChatToolInvocation[]) => { const keybindingService = accessor.get(IKeybindingService); @@ -113,8 +113,9 @@ export class ChatAccessibilityProvider implements IListAccessibilityProvider; private readonly _clientToolsByType = new Map>(); private readonly _scopes = new Map(); + private readonly _mcpServerSupportScopes = new Map(); private readonly _syncProviders = new Map(); private _isDisposed = false; @@ -308,6 +315,27 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo return scope.acquire(); } + acquireMcpServerSupportScope(sessionType: string, roots: readonly URI[] | undefined): IAgentHostMcpServerSupportScope | undefined { + if (!isCopilotCliSessionType(sessionType)) { + return undefined; + } + const normalizedRoots = roots === undefined ? undefined : normalizeRoots(roots, this._uriIdentityService.extUri); + const rootsKey = normalizedRoots === undefined ? undefined : getScopeKey(normalizedRoots, this._uriIdentityService.extUri); + const serviceScopeKey = JSON.stringify([sessionType, rootsKey]); + let scope = this._mcpServerSupportScopes.get(serviceScopeKey); + if (!scope) { + const createdScope = this._instantiationService.createInstance( + AgentHostMcpServerSupportScope, + sessionType, + normalizedRoots, + () => this._removeMcpServerSupportScope(serviceScopeKey, createdScope), + ); + scope = createdScope; + this._mcpServerSupportScopes.set(serviceScopeKey, scope); + } + return scope.acquire(); + } + getSyncProvider(sessionType: string): ICustomizationSyncProvider { let syncProvider = this._syncProviders.get(sessionType); if (!syncProvider) { @@ -388,9 +416,14 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo this._isDisposed = true; const scopes = [...this._scopes.values()]; this._scopes.clear(); + const mcpServerSupportScopes = [...this._mcpServerSupportScopes.values()]; + this._mcpServerSupportScopes.clear(); for (const scope of scopes) { scope.dispose(); } + for (const scope of mcpServerSupportScopes) { + scope.dispose(); + } super.dispose(); } @@ -399,6 +432,12 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo this._scopes.delete(scopeKey); } } + + private _removeMcpServerSupportScope(scopeKey: string, scope: AgentHostMcpServerSupportScope): void { + if (this._mcpServerSupportScopes.get(scopeKey) === scope) { + this._mcpServerSupportScopes.delete(scopeKey); + } + } } function normalizeRoots(roots: readonly URI[], extUri: IExtUri): readonly URI[] { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index aa033e9625b11a..0a711c3b342d3d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -34,7 +34,7 @@ import { ILanguageModelsService } from '../../../common/languageModels.js'; import { languageModelSourcePresentationRegistry } from '../../../common/languageModelSourcePresentation.js'; import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { AgentCustomizationItemProvider } from './agentCustomizationItemProvider.js'; -import { agentHostProviderHasBuiltInGitHubMcpServer, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID } from './agentHostLocalCustomizations.js'; +import { agentHostProviderHasBuiltInGitHubMcpServer, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID } from './agentHostMcpServerSupport.js'; import { AgentHostDownloadProgress } from './agentHostDownloadProgress.js'; import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from './agentHostAuth.js'; import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from './agentHostLanguageModelProvider.js'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 3974a4493edcd6..b09da01d5b5df7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -4,45 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Iterable } from '../../../../../../base/common/iterator.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { basename, isEqualOrParent } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CustomizationEnablementKind, type AgentCustomization, CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; -import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; -import { IMcpServerConfiguration, McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { AICustomizationSource, AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptsService, isUserToggleableCustomization, matchesSessionType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { type ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; -import { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../../mcp/common/discovery/pluginMcpDiscovery.js'; -import { extensionPrefixedIdentifier, IMcpService, McpCollectionDefinition, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; -import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; -import { IWorkspaceFolderData } from '../../../../../../platform/workspace/common/workspace.js'; import type { ISyncableFile, ISyncableMcpServer, SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { isDefined } from '../../../../../../base/common/types.js'; import { PromptFileParser } from '../../../common/promptSyntax/promptFileParser.js'; - -const COPILOT_CHAT_EXTENSION_ID = 'github.copilot-chat'; -export const COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID = extensionPrefixedIdentifier(new ExtensionIdentifier(COPILOT_CHAT_EXTENSION_ID), 'github'); -const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-'; -const AGENT_HOST_PROVIDERS_WITH_GITHUB_MCP = new Set(['copilotcli', 'claude', 'codex']); - -export function agentHostProviderHasBuiltInGitHubMcpServer(provider: string): boolean { - return AGENT_HOST_PROVIDERS_WITH_GITHUB_MCP.has(provider); -} - -function hasBuiltInGitHubMcpServer(sessionType: string): boolean { - const localProvider = sessionType.startsWith(LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX) - ? sessionType.slice(LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX.length) - : undefined; - return agentHostProviderHasBuiltInGitHubMcpServer(localProvider ?? ''); -} +import { AgentHostMcpServerDelivery, resolveMcpServersForAgentHostDelivery } from './agentHostMcpServerSupport.js'; /** * Prompt types that participate in auto-sync to an agent host harness. @@ -209,83 +188,6 @@ export async function resolveLocalCustomAgents( return result; } -/** - * Converts an {@link McpServerLaunch} back into the declarative - * {@link IMcpServerConfiguration} shape understood by the agent host's - * Open Plugin `.mcp.json` reader. Returns `undefined` for launches that - * cannot be expressed declaratively (e.g. extension-resolved servers with - * no command or URL). - */ -function launchToMcpServerConfiguration(launch: McpServerLaunch): IMcpServerConfiguration | undefined { - switch (launch.type) { - case McpServerTransportType.Stdio: - if (!launch.command) { - return undefined; - } - return { - type: McpServerType.LOCAL, - command: launch.command, - args: launch.args.length > 0 ? [...launch.args] : undefined, - env: Object.keys(launch.env).length > 0 ? { ...launch.env } : undefined, - envFile: launch.envFile, - cwd: launch.cwd, - }; - case McpServerTransportType.HTTP: - return { - type: McpServerType.REMOTE, - url: launch.uri.toString(), - headers: launch.headers.length > 0 ? Object.fromEntries(launch.headers) : undefined, - }; - } -} - -/** - * Attempts to resolve every configuration variable (`${workspaceFolder}`, - * `${env:…}`, …) in an MCP server config without any user interaction, using - * {@link IConfigurationResolverService.resolveAsync}. Returns the resolved - * config, or `undefined` when it cannot be fully resolved without prompting the - * user. - * - * The synced `.mcp.json` is launched by the agent host verbatim, so any - * variable the agent host can't itself expand must be resolved here up front. - * Variables requiring interaction (`${input:…}`, `${command:…}`) or context we - * don't have (e.g. `${workspaceFolder}` outside a folder) cause the server to - * be skipped. - */ -async function resolveConfigurationForSync( - configurationResolverService: IConfigurationResolverService, - folder: IWorkspaceFolderData | undefined, - configuration: IMcpServerConfiguration, -): Promise { - const expr = ConfigurationResolverExpression.parse(configuration); - - // Interactive variables (`${input:…}`, `${command:…}`) can only be resolved - // by prompting the user, so a server referencing them is skipped. This is - // checked up front because `resolveAsync` "resolves" them to their own - // literal text when no value mapping is supplied, which would otherwise - // leave them out of `unresolved()` below. - for (const replacement of expr.unresolved()) { - if (replacement.name === 'input' || replacement.name === 'command') { - return undefined; - } - } - - try { - // Resolves everything that can be resolved without interaction; throws - // when a variable requires context we don't have (e.g. no folder). - await configurationResolverService.resolveAsync(folder, expr); - } catch { - return undefined; - } - - // Any replacement left unresolved would require user interaction. - if (!Iterable.isEmpty(expr.unresolved())) { - return undefined; - } - - return expr.toObject(); -} - /** * Enumerates MCP servers configured directly in VS Code — i.e. those that * are not contributed by an agent plugin — so they can be bundled into the @@ -302,54 +204,21 @@ async function resolveConfigurationForSync( * Copilot Chat extension's duplicate provider is excluded. */ export async function collectNonPluginMcpServers(mcpService: IMcpService, configurationResolverService: IConfigurationResolverService, sessionType: string, workingDirectories: readonly URI[]): Promise { - const result: ISyncableMcpServer[] = []; - for (const server of mcpService.servers.get()) { - if (server.collection.id.startsWith(MCP_PLUGIN_COLLECTION_ID_PREFIX)) { - continue; - } - const definitions = server.readDefinitions().get(); - const definition = definitions.server; - const launch = definition?.launch; - if (!launch) { - continue; - } - const collection = definitions.collection; - if (hasBuiltInGitHubMcpServer(sessionType) - && collection?.id === COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID - && collection.source instanceof ExtensionIdentifier - && ExtensionIdentifier.equals(collection.source, COPILOT_CHAT_EXTENSION_ID)) { - continue; + const resolved = await resolveMcpServersForAgentHostDelivery(mcpService.servers.get(), configurationResolverService, sessionType, workingDirectories); + return resolved.flatMap(({ server, definition, delivery, projectedConfiguration }) => { + if (delivery !== AgentHostMcpServerDelivery.ClientForwarded || !definition || !projectedConfiguration) { + return []; } - let configuration = launchToMcpServerConfiguration(launch); - if (!configuration) { - continue; - } - if (collection && McpCollectionDefinition.isWorkspaceDiscovered(collection)) { - if (McpCollectionDefinition.isVscodeMcpJson(collection)) { - const origin = collection.presentation?.origin; - if (!origin || !workingDirectories.some(workingDirectory => isEqualOrParent(origin, workingDirectory))) { - continue; - } - const resolved = await resolveConfigurationForSync(configurationResolverService, definition.variableReplacement?.folder, configuration); - if (!resolved) { - continue; - } - configuration = resolved; - } else { - continue; - } - } - result.push({ + return [{ name: server.definition.label, - configuration, + configuration: projectedConfiguration, ...(definition.defaultCwd && { defaultCwd: definition.defaultCwd }), enablement: withCustomizationEnablement(undefined, CustomizationEnablementKind.Global, { kind: CustomizationEnablementKind.Global, enabled: mcpService.enablementModel.readProfileEnabled(server.definition.id), }), - }); - } - return result; + } satisfies ISyncableMcpServer]; + }); } /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts new file mode 100644 index 00000000000000..6cd85e7f309c44 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts @@ -0,0 +1,718 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Iterable } from '../../../../../../base/common/iterator.js'; +import { isEqualOrParent } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { Location } from '../../../../../../editor/common/languages.js'; +import { ConfigurationTarget } from '../../../../../../platform/configuration/common/configuration.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { IMcpSandboxConfiguration, IMcpServerConfiguration, McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { IWorkspaceFolderData } from '../../../../../../platform/workspace/common/workspace.js'; +import { AICustomizationSource, AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; +import { ContributionEnablementState } from '../../../common/enablement.js'; +import { IAgentHostMcpServerSupportCoverage } from '../../../common/promptSyntax/service/customizationMigrationService.js'; +import { ExternalDiscoverySource } from '../../../../mcp/common/mcpConfiguration.js'; +import { CURSOR_WORKSPACE_MCP_COLLECTION_ID_PREFIX, extensionMcpCollectionPrefix, extensionPrefixedIdentifier, getMcpCollectionProvenance, IMcpConfigPath, IMcpServer, LazyCollectionState, MCP_CONFIGURATION_COLLECTION_ID_PREFIX, MCP_PLUGIN_COLLECTION_ID_PREFIX, McpCollectionDefinition, McpCollectionProvenance, McpServerDefinition, McpServerEnablementState, McpServerLaunch, McpServerTransportType, WORKSPACE_DOT_MCP_COLLECTION_ID_PREFIX } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { isCopilotCliSessionType } from './agentHostToolSetEnablementService.js'; + +const COPILOT_CHAT_EXTENSION_ID = 'github.copilot-chat'; +const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-'; +const AGENT_HOST_PROVIDERS_WITH_GITHUB_MCP = new Set(['copilotcli', 'claude', 'codex']); + +export const COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID = extensionPrefixedIdentifier(new ExtensionIdentifier(COPILOT_CHAT_EXTENSION_ID), 'github'); + +export const enum AgentHostMcpServerDelivery { + ClientForwarded = 'clientForwarded', + RuntimeDiscovered = 'runtimeDiscovered', + AgentPlugin = 'agentPlugin', + ProviderBuiltIn = 'providerBuiltIn', + NotDelivered = 'notDelivered', + Unknown = 'unknown', +} + +export const enum AgentHostMcpServerApplicability { + Applicable = 'applicable', + OutsideCurrentScope = 'outsideCurrentScope', + Unknown = 'unknown', +} + +export const enum AgentHostMcpServerEnablementState { + EnabledProfile = 'enabledProfile', + EnabledWorkspace = 'enabledWorkspace', + DisabledProfile = 'disabledProfile', + DisabledWorkspace = 'disabledWorkspace', + DisabledByAccess = 'disabledByAccess', + DisabledNotRegistered = 'disabledNotRegistered', +} + +export const enum AgentHostMcpServerSourceKind { + UserProfile = 'userProfile', + RemoteUser = 'remoteUser', + VscodeWorkspaceFolder = 'vscodeWorkspaceFolder', + WorkspaceConfiguration = 'workspaceConfiguration', + WorkspaceDotMcp = 'workspaceDotMcp', + ClaudeDesktop = 'claudeDesktop', + Windsurf = 'windsurf', + CursorUser = 'cursorUser', + CursorWorkspace = 'cursorWorkspace', + Extension = 'extension', + AgentPlugin = 'agentPlugin', + Unknown = 'unknown', +} + +export const enum AgentHostMcpSupportReason { + UnsupportedSourceLocation = 'unsupportedSourceLocation', + RequiresUserInteraction = 'requiresUserInteraction', + UnresolvedConfiguration = 'unresolvedConfiguration', + LaunchNotRepresentable = 'launchNotRepresentable', + EnvironmentFileIgnored = 'environmentFileIgnored', + SandboxConfigurationIgnored = 'sandboxConfigurationIgnored', + DevelopmentModeIgnored = 'developmentModeIgnored', + OAuthClientConfigurationIgnored = 'oauthClientConfigurationIgnored', + DefinitionNotLoaded = 'definitionNotLoaded', + SourceUnknown = 'sourceUnknown', +} + +export type AgentHostMcpServerCompatibility = + | { readonly kind: 'supported' } + | { readonly kind: 'partiallySupported'; readonly reasons: readonly AgentHostMcpSupportReason[] } + | { readonly kind: 'unsupported'; readonly reasons: readonly AgentHostMcpSupportReason[] } + | { readonly kind: 'unknown'; readonly reasons: readonly AgentHostMcpSupportReason[] }; + +/** Client-side enablement, reported independently of Agent Host compatibility. */ +export interface IAgentHostMcpServerEnablement { + readonly enabled: boolean; + readonly state: AgentHostMcpServerEnablementState; +} + +/** The configuration origin used to explain an MCP support result. */ +export interface IAgentHostMcpServerSource { + readonly group: AICustomizationSource | undefined; + readonly kind: AgentHostMcpServerSourceKind; + readonly label: string; + readonly collectionUri: URI | undefined; + readonly definitionLocation: Location | undefined; + readonly remoteAuthority: string | null; + readonly extensionId: string | undefined; + readonly pluginUri: URI | undefined; +} + +/** The independent support dimensions for one MCP server known to the client. */ +export interface IAgentHostMcpServerSupport { + readonly id: string; + readonly name: string; + readonly collectionId: string; + readonly source: IAgentHostMcpServerSource; + /** Whether the client currently enables the server. */ + readonly enablement: IAgentHostMcpServerEnablement; + /** Whether the server belongs to the requested working-directory scope. */ + readonly applicability: AgentHostMcpServerApplicability; + /** Which process is expected to provide the server to the Agent Host. */ + readonly delivery: AgentHostMcpServerDelivery; + /** Whether that delivery preserves the configuration's behavior. */ + readonly compatibility: AgentHostMcpServerCompatibility; +} + +export interface IAgentHostMcpServerSupportAssessment { + readonly servers: readonly IAgentHostMcpServerSupport[]; + /** Whether all lazy MCP collections known to the client have loaded. */ + readonly discoveryComplete: boolean; +} + +export interface IAgentHostMcpServerSupportSnapshot extends IAgentHostMcpServerSupportAssessment { + readonly coverage: IAgentHostMcpServerSupportCoverage; +} + +/** Internal delivery data shared by support reporting and the actual forwarding path. */ +export interface IAgentHostMcpServerDeliveryResolution { + readonly server: IMcpServer; + readonly definition: McpServerDefinition | undefined; + readonly applicability: AgentHostMcpServerApplicability; + readonly delivery: AgentHostMcpServerDelivery; + readonly compatibility: AgentHostMcpServerCompatibility; + readonly source: IAgentHostMcpServerSource; + readonly projectedConfiguration: IMcpServerConfiguration | undefined; +} + +export interface IAgentHostInstalledMcpServer { + readonly id: string; + readonly name: string; + readonly label: string; + readonly configuration: IMcpServerConfiguration; + readonly configPath: IMcpConfigPath | undefined; + readonly sandbox: IMcpSandboxConfiguration | undefined; + readonly runtimeState: McpServerEnablementState | undefined; +} + +export function agentHostProviderHasBuiltInGitHubMcpServer(provider: string): boolean { + return AGENT_HOST_PROVIDERS_WITH_GITHUB_MCP.has(provider); +} + +export async function assessMcpServersForCopilotAgentHost( + servers: readonly IMcpServer[], + configurationResolverService: IConfigurationResolverService, + sessionType: string, + workingDirectories: readonly URI[] | undefined, + lazyCollectionState: LazyCollectionState, +): Promise { + if (!isCopilotCliSessionType(sessionType)) { + return undefined; + } + + const resolved = await resolveMcpServersForAgentHostDelivery(servers, configurationResolverService, sessionType, workingDirectories); + return { + servers: resolved.map(({ server, source, applicability, delivery, compatibility }) => ({ + id: server.definition.id, + name: server.definition.label, + collectionId: server.collection.id, + source, + enablement: getMcpServerEnablement(server), + applicability, + delivery, + compatibility, + })), + discoveryComplete: lazyCollectionState === LazyCollectionState.AllKnown, + }; +} + +export async function mergeInstalledMcpServersIntoAgentHostSupportAssessment( + assessment: IAgentHostMcpServerSupportAssessment, + installedServers: readonly IAgentHostInstalledMcpServer[], + configurationResolverService: IConfigurationResolverService, + workingDirectories: readonly URI[] | undefined, +): Promise { + const installedById = new Map(installedServers.map(server => [server.id, server])); + const servers = assessment.servers.map(server => { + const installed = installedById.get(server.id); + const runtimeState = installed?.runtimeState; + const enablement = getInstalledMcpServerEnablementOverride(runtimeState); + return enablement ? { + ...server, + enablement, + delivery: runtimeState === McpServerEnablementState.DisabledByAccess + ? AgentHostMcpServerDelivery.NotDelivered + : server.delivery, + } : server; + }); + const assessedIds = new Set(servers.map(server => server.id)); + const missingDisabledServers = await Promise.all(installedServers + .filter(server => !assessedIds.has(server.id) && server.runtimeState !== McpServerEnablementState.Enabled) + .map(server => assessDisabledInstalledMcpServer(server, configurationResolverService, workingDirectories))); + return { + ...assessment, + servers: [...servers, ...missingDisabledServers], + }; +} + +export function resolveMcpServersForAgentHostDelivery( + servers: readonly IMcpServer[], + configurationResolverService: IConfigurationResolverService, + sessionType: string, + workingDirectories: readonly URI[] | undefined, +): Promise { + return Promise.all(servers.map(server => resolveMcpServerForAgentHostDelivery(server, configurationResolverService, sessionType, workingDirectories))); +} + +async function resolveMcpServerForAgentHostDelivery( + server: IMcpServer, + configurationResolverService: IConfigurationResolverService, + sessionType: string, + workingDirectories: readonly URI[] | undefined, +): Promise { + const definitions = server.readDefinitions().get(); + const definition = definitions.server; + const collection = definitions.collection; + const source = getMcpServerSource(server, collection, definition); + const applicability = getMcpServerApplicability(collection, source.kind, workingDirectories); + + if (isPluginCollection(server, collection)) { + return createResolution(server, definition, source, applicability, AgentHostMcpServerDelivery.AgentPlugin, supported()); + } + + if (isProviderBuiltInReplacement(server, collection, sessionType)) { + return createResolution(server, definition, source, applicability, AgentHostMcpServerDelivery.ProviderBuiltIn, supported()); + } + + if (!definition?.launch) { + return createResolution(server, definition, source, applicability, AgentHostMcpServerDelivery.Unknown, unknown([AgentHostMcpSupportReason.DefinitionNotLoaded])); + } + + if (source.kind === AgentHostMcpServerSourceKind.WorkspaceDotMcp) { + return createResolution( + server, + definition, + source, + applicability, + applicability === AgentHostMcpServerApplicability.Applicable ? AgentHostMcpServerDelivery.RuntimeDiscovered : deliveryForInapplicable(applicability), + supported(), + ); + } + + if (collection && McpCollectionDefinition.isWorkspaceDiscovered(collection) && !McpCollectionDefinition.isVscodeMcpJson(collection)) { + return createResolution( + server, + definition, + source, + applicability, + AgentHostMcpServerDelivery.NotDelivered, + unsupported([AgentHostMcpSupportReason.UnsupportedSourceLocation]), + ); + } + + let projectedConfiguration = projectMcpServerConfiguration(definition.launch); + if (!projectedConfiguration) { + return createResolution( + server, + definition, + source, + applicability, + AgentHostMcpServerDelivery.NotDelivered, + unsupported([AgentHostMcpSupportReason.LaunchNotRepresentable]), + ); + } + + const unsupportedReasons: AgentHostMcpSupportReason[] = []; + if (collection && McpCollectionDefinition.isVscodeMcpJson(collection)) { + const resolved = await resolveMcpConfigurationForSync(configurationResolverService, definition.variableReplacement?.folder, projectedConfiguration); + if (resolved.kind === 'error') { + unsupportedReasons.push(resolved.reason); + } else { + projectedConfiguration = resolved.configuration; + } + } else { + const unresolvedReason = getUnresolvedConfigurationReason(projectedConfiguration); + if (unresolvedReason) { + unsupportedReasons.push(unresolvedReason); + } + } + + const partialReasons = getPartialSupportReasons(definition.launch, definition.sandboxEnabled, definition.devMode); + const unknownReasons = source.kind === AgentHostMcpServerSourceKind.Unknown ? [AgentHostMcpSupportReason.SourceUnknown] : []; + const delivery = applicability === AgentHostMcpServerApplicability.Applicable + ? AgentHostMcpServerDelivery.ClientForwarded + : deliveryForInapplicable(applicability); + + return { + server, + definition, + source, + applicability, + delivery: unsupportedReasons.length > 0 && collection && McpCollectionDefinition.isVscodeMcpJson(collection) + ? AgentHostMcpServerDelivery.NotDelivered + : delivery, + compatibility: getCompatibility(unsupportedReasons, partialReasons, unknownReasons), + projectedConfiguration, + }; +} + +function createResolution( + server: IMcpServer, + definition: McpServerDefinition | undefined, + source: IAgentHostMcpServerSource, + applicability: AgentHostMcpServerApplicability, + delivery: AgentHostMcpServerDelivery, + compatibility: AgentHostMcpServerCompatibility, +): IAgentHostMcpServerDeliveryResolution { + return { server, definition, source, applicability, delivery, compatibility, projectedConfiguration: undefined }; +} + +function getMcpServerSource(server: IMcpServer, collection: McpCollectionDefinition | undefined, definition: McpServerDefinition | undefined): IAgentHostMcpServerSource { + const kind = getMcpServerSourceKind(server, collection); + return { + group: getMcpServerSourceGroup(kind), + kind, + label: collection?.label ?? server.collection.label, + collectionUri: collection?.presentation?.origin, + definitionLocation: definition?.presentation?.origin, + remoteAuthority: collection?.remoteAuthority ?? null, + extensionId: collection?.source instanceof ExtensionIdentifier ? collection.source.value : undefined, + pluginUri: getPluginUri(server, collection), + }; +} + +function getMcpServerSourceKind(server: IMcpServer, collection: McpCollectionDefinition | undefined): AgentHostMcpServerSourceKind { + const provenanceKind = getMcpCollectionSourceKind(collection?.provenance, collection?.discoverySource); + if (provenanceKind !== undefined) { + return provenanceKind; + } + + const collectionId = collection?.id ?? server.collection.id; + if (collectionId.startsWith(MCP_PLUGIN_COLLECTION_ID_PREFIX)) { + return AgentHostMcpServerSourceKind.AgentPlugin; + } + if (collectionId.startsWith(WORKSPACE_DOT_MCP_COLLECTION_ID_PREFIX)) { + return AgentHostMcpServerSourceKind.WorkspaceDotMcp; + } + if (collectionId.startsWith(CURSOR_WORKSPACE_MCP_COLLECTION_ID_PREFIX)) { + return AgentHostMcpServerSourceKind.CursorWorkspace; + } + if (collectionId.startsWith(`${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}ws`)) { + return AgentHostMcpServerSourceKind.VscodeWorkspaceFolder; + } + if (collectionId === `${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}workspace`) { + return AgentHostMcpServerSourceKind.WorkspaceConfiguration; + } + if (collectionId === `${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}usrlocal`) { + return AgentHostMcpServerSourceKind.UserProfile; + } + if (collectionId === `${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}usrremote`) { + return AgentHostMcpServerSourceKind.RemoteUser; + } + if (collectionId.startsWith(extensionMcpCollectionPrefix) || collection?.source instanceof ExtensionIdentifier) { + return AgentHostMcpServerSourceKind.Extension; + } + return AgentHostMcpServerSourceKind.Unknown; +} + +function getMcpCollectionSourceKind(provenance: McpCollectionProvenance | undefined, discoverySource: ExternalDiscoverySource | undefined): AgentHostMcpServerSourceKind | undefined { + switch (provenance) { + case McpCollectionProvenance.UserProfile: + return AgentHostMcpServerSourceKind.UserProfile; + case McpCollectionProvenance.RemoteUser: + return AgentHostMcpServerSourceKind.RemoteUser; + case McpCollectionProvenance.WorkspaceConfiguration: + return AgentHostMcpServerSourceKind.WorkspaceConfiguration; + case McpCollectionProvenance.WorkspaceFolderConfiguration: + return AgentHostMcpServerSourceKind.VscodeWorkspaceFolder; + case McpCollectionProvenance.WorkspaceDotMcp: + return AgentHostMcpServerSourceKind.WorkspaceDotMcp; + case McpCollectionProvenance.ExternalConfiguration: + return getExternalConfigurationSourceKind(discoverySource); + case McpCollectionProvenance.Extension: + return AgentHostMcpServerSourceKind.Extension; + case McpCollectionProvenance.Plugin: + return AgentHostMcpServerSourceKind.AgentPlugin; + default: + return undefined; + } +} + +function getExternalConfigurationSourceKind(discoverySource: ExternalDiscoverySource | undefined): AgentHostMcpServerSourceKind { + switch (discoverySource) { + case ExternalDiscoverySource.ClaudeDesktop: + return AgentHostMcpServerSourceKind.ClaudeDesktop; + case ExternalDiscoverySource.Windsurf: + return AgentHostMcpServerSourceKind.Windsurf; + case ExternalDiscoverySource.CursorGlobal: + return AgentHostMcpServerSourceKind.CursorUser; + case ExternalDiscoverySource.CursorWorkspace: + return AgentHostMcpServerSourceKind.CursorWorkspace; + default: + return AgentHostMcpServerSourceKind.Unknown; + } +} + +function getMcpServerSourceGroup(kind: AgentHostMcpServerSourceKind): AICustomizationSource | undefined { + switch (kind) { + case AgentHostMcpServerSourceKind.UserProfile: + case AgentHostMcpServerSourceKind.RemoteUser: + case AgentHostMcpServerSourceKind.ClaudeDesktop: + case AgentHostMcpServerSourceKind.Windsurf: + case AgentHostMcpServerSourceKind.CursorUser: + return AICustomizationSources.user; + case AgentHostMcpServerSourceKind.VscodeWorkspaceFolder: + case AgentHostMcpServerSourceKind.WorkspaceConfiguration: + case AgentHostMcpServerSourceKind.WorkspaceDotMcp: + case AgentHostMcpServerSourceKind.CursorWorkspace: + return AICustomizationSources.local; + case AgentHostMcpServerSourceKind.Extension: + return AICustomizationSources.extension; + case AgentHostMcpServerSourceKind.AgentPlugin: + return AICustomizationSources.plugin; + case AgentHostMcpServerSourceKind.Unknown: + return undefined; + } +} + +function getMcpServerApplicability( + collection: McpCollectionDefinition | undefined, + sourceKind: AgentHostMcpServerSourceKind, + workingDirectories: readonly URI[] | undefined, +): AgentHostMcpServerApplicability { + return getMcpConfigurationApplicability(collection?.configTarget, collection?.presentation?.origin, sourceKind, workingDirectories); +} + +function getMcpConfigurationApplicability( + configTarget: ConfigurationTarget | undefined, + origin: URI | undefined, + sourceKind: AgentHostMcpServerSourceKind, + workingDirectories: readonly URI[] | undefined, +): AgentHostMcpServerApplicability { + if (configTarget !== ConfigurationTarget.WORKSPACE && configTarget !== ConfigurationTarget.WORKSPACE_FOLDER) { + return AgentHostMcpServerApplicability.Applicable; + } + if (workingDirectories === undefined) { + return AgentHostMcpServerApplicability.Unknown; + } + if (workingDirectories.length === 0) { + return AgentHostMcpServerApplicability.OutsideCurrentScope; + } + if (sourceKind === AgentHostMcpServerSourceKind.WorkspaceConfiguration) { + return AgentHostMcpServerApplicability.Applicable; + } + return origin && workingDirectories.some(workingDirectory => isEqualOrParent(origin, workingDirectory)) + ? AgentHostMcpServerApplicability.Applicable + : AgentHostMcpServerApplicability.OutsideCurrentScope; +} + +function getMcpServerEnablement(server: IMcpServer): IAgentHostMcpServerEnablement { + switch (server.enablement.get()) { + case ContributionEnablementState.EnabledProfile: + return { enabled: true, state: AgentHostMcpServerEnablementState.EnabledProfile }; + case ContributionEnablementState.EnabledWorkspace: + return { enabled: true, state: AgentHostMcpServerEnablementState.EnabledWorkspace }; + case ContributionEnablementState.DisabledProfile: + return { enabled: false, state: AgentHostMcpServerEnablementState.DisabledProfile }; + case ContributionEnablementState.DisabledWorkspace: + return { enabled: false, state: AgentHostMcpServerEnablementState.DisabledWorkspace }; + } +} + +function getInstalledMcpServerEnablementOverride(runtimeState: McpServerEnablementState | undefined): IAgentHostMcpServerEnablement | undefined { + switch (runtimeState) { + case McpServerEnablementState.Disabled: + return { enabled: false, state: AgentHostMcpServerEnablementState.DisabledNotRegistered }; + case McpServerEnablementState.DisabledByAccess: + return { enabled: false, state: AgentHostMcpServerEnablementState.DisabledByAccess }; + case McpServerEnablementState.DisabledProfile: + return { enabled: false, state: AgentHostMcpServerEnablementState.DisabledProfile }; + case McpServerEnablementState.DisabledWorkspace: + return { enabled: false, state: AgentHostMcpServerEnablementState.DisabledWorkspace }; + case McpServerEnablementState.Enabled: + case undefined: + return undefined; + } +} + +async function assessDisabledInstalledMcpServer( + server: IAgentHostInstalledMcpServer, + configurationResolverService: IConfigurationResolverService, + workingDirectories: readonly URI[] | undefined, +): Promise { + const sourceKind = getMcpConfigurationSourceKind(server.configPath?.target); + const compatibility = await getInstalledMcpServerCompatibility(server, sourceKind, configurationResolverService); + const collectionId = server.configPath + ? `${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}${server.configPath.id}` + : getCollectionIdFromInstalledServer(server); + return { + id: server.id, + name: server.name, + collectionId, + source: { + group: getMcpServerSourceGroup(sourceKind), + kind: sourceKind, + label: server.configPath?.label ?? server.label, + collectionUri: server.configPath?.uri, + definitionLocation: undefined, + remoteAuthority: server.configPath?.remoteAuthority ?? null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: getInstalledMcpServerEnablementOverride(server.runtimeState) ?? { + enabled: false, + state: AgentHostMcpServerEnablementState.DisabledNotRegistered, + }, + applicability: getMcpConfigurationApplicability(server.configPath?.target, server.configPath?.uri, sourceKind, workingDirectories), + delivery: AgentHostMcpServerDelivery.NotDelivered, + compatibility, + }; +} + +function getCollectionIdFromInstalledServer(server: IAgentHostInstalledMcpServer): string { + const nameSuffix = `.${server.name}`; + return server.id.endsWith(nameSuffix) + ? server.id.slice(0, -nameSuffix.length) + : `${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}unknown`; +} + +function getMcpConfigurationSourceKind(configTarget: ConfigurationTarget | undefined): AgentHostMcpServerSourceKind { + return getMcpCollectionSourceKind(getMcpCollectionProvenance(configTarget), undefined) + ?? AgentHostMcpServerSourceKind.Unknown; +} + +async function getInstalledMcpServerCompatibility( + server: IAgentHostInstalledMcpServer, + sourceKind: AgentHostMcpServerSourceKind, + configurationResolverService: IConfigurationResolverService, +): Promise { + if (sourceKind === AgentHostMcpServerSourceKind.WorkspaceConfiguration) { + return unsupported([AgentHostMcpSupportReason.UnsupportedSourceLocation]); + } + const launch = McpServerLaunch.fromServerConfiguration(server.configuration, server.sandbox); + if (!launch) { + return unsupported([AgentHostMcpSupportReason.LaunchNotRepresentable]); + } + const projectedConfiguration = projectMcpServerConfiguration(launch); + if (!projectedConfiguration) { + return unsupported([AgentHostMcpSupportReason.LaunchNotRepresentable]); + } + + const unsupportedReasons: AgentHostMcpSupportReason[] = []; + if (server.configPath?.target === ConfigurationTarget.WORKSPACE_FOLDER) { + const resolved = await resolveMcpConfigurationForSync(configurationResolverService, server.configPath.workspaceFolder, projectedConfiguration); + if (resolved.kind === 'error') { + unsupportedReasons.push(resolved.reason); + } + } else { + const unresolvedReason = getUnresolvedConfigurationReason(projectedConfiguration); + if (unresolvedReason) { + unsupportedReasons.push(unresolvedReason); + } + } + + const partialReasons = getPartialSupportReasons( + launch, + server.configuration.type === McpServerType.LOCAL ? server.configuration.sandboxEnabled : undefined, + server.configuration.dev, + ); + const unknownReasons = sourceKind === AgentHostMcpServerSourceKind.Unknown ? [AgentHostMcpSupportReason.SourceUnknown] : []; + return getCompatibility(unsupportedReasons, partialReasons, unknownReasons); +} + +function isPluginCollection(server: IMcpServer, collection: McpCollectionDefinition | undefined): boolean { + return collection?.provenance === McpCollectionProvenance.Plugin + || server.collection.id.startsWith(MCP_PLUGIN_COLLECTION_ID_PREFIX); +} + +function getPluginUri(server: IMcpServer, collection: McpCollectionDefinition | undefined): URI | undefined { + if (!isPluginCollection(server, collection)) { + return undefined; + } + const collectionId = collection?.id ?? server.collection.id; + return collectionId.startsWith(MCP_PLUGIN_COLLECTION_ID_PREFIX) + ? URI.parse(collectionId.slice(MCP_PLUGIN_COLLECTION_ID_PREFIX.length)) + : undefined; +} + +function isProviderBuiltInReplacement(server: IMcpServer, collection: McpCollectionDefinition | undefined, sessionType: string): boolean { + return hasBuiltInGitHubMcpServer(sessionType) + && server.collection.id === COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID + && collection?.source instanceof ExtensionIdentifier + && ExtensionIdentifier.equals(collection.source, COPILOT_CHAT_EXTENSION_ID); +} + +function hasBuiltInGitHubMcpServer(sessionType: string): boolean { + const localProvider = sessionType.startsWith(LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX) + ? sessionType.slice(LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX.length) + : undefined; + return agentHostProviderHasBuiltInGitHubMcpServer(localProvider ?? ''); +} + +function deliveryForInapplicable(applicability: AgentHostMcpServerApplicability): AgentHostMcpServerDelivery { + return applicability === AgentHostMcpServerApplicability.Unknown + ? AgentHostMcpServerDelivery.Unknown + : AgentHostMcpServerDelivery.NotDelivered; +} + +function projectMcpServerConfiguration(launch: McpServerLaunch): IMcpServerConfiguration | undefined { + switch (launch.type) { + case McpServerTransportType.Stdio: + if (!launch.command) { + return undefined; + } + return { + type: McpServerType.LOCAL, + command: launch.command, + args: launch.args.length > 0 ? [...launch.args] : undefined, + env: Object.keys(launch.env).length > 0 ? { ...launch.env } : undefined, + envFile: launch.envFile, + cwd: launch.cwd, + }; + case McpServerTransportType.HTTP: + return { + type: McpServerType.REMOTE, + url: launch.uri.toString(), + headers: launch.headers.length > 0 ? Object.fromEntries(launch.headers) : undefined, + }; + } +} + +async function resolveMcpConfigurationForSync( + configurationResolverService: IConfigurationResolverService, + folder: IWorkspaceFolderData | undefined, + configuration: IMcpServerConfiguration, +): Promise<{ readonly kind: 'success'; readonly configuration: IMcpServerConfiguration } | { readonly kind: 'error'; readonly reason: AgentHostMcpSupportReason }> { + const expression = ConfigurationResolverExpression.parse(configuration); + const unresolvedReason = getExpressionUnresolvedReason(expression); + if (unresolvedReason === AgentHostMcpSupportReason.RequiresUserInteraction) { + return { kind: 'error', reason: unresolvedReason }; + } + + try { + await configurationResolverService.resolveAsync(folder, expression); + } catch { + return { kind: 'error', reason: AgentHostMcpSupportReason.UnresolvedConfiguration }; + } + + if (!Iterable.isEmpty(expression.unresolved())) { + return { kind: 'error', reason: AgentHostMcpSupportReason.UnresolvedConfiguration }; + } + return { kind: 'success', configuration: expression.toObject() }; +} + +function getUnresolvedConfigurationReason(configuration: IMcpServerConfiguration): AgentHostMcpSupportReason | undefined { + return getExpressionUnresolvedReason(ConfigurationResolverExpression.parse(configuration)); +} + +function getExpressionUnresolvedReason(expression: ConfigurationResolverExpression): AgentHostMcpSupportReason | undefined { + let hasUnresolved = false; + for (const replacement of expression.unresolved()) { + if (replacement.name === 'input' || replacement.name === 'command') { + return AgentHostMcpSupportReason.RequiresUserInteraction; + } + hasUnresolved = true; + } + return hasUnresolved ? AgentHostMcpSupportReason.UnresolvedConfiguration : undefined; +} + +function getPartialSupportReasons(launch: McpServerLaunch, sandboxEnabled: boolean | undefined, devMode: McpServerDefinition['devMode']): AgentHostMcpSupportReason[] { + const reasons: AgentHostMcpSupportReason[] = []; + if (launch.type === McpServerTransportType.Stdio) { + if (launch.envFile) { + reasons.push(AgentHostMcpSupportReason.EnvironmentFileIgnored); + } + if (launch.sandbox || sandboxEnabled) { + reasons.push(AgentHostMcpSupportReason.SandboxConfigurationIgnored); + } + } else if (launch.oauth) { + reasons.push(AgentHostMcpSupportReason.OAuthClientConfigurationIgnored); + } + if (devMode) { + reasons.push(AgentHostMcpSupportReason.DevelopmentModeIgnored); + } + return reasons; +} + +function getCompatibility( + unsupportedReasons: readonly AgentHostMcpSupportReason[], + partialReasons: readonly AgentHostMcpSupportReason[], + unknownReasons: readonly AgentHostMcpSupportReason[], +): AgentHostMcpServerCompatibility { + if (unsupportedReasons.length > 0) { + return unsupported(unsupportedReasons); + } + if (unknownReasons.length > 0) { + return unknown(unknownReasons); + } + if (partialReasons.length > 0) { + return { kind: 'partiallySupported', reasons: partialReasons }; + } + return supported(); +} + +function supported(): AgentHostMcpServerCompatibility { + return { kind: 'supported' }; +} + +function unsupported(reasons: readonly AgentHostMcpSupportReason[]): AgentHostMcpServerCompatibility { + return { kind: 'unsupported', reasons }; +} + +function unknown(reasons: readonly AgentHostMcpSupportReason[]): AgentHostMcpServerCompatibility { + return { kind: 'unknown', reasons }; +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupportScope.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupportScope.ts new file mode 100644 index 00000000000000..394303ba14620a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupportScope.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise, Delayer } from '../../../../../../base/common/async.js'; +import { onUnexpectedError } from '../../../../../../base/common/errors.js'; +import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { mcpAccessConfig, McpAccessValue } from '../../../../../../platform/mcp/common/mcpManagement.js'; +import { COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; +import { isStrictPluginOnlyCustomizationEnabled, StrictPluginOnlyCustomization } from '../../../common/customizationLockdown.js'; +import { IMcpService, IMcpWorkbenchService } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { assessMcpServersForCopilotAgentHost, IAgentHostInstalledMcpServer, IAgentHostMcpServerSupportSnapshot, mergeInstalledMcpServersIntoAgentHostSupportAssessment } from './agentHostMcpServerSupport.js'; + +const MCP_SUPPORT_UPDATE_DEBOUNCE_DELAY = 50; + +/** A refcounted reactive view of MCP support for one Agent Host working-directory scope. */ +export interface IAgentHostMcpServerSupportScope extends IDisposable { + /** The latest complete support snapshot. */ + readonly support: IObservable; + /** Whether the initial support snapshot has resolved. */ + readonly isResolved: IObservable; + /** Resolves after the initial snapshot succeeds or fails. */ + whenResolved(): Promise; +} + +/** Owns MCP support assessment and refreshes it while at least one consumer holds a reference. */ +export class AgentHostMcpServerSupportScope extends Disposable { + private readonly _updateDelayer: Delayer; + private readonly _support = observableValue('agentHostMcpServerSupport', { + servers: [], + discoveryComplete: false, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }); + private readonly _isResolved = observableValue('agentHostMcpServerSupportResolved', false); + private readonly _initialResolution = new DeferredPromise(); + private _refCount = 0; + private _updateSequence = 0; + private _isDisposed = false; + + constructor( + private readonly _sessionType: string, + private readonly _roots: readonly URI[] | undefined, + private readonly _onDispose: () => void, + @IMcpService private readonly _mcpService: IMcpService, + @IMcpWorkbenchService private readonly _mcpWorkbenchService: IMcpWorkbenchService, + @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + this._updateDelayer = this._register(new Delayer(MCP_SUPPORT_UPDATE_DEBOUNCE_DELAY)); + + const update = async () => { + const sequence = ++this._updateSequence; + try { + const lazyState = this._mcpService.lazyCollectionState.get(); + const initialAssessment = await assessMcpServersForCopilotAgentHost( + this._mcpService.servers.get(), + this._configurationResolverService, + this._sessionType, + this._roots, + lazyState.state, + ); + if (!initialAssessment) { + return; + } + await this._mcpWorkbenchService.whenInitialLocalMcpServersLoaded; + const installedServers = this._mcpWorkbenchService.local.flatMap(server => { + const local = server.local; + return local ? [{ + id: local.id, + name: server.name, + label: server.label, + configuration: local.config, + configPath: this._mcpWorkbenchService.getMcpConfigPath(local), + sandbox: local.rootSandbox, + runtimeState: server.runtimeStatus?.state, + } satisfies IAgentHostInstalledMcpServer] : []; + }); + const assessment = await mergeInstalledMcpServersIntoAgentHostSupportAssessment( + initialAssessment, + installedServers, + this._configurationResolverService, + this._roots, + ); + if (sequence !== this._updateSequence) { + return; + } + const access = this._configurationService.getValue(mcpAccessConfig); + const strictPluginOnly = this._configurationService.getValue(COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG); + transaction(tx => { + this._support.set({ + ...assessment, + coverage: { + restrictedByMcpAccess: access !== McpAccessValue.All, + restrictedByCustomizationPolicy: isStrictPluginOnlyCustomizationEnabled(strictPluginOnly), + }, + }, tx); + this._isResolved.set(true, tx); + }); + } catch (error) { + onUnexpectedError(error); + if (sequence === this._updateSequence) { + transaction(tx => this._isResolved.set(true, tx)); + } + } finally { + if (sequence === this._updateSequence && !this._initialResolution.isSettled) { + this._initialResolution.complete(); + } + } + }; + const scheduleUpdate = () => { + this._updateDelayer.trigger(() => update()).catch(() => { /* scope disposed */ }); + }; + + this._register(autorun(reader => { + for (const server of this._mcpService.servers.read(reader)) { + server.readDefinitions().read(reader); + server.enablement.read(reader); + } + this._mcpService.lazyCollectionState.read(reader); + scheduleUpdate(); + })); + this._register(this._mcpWorkbenchService.onChange(scheduleUpdate)); + this._register(this._configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(mcpAccessConfig) || event.affectsConfiguration(COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG)) { + scheduleUpdate(); + } + })); + } + + acquire(): IAgentHostMcpServerSupportScope { + this._refCount++; + let released = false; + return { + support: this._support, + isResolved: this._isResolved, + whenResolved: () => this._initialResolution.p, + dispose: () => { + if (!released) { + released = true; + this._release(); + } + }, + }; + } + + override dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + this._updateSequence++; + if (!this._initialResolution.isSettled) { + this._initialResolution.complete(); + } + super.dispose(); + this._onDispose(); + } + + private _release(): void { + if (--this._refCount === 0) { + this.dispose(); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index c45b229b877a2e..931b7b85cf3ec3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -25,6 +25,7 @@ import { FileSystemProviderCapabilities, IFileService } from '../../../../../pla import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../browser/editor.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; @@ -34,8 +35,9 @@ import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../services/agentHost/comm import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IWorkbenchExtensionManagementService } from '../../../../services/extensionManagement/common/extensionManagement.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSources, getCustomizationMigrationHintDismissedStorageKey, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; @@ -50,10 +52,12 @@ import { AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, + AICustomizationManagementOpenEditorTarget, AICustomizationManagementCommands, AICustomizationManagementItemMenuId, AICustomizationManagementSection, AICustomizationSource, + resolveAICustomizationManagementOpenEditorTarget, } from './aiCustomizationManagement.js'; import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; @@ -745,6 +749,30 @@ class AICustomizationManagementActionsContribution extends Disposable implements } private registerActions(): void { + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: AICustomizationManagementCommands.DismissMigrationHint, + title: localize2('dismissCustomizationMigrationHint', "Don't Show Customization Migration Hints Again"), + f1: false, + }); + } + + run(accessor: ServicesAccessor): void { + const sessionResource = accessor.get(IChatWidgetService).lastFocusedWidget?.viewModel?.sessionResource; + if (!sessionResource) { + throw new Error('Expected an active chat session when dismissing customization migration hints'); + } + const sessionType = getChatSessionType(sessionResource); + accessor.get(IStorageService).store( + getCustomizationMigrationHintDismissedStorageKey(sessionType), + true, + StorageScope.WORKSPACE, + StorageTarget.USER + ); + } + })); + // Open AI Customizations Editor this._register(registerAction2(class extends Action2 { constructor() { @@ -758,23 +786,17 @@ class AICustomizationManagementActionsContribution extends Disposable implements }); } - async run(accessor: ServicesAccessor, target?: AICustomizationManagementSection | { readonly section?: AICustomizationManagementSection; readonly sessionType?: string; readonly revealUri?: URI }): Promise { + async run(accessor: ServicesAccessor, target?: AICustomizationManagementOpenEditorTarget): Promise { const editorService = accessor.get(IEditorService); const chatWidgetService = accessor.get(IChatWidgetService); const harnessService = accessor.get(ICustomizationHarnessService); - const section = typeof target === 'string' ? target : target?.section; - const revealUri = typeof target === 'string' ? undefined : target?.revealUri; - - // Detect the active chat session type and switch the harness - // so the customization editor opens in the matching context. - const explicitSessionType = typeof target === 'string' ? undefined : target?.sessionType; - const widget = explicitSessionType ? undefined : chatWidgetService.lastFocusedWidget; - const pendingSessionType = widget?.input.pendingDelegationTarget; - const sessionResource = explicitSessionType - ? harnessService.getSessionResourceForHarness(explicitSessionType) - : pendingSessionType - ? harnessService.getSessionResourceForHarness(pendingSessionType) - : widget?.viewModel?.sessionResource; + const widget = chatWidgetService.lastFocusedWidget; + const { section, revealUri, sessionResource } = resolveAICustomizationManagementOpenEditorTarget( + target, + widget?.input.pendingDelegationTarget, + widget?.viewModel?.sessionResource, + sessionType => harnessService.getSessionResourceForHarness(sessionType), + ); if (sessionResource) { harnessService.setActiveSession(sessionResource); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index 2adb6d9c029ea0..af4a2a46f65eb6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -4,16 +4,46 @@ *--------------------------------------------------------------------------------------------*/ import { RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IChatViewTitleActionContext, isChatViewTitleActionContext } from '../../common/actions/chatActions.js'; import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; // Re-export for convenience — consumers import from this file -export { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +export { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; export type { AICustomizationSource } from '../../common/aiCustomizationWorkspaceService.js'; export { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; +export type AICustomizationManagementOpenEditorTarget = + | AICustomizationManagementSection + | { + readonly section?: AICustomizationManagementSection; + readonly sessionType?: string; + readonly revealUri?: URI; + } + | IChatViewTitleActionContext; + +export function resolveAICustomizationManagementOpenEditorTarget( + target: AICustomizationManagementOpenEditorTarget | undefined, + pendingSessionType: string | undefined, + chatSessionResource: URI | undefined, + getSessionResourceForHarness: (sessionType: string) => URI, +): { readonly section?: AICustomizationManagementSection; readonly revealUri?: URI; readonly sessionResource?: URI } { + if (isChatViewTitleActionContext(target)) { + return { sessionResource: target.sessionResource }; + } + + const options = typeof target === 'string' ? { section: target } : target; + const sessionType = options?.sessionType ?? pendingSessionType; + return { + section: options?.section, + revealUri: options?.revealUri, + sessionResource: sessionType ? getSessionResourceForHarness(sessionType) : chatSessionResource, + }; +} + export function sectionToPromptType(section: AICustomizationManagementSection): PromptsType { switch (section) { case AICustomizationManagementSection.Agents: @@ -40,19 +70,6 @@ export const AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID = 'workbench.editor.aiCustomi */ export const AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID = 'workbench.input.aiCustomizationManagement'; -/** - * Command IDs for the AI Customizations Management Editor. - */ -export const AICustomizationManagementCommands = { - OpenEditor: 'aiCustomization.openManagementEditor', - OpenMarketplace: 'aiCustomization.openMarketplace', - CreateNewAgent: 'aiCustomization.createNewAgent', - CreateNewSkill: 'aiCustomization.createNewSkill', - CreateNewInstructions: 'aiCustomization.createNewInstructions', - CreateNewPrompt: 'aiCustomization.createNewPrompt', - GenerateDebugReport: 'aiCustomization.generateDebugReport', -} as const; - /** * Context key indicating the AI Customization Management Editor is focused. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationServiceImpl.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationServiceImpl.ts new file mode 100644 index 00000000000000..b37d51b5b4aae3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationServiceImpl.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { isAgentHostSessionResource } from '../../common/chatSessionsService.js'; +import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { CustomizationMigration, CustomizationMigrationType, FileCustomizationMigration, FileCustomizationMigrationType, getCustomizationMigrationTargetType, ICustomizationMigrationService, isPromptFileMigrationCandidate, isUserDataMigrationCandidate, McpServerCustomizationMigration, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { IAgentHostActiveClientService } from '../agentSessions/agentHost/agentHostActiveClientService.js'; +import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; +import { AgentHostMcpServerApplicability } from '../agentSessions/agentHost/agentHostMcpServerSupport.js'; + +export class CustomizationMigrationService implements ICustomizationMigrationService { + declare readonly _serviceBrand: undefined; + + constructor( + @IPromptsService private readonly promptsService: IPromptsService, + @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, + @IAgentHostActiveClientService private readonly activeClientService: IAgentHostActiveClientService, + @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, + ) { } + + computeMigration(sessionResource: URI, type: FileCustomizationMigrationType): Promise; + computeMigration(sessionResource: URI, type: CustomizationMigrationType.McpServers): Promise; + async computeMigration(sessionResource: URI, type: CustomizationMigrationType): Promise { + if (!isAgentHostSessionResource(sessionResource)) { + return type === CustomizationMigrationType.McpServers + ? this.emptyMcpServerMigration() + : { type, files: [], candidates: [] }; + } + + switch (type) { + case CustomizationMigrationType.UserData: { + const customizations = (await Promise.all([ + this.promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None), + this.promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), + ])).flat(); + return this.createFileMigration(sessionResource, type, customizations.filter(isUserDataMigrationCandidate)); + } + case CustomizationMigrationType.PromptFiles: { + const customizations = await this.promptsService.listPromptFiles(PromptsType.prompt, CancellationToken.None); + return this.createFileMigration(sessionResource, type, customizations.filter(isPromptFileMigrationCandidate)); + } + case CustomizationMigrationType.McpServers: + return this.computeMcpServerMigration(sessionResource); + } + } + + async computeMigrations(sessionResource: URI): Promise { + return Promise.all([ + this.computeMigration(sessionResource, CustomizationMigrationType.UserData), + this.computeMigration(sessionResource, CustomizationMigrationType.PromptFiles), + this.computeMigration(sessionResource, CustomizationMigrationType.McpServers), + ]); + } + + async computeMigrationHint(sessionResource: URI): Promise { + const harness = this.customizationHarnessService.findHarnessById(getChatSessionType(sessionResource)); + if (!harness) { + return undefined; + } + + const [userDataMigration, promptFilesMigration, mcpServerMigration] = await Promise.all([ + this.computeMigration(sessionResource, CustomizationMigrationType.UserData), + this.computeMigration(sessionResource, CustomizationMigrationType.PromptFiles), + this.computeMigration(sessionResource, CustomizationMigrationType.McpServers), + ]); + const fileCount = userDataMigration.files.length + promptFilesMigration.files.length; + const unsupportedMcpServerCount = mcpServerMigration.servers.filter(server => !server.supported).length; + const fileHint = fileCount === 0 + ? undefined + : fileCount === 1 + ? localize('customizationMigrationHintSingle', "Found 1 customization file that is present but not used by {0} and could be migrated.", harness.label) + : localize('customizationMigrationHintMultiple', "Found {0} customization files that are present but not used by {1} and could be migrated.", fileCount, harness.label); + const mcpHint = unsupportedMcpServerCount === 0 + ? undefined + : unsupportedMcpServerCount === 1 + ? localize('customizationMigrationHintMcpSingle', "Found 1 MCP server that is not fully supported by {0}.", harness.label) + : localize('customizationMigrationHintMcpMultiple', "Found {0} MCP servers that are not fully supported by {1}.", unsupportedMcpServerCount, harness.label); + if (fileHint && mcpHint) { + return localize('customizationMigrationHintCombined', "{0} {1}", fileHint, mcpHint); + } + return fileHint ?? mcpHint; + } + + private async createFileMigration(sessionResource: URI, type: FileCustomizationMigrationType, candidates: readonly MigratableConfiguration[]): Promise { + const provider = this.customizationHarnessService.findHarnessById(getChatSessionType(sessionResource))?.itemProvider; + if (!provider?.provideSourceFolders) { + return { type, files: [], candidates: [] }; + } + + const targetTypes = new Set(candidates.map(getCustomizationMigrationTargetType)); + const sourceFolders = new Map(); + for (const targetType of targetTypes) { + const folders = await provider.provideSourceFolders(sessionResource, targetType, CancellationToken.None); + sourceFolders.set(targetType, folders ?? []); + } + const filteredCandidates = candidates.filter(customization => { + const targetType = getCustomizationMigrationTargetType(customization); + return sourceFolders.get(targetType)?.some(folder => folder.source === customization.storage) === true; + }); + return { type, files: filteredCandidates.map(customization => customization.uri), candidates: filteredCandidates }; + } + + private async computeMcpServerMigration(sessionResource: URI): Promise { + const roots = this.agentHostCustomizationService.getWorkingDirectories(sessionResource).map(path => URI.file(path)); + const scope = this.activeClientService.acquireMcpServerSupportScope(getChatSessionType(sessionResource), roots); + if (!scope) { + return this.emptyMcpServerMigration(); + } + + try { + await scope.whenResolved(); + const snapshot = scope.support.get(); + return { + type: CustomizationMigrationType.McpServers, + servers: snapshot.servers + .filter(server => server.applicability !== AgentHostMcpServerApplicability.OutsideCurrentScope) + .map(server => ({ + id: server.id, + name: server.name, + supported: server.compatibility.kind === 'supported', + })), + discoveryComplete: snapshot.discoveryComplete, + coverage: snapshot.coverage, + }; + } finally { + scope.dispose(); + } + } + + private emptyMcpServerMigration(): McpServerCustomizationMigration { + return { + type: CustomizationMigrationType.McpServers, + servers: [], + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }; + } +} 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 6ebfd17ba5d7ea..bdacedea2eb6a9 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -253,12 +253,20 @@ .plugin-list-widget > .section-title-header, .plugin-list-widget > .plugin-marketplace-back-container, .plugin-list-widget > .list-search-and-button-container, -.plugin-list-widget > .mcp-list-container { +.plugin-list-widget > .mcp-list-container, +.tools-list-widget > .section-title-header, +.tools-list-widget > .tools-list-search-and-button-container { width: min(100%, 840px); margin-inline: auto; box-sizing: border-box; } +.tools-list-widget > .tools-gallery-container { + width: min(calc(100% - var(--vscode-spacing-size40)), 840px); + margin-inline: auto; + box-sizing: border-box; +} + .ai-customization-management-editor .contributed-section-container { flex: 1 1 auto; min-height: 0; @@ -1057,7 +1065,7 @@ per-word capitalization does not survive translation. */ display: flex; flex-direction: column; overflow: hidden; - padding-bottom: 6px; + padding-bottom: var(--vscode-spacing-size60); } .ai-customization-management-editor .prompt-migration-group { @@ -1112,6 +1120,7 @@ per-word capitalization does not survive translation. */ } .ai-customization-management-editor .prompt-migration-select-all-label { + padding-inline-end: var(--vscode-spacing-size20); font-size: var(--vscode-agents-fontSize-body2); color: var(--vscode-descriptionForeground); cursor: pointer; @@ -1334,7 +1343,7 @@ per-word capitalization does not survive translation. */ display: flex; flex-direction: column; gap: var(--vscode-spacing-size240); - padding-bottom: var(--vscode-spacing-size200); + padding: 0 max(var(--vscode-spacing-size20), calc((100% - 840px) / 2)) var(--vscode-spacing-size200); box-sizing: border-box; } @@ -2632,6 +2641,7 @@ per-word capitalization does not survive translation. */ } .plugin-list-widget .mcp-runtime-status-badge.running { + color: var(--vscode-foreground); background-color: color-mix(in srgb, var(--vscode-charts-green) 18%, transparent); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 4d2729d64eb192..06a2fd57b13e65 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -62,7 +62,7 @@ import { ChatRequestOriginService, IChatRequestOriginService } from '../common/c import { ChatService } from '../common/chatService/chatServiceImpl.js'; import { IChatSessionsService } from '../common/chatSessionsService.js'; import { ChatSideChatService, IChatSideChatService } from '../common/chatSideChatService.js'; -import { BYOKUtilityModelDefault, ChatAIDisabledSettingId, ChatAgentLocation, ChatConfiguration, ChatDefaultPermissionLevel, ChatNotificationMode, ChatPermissionLevel } from '../common/constants.js'; +import { BYOKUtilityModelDefault, ChatAIDisabledSettingId, ChatAgentLocation, ChatConfiguration, ChatDefaultPermissionLevel, CustomizationMigrationHintMode, ChatNotificationMode, ChatPermissionLevel } from '../common/constants.js'; import { CodeMapperService, ICodeMapperService } from '../common/editing/chatCodeMapperService.js'; import { IChatEditingService } from '../common/editing/chatEditingService.js'; import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; @@ -78,7 +78,7 @@ import { AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, CLAUDE_AGENTS_SOURCE_FOLDER import { HOOK_SCHEMA_URI, hookFileSchema } from '../common/promptSyntax/hookSchema.js'; import { AGENT_DOCUMENTATION_URL, AgentHostAgentDebugLogEnabledSettingId, AgentHostAgentDebugLogMaxEventsSettingId, HOOK_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, PromptFileSource, PromptsType, SKILL_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; import { ICustomizationMigrationService } from '../common/promptSyntax/service/customizationMigrationService.js'; -import { CustomizationMigrationService } from '../common/promptSyntax/service/customizationMigrationServiceImpl.js'; +import { CustomizationMigrationService } from './aiCustomization/customizationMigrationServiceImpl.js'; import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js'; @@ -2413,6 +2413,19 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], description: nls.localize('chat.customizations.userDataMigration.enabled', "Controls whether the Chat Customizations editor offers to move agents and instructions stored in user data to the active agent-host harness, which ignores the user data location. When disabled, the migration card and sidebar shortcut are hidden."), default: false, + }, + [ChatConfiguration.ChatCustomizationsMigrationHint]: { + type: 'string', + enum: [CustomizationMigrationHintMode.Never, CustomizationMigrationHintMode.Once, CustomizationMigrationHintMode.Always], + enumDescriptions: [ + nls.localize('chat.customizations.migrationHint.never', "Never show customization migration hints in chat."), + nls.localize('chat.customizations.migrationHint.once', "Show a customization migration hint once per chat session."), + nls.localize('chat.customizations.migrationHint.always', "Show a customization migration hint for every chat request."), + ], + description: nls.localize('chat.customizations.migrationHint', "Controls whether chat shows information about customizations that are not used by the active Agent Host harness and how often it shows them."), + default: CustomizationMigrationHintMode.Never, + tags: ['experimental'], + experiment: { mode: 'auto' }, } } }); @@ -3085,6 +3098,21 @@ class ChatSpeechToTextInitContribution implements IWorkbenchContribution { } } +class CustomizationMigrationHintContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.customizationMigrationHint'; + + constructor( + @IChatService chatService: IChatService, + @ICustomizationMigrationService customizationMigrationService: ICustomizationMigrationService, + ) { + super(); + this._register(chatService.registerCustomizationMigrationHintProvider( + sessionResource => customizationMigrationService.computeMigrationHint(sessionResource) + )); + } +} + AccessibleViewRegistry.register(new ChatTerminalOutputAccessibleView()); AccessibleViewRegistry.register(new ChatResponseAccessibleView()); AccessibleViewRegistry.register(new PanelChatAccessibilityHelp()); @@ -3100,6 +3128,7 @@ Registry.as(EditorExtensions.EditorFactory).registerEdit registerWorkbenchContribution2(CopilotTelemetryContribution.ID, CopilotTelemetryContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSpeechToTextInitContribution.ID, ChatSpeechToTextInitContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(CustomizationMigrationHintContribution.ID, CustomizationMigrationHintContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatDebugResolverContribution.ID, ChatDebugResolverContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(PromptsDebugContribution.ID, PromptsDebugContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts index da5a811ef5e32a..24c23a02120c57 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts @@ -6,7 +6,7 @@ import { distinct } from '../../../../../base/common/arrays.js'; import { IMatch, IFilter, or, matchesCamelCase, matchesWords, matchesBaseContiguousSubString } from '../../../../../base/common/filters.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { getLanguageModelProviderDisplayName, ILanguageModelChatMetadata, ILanguageModelsService, ILanguageModelProviderDescriptor, ILanguageModelChatMetadataAndIdentifier } from '../../../chat/common/languageModels.js'; +import { canHideModel, getLanguageModelProviderDisplayName, ILanguageModelsService, ILanguageModelProviderDescriptor, ILanguageModelChatMetadataAndIdentifier } from '../../../chat/common/languageModels.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; import Severity from '../../../../../base/common/severity.js'; @@ -482,15 +482,9 @@ export class ChatModelsViewModel extends Disposable { if (!metadata) { continue; } - if (vendor.isDefault && metadata.id === 'auto') { - continue; - } - // Agent-host BYOK models are copies of the user's own BYOK models surfaced - // by an agent host (e.g. Copilot CLI). They already appear under their real - // provider group, so listing them again under the agent-host vendor would - // duplicate the entire BYOK catalogue (e.g. hundreds of OpenRouter models - // under "Copilot"). Skip them here. - if (ILanguageModelChatMetadata.getAgentHostByokManageModelsIdentifier(metadata) !== undefined) { + // Models with no toggle of their own are not listed here. Listing the + // agent-host BYOK copies would also duplicate the whole BYOK catalogue. + if (!canHideModel(identifier, metadata)) { continue; } const sourcePresentation = metadata.modelGroup?.sourceId diff --git a/src/vs/workbench/contrib/chat/browser/chatRequestText.ts b/src/vs/workbench/contrib/chat/browser/chatRequestText.ts new file mode 100644 index 00000000000000..3695bc125901cc --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatRequestText.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatRequestViewModel } from '../common/model/chatViewModel.js'; +import { getAgentMergeRequestLabel } from './widget/chatContentParts/chatAgentMergeContentPart.js'; + +/** + * Text that stands for a request wherever its row is described rather than + * rendered — the timeline, transcript find and screen readers. A request whose + * row shows something other than its own text (an Agent Merge turn renders a + * summary of its machine-facing state block) contributes its own label; every + * other request keeps its message text. Further kinds add a check of their own. + */ +export function getChatRequestText(item: IChatRequestViewModel): string { + const agentMergeLabel = getAgentMergeRequestLabel(item); + if (agentMergeLabel !== undefined) { + return agentMergeLabel; + } + return item.messageText; +} diff --git a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts index 0d593c063a2215..d1b499919528e7 100644 --- a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts +++ b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts @@ -16,6 +16,7 @@ import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiff import { MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; import { IMultiDiffEditorOptions } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { ChatWidget } from '../widget/chatWidget.js'; +import { getChatRequestText } from '../chatRequestText.js'; import { ChatTreeItem } from '../chat.js'; import { IChatResponseFileChangesService } from '../chatResponseFileChangesService.js'; import { IChatEditingService, IEditSessionEntryDiff } from '../../common/editing/chatEditingService.js'; @@ -225,6 +226,9 @@ export class PromptTimelineModel extends Disposable { /** Per-item content-signal cache (id -> {version, signal}) for height estimation; version invalidates on content growth. */ private readonly _signalCache = new Map(); + /** Per-request preview cache (id -> {messageText, preview}); recognizing an Agent Merge turn parses its whole state block, and `_recompute` runs on every streamed token. */ + private readonly _previewCache = new Map(); + constructor( private readonly widget: ChatWidget, @IChatEditingService private readonly chatEditingService: IChatEditingService, @@ -360,9 +364,25 @@ export class PromptTimelineModel extends Disposable { return 1; } + /** + * Preview of a prompt, which for a request rendered as something other than + * its own text (an Agent Merge turn) previews that stand-in label instead. + */ + private _requestPreview(item: IChatRequestViewModel): string { + const messageText = item.messageText; + const cached = this._previewCache.get(item.id); + if (cached && cached.messageText === messageText) { + return cached.preview; + } + const preview = getPromptPreview(getChatRequestText(item)); + this._previewCache.set(item.id, { messageText, preview }); + return preview; + } + private _bindViewModel(): void { - // Different session's items have unrelated ids; drop stale signal estimates. + // Different session's items have unrelated ids; drop stale per-item caches. this._signalCache.clear(); + this._previewCache.clear(); this._viewModelListener.value = this.widget.viewModel?.onDidChange(() => this._recompute()); this._recompute(); } @@ -371,7 +391,7 @@ export class PromptTimelineModel extends Disposable { const prompts: PromptItem[] = []; for (const item of this.widget.viewModel?.getItems() ?? []) { if (isPromptTimelineRequest(item)) { - prompts.push({ requestId: item.id, text: getPromptPreview(item.messageText), timestamp: item.timestamp }); + prompts.push({ requestId: item.id, text: this._requestPreview(item), timestamp: item.timestamp }); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindModel.ts b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindModel.ts index 022402c01096d7..af9dad57b1ca25 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindModel.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindModel.ts @@ -11,7 +11,7 @@ import { getChatFindTextParts } from './chatFindContent.js'; import { isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { annotateSpecialMarkdownContentWithSource } from '../../../common/widget/annotations.js'; import { moveResponseOutcomeToolsAfterFinalResponse } from '../chatListRenderer.js'; -import { getAgentMergeRequestLabel } from '../chatContentParts/chatAgentMergeContentPart.js'; +import { getChatRequestText } from '../../chatRequestText.js'; /** Upper bound on tracked matches, mirroring `LIMIT_FIND_COUNT` in `textModelSearch.ts`, so a pathological regex can't pin the UI. */ export const MAX_FIND_MATCHES = 9999; @@ -61,10 +61,10 @@ function buildSegments(items: readonly ChatTreeItem[]): IChatFindSegment[] { const segments: IChatFindSegment[] = []; for (const item of items) { if (isRequestVM(item)) { - // An Agent Merge request renders a summary widget instead of its own - // text, so index that summary: the protocol block behind it is never - // in the DOM and a match there could not be revealed. - const text = getAgentMergeRequestLabel(item) ?? item.messageText; + // Index the text the row actually displays: a request rendered as a + // widget (an Agent Merge summary) keeps its own text out of the DOM, + // where a match could not be revealed. + const text = getChatRequestText(item); if (text && text.trim().length > 0) { segments.push({ itemId: item.id, itemKind: 'request', partIndex: -1, text }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindWidget.ts index ebab92852cc692..abe776b7a9bd27 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindWidget.ts @@ -345,7 +345,6 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl this._revealScheduler.clear(); this._repaintScheduler.clear(); this._clearHighlights(); - this._model.clear(); this._restoreFocus(); } @@ -410,10 +409,16 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl } next(): void { + if (!this.visible) { + this.show(undefined, false); + } this.find(false); } previous(): void { + if (!this.visible) { + this.show(undefined, false); + } this.find(true); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 2ad258ef0cfabf..8fa6099b855ed4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -572,6 +572,7 @@ export class ChatListWidget extends Disposable { expandOnlyOnTwistieClick: true, allowNonCollapsibleParents: true, renderIndentGuides: RenderIndentGuides.None, + findWidgetEnabled: false, accessibilityProvider: this.instantiationService.createInstance(ChatAccessibilityProvider), keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: ChatTreeItem) => diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 6d7b87f68922bd..9e3aa724b20472 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -51,6 +51,23 @@ export const AICustomizationManagementSection = { export type AICustomizationManagementSection = typeof AICustomizationManagementSection[keyof typeof AICustomizationManagementSection]; +/** Command IDs for the AI Customizations Management Editor. */ +export const AICustomizationManagementCommands = { + OpenEditor: 'aiCustomization.openManagementEditor', + OpenMarketplace: 'aiCustomization.openMarketplace', + CreateNewAgent: 'aiCustomization.createNewAgent', + CreateNewSkill: 'aiCustomization.createNewSkill', + CreateNewInstructions: 'aiCustomization.createNewInstructions', + CreateNewPrompt: 'aiCustomization.createNewPrompt', + GenerateDebugReport: 'aiCustomization.generateDebugReport', + DismissMigrationHint: 'aiCustomization.dismissMigrationHint', +} as const; + +/** Returns the workspace storage key for a harness-specific migration hint dismissal. */ +export function getCustomizationMigrationHintDismissedStorageKey(sessionType: string): string { + return `chat.customizationMigrationHint.dismissed.${sessionType}`; +} + /** * Per-type filter policy controlling which storage sources are visible * for a given customization type. diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index 6548ac8ec62063..89727f98189d8c 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -14,6 +14,7 @@ import { IOffsetRange } from '../../../../../editor/common/core/ranges/offsetRan import { isLocation, Location, SymbolKind } from '../../../../../editor/common/languages.js'; import { localize } from '../../../../../nls.js'; import { MarkerSeverity, IMarker } from '../../../../../platform/markers/common/markers.js'; +import type { IFeedbackPullRequest } from '../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { ISCMHistoryItem } from '../../../scm/common/history.js'; import { IChatContentReference } from '../chatService/chatService.js'; import { IChatRequestVariableValue } from './chatVariables.js'; @@ -549,6 +550,8 @@ export interface IAgentFeedbackVariableEntry extends IBaseChatRequestVariableEnt readonly diffHunks?: string; /** When this item was converted from a PR review comment, the original thread ID. */ readonly sourcePRReviewCommentId?: string; + /** Pull request that originated this PR review comment. */ + readonly sourcePullRequest?: IFeedbackPullRequest; /** Additional replies that belong to the same comment thread as {@link text}. */ readonly replies?: readonly string[]; }>; diff --git a/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts b/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts index a9461317aca6a7..a16fe115767740 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts @@ -7,9 +7,9 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { IAutomationDescriptor } from './automation.js'; import { ICreateAutomationOptions, IUpdateAutomationOptions } from './automationService.js'; -export interface IShowAutomationDialogOptions { - readonly existing?: IAutomationDescriptor; -} +export type IShowAutomationDialogOptions = + | { readonly existing: IAutomationDescriptor; readonly initialValues?: never } + | { readonly existing?: never; readonly initialValues?: ICreateAutomationOptions }; export type IAutomationDialogResult = | { readonly kind: 'create'; readonly value: ICreateAutomationOptions } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index f82b3e2e7a656c..0612938936a16b 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -10,7 +10,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IStringDictionary } from '../../../../../base/common/collections.js'; import { Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; -import { DisposableStore, IReference } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, IReference } from '../../../../../base/common/lifecycle.js'; import { autorun, autorunSelfDisposable, IObservable, IReader } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { hasKey } from '../../../../../base/common/types.js'; @@ -1984,6 +1984,8 @@ export interface IChatService { readonly onDidCreateModel: Event; + registerCustomizationMigrationHintProvider(provider: (sessionResource: URI) => Promise): IDisposable; + /** * An observable containing all live chat models. */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 08ba8836c7265a..7ad78f421d5d04 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -9,9 +9,10 @@ import { IStringDictionary } from '../../../../../base/common/collections.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { BugIndicatingError, ErrorNoTelemetry } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { createMarkdownCommandLink, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; -import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { equals } from '../../../../../base/common/objects.js'; @@ -52,7 +53,7 @@ import { IChatTransferService } from '../model/chatTransferService.js'; import { chatSessionResourceToId, getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../model/chatUri.js'; import { ChatRequestVariableSet, IChatRequestVariableEntry, isExplicitFileOrImageVariableEntry, isPromptTextVariableEntry } from '../attachments/chatVariableEntries.js'; import { IDynamicVariable } from '../attachments/chatVariables.js'; -import { ChatAgentLocation, SessionTypeSelectionReason, ChatConfiguration, ChatModeKind } from '../constants.js'; +import { ChatAgentLocation, SessionTypeSelectionReason, ChatConfiguration, ChatModeKind, CustomizationMigrationHintMode } from '../constants.js'; import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../languageModels.js'; import { ModelSelectionReason } from '../modelSelection.js'; import { ILanguageModelToolsService, ToolAndToolSetEnablementMap } from '../tools/languageModelToolsService.js'; @@ -63,8 +64,10 @@ import { ChatRequestHooks, mergeHooks } from '../promptSyntax/hookSchema.js'; import { ComputeAutomaticInstructions } from '../promptSyntax/computeAutomaticInstructions.js'; import { findLast } from '../../../../../base/common/arraysFind.js'; import { ChatMode } from '../chatModes.js'; +import { AICustomizationManagementCommands, getCustomizationMigrationHintDismissedStorageKey } from '../aiCustomizationWorkspaceService.js'; const serializedChatKey = 'interactive.sessions'; +const customizationMigrationHintShownStateKey = 'customizationMigrationHintShown'; /** * True when the user has typed text or attached non-trivial context to the input @@ -219,6 +222,7 @@ export class ChatService extends Disposable implements IChatService { private readonly _sessionFollowupCancelTokens = this._register(new DisposableResourceMap()); private readonly _chatServiceTelemetry: ChatServiceTelemetry; private readonly _chatSessionStore: ChatSessionStore; + private _customizationMigrationHintProvider: ((sessionResource: URI) => Promise) | undefined; readonly requestInProgressObs: IObservable; @@ -238,6 +242,19 @@ export class ChatService extends Disposable implements IChatService { return this._sessionModels.waitForModelDisposals(); } + registerCustomizationMigrationHintProvider(provider: (sessionResource: URI) => Promise): IDisposable { + if (this._customizationMigrationHintProvider) { + throw new BugIndicatingError('A customization migration hint provider is already registered'); + } + + this._customizationMigrationHintProvider = provider; + return toDisposable(() => { + if (this._customizationMigrationHintProvider === provider) { + this._customizationMigrationHintProvider = undefined; + } + }); + } + private get isEmptyWindow(): boolean { const workspace = this.workspaceContextService.getWorkspace(); return !workspace.configuration && workspace.folders.length === 0; @@ -1560,6 +1577,30 @@ export class ChatService extends Disposable implements IChatService { return { hooks: collectedHooks, hasDisabledClaudeHooks }; }; + const collectCustomizationMigrationHint = async (): Promise => { + const sessionType = getChatSessionType(sessionResource); + const hintMode = this.configurationService.getValue(ChatConfiguration.ChatCustomizationsMigrationHint); + const hintAlreadyShown = model.inputModel.state.get()?.contrib[customizationMigrationHintShownStateKey] === true; + if (!isAgentHostTarget(sessionType) + || (hintMode !== CustomizationMigrationHintMode.Once && hintMode !== CustomizationMigrationHintMode.Always) + || this.storageService.getBoolean(getCustomizationMigrationHintDismissedStorageKey(sessionType), StorageScope.WORKSPACE) + || (hintMode === CustomizationMigrationHintMode.Once && hintAlreadyShown) + || !this._customizationMigrationHintProvider) { + return undefined; + } + + try { + const message = await this._customizationMigrationHintProvider(sessionResource); + if (message && !token.isCancellationRequested) { + return message; + } + return undefined; + } catch (error) { + this.logService.warn('[ChatService] Failed to compute customization migration hint:', error); + return undefined; + } + }; + // Collect automatic instructions (.instructions.md, skills, etc.) const collectInstructions = async (): Promise => { const ctx = options?.instructionContext; @@ -1624,10 +1665,11 @@ export class ChatService extends Disposable implements IChatService { const thisRequest = request; completeResponseCreated(); - // --- Step 2: Collect hooks + instructions in parallel (after UI is shown) --- - const [hooksResult, instructionEntries] = await Promise.all([ + // --- Step 2: Collect request context and hints in parallel (after UI is shown) --- + const [hooksResult, instructionEntries, customizationMigrationHint] = await Promise.all([ collectHooks(), collectInstructions(), + collectCustomizationMigrationHint(), ]); const collectedHooks = hooksResult.hooks; const hasDisabledClaudeHooks = hooksResult.hasDisabledClaudeHooks; @@ -1759,6 +1801,37 @@ export class ChatService extends Disposable implements IChatService { } } + const hintMode = this.configurationService.getValue(ChatConfiguration.ChatCustomizationsMigrationHint); + const hintAlreadyShown = model.inputModel.state.get()?.contrib[customizationMigrationHintShownStateKey] === true; + if (customizationMigrationHint + && !token.isCancellationRequested + && (hintMode !== CustomizationMigrationHintMode.Once || !hintAlreadyShown)) { + if (hintMode === CustomizationMigrationHintMode.Once) { + model.inputModel.setState({ + contrib: { ...model.inputModel.state.get()?.contrib, [customizationMigrationHintShownStateKey]: true } + }); + } + + const reviewLink = createMarkdownCommandLink({ + id: AICustomizationManagementCommands.OpenEditor, + text: localize('customizationMigrationHint.review', "Review customizations"), + tooltip: localize('customizationMigrationHint.review.tooltip', "Open Chat Customizations"), + }); + const dismissLink = createMarkdownCommandLink({ + id: AICustomizationManagementCommands.DismissMigrationHint, + text: localize('customizationMigrationHint.dismiss', "Hide for this workspace"), + tooltip: localize('customizationMigrationHint.dismiss.tooltip', "Stop Showing Migration Hints for This Harness"), + }); + progressCallback([{ + kind: 'systemNotification', + content: new MarkdownString( + localize('customizationMigrationHint', "*{0} {1} | {2}*", customizationMigrationHint, reviewLink, dismissLink), + { isTrusted: { enabledCommands: [AICustomizationManagementCommands.OpenEditor, AICustomizationManagementCommands.DismissMigrationHint] } } + ), + icon: Codicon.info, + }]); + } + // Check for disabled Claude Code hooks and notify the user once per workspace. // Only set the flag when actually showing the hint, so the setup agent flow // (which may resend requests) doesn't consume the flag before the real request runs. diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 227a080c682eb9..55f4569cd57688 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -26,6 +26,12 @@ export const enum BYOKUtilityModelDefault { Copilot = 'copilot', } +export const enum CustomizationMigrationHintMode { + Never = 'never', + Once = 'once', + Always = 'always', +} + export enum ChatConfiguration { PluginsEnabled = 'chat.plugins.enabled', PluginLocations = 'chat.pluginLocations', @@ -97,6 +103,7 @@ export enum ChatConfiguration { ChatCustomizationsStructuredPreviewEnabled = 'chat.customizations.structuredPreview.enabled', ChatCustomizationsPromptMigrationEnabled = 'chat.customizations.promptMigration.enabled', ChatCustomizationsUserDataMigrationEnabled = 'chat.customizations.userDataMigration.enabled', + ChatCustomizationsMigrationHint = 'chat.customizations.migrationHint', AutopilotAdvancedEnabled = 'chat.autopilot.advanced.enabled', DefaultPermissionLevel = 'chat.permissions.default', AssistedPermissionsEnabled = 'chat.assistedPermissions.enabled', diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index b47809d7596efe..cdd3b62f1d7064 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -686,6 +686,7 @@ export interface ILanguageModelsService { /** * Hide or show multiple exact model identifiers in the chat model picker. + * Models with no row in the Manage Language Models editor cannot be hidden. */ setModelsHidden(modelIdentifiers: readonly string[], hidden: boolean): void; @@ -695,7 +696,8 @@ export interface ILanguageModelsService { setGroupHidden(vendor: string, groupName: string, hidden: boolean): void; /** - * Returns the persisted per-model hidden identifiers. + * Returns the persisted per-model hidden identifiers. May include models that + * cannot be hidden — use {@link isModelHidden} to test a single model. */ getHiddenModelIds(): string[]; @@ -897,6 +899,24 @@ export function isAutoLanguageModel(model: ILanguageModelChatMetadataAndIdentifi return model?.metadata.id === AUTO_RAW_MODEL_ID || model?.identifier === AUTO_MODEL_IDENTIFIER; } +/** + * Whether a model can be hidden from the picker. The default provider's `Auto` and + * agent-host BYOK copies have no row in Manage Language Models, so hiding them would + * be permanent. `metadata` is undefined before models resolve, hence the id check. + */ +export function canHideModel(identifier: string, metadata: ILanguageModelChatMetadata | undefined): boolean { + if (identifier === AUTO_MODEL_IDENTIFIER) { + return false; + } + if (!metadata) { + return true; + } + if (metadata.vendor === COPILOT_VENDOR_ID && metadata.id === AUTO_RAW_MODEL_ID) { + return false; + } + return ILanguageModelChatMetadata.getAgentHostByokManageModelsIdentifier(metadata) === undefined; +} + const CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY = 'chat.participantNameRegistry'; const CHAT_MODELS_CONTROL_STORAGE_KEY = 'chat.modelsControl'; @@ -2377,15 +2397,8 @@ export class LanguageModelsService implements ILanguageModelsService { const name = g.group?.name ?? fallbackName; if (name === groupName) { for (const id of g.modelIdentifiers) { - // Exclude agent-host BYOK copies. They are not shown as rows in this - // group (they surface under their real provider), so group-level - // visibility toggles (`isGroupHidden` / `setGroupHidden`) must not - // touch them — otherwise hiding the agent-host group would flip the - // hidden state of these copies in the underlying model set even though - // the UI never lists them here. Their visibility is owned by the real - // provider row and honoured in the picker via the reconstructed id. - const metadata = this._modelCache.get(id); - if (metadata && ILanguageModelChatMetadata.getAgentHostByokManageModelsIdentifier(metadata) !== undefined) { + // Group toggles only own the models that have a row of their own. + if (!canHideModel(id, this._modelCache.get(id))) { continue; } result.push(id); @@ -2415,7 +2428,11 @@ export class LanguageModelsService implements ILanguageModelsService { } isModelHidden(modelIdentifier: string): boolean { - return this._hiddenModelIds.has(modelIdentifier); + if (!this._hiddenModelIds.has(modelIdentifier)) { + return false; + } + // Ignore entries an older version persisted for models that have no toggle. + return canHideModel(modelIdentifier, this._modelCache.get(modelIdentifier)); } setGroupHidden(vendor: string, groupName: string, hidden: boolean): void { @@ -2430,6 +2447,10 @@ export class LanguageModelsService implements ILanguageModelsService { let changed = false; for (const id of modelIdentifiers) { if (hidden) { + // Showing is always allowed so stale state can still be cleared. + if (!canHideModel(id, this._modelCache.get(id))) { + continue; + } if (!this._hiddenModelIds.has(id)) { this._hiddenModelIds.add(id); changed = true; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts index 8e7b3d89a1fe35..c772bce4565a70 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts @@ -13,6 +13,7 @@ export const ICustomizationMigrationService = createDecorator; + computeMigration(sessionResource: URI, type: FileCustomizationMigrationType): Promise; + computeMigration(sessionResource: URI, type: CustomizationMigrationType.McpServers): Promise; computeMigrations(sessionResource: URI): Promise; computeMigrationHint(sessionResource: URI): Promise; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationServiceImpl.ts deleted file mode 100644 index a6ebb411ed3414..00000000000000 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationServiceImpl.ts +++ /dev/null @@ -1,87 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { localize } from '../../../../../../nls.js'; -import { isAgentHostSessionResource } from '../../chatSessionsService.js'; -import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../customizationHarnessService.js'; -import { getChatSessionType } from '../../model/chatUri.js'; -import { PromptsType } from '../promptTypes.js'; -import { IPromptsService } from './promptsService.js'; -import { CustomizationMigration, CustomizationMigrationType, getCustomizationMigrationTargetType, ICustomizationMigrationService, isPromptFileMigrationCandidate, isUserDataMigrationCandidate, MigratableConfiguration } from './customizationMigrationService.js'; - -export class CustomizationMigrationService implements ICustomizationMigrationService { - declare readonly _serviceBrand: undefined; - - constructor( - @IPromptsService private readonly promptsService: IPromptsService, - @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, - ) { } - - async computeMigration(sessionResource: URI, type: CustomizationMigrationType): Promise { - if (!isAgentHostSessionResource(sessionResource)) { - return { type, files: [], candidates: [] }; - } - - switch (type) { - case CustomizationMigrationType.UserData: { - const customizations = (await Promise.all([ - this.promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None), - this.promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), - ])).flat(); - const candidates = customizations.filter(isUserDataMigrationCandidate); - return this.createMigration(sessionResource, type, candidates); - } - case CustomizationMigrationType.PromptFiles: { - const customizations = await this.promptsService.listPromptFiles(PromptsType.prompt, CancellationToken.None); - const candidates = customizations.filter(isPromptFileMigrationCandidate); - return this.createMigration(sessionResource, type, candidates); - } - } - } - - private async createMigration(sessionResource: URI, type: CustomizationMigrationType, candidates: readonly MigratableConfiguration[]): Promise { - const provider = this.customizationHarnessService.findHarnessById(getChatSessionType(sessionResource))?.itemProvider; - if (!provider?.provideSourceFolders) { - return { type, files: [], candidates: [] }; - } - - const targetTypes = new Set(candidates.map(getCustomizationMigrationTargetType)); - const sourceFolders = new Map(); - for (const targetType of targetTypes) { - const folders = await provider.provideSourceFolders(sessionResource, targetType, CancellationToken.None); - sourceFolders.set(targetType, folders ?? []); - } - const filteredCandidates = candidates.filter(customization => { - const targetType = getCustomizationMigrationTargetType(customization); - return sourceFolders.get(targetType)?.some(folder => folder.source === customization.storage) === true; - }); - return { type, files: filteredCandidates.map(customization => customization.uri), candidates: filteredCandidates }; - } - - async computeMigrations(sessionResource: URI): Promise { - return Promise.all([ - this.computeMigration(sessionResource, CustomizationMigrationType.UserData), - this.computeMigration(sessionResource, CustomizationMigrationType.PromptFiles), - ]); - } - - async computeMigrationHint(sessionResource: URI): Promise { - const harness = this.customizationHarnessService.findHarnessById(getChatSessionType(sessionResource)); - if (!harness) { - return undefined; - } - - const migrations = await this.computeMigrations(sessionResource); - const fileCount = migrations.reduce((total, migration) => total + migration.files.length, 0); - if (fileCount === 0) { - return undefined; - } - return fileCount === 1 - ? localize('customizationMigrationHintSingle', "Found 1 customization file that is present but not used by {0} and could be migrated.", harness.label) - : localize('customizationMigrationHintMultiple', "Found {0} customization files that are present but not used by {1} and could be migrated.", fileCount, harness.label); - } -} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 23183977146db7..7447e071477f76 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -171,6 +171,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const effectiveSubAgentName = subAgentName ?? currentModeInstructions?.name; if (subAgentName) { + this.validateSubagentAllowed(subAgentName, currentModeInstructions); subagent = await this.getSubAgentByName(subAgentName); if (subagent) { // Check the pre-resolved model cache from prepareToolInvocation @@ -204,8 +205,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { modeInstructions = instructions && { name: subAgentName, content: instructions.content, - toolReferences: this.languageModelToolsService.toToolReferences(instructions.toolReferences), - allowedSubagents: undefined, + toolReferences: instructions.toolReferences.length ? this.languageModelToolsService.toToolReferences(instructions.toolReferences) : [], + allowedSubagents: subagent.agents, metadata: instructions.metadata, isBuiltin: isBuiltinAgent(subagent.source, subagent.uri, this.productService), }; @@ -302,7 +303,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const variableSet = new ChatRequestVariableSet(); // When the extension is responsible for instruction collection, skip the core path entirely. if (this.configurationService.getValue(ChatConfiguration.CollectInstructionsInExtension) !== true) { - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined, getChatSessionType(invocation.context.sessionResource)); + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, modeInstructions?.allowedSubagents, getChatSessionType(invocation.context.sessionResource)); await computer.collect(variableSet, token); } @@ -552,9 +553,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const args = context.parameters as IRunSubagentToolInputParams; const requestedAgentName = this.normalizeRequestedAgentName(args.agentName); + const currentModeInstructions = context.chatSessionResource ? this.getCurrentModeInstructions(context.chatSessionResource) : undefined; + if (requestedAgentName) { + this.validateSubagentAllowed(requestedAgentName, currentModeInstructions); + } const subagent = requestedAgentName ? await this.getSubAgentByName(requestedAgentName) : undefined; - const currentModeInstructions = context.chatSessionResource ? this.getCurrentModeInstructions(context.chatSessionResource) : undefined; // Resolve the model early and cache it for invoke() const resolved = this.resolveSubagentModel(subagent, context.modelId, args.model); @@ -584,4 +588,11 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const model = this.chatService.getSession(sessionResource) as ChatModel | undefined; return model?.getRequests().at(-1)?.modeInfo?.modeInstructions; } + + private validateSubagentAllowed(subAgentName: string, currentModeInstructions: IChatRequestModeInstructions | undefined): void { + const allowedSubagents = currentModeInstructions?.allowedSubagents; + if (allowedSubagents && !allowedSubagents.includes('*') && !allowedSubagents.includes(subAgentName)) { + throw new Error(`Requested agent '${subAgentName}' is not allowed by the current agent.`); + } + } } diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/installDictationModelAction.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/installDictationModelAction.ts index c8548e5a0670e5..d21bc583e9e397 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/installDictationModelAction.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/installDictationModelAction.ts @@ -55,7 +55,6 @@ export function registerInstallDictationModelAction(): void { canSelectFiles: true, canSelectFolders: true, canSelectMany: false, - filters: [{ name: localize('chat.installDictationModel.filter', "Foundry Local Model Package"), extensions: ['zip'] }], }); const source = sources?.[0]; if (!source) { diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatFindActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatFindActions.test.ts index f3b55e529f1dbe..a89f8f553c2dd8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatFindActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatFindActions.test.ts @@ -8,9 +8,10 @@ import { getActiveDocument } from '../../../../../../base/browser/dom.js'; import { OS } from '../../../../../../base/common/platform.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { EditorContextKeys } from '../../../../../../editor/common/editorContextKeys.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../../platform/contextkey/browser/contextKeyService.js'; -import { KeybindingResolver } from '../../../../../../platform/keybinding/common/keybindingResolver.js'; +import { KeybindingResolver, ResultKind } from '../../../../../../platform/keybinding/common/keybindingResolver.js'; import { KeybindingsRegistry } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ResolvedKeybindingItem } from '../../../../../../platform/keybinding/common/resolvedKeybindingItem.js'; import { USLayoutResolvedKeybinding } from '../../../../../../platform/keybinding/common/usLayoutResolvedKeybinding.js'; @@ -88,4 +89,43 @@ suite('Chat Find keybinding resolution', () => { ctxService.dispose(); }); + + test('F3 finds next and Shift+F3 finds previous while transcript Find is hidden', () => { + const config = new TestConfigurationService(); + const ctxService = new ContextKeyService(config); + const overlay = ctxService.createOverlay([ + [ChatContextKeys.findSupported.key, true], + [ChatContextKeys.inChatSession.key, true], + ]); + const resolver = buildResolverForCommands([ChatFindCommandId.FindNext, ChatFindCommandId.FindPrevious]); + const context = overlay.getContext(null); + const next = resolver.resolve(context, [], 'F3'); + const previous = resolver.resolve(context, [], 'shift+F3'); + + assert.deepStrictEqual({ + next: next.kind === ResultKind.KbFound ? next.commandId : undefined, + previous: previous.kind === ResultKind.KbFound ? previous.commandId : undefined, + }, { + next: ChatFindCommandId.FindNext, + previous: ChatFindCommandId.FindPrevious, + }); + + ctxService.dispose(); + }); + + test('F3 does not override Find Next in an embedded editor', () => { + const config = new TestConfigurationService(); + const ctxService = new ContextKeyService(config); + const overlay = ctxService.createOverlay([ + [ChatContextKeys.findSupported.key, true], + [ChatContextKeys.inChatSession.key, true], + [EditorContextKeys.focus.key, true], + ]); + const resolver = buildResolverForCommands([ChatFindCommandId.FindNext]); + const result = resolver.resolve(overlay.getContext(null), [], 'F3'); + + assert.strictEqual(result.kind === ResultKind.KbFound ? result.commandId : undefined, undefined); + + ctxService.dispose(); + }); }); 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 830e5aa4acf663..b471cf3b72e1aa 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 @@ -1000,6 +1000,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv getSyncProvider: () => syncProvider, getOrigin: () => undefined, acquireScope, + acquireMcpServerSupportScope: () => undefined, areScopeRootsEqual: (first, second) => JSON.stringify(first) === JSON.stringify(second), isBundledMcpServer: () => false, }; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index c38792400de2b1..b5ee6515c69905 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -51,6 +51,7 @@ import { MockLabelService } from '../../../../../services/label/test/common/mock import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IAgentHostImportConversationStore } from '../../../browser/agentSessions/agentHost/agentHostImportConversationStore.js'; import { IStorageService, InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { mcpAccessConfig, McpAccessValue } from '../../../../../../platform/mcp/common/mcpManagement.js'; import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; import { NullWorkbenchAssignmentService } from '../../../../../services/assignment/test/common/nullAssignmentService.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -69,7 +70,7 @@ import { IDefaultAccountService } from '../../../../../../platform/defaultAccoun import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; -import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; +import { IMcpService, IMcpWorkbenchService, LazyCollectionState } from '../../../../mcp/common/mcpTypes.js'; import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; // ============================================================================= @@ -102,7 +103,11 @@ suite('AgentHostClientTools', () => { override readonly extUri = extUriBiasedIgnorePathCase; }); instantiationService.stub(IConfigurationService, { - getValue: (section: string) => section === CopilotSemanticSearchEnabledSettingId ? semanticSearchEnabled : false, + getValue: (section: string) => section === CopilotSemanticSearchEnabledSettingId + ? semanticSearchEnabled + : section === mcpAccessConfig + ? McpAccessValue.All + : false, onDidChangeConfiguration: onDidChangeConfiguration.event, } as Partial as IConfigurationService); instantiationService.stub(IConfigurationResolverService, {} as Partial); @@ -121,7 +126,13 @@ suite('AgentHostClientTools', () => { }); instantiationService.stub(IMcpService, { servers: observableValue('mcpServers', []), + lazyCollectionState: observableValue('mcpLazyCollectionState', { state: LazyCollectionState.AllKnown, collections: [] }), }); + instantiationService.stub(IMcpWorkbenchService, { + local: [], + onChange: Event.None, + whenInitialLocalMcpServersLoaded: Promise.resolve(), + } as Partial as IMcpWorkbenchService); instantiationService.stub(ILanguageModelToolsService, { observeTools: () => tools, toolSets, @@ -188,6 +199,45 @@ suite('AgentHostClientTools', () => { }); }); + test('provides MCP support before a session and keeps unavailable roots distinct from no roots', async () => { + const { service } = createActiveClientService(); + const unavailable = service.acquireMcpServerSupportScope(AGENT_HOST_COPILOT_CLI_SESSION_TYPE, undefined); + const workspaceless = service.acquireMcpServerSupportScope(AGENT_HOST_COPILOT_CLI_SESSION_TYPE, []); + const workspacelessAgain = service.acquireMcpServerSupportScope(AGENT_HOST_COPILOT_CLI_SESSION_TYPE, []); + const otherHarness = service.acquireMcpServerSupportScope('agent-host-claude', []); + assert.ok(unavailable); + assert.ok(workspaceless); + assert.ok(workspacelessAgain); + await Promise.all([unavailable.whenResolved(), workspaceless.whenResolved()]); + + assert.deepStrictEqual({ + unavailableResolved: unavailable.isResolved.get(), + workspacelessResolved: workspaceless.isResolved.get(), + sameWorkspacelessObservable: workspaceless.support === workspacelessAgain.support, + unavailableIsDistinct: unavailable.support !== workspaceless.support, + snapshot: workspaceless.support.get(), + otherHarness, + }, { + unavailableResolved: true, + workspacelessResolved: true, + sameWorkspacelessObservable: true, + unavailableIsDistinct: true, + snapshot: { + servers: [], + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }, + otherHarness: undefined, + }); + + unavailable.dispose(); + workspaceless.dispose(); + workspacelessAgain.dispose(); + }); + const semanticSearchTool: IToolData = { id: CLIENT_SEMANTIC_SEARCH_TOOL_ID, toolReferenceName: CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts new file mode 100644 index 00000000000000..18492e31935025 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts @@ -0,0 +1,619 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { ISettableObservable, observableValue, waitForState } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationService, ConfigurationTarget } from '../../../../../../platform/configuration/common/configuration.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { mcpAccessConfig, McpAccessValue } from '../../../../../../platform/mcp/common/mcpManagement.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { StorageScope } from '../../../../../../platform/storage/common/storage.js'; +import { AgentHostMcpServerApplicability, AgentHostMcpServerDelivery, AgentHostMcpServerEnablementState, AgentHostMcpServerSourceKind, AgentHostMcpSupportReason, assessMcpServersForCopilotAgentHost, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID, mergeInstalledMcpServersIntoAgentHostSupportAssessment } from '../../../browser/agentSessions/agentHost/agentHostMcpServerSupport.js'; +import { AgentHostMcpServerSupportScope } from '../../../browser/agentSessions/agentHost/agentHostMcpServerSupportScope.js'; +import { ContributionEnablementState } from '../../../common/enablement.js'; +import { ExternalDiscoverySource } from '../../../../mcp/common/mcpConfiguration.js'; +import { IMcpConfigPath, IMcpServer, IMcpService, IMcpWorkbenchService, IWorkbenchMcpServer, LazyCollectionState, McpCollectionDefinition, McpCollectionProvenance, McpServerDefinition, McpServerEnablementState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType, McpServerTrust } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { IWorkbenchLocalMcpServer } from '../../../../../services/mcp/common/mcpWorkbenchManagementService.js'; + +suite('agentHostMcpServerSupport', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('classifies supported delivery paths and source locations', async () => { + const root = URI.file('/workspace'); + const servers = [ + makeMcpServer({ + id: 'mcp.config.usrlocal.user', + collectionId: 'mcp.config.usrlocal', + provenance: McpCollectionProvenance.UserProfile, + }), + makeMcpServer({ + id: 'workspace-dot-mcp.0.root', + collectionId: 'workspace-dot-mcp.0', + provenance: McpCollectionProvenance.WorkspaceDotMcp, + configTarget: ConfigurationTarget.WORKSPACE_FOLDER, + collectionOrigin: URI.joinPath(root, '.mcp.json'), + }), + makeMcpServer({ + id: 'plugin.test/server', + collectionId: 'plugin.file:///plugin', + provenance: McpCollectionProvenance.Plugin, + collectionOrigin: URI.file('/plugin/.mcp.json'), + }), + makeMcpServer({ + id: 'extension.server', + collectionId: 'publisher.extension/provider', + provenance: McpCollectionProvenance.Extension, + collectionSource: new ExtensionIdentifier('publisher.extension'), + }), + makeMcpServer({ + id: 'github', + collectionId: COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID, + provenance: McpCollectionProvenance.Extension, + collectionSource: new ExtensionIdentifier('github.copilot-chat'), + }), + ]; + + const result = await assess(servers, [root]); + + assert.deepStrictEqual(result.servers.map(server => ({ + name: server.name, + source: server.source.kind, + delivery: server.delivery, + compatibility: server.compatibility.kind, + })), [ + { name: 'mcp.config.usrlocal.user', source: AgentHostMcpServerSourceKind.UserProfile, delivery: AgentHostMcpServerDelivery.ClientForwarded, compatibility: 'supported' }, + { name: 'workspace-dot-mcp.0.root', source: AgentHostMcpServerSourceKind.WorkspaceDotMcp, delivery: AgentHostMcpServerDelivery.RuntimeDiscovered, compatibility: 'supported' }, + { name: 'plugin.test/server', source: AgentHostMcpServerSourceKind.AgentPlugin, delivery: AgentHostMcpServerDelivery.AgentPlugin, compatibility: 'supported' }, + { name: 'extension.server', source: AgentHostMcpServerSourceKind.Extension, delivery: AgentHostMcpServerDelivery.ClientForwarded, compatibility: 'supported' }, + { name: 'github', source: AgentHostMcpServerSourceKind.Extension, delivery: AgentHostMcpServerDelivery.ProviderBuiltIn, compatibility: 'supported' }, + ]); + }); + + test('reports unsupported configuration without changing existing direct forwarding', async () => { + const root = URI.file('/workspace'); + const servers = [ + makeMcpServer({ + id: 'mcp.config.ws0.input', + collectionId: 'mcp.config.ws0', + provenance: McpCollectionProvenance.WorkspaceFolderConfiguration, + configTarget: ConfigurationTarget.WORKSPACE_FOLDER, + collectionOrigin: URI.joinPath(root, '.vscode', 'mcp.json'), + launch: stdioLaunch('${input:token}'), + }), + makeMcpServer({ + id: 'mcp.config.usrlocal.folder', + collectionId: 'mcp.config.usrlocal', + provenance: McpCollectionProvenance.UserProfile, + launch: stdioLaunch('${workspaceFolder}'), + }), + makeMcpServer({ + id: 'mcp.config.workspace.workspace', + collectionId: 'mcp.config.workspace', + provenance: McpCollectionProvenance.WorkspaceConfiguration, + configTarget: ConfigurationTarget.WORKSPACE, + collectionOrigin: URI.file('/workspace.code-workspace'), + }), + ]; + + const result = await assess(servers, [root]); + + assert.deepStrictEqual(result.servers.map(server => ({ + name: server.name, + delivery: server.delivery, + compatibility: server.compatibility, + })), [ + { + name: 'mcp.config.ws0.input', + delivery: AgentHostMcpServerDelivery.NotDelivered, + compatibility: { kind: 'unsupported', reasons: [AgentHostMcpSupportReason.RequiresUserInteraction] }, + }, + { + name: 'mcp.config.usrlocal.folder', + delivery: AgentHostMcpServerDelivery.ClientForwarded, + compatibility: { kind: 'unsupported', reasons: [AgentHostMcpSupportReason.UnresolvedConfiguration] }, + }, + { + name: 'mcp.config.workspace.workspace', + delivery: AgentHostMcpServerDelivery.NotDelivered, + compatibility: { kind: 'unsupported', reasons: [AgentHostMcpSupportReason.UnsupportedSourceLocation] }, + }, + ]); + }); + + test('uses discovery metadata to distinguish external user and workspace configurations', async () => { + const root = URI.file('/workspace'); + const result = await assess([ + makeMcpServer({ + id: 'cursor-global', + collectionId: 'cursor-global', + provenance: McpCollectionProvenance.ExternalConfiguration, + discoverySource: ExternalDiscoverySource.CursorGlobal, + }), + makeMcpServer({ + id: 'cursor-workspace.0', + collectionId: 'cursor-workspace.0', + provenance: McpCollectionProvenance.ExternalConfiguration, + discoverySource: ExternalDiscoverySource.CursorWorkspace, + configTarget: ConfigurationTarget.WORKSPACE_FOLDER, + collectionOrigin: URI.joinPath(root, '.cursor', 'mcp.json'), + }), + ], [root]); + + assert.deepStrictEqual(result.servers.map(server => ({ + source: server.source.kind, + applicability: server.applicability, + delivery: server.delivery, + compatibility: server.compatibility, + })), [ + { + source: AgentHostMcpServerSourceKind.CursorUser, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.ClientForwarded, + compatibility: { kind: 'supported' }, + }, + { + source: AgentHostMcpServerSourceKind.CursorWorkspace, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.NotDelivered, + compatibility: { kind: 'unsupported', reasons: [AgentHostMcpSupportReason.UnsupportedSourceLocation] }, + }, + ]); + }); + + test('keeps scope applicability separate from configuration compatibility', async () => { + const server = makeMcpServer({ + id: 'mcp.config.ws0.server', + collectionId: 'mcp.config.ws0', + provenance: McpCollectionProvenance.WorkspaceFolderConfiguration, + configTarget: ConfigurationTarget.WORKSPACE_FOLDER, + collectionOrigin: URI.file('/workspace-a/.vscode/mcp.json'), + }); + + const outside = await assess([server], [URI.file('/workspace-b')]); + const unavailable = await assess([server], undefined); + + assert.deepStrictEqual({ + outside: { + applicability: outside.servers[0].applicability, + delivery: outside.servers[0].delivery, + compatibility: outside.servers[0].compatibility, + }, + unavailable: { + applicability: unavailable.servers[0].applicability, + delivery: unavailable.servers[0].delivery, + compatibility: unavailable.servers[0].compatibility, + }, + }, { + outside: { + applicability: AgentHostMcpServerApplicability.OutsideCurrentScope, + delivery: AgentHostMcpServerDelivery.NotDelivered, + compatibility: { kind: 'supported' }, + }, + unavailable: { + applicability: AgentHostMcpServerApplicability.Unknown, + delivery: AgentHostMcpServerDelivery.Unknown, + compatibility: { kind: 'supported' }, + }, + }); + }); + + test('reports configuration features dropped by the Copilot delivery chain', async () => { + const server = makeMcpServer({ + id: 'mcp.config.usrlocal.partial', + collectionId: 'mcp.config.usrlocal', + provenance: McpCollectionProvenance.UserProfile, + launch: { + ...stdioLaunch(), + envFile: '/workspace/.env', + sandbox: { network: { allowedDomains: ['example.com'] } }, + }, + sandboxEnabled: true, + devMode: { watch: '**/*.ts' }, + }); + + const result = await assess([server], []); + + assert.deepStrictEqual(result.servers[0].compatibility, { + kind: 'partiallySupported', + reasons: [ + AgentHostMcpSupportReason.EnvironmentFileIgnored, + AgentHostMcpSupportReason.SandboxConfigurationIgnored, + AgentHostMcpSupportReason.DevelopmentModeIgnored, + ], + }); + }); + + test('reports runtime server enablement separately from compatibility', async () => { + const result = await assess([ + makeMcpServer({ + id: 'mcp.config.usrlocal.disabled', + collectionId: 'mcp.config.usrlocal', + provenance: McpCollectionProvenance.UserProfile, + enablement: ContributionEnablementState.DisabledWorkspace, + }), + ], []); + + assert.deepStrictEqual(result.servers[0], { + id: 'mcp.config.usrlocal.disabled', + name: 'mcp.config.usrlocal.disabled', + collectionId: 'mcp.config.usrlocal', + source: { + group: 'user', + kind: AgentHostMcpServerSourceKind.UserProfile, + label: 'mcp.config.usrlocal', + collectionUri: undefined, + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { + enabled: false, + state: AgentHostMcpServerEnablementState.DisabledWorkspace, + }, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.ClientForwarded, + compatibility: { kind: 'supported' }, + }); + }); + + test('includes access-disabled installed servers omitted from the runtime registry', async () => { + const root = URI.file('/workspace'); + const initial = await assess([], [root]); + const result = await mergeInstalledMcpServersIntoAgentHostSupportAssessment( + initial, + [{ + id: 'mcp.config.ws0.disabled', + name: 'disabled', + label: 'Disabled server', + configuration: { + type: McpServerType.LOCAL, + command: 'server', + }, + configPath: { + id: 'ws0', + key: 'workspaceFolderValue', + label: 'Workspace Folder', + scope: StorageScope.WORKSPACE, + target: ConfigurationTarget.WORKSPACE_FOLDER, + order: 0, + uri: URI.joinPath(root, '.vscode', 'mcp.json'), + }, + sandbox: undefined, + runtimeState: McpServerEnablementState.DisabledByAccess, + }], + makeConfigurationResolverService(), + [root], + ); + + assert.deepStrictEqual(result, { + discoveryComplete: true, + servers: [{ + id: 'mcp.config.ws0.disabled', + name: 'disabled', + collectionId: 'mcp.config.ws0', + source: { + group: 'local', + kind: AgentHostMcpServerSourceKind.VscodeWorkspaceFolder, + label: 'Workspace Folder', + collectionUri: URI.joinPath(root, '.vscode', 'mcp.json'), + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { + enabled: false, + state: AgentHostMcpServerEnablementState.DisabledByAccess, + }, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.NotDelivered, + compatibility: { kind: 'supported' }, + }], + }); + }); + + test('uses the same compatibility assessment for registered and access-disabled installed servers', async () => { + const initial = await assess([ + makeMcpServer({ + id: 'mcp.config.usrlocal.runtime', + collectionId: 'mcp.config.usrlocal', + provenance: McpCollectionProvenance.UserProfile, + launch: { + ...stdioLaunch(), + envFile: '/workspace/.env', + sandbox: { network: { allowedDomains: ['example.com'] } }, + }, + sandboxEnabled: true, + devMode: { watch: '**/*.ts' }, + }), + ], []); + const result = await mergeInstalledMcpServersIntoAgentHostSupportAssessment( + initial, + [{ + id: 'mcp.config.usrlocal.installed', + name: 'installed', + label: 'Installed server', + configuration: { + type: McpServerType.LOCAL, + command: 'server', + envFile: '/workspace/.env', + sandboxEnabled: true, + dev: { watch: '**/*.ts' }, + }, + configPath: { + id: 'usrlocal', + key: 'userLocalValue', + label: 'User', + scope: StorageScope.PROFILE, + target: ConfigurationTarget.USER, + order: 0, + uri: undefined, + }, + sandbox: { network: { allowedDomains: ['example.com'] } }, + runtimeState: McpServerEnablementState.DisabledByAccess, + }], + makeConfigurationResolverService(), + [], + ); + + assert.deepStrictEqual(result.servers.map(server => server.compatibility), [ + { + kind: 'partiallySupported', + reasons: [ + AgentHostMcpSupportReason.EnvironmentFileIgnored, + AgentHostMcpSupportReason.SandboxConfigurationIgnored, + AgentHostMcpSupportReason.DevelopmentModeIgnored, + ], + }, + { + kind: 'partiallySupported', + reasons: [ + AgentHostMcpSupportReason.EnvironmentFileIgnored, + AgentHostMcpSupportReason.SandboxConfigurationIgnored, + AgentHostMcpSupportReason.DevelopmentModeIgnored, + ], + }, + ]); + }); + + test('reacts to runtime enablement changes', async () => { + const enablement = observableValue('enablement', ContributionEnablementState.EnabledProfile); + const server = makeMcpServer({ + id: 'mcp.config.usrlocal.reactive', + collectionId: 'mcp.config.usrlocal', + provenance: McpCollectionProvenance.UserProfile, + enablementObservable: enablement, + }); + const mcpService = { + servers: observableValue('mcpServers', [server]), + lazyCollectionState: observableValue('lazyCollectionState', { state: LazyCollectionState.AllKnown, collections: [] }), + } as Partial as IMcpService; + const mcpWorkbenchService = { + local: [], + onChange: Event.None, + whenInitialLocalMcpServersLoaded: Promise.resolve(), + } as Partial as IMcpWorkbenchService; + const configurationService = { + getValue: (section: string) => section === mcpAccessConfig ? McpAccessValue.All : false, + onDidChangeConfiguration: Event.None, + } as Partial as IConfigurationService; + const owner = new AgentHostMcpServerSupportScope( + 'agent-host-copilotcli', + [], + () => { }, + mcpService, + mcpWorkbenchService, + makeConfigurationResolverService(), + configurationService, + ); + const scope = store.add(owner.acquire()); + await scope.whenResolved(); + + const updated = waitForState(scope.support, snapshot => snapshot.servers[0]?.enablement.state === AgentHostMcpServerEnablementState.DisabledWorkspace); + enablement.set(ContributionEnablementState.DisabledWorkspace, undefined); + + assert.deepStrictEqual((await updated).servers[0].enablement, { + enabled: false, + state: AgentHostMcpServerEnablementState.DisabledWorkspace, + }); + }); + + test('waits for the installed inventory before resolving the initial snapshot', async () => { + const inventoryReady = new DeferredPromise(); + const readinessRequested = new DeferredPromise(); + let localServers: readonly IWorkbenchMcpServer[] = []; + const mcpService = { + servers: observableValue('mcpServers', []), + lazyCollectionState: observableValue('lazyCollectionState', { state: LazyCollectionState.AllKnown, collections: [] }), + } as Partial as IMcpService; + const mcpWorkbenchService = { + get local() { return localServers; }, + onChange: Event.None, + getMcpConfigPath: getUndefinedMcpConfigPath, + get whenInitialLocalMcpServersLoaded() { + readinessRequested.complete(); + return inventoryReady.p; + }, + } as Partial as IMcpWorkbenchService; + const configurationService = { + getValue: (section: string) => section === mcpAccessConfig ? McpAccessValue.All : false, + onDidChangeConfiguration: Event.None, + } as Partial as IConfigurationService; + const owner = new AgentHostMcpServerSupportScope( + 'agent-host-copilotcli', + [], + () => { }, + mcpService, + mcpWorkbenchService, + makeConfigurationResolverService(), + configurationService, + ); + const scope = store.add(owner.acquire()); + await readinessRequested.p; + const resolvedBeforeInventory = scope.isResolved.get(); + + localServers = [{ + name: 'delayed', + label: 'Delayed server', + local: { + id: 'mcp.config.usrlocal.delayed', + config: { + type: McpServerType.LOCAL, + command: 'server', + }, + rootSandbox: undefined, + }, + runtimeStatus: { state: McpServerEnablementState.DisabledByAccess }, + } as Partial as IWorkbenchMcpServer]; + await inventoryReady.complete(); + await scope.whenResolved(); + + assert.deepStrictEqual({ + resolvedBeforeInventory, + resolvedAfterInventory: scope.isResolved.get(), + serverIds: scope.support.get().servers.map(server => server.id), + }, { + resolvedBeforeInventory: false, + resolvedAfterInventory: true, + serverIds: ['mcp.config.usrlocal.delayed'], + }); + }); + + test('reports incomplete discovery without fabricating unknown servers', async () => { + const result = await assessMcpServersForCopilotAgentHost( + [], + makeConfigurationResolverService(), + 'agent-host-copilotcli', + [], + LazyCollectionState.HasUnknown, + ); + + assert.deepStrictEqual(result, { servers: [], discoveryComplete: false }); + }); + + test('does not expose the Copilot support assessment for another harness', async () => { + const result = await assessMcpServersForCopilotAgentHost( + [], + makeConfigurationResolverService(), + 'agent-host-claude', + [], + LazyCollectionState.AllKnown, + ); + + assert.strictEqual(result, undefined); + }); +}); + +function makeMcpServer(options: { + readonly id: string; + readonly collectionId: string; + readonly provenance?: McpCollectionProvenance; + readonly discoverySource?: ExternalDiscoverySource; + readonly configTarget?: ConfigurationTarget; + readonly collectionOrigin?: URI; + readonly collectionSource?: ExtensionIdentifier; + readonly launch?: McpServerLaunch; + readonly sandboxEnabled?: boolean; + readonly devMode?: McpServerDefinition['devMode']; + readonly enablement?: ContributionEnablementState; + readonly enablementObservable?: ISettableObservable; +}): IMcpServer { + const { + id, + collectionId, + provenance, + discoverySource, + configTarget = ConfigurationTarget.USER, + collectionOrigin, + collectionSource, + launch = stdioLaunch(), + sandboxEnabled, + devMode, + enablement = ContributionEnablementState.EnabledProfile, + enablementObservable, + } = options; + const collection: McpCollectionDefinition = { + id: collectionId, + provenance, + discoverySource, + label: collectionId, + remoteAuthority: null, + serverDefinitions: observableValue('serverDefinitions', []), + trustBehavior: McpServerTrust.Kind.Trusted, + scope: StorageScope.PROFILE, + configTarget, + source: collectionSource, + order: 0, + presentation: collectionOrigin ? { origin: collectionOrigin } : undefined, + }; + const definition: McpServerDefinition = { + id, + label: id, + launch, + cacheNonce: id, + sandboxEnabled, + devMode, + }; + const definitions = observableValue('definitions', { server: definition, collection }); + return { + collection: { id: collectionId, label: collectionId, order: 0 }, + definition: { id, label: id }, + enablement: enablementObservable ?? observableValue('enablement', enablement), + readDefinitions: () => definitions, + } as unknown as IMcpServer; +} + +function stdioLaunch(argument?: string): McpServerTransportStdio { + return { + type: McpServerTransportType.Stdio, + command: 'server', + args: argument ? [argument] : [], + env: {}, + envFile: undefined, + cwd: undefined, + sandbox: undefined, + }; +} + +function makeConfigurationResolverService(resolutions: Record = {}): IConfigurationResolverService { + return { + async resolveAsync(_folder: unknown, config: unknown) { + const expression = ConfigurationResolverExpression.parse(config as object); + for (const replacement of expression.unresolved()) { + if (Object.prototype.hasOwnProperty.call(resolutions, replacement.id)) { + expression.resolve(replacement, resolutions[replacement.id]); + } else if (replacement.name === 'input' || replacement.name === 'command') { + expression.resolve(replacement, replacement.id); + } + } + return expression.toObject(); + }, + } as unknown as IConfigurationResolverService; +} + +function getUndefinedMcpConfigPath(arg: IWorkbenchLocalMcpServer): IMcpConfigPath | undefined; +function getUndefinedMcpConfigPath(arg: URI): Promise; +function getUndefinedMcpConfigPath(arg: IWorkbenchLocalMcpServer | URI): IMcpConfigPath | undefined | Promise { + return URI.isUri(arg) ? Promise.resolve(undefined) : undefined; +} + +async function assess(servers: readonly IMcpServer[], roots: readonly URI[] | undefined) { + const result = await assessMcpServersForCopilotAgentHost( + servers, + makeConfigurationResolverService(), + 'agent-host-copilotcli', + roots, + LazyCollectionState.AllKnown, + ); + assert.ok(result); + return result; +} diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagement.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagement.test.ts new file mode 100644 index 00000000000000..1e05f43edbca20 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagement.test.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AICustomizationManagementSection, resolveAICustomizationManagementOpenEditorTarget } from '../../../browser/aiCustomization/aiCustomizationManagement.js'; + +suite('aiCustomizationManagement', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('resolves the originating chat title context before fallback session state', () => { + const titleSessionResource = URI.parse('agent-host-copilot:/title-session'); + const fallbackSessionResource = URI.parse('local:/fallback-session'); + const revealUri = URI.file('/workspace/skill.md'); + const getSessionResourceForHarness = (sessionType: string) => URI.from({ scheme: sessionType, path: '/new-session' }); + + const results = [ + resolveAICustomizationManagementOpenEditorTarget({ + $mid: MarshalledId.ChatViewContext, + sessionResource: titleSessionResource, + }, 'agent-host-claude', fallbackSessionResource, getSessionResourceForHarness), + resolveAICustomizationManagementOpenEditorTarget({ + section: AICustomizationManagementSection.Skills, + sessionType: 'agent-host-copilot', + revealUri, + }, 'agent-host-claude', fallbackSessionResource, getSessionResourceForHarness), + resolveAICustomizationManagementOpenEditorTarget( + AICustomizationManagementSection.Instructions, + 'agent-host-claude', + fallbackSessionResource, + getSessionResourceForHarness, + ), + resolveAICustomizationManagementOpenEditorTarget(undefined, undefined, fallbackSessionResource, getSessionResourceForHarness), + ].map(result => ({ + section: result.section, + revealUri: result.revealUri?.toString(), + sessionResource: result.sessionResource?.toString(), + })); + + assert.deepStrictEqual(results, [ + { section: undefined, revealUri: undefined, sessionResource: 'agent-host-copilot:/title-session' }, + { section: AICustomizationManagementSection.Skills, revealUri: revealUri.toString(), sessionResource: 'agent-host-copilot:/new-session' }, + { section: AICustomizationManagementSection.Instructions, revealUri: undefined, sessionResource: 'agent-host-claude:/new-session' }, + { section: undefined, revealUri: undefined, sessionResource: 'local:/fallback-session' }, + ]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationServiceImpl.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationServiceImpl.test.ts new file mode 100644 index 00000000000000..c7ab321115955c --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationServiceImpl.test.ts @@ -0,0 +1,330 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CustomizationMigrationService } from '../../../browser/aiCustomization/customizationMigrationServiceImpl.js'; +import { IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; +import { IAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { AgentHostMcpServerApplicability, AgentHostMcpServerDelivery, AgentHostMcpServerEnablementState, AgentHostMcpServerSourceKind, AgentHostMcpSupportReason, IAgentHostMcpServerSupportSnapshot } from '../../../browser/agentSessions/agentHost/agentHostMcpServerSupport.js'; +import { SessionType } from '../../../common/chatSessionsService.js'; +import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; +import { PromptFileSource, PromptsType } from '../../../common/promptSyntax/promptTypes.js'; +import { CustomizationMigrationType } from '../../../common/promptSyntax/service/customizationMigrationService.js'; +import { IPromptPath, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; + +class TestPromptsService extends MockPromptsService { + readonly requestedTypes: PromptsType[] = []; + + constructor(private readonly files: readonly IPromptPath[]) { + super(); + } + + override async listPromptFiles(type: PromptsType): Promise { + this.requestedTypes.push(type); + return this.files.filter(file => file.type === type); + } +} + +class TestCustomizationHarnessService extends mock() { + readonly requestedSourceFolderTypes: PromptsType[] = []; + + constructor( + private readonly sessionType = SessionType.AgentHostCopilot, + private readonly harnessLabel = 'Copilot', + ) { + super(); + } + + override findHarnessById(sessionType: string): IHarnessDescriptor | undefined { + if (sessionType !== this.sessionType) { + return undefined; + } + return { + id: sessionType, + label: this.harnessLabel, + icon: Codicon.copilot, + itemProvider: { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => [], + provideSourceFolders: async (_sessionResource, type) => { + this.requestedSourceFolderTypes.push(type); + switch (type) { + case PromptsType.agent: + return [{ uri: URI.file('/copilot/agents'), label: 'Agents', source: PromptsStorage.user }]; + case PromptsType.skill: + return [ + { uri: URI.file('/workspace/.github/skills'), label: 'Workspace Skills', source: PromptsStorage.local }, + { uri: URI.file('/copilot/skills'), label: 'User Skills', source: PromptsStorage.user }, + ]; + default: + return []; + } + }, + }, + }; + } +} + +suite('CustomizationMigrationService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('computes file and MCP migration candidates for Agent Host sessions', async () => { + const root = URI.file('/workspace'); + const promptsService = store.add(new TestPromptsService([ + { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace }, + { uri: URI.file('/user-data/prompts/release.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, source: PromptFileSource.UserData }, + { uri: URI.file('/user-data/prompts/reviewer.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, source: PromptFileSource.UserData }, + { uri: URI.file('/user-data/prompts/style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, source: PromptFileSource.UserData }, + { uri: URI.file('/home/test/.copilot/agents/planner.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, source: PromptFileSource.CopilotPersonal }, + { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, source: PromptFileSource.GitHubWorkspace }, + ])); + const harnessService = new TestCustomizationHarnessService(); + const snapshot: IAgentHostMcpServerSupportSnapshot = { + servers: [ + { + id: 'supported', + name: 'Supported server', + collectionId: 'test', + source: { + group: undefined, + kind: AgentHostMcpServerSourceKind.UserProfile, + label: 'User', + collectionUri: undefined, + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { enabled: true, state: AgentHostMcpServerEnablementState.EnabledProfile }, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.ClientForwarded, + compatibility: { kind: 'supported' }, + }, + { + id: 'unsupported', + name: 'Unsupported server', + collectionId: 'test', + source: { + group: undefined, + kind: AgentHostMcpServerSourceKind.WorkspaceConfiguration, + label: 'Workspace', + collectionUri: undefined, + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { enabled: true, state: AgentHostMcpServerEnablementState.EnabledWorkspace }, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.NotDelivered, + compatibility: { kind: 'unsupported', reasons: [AgentHostMcpSupportReason.UnsupportedSourceLocation] }, + }, + { + id: 'outside-scope', + name: 'Outside scope', + collectionId: 'test', + source: { + group: undefined, + kind: AgentHostMcpServerSourceKind.VscodeWorkspaceFolder, + label: 'Other workspace', + collectionUri: undefined, + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { enabled: true, state: AgentHostMcpServerEnablementState.EnabledWorkspace }, + applicability: AgentHostMcpServerApplicability.OutsideCurrentScope, + delivery: AgentHostMcpServerDelivery.NotDelivered, + compatibility: { kind: 'supported' }, + }, + ], + discoveryComplete: false, + coverage: { + restrictedByMcpAccess: true, + restrictedByCustomizationPolicy: false, + }, + }; + let requestedSessionType: string | undefined; + let requestedRoots: readonly URI[] | undefined; + let supportScopeDisposed = false; + const activeClientService = { + acquireMcpServerSupportScope: (sessionType: string, roots: readonly URI[] | undefined) => { + requestedSessionType = sessionType; + requestedRoots = roots; + return { + support: constObservable(snapshot), + isResolved: constObservable(true), + whenResolved: () => Promise.resolve(), + dispose: () => supportScopeDisposed = true, + }; + }, + } as Partial as IAgentHostActiveClientService; + const agentHostCustomizationService = { + getWorkingDirectories: () => [root.fsPath], + } as Partial as IAgentHostCustomizationService; + const service = new CustomizationMigrationService(promptsService, harnessService, activeClientService, agentHostCustomizationService); + const agentHostSessionResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }); + const localSessionResource = URI.from({ scheme: SessionType.Local, path: '/session' }); + + const migrations = await service.computeMigrations(agentHostSessionResource); + const localMigrations = await service.computeMigrations(localSessionResource); + const hint = await service.computeMigrationHint(agentHostSessionResource); + const localHint = await service.computeMigrationHint(localSessionResource); + + assert.deepStrictEqual({ + migrations: migrations.map(migration => ({ + type: migration.type, + ...(migration.type === CustomizationMigrationType.McpServers + ? { + servers: migration.servers, + discoveryComplete: migration.discoveryComplete, + coverage: migration.coverage, + } + : { + files: migration.files.map(file => file.path), + candidates: migration.candidates.map(candidate => candidate.uri.path), + }), + })), + localMigrations, + hint, + localHint, + requestedTypes: promptsService.requestedTypes, + requestedSourceFolderTypes: harnessService.requestedSourceFolderTypes.toSorted(), + requestedSessionType, + requestedRoots: requestedRoots?.map(requestedRoot => requestedRoot.path), + supportScopeDisposed, + }, { + migrations: [ + { + type: 'userData', + files: ['/user-data/prompts/reviewer.agent.md'], + candidates: ['/user-data/prompts/reviewer.agent.md'], + }, + { + type: 'promptFiles', + files: [ + '/workspace/.github/prompts/review.prompt.md', + '/user-data/prompts/release.prompt.md', + ], + candidates: [ + '/workspace/.github/prompts/review.prompt.md', + '/user-data/prompts/release.prompt.md', + ], + }, + { + type: 'mcpServers', + servers: [ + { id: 'supported', name: 'Supported server', supported: true }, + { id: 'unsupported', name: 'Unsupported server', supported: false }, + ], + discoveryComplete: false, + coverage: { + restrictedByMcpAccess: true, + restrictedByCustomizationPolicy: false, + }, + }, + ], + localMigrations: [ + { type: 'userData', files: [], candidates: [] }, + { type: 'promptFiles', files: [], candidates: [] }, + { + type: 'mcpServers', + servers: [], + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }, + ], + hint: 'Found 3 customization files that are present but not used by Copilot and could be migrated. Found 1 MCP server that is not fully supported by Copilot.', + localHint: undefined, + requestedTypes: [ + PromptsType.agent, PromptsType.instructions, PromptsType.prompt, + PromptsType.agent, PromptsType.instructions, PromptsType.prompt, + ], + requestedSourceFolderTypes: [ + PromptsType.agent, PromptsType.agent, PromptsType.instructions, + PromptsType.instructions, PromptsType.skill, PromptsType.skill, + ], + requestedSessionType: SessionType.AgentHostCopilot, + requestedRoots: ['/workspace'], + supportScopeDisposed: true, + }); + }); + + test('uses the session harness label in migration hints', async () => { + const promptsService = store.add(new TestPromptsService([ + { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace }, + ])); + const harnessService = new TestCustomizationHarnessService(SessionType.AgentHostClaude, 'Claude'); + const activeClientService = new class extends mock() { + override acquireMcpServerSupportScope() { return undefined; } + }(); + const agentHostCustomizationService = new class extends mock() { + override getWorkingDirectories() { return []; } + }(); + const service = new CustomizationMigrationService(promptsService, harnessService, activeClientService, agentHostCustomizationService); + + const hint = await service.computeMigrationHint(URI.from({ scheme: SessionType.AgentHostClaude, path: '/session' })); + + assert.strictEqual(hint, 'Found 1 customization file that is present but not used by Claude and could be migrated.'); + }); + + test('reports unsupported MCP servers when there are no file migrations', async () => { + const promptsService = store.add(new TestPromptsService([])); + const harnessService = new TestCustomizationHarnessService(); + const snapshot: IAgentHostMcpServerSupportSnapshot = { + servers: [0, 1].map(index => ({ + id: `unsupported-${index}`, + name: `Unsupported server ${index}`, + collectionId: 'test', + source: { + group: undefined, + kind: AgentHostMcpServerSourceKind.UserProfile, + label: 'User', + collectionUri: undefined, + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { enabled: true, state: AgentHostMcpServerEnablementState.EnabledProfile }, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.NotDelivered, + compatibility: { kind: 'unsupported', reasons: [AgentHostMcpSupportReason.LaunchNotRepresentable] }, + })), + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }; + const activeClientService = { + acquireMcpServerSupportScope: () => ({ + support: constObservable(snapshot), + isResolved: constObservable(true), + whenResolved: () => Promise.resolve(), + dispose: () => { }, + }), + } as Partial as IAgentHostActiveClientService; + const agentHostCustomizationService = new class extends mock() { + override getWorkingDirectories() { return []; } + }(); + const service = new CustomizationMigrationService(promptsService, harnessService, activeClientService, agentHostCustomizationService); + + const hint = await service.computeMigrationHint(URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' })); + + assert.strictEqual(hint, 'Found 2 MCP servers that are not fully supported by Copilot.'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index 9739d0d14929fe..a59887c588e1a5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -47,6 +47,7 @@ import { ChatTransferService, IChatTransferService } from '../../../common/model import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { ICustomizationMigrationService } from '../../../common/promptSyntax/service/customizationMigrationService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { NullLanguageModelsService } from '../../common/languageModels.js'; import { MockChatVariablesService } from '../../common/mockChatVariables.js'; @@ -92,6 +93,7 @@ suite('ChatEditingService', function () { collection.set(IChatService, new SyncDescriptor(ChatService)); collection.set(IMcpService, new TestMcpService()); collection.set(IPromptsService, new MockPromptsService()); + collection.set(ICustomizationMigrationService, new class extends mock() { }); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); const contextKeyService = store.add(new MockContextKeyService()); collection.set(IChatDebugService, store.add(new ChatDebugServiceImpl(new TestConfigurationService(), contextKeyService))); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 9412e1b4181b6c..34cb940c1846f2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -464,6 +464,40 @@ suite('ChatModelsViewModel', () => { }); }); + test('the default provider Auto model is never listed, so a group toggle cannot hide it', async () => { + // A non-default provider's Auto does get a row and stays hideable. + const service = new MockLanguageModelsService(); + service.addVendor({ vendor: 'copilot', displayName: 'GitHub Copilot', managementCommand: undefined, when: undefined, configuration: undefined }); + service.addVendor({ vendor: 'copilotcli', displayName: 'Copilot CLI', managementCommand: undefined, when: undefined, configuration: undefined }); + const metadata = { + extension: new ExtensionIdentifier('github.copilot'), + name: 'Auto', + family: 'auto', + version: '1.0', + maxInputTokens: 8192, + maxOutputTokens: 4096, + isDefaultForLocation: {}, + }; + service.addModel('copilot', 'copilot/auto', { ...metadata, id: 'auto', vendor: 'copilot' }); + service.addModel('copilot', 'copilot/gpt-5', { ...metadata, id: 'gpt-5', name: 'GPT-5', family: 'gpt-5', vendor: 'copilot' }); + service.addModel('copilotcli', 'copilotcli/auto', { ...metadata, id: 'auto', vendor: 'copilotcli' }); + + const model = store.add(new ChatModelsViewModel(service)); + await model.refresh(); + const copilotGroup = model.filter('').find(entry => isLanguageModelProviderEntry(entry) && entry.vendorEntry.vendor.vendor === 'copilot'); + assert.ok(copilotGroup && isLanguageModelProviderEntry(copilotGroup)); + + model.toggleGroupHidden(copilotGroup); + + assert.deepStrictEqual({ + renderedModelIds: model.filter('').filter(entry => entry.type === 'model').map(entry => entry.model.identifier), + setModelsHiddenCalls: service.setModelsHiddenCalls, + }, { + renderedModelIds: ['copilot/gpt-5', 'copilotcli/auto'], + setModelsHiddenCalls: [{ modelIdentifiers: ['copilot/gpt-5'], hidden: true }], + }); + }); + test('should filter by provider name (vendor ID and display name)', () => { const resultsByCopilotId = viewModel.filter('@provider:copilot'); assert.strictEqual(resultsByCopilotId.length, 3); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatRequestText.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatRequestText.test.ts new file mode 100644 index 00000000000000..15e67cba1fccd9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatRequestText.test.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { buildAgentMergePrompt } from '../../../../../platform/agentHost/common/agentMergePrompt.js'; +import { getChatRequestText } from '../../browser/chatRequestText.js'; +import { IChatRequestViewModel } from '../../common/model/chatViewModel.js'; + +function request(messageText: string, systemInitiatedLabel?: string): IChatRequestViewModel { + return { id: 'r', messageText, systemInitiatedLabel } as unknown as IChatRequestViewModel; +} + +suite('getChatRequestText', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('names a request by its own text, an Agent Merge request by its summary', () => { + const agentMergePrompt = buildAgentMergePrompt(['addressReviews'], { + pullRequestUrl: 'https://github.com/microsoft/vscode/pull/1', + title: 'chat: keep the timeline readable', + headRef: 'user/branch', + headSha: '1dd23747a306c10416d6f8a4a6ef032d541b310e', + baseRef: 'main', + reviewThreads: [{ id: 'thread-1', path: 'src/file.ts', line: 12, comments: [{ author: 'octocat', body: 'Please fix this.' }] }], + reviewSummaries: [], + newComments: [], + failedChecks: [], + behind: false, + conflicting: false, + commentWatermark: '2026-08-24T10:00:00.000Z', + }); + + assert.deepStrictEqual([ + getChatRequestText(request('Rename the widget')), + getChatRequestText(request(agentMergePrompt)), + // A request the transcript renders with its own notification label keeps its text. + getChatRequestText(request(agentMergePrompt, 'Terminal needs input')), + ], [ + 'Rename the widget', + 'Agent Merge, 1 comment', + agentMergePrompt, + ]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSetupController.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSetupController.test.ts index 74a2a500821320..1e3b03a295e41f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatSetupController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatSetupController.test.ts @@ -38,6 +38,7 @@ suite('ChatSetupController', () => { entitlement: ChatEntitlement.Unknown, sku: undefined, organisations: undefined, + isStaff: undefined, copilotTrackingId: undefined, }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineModel.test.ts index 00cf849ea2f140..fb3e10a802e2ac 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineModel.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { buildAgentMergePrompt } from '../../../../../../platform/agentHost/common/agentMergePrompt.js'; import { ChatTreeItem } from '../../../browser/chat.js'; import { PromptTimelineModel } from '../../../browser/promptTimeline/promptTimelineModel.js'; import { ChatWidget } from '../../../browser/widget/chatWidget.js'; @@ -123,4 +124,30 @@ suite('PromptTimelineModel', () => { { active: { text: 'Second prompt', index: 2, total: 2 }, pinned: true }, ]); }); + + test('previews an Agent Merge turn with its summary, not the state block', () => { + const agentMergePrompt = buildAgentMergePrompt(['addressReviews', 'fixCI'], { + pullRequestUrl: 'https://github.com/microsoft/vscode/pull/1', + title: 'chat: keep the timeline readable', + headRef: 'user/branch', + headSha: '1dd23747a306c10416d6f8a4a6ef032d541b310e', + baseRef: 'main', + reviewThreads: [{ id: 'thread-1', path: 'src/file.ts', line: 12, comments: [{ author: 'octocat', body: 'Please fix this.' }] }], + reviewSummaries: [], + newComments: [], + failedChecks: ['Compile (ubuntu-latest)'], + behind: false, + conflicting: false, + commentWatermark: '2026-08-24T10:00:00.000Z', + }); + const { model } = createModel([ + { item: request('request-1', 'First prompt', 1), top: 0 }, + { item: request('request-2', agentMergePrompt, 2), top: 400 }, + ]); + + assert.deepStrictEqual(model.promptTicks.get().map(tick => tick.text), [ + 'First prompt', + 'Agent Merge, 1 comment, 1 check failing', + ]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatFindWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatFindWidget.test.ts index a4b39c41b8d737..dfb5061ed1ce7f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatFindWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatFindWidget.test.ts @@ -10,20 +10,21 @@ import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { createRegExp } from '../../../../../../base/common/strings.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { ChatTreeItem } from '../../../browser/chat.js'; import { ChatFindModel } from '../../../browser/widget/chatFind/chatFindModel.js'; import { ChatFindWidget, computeRevealScrollTop, findMatchRangesInDom, IChatFindHost, openAncestorDisclosures, rangesEqual, shouldCaptureFocusBeforeShow } from '../../../browser/widget/chatFind/chatFindWidget.js'; -suite('ChatFindWidget IME', () => { +suite('ChatFindWidget', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('does not search intermediate IME composition text', async () => { + function createWidget(items: readonly ChatTreeItem[] = []) { const disposables = store.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, disposables); const container = document.createElement('div'); const transcriptDomNode = container.appendChild(document.createElement('div')); const host: IChatFindHost = { transcriptDomNode, - getItems: () => [], + getItems: () => items, onDidChangeContent: BaseEvent.None, reveal: () => { }, getTemplateDataForRequestId: () => undefined, @@ -36,6 +37,11 @@ suite('ChatFindWidget IME', () => { }; const widget = disposables.add(instantiationService.createInstance(ChatFindWidget, host)); const model = Reflect.get(widget, '_model') as ChatFindModel; + return { disposables, model, widget }; + } + + test('does not search intermediate IME composition text', async () => { + const { model, widget } = createWidget(); const input = widget.getDomNode().querySelector('input'); assert.ok(input); @@ -58,6 +64,51 @@ suite('ChatFindWidget IME', () => { afterComposition: '你好', }); }); + + test('next and previous reopen the hidden widget and continue from the active match without focusing it', async () => { + const item = { id: 'request', message: {}, messageText: 'needle needle needle' } as unknown as ChatTreeItem; + const next = createWidget([item]); + const previous = createWidget([item]); + Reflect.set(next.widget, '_navigateToActive', () => { }); + Reflect.set(previous.widget, '_navigateToActive', () => { }); + const focusedElement = document.activeElement; + + next.widget.show('needle', false); + next.model.next(); + next.widget.hide(); + previous.widget.show('needle', false); + previous.model.next(); + previous.widget.hide(); + await timeout(250); + + next.widget.next(); + previous.widget.previous(); + await timeout(0); + + assert.deepStrictEqual({ + nextQuery: next.model.query, + nextActiveIndex: next.model.activeIndex, + nextVisible: next.widget.visible, + nextRendered: next.widget.getDomNode().querySelector('.simple-find-part')?.classList.contains('visible'), + previousQuery: previous.model.query, + previousActiveIndex: previous.model.activeIndex, + previousVisible: previous.widget.visible, + previousRendered: previous.widget.getDomNode().querySelector('.simple-find-part')?.classList.contains('visible'), + focusedElement: document.activeElement, + }, { + nextQuery: 'needle', + nextActiveIndex: 2, + nextVisible: true, + nextRendered: true, + previousQuery: 'needle', + previousActiveIndex: 0, + previousVisible: true, + previousRendered: true, + focusedElement, + }); + next.disposables.dispose(); + previous.disposables.dispose(); + }); }); suite('ChatFindWidget DOM highlighting', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts index 881f5915fa69e7..9dfe421336f7dd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts @@ -15,6 +15,8 @@ import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetR import { IAccessibleViewService } from '../../../../../../platform/accessibility/browser/accessibleView.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { WorkbenchListSupportsFind } from '../../../../../../platform/list/browser/listService.js'; import { scrollbarShadow } from '../../../../../../platform/theme/common/colorRegistry.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; @@ -122,7 +124,7 @@ suite('ChatListWidget', () => { })); widget.setViewModel(viewModel); widget.setVisible(true); - return { disposables, model, viewModel, container, widget }; + return { disposables, model, viewModel, container, widget, contextKeyService: instantiationService.get(IContextKeyService) }; } async function measureFirstRequestPushOut(firstText: string) { @@ -224,6 +226,14 @@ suite('ChatListWidget', () => { assert.deepStrictEqual(states, [false, true, true, true, false]); }); + test('disables the generic tree Find widget', () => { + const { disposables, contextKeyService } = createWidget(); + + assert.strictEqual(contextKeyService.getContextKeyValue(WorkbenchListSupportsFind.key), false); + + disposables.dispose(); + }); + test('keeps user toggle tracking active until resizing settles', () => { const state = new UserToggleResizeState(2); const states = [state.isActive]; diff --git a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts index c6d74cbeb42f8b..8c55dbbdf6271f 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts @@ -13,7 +13,7 @@ import { MockContextKeyService } from '../../../../../platform/keybinding/test/c import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; -import { ChatEntitlementService, getQuotaReset, getQuotaUsage, parseQuotas, QuotaUsageKind } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementContextKeys, ChatEntitlementService, getQuotaReset, getQuotaUsage, parseQuotas, QuotaUsageKind } from '../../../../services/chat/common/chatEntitlementService.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; @@ -657,3 +657,36 @@ suite('ChatEntitlementService', () => { }); }); }); + +suite('ChatEntitlementContext', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + async function resolveInternal(organisations: string[], isStaff: boolean | undefined): Promise { + const contextKeyService = store.add(new MockContextKeyService()); + const context = store.add(new ChatEntitlementContext( + contextKeyService, + store.add(new TestStorageService()), + new NullLogService(), + new TestConfigurationService(), + NullTelemetryService, + )); + + await context.update({ entitlement: ChatEntitlement.Pro, organisations, isStaff, sku: undefined, copilotTrackingId: undefined }); + + return contextKeyService.getContextKeyValue(ChatEntitlementContextKeys.Entitlement.internal.key); + } + + test('treats staff and internal organisation members as internal', async () => { + assert.deepStrictEqual({ + external: await resolveInternal(['contoso'], false), + staff: await resolveInternal(['contoso'], true), + internalOrg: await resolveInternal(['microsoft'], false), + unknownStaff: await resolveInternal(['contoso'], undefined), + }, { + external: false, + staff: true, + internalOrg: true, + unknownStaff: false, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index c28a15177038b7..dc61678843838f 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -26,7 +26,7 @@ import { ServiceCollection } from '../../../../../../platform/instantiation/comm import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope, WillSaveStateReason } from '../../../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget, WillSaveStateReason } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from '../../../../../../platform/telemetry/common/gdprTypings.js'; @@ -45,13 +45,14 @@ import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; +import { getCustomizationMigrationHintDismissedStorageKey } from '../../../common/aiCustomizationWorkspaceService.js'; import { IChatDebugService } from '../../../common/chatDebugService.js'; import { ChatDebugServiceImpl } from '../../../common/chatDebugServiceImpl.js'; import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatProgress, IChatService, IChatUserActionEvent, ResponseModelState } from '../../../common/chatService/chatService.js'; import { backfillTransferredModel, backfillRestoredPickerState, ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatServiceTelemetry } from '../../../common/chatService/chatServiceTelemetry.js'; import { ChatRequestOriginKind } from '../../../common/chatRequestOrigin.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CustomizationMigrationHintMode } from '../../../common/constants.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatModel, IChatModel, ISerializableChatData, ISerializableChatModelInputState } from '../../../common/model/chatModel.js'; @@ -60,6 +61,7 @@ import { ChatViewModel, isPendingDividerVM } from '../../../common/model/chatVie import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js'; import { IConfiguredHooksInfo, IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; +import { ICustomizationMigrationService } from '../../../common/promptSyntax/service/customizationMigrationService.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { MockChatVariablesService } from '../mockChatVariables.js'; import { MockPromptsService } from '../promptSyntax/service/mockPromptsService.js'; @@ -184,6 +186,7 @@ suite('ChatService', () => { [IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()], [IMcpService, new TestMcpService()], [IPromptsService, new MockPromptsService()], + [ICustomizationMigrationService, mockObject()({ _serviceBrand: undefined })], [ILanguageModelToolsService, testDisposables.add(new MockLanguageModelToolsService())] ))); instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); @@ -2115,6 +2118,225 @@ suite('ChatService', () => { }); }); + test('customization migration hint respects never, once, always, and workspace harness dismissal', async () => { + const sessionType = SessionType.AgentHostCopilot; + const sessionResource = URI.from({ scheme: sessionType, path: '/session' }); + const migrationService = mockObject()({ _serviceBrand: undefined }); + migrationService.computeMigrationHint.resolves('Found 3 customization files that could be migrated.'); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Agent Host', + displayName: 'Agent Host', + description: 'Agent Host', + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { async invoke() { return {}; } })); + + const testService = createChatService(); + testDisposables.add(testService.registerCustomizationMigrationHintProvider( + sessionResource => migrationService.computeMigrationHint(sessionResource) + )); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const neverModeResponse = await testService.sendRequest(sessionResource, 'never', { agentId: sessionType }); + ChatSendResult.assertSent(neverModeResponse); + await neverModeResponse.data.responseCompletePromise; + + const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + await configurationService.setUserConfiguration(ChatConfiguration.ChatCustomizationsMigrationHint, CustomizationMigrationHintMode.Once); + const first = await testService.sendRequest(sessionResource, 'first', { agentId: sessionType }); + ChatSendResult.assertSent(first); + await first.data.responseCompletePromise; + const second = await testService.sendRequest(sessionResource, 'second', { agentId: sessionType }); + ChatSendResult.assertSent(second); + await second.data.responseCompletePromise; + + await configurationService.setUserConfiguration(ChatConfiguration.ChatCustomizationsMigrationHint, CustomizationMigrationHintMode.Always); + const otherSessionResource = URI.from({ scheme: sessionType, path: '/other-session' }); + const otherRef = await testService.acquireOrLoadSession(otherSessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(otherRef); + testDisposables.add(otherRef); + const third = await testService.sendRequest(otherSessionResource, 'third', { agentId: sessionType }); + ChatSendResult.assertSent(third); + await third.data.responseCompletePromise; + const fourth = await testService.sendRequest(otherSessionResource, 'fourth', { agentId: sessionType }); + ChatSendResult.assertSent(fourth); + await fourth.data.responseCompletePromise; + + const storageService = instantiationService.get(IStorageService); + storageService.store(getCustomizationMigrationHintDismissedStorageKey(sessionType), true, StorageScope.WORKSPACE, StorageTarget.USER); + const dismissedSessionResource = URI.from({ scheme: sessionType, path: '/dismissed-session' }); + const dismissedRef = await testService.acquireOrLoadSession(dismissedSessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(dismissedRef); + testDisposables.add(dismissedRef); + const fifth = await testService.sendRequest(dismissedSessionResource, 'fifth', { agentId: sessionType }); + ChatSendResult.assertSent(fifth); + await fifth.data.responseCompletePromise; + + const requests = (testService.getSession(sessionResource) as ChatModel).getRequests(); + const getHintContent = (requestIndex: number) => (requests[requestIndex].response?.response.value ?? []) + .filter(part => part.kind === 'systemNotification') + .map(part => part.content.value); + const otherSessionHints = (testService.getSession(otherSessionResource) as ChatModel).getRequests() + .map(request => (request.response?.response.value ?? []) + .filter(part => part.kind === 'systemNotification') + .map(part => part.content.value)); + const dismissedSessionHint = ((testService.getSession(dismissedSessionResource) as ChatModel).getRequests()[0].response?.response.value ?? []) + .filter(part => part.kind === 'systemNotification') + .map(part => part.content.value); + const expectedHint = `*Found 3 customization files that could be migrated. [Review customizations](command:aiCustomization.openManagementEditor "Open Chat Customizations") | [Hide for this workspace](command:aiCustomization.dismissMigrationHint "Stop Showing Migration Hints for This Harness")*`; + assert.deepStrictEqual({ + computeCalls: migrationService.computeMigrationHint.callCount, + computedFor: migrationService.computeMigrationHint.firstCall.args[0].toString(), + neverHint: getHintContent(0), + firstHint: getHintContent(1), + secondHint: getHintContent(2), + otherSessionHints, + dismissedSessionHint, + dismissedForSessionType: storageService.getBoolean(getCustomizationMigrationHintDismissedStorageKey(sessionType), StorageScope.WORKSPACE), + dismissedForOtherSessionType: storageService.getBoolean(getCustomizationMigrationHintDismissedStorageKey(SessionType.AgentHostClaude), StorageScope.WORKSPACE), + }, { + computeCalls: 3, + computedFor: sessionResource.toString(), + neverHint: [], + firstHint: [expectedHint], + secondHint: [], + otherSessionHints: [[expectedHint], [expectedHint]], + dismissedSessionHint: [], + dismissedForSessionType: true, + dismissedForOtherSessionType: undefined, + }); + }); + + test('once customization migration hint remains shown after the session is reloaded', async () => { + const sessionType = SessionType.AgentHostCopilot; + const sessionResource = URI.from({ scheme: sessionType, path: '/restored-session' }); + const migrationService = mockObject()({ _serviceBrand: undefined }); + migrationService.computeMigrationHint.resolves('Found customization files that could be migrated.'); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Agent Host', + displayName: 'Agent Host', + description: 'Agent Host', + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { async invoke() { return {}; } })); + + const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + await configurationService.setUserConfiguration(ChatConfiguration.ChatCustomizationsMigrationHint, CustomizationMigrationHintMode.Once); + const testService = createChatService(); + testDisposables.add(testService.registerCustomizationMigrationHintProvider( + sessionResource => migrationService.computeMigrationHint(sessionResource) + )); + const firstRef = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(firstRef); + const firstResponse = await testService.sendRequest(sessionResource, 'first', { agentId: sessionType }); + ChatSendResult.assertSent(firstResponse); + await firstResponse.data.responseCompletePromise; + const firstHintCount = firstRef.object.getRequests()[0].response?.response.value.filter(part => part.kind === 'systemNotification').length; + + firstRef.dispose(); + await testService.waitForModelDisposals(); + + const restoredRef = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(restoredRef); + testDisposables.add(restoredRef); + const secondResponse = await testService.sendRequest(sessionResource, 'second', { agentId: sessionType }); + ChatSendResult.assertSent(secondResponse); + await secondResponse.data.responseCompletePromise; + const restoredHintCount = restoredRef.object.getRequests()[0].response?.response.value.filter(part => part.kind === 'systemNotification').length; + + assert.deepStrictEqual({ computeCalls: migrationService.computeMigrationHint.callCount, firstHintCount, restoredHintCount }, { + computeCalls: 1, + firstHintCount: 1, + restoredHintCount: 0, + }); + }); + + test('customization migration hint is not computed for local sessions', async () => { + const migrationService = mockObject()({ _serviceBrand: undefined }); + const testService = createChatService(); + testDisposables.add(testService.registerCustomizationMigrationHintProvider( + sessionResource => migrationService.computeMigrationHint(sessionResource) + )); + const model = startSessionModel(testService).object; + const response = await testService.sendRequest(model.sessionResource, 'test'); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + + assert.deepStrictEqual({ + computeCalls: migrationService.computeMigrationHint.callCount, + hints: (model.getRequests()[0].response?.response.value ?? []) + .filter(part => part.kind === 'systemNotification').length, + }, { computeCalls: 0, hints: 0 }); + }); + + test('customization migration hint is not computed for extension host harnesses', async () => { + const sessionType = 'extension-host-harness'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session' }); + const migrationService = mockObject()({ _serviceBrand: undefined }); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Extension Host', + displayName: 'Extension Host', + description: 'Extension Host', + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { async invoke() { return {}; } })); + + const testService = createChatService(); + testDisposables.add(testService.registerCustomizationMigrationHintProvider( + sessionResource => migrationService.computeMigrationHint(sessionResource) + )); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + const response = await testService.sendRequest(sessionResource, 'test', { agentId: sessionType }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + + assert.deepStrictEqual({ + computeCalls: migrationService.computeMigrationHint.callCount, + hints: ((testService.getSession(sessionResource) as ChatModel).getRequests()[0].response?.response.value ?? []) + .filter(part => part.kind === 'systemNotification').length, + }, { computeCalls: 0, hints: 0 }); + }); + test('sendRequest passes agent host session capabilities to the request parser', async () => { const sessionType = 'agent-host-copilot'; const sessionResource = URI.from({ scheme: sessionType, path: '/session' }); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index dc611e8fe6615b..608b50a2ee0474 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -5,6 +5,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -26,6 +27,10 @@ export class MockChatService implements IChatService { private readonly _onDidCreateModel = new Emitter(); readonly onDidCreateModel = this._onDidCreateModel.event; + registerCustomizationMigrationHintProvider(_provider: (sessionResource: URI) => Promise): IDisposable { + return Disposable.None; + } + private readonly sessions = new ResourceMap(); private liveSessionItems: IChatDetail[] = []; private historySessionItems: IChatDetail[] = []; diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 56a6552bb6f952..8af226d864dcaf 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -22,6 +22,7 @@ import { IOpenerService } from '../../../../../platform/opener/common/opener.js' import { IExtensionService, nullExtensionDescription } from '../../../../services/extensions/common/extensions.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -41,9 +42,8 @@ suite('LanguageModels', function () { const store = new DisposableStore(); const activationEvents = new Set(); - setup(function () { - - languageModels = new LanguageModelsService( + function createLanguageModelsService(storageService: IStorageService): LanguageModelsService { + return new LanguageModelsService( new class extends mock() { override activateByEvent(name: string) { activationEvents.add(name); @@ -51,7 +51,7 @@ suite('LanguageModels', function () { } }, new NullLogService(), - new TestStorageService(), + storageService, new MockContextKeyService(), new class extends mock() { override onDidChangeLanguageModelGroups = Event.None; @@ -67,6 +67,11 @@ suite('LanguageModels', function () { NullOpenerService, NullTelemetryService, ); + } + + setup(function () { + + languageModels = createLanguageModelsService(new TestStorageService()); languageModels.deltaLanguageModelChatProviderDescriptors([ { vendor: 'test-vendor', displayName: 'Test Vendor', configuration: undefined, managementCommand: undefined, when: undefined }, @@ -594,6 +599,121 @@ suite('LanguageModels', function () { byokCopyHidden: false, }); }); + + /** Registers the default provider with its `Auto` model plus one regular model. */ + async function registerDefaultVendorWithAutoModel(): Promise { + languageModels.deltaLanguageModelChatProviderDescriptors([ + { vendor: COPILOT_VENDOR_ID, displayName: 'GitHub Copilot', configuration: undefined, managementCommand: undefined, when: undefined } + ], []); + store.add(languageModels.registerLanguageModelProvider(COPILOT_VENDOR_ID, { + onDidChange: Event.None, + provideLanguageModelChatInfo: async () => [ + { + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Auto', vendor: COPILOT_VENDOR_ID, family: 'auto', version: '1.0', + id: 'auto', maxInputTokens: 100, maxOutputTokens: 100, isDefaultForLocation: {}, + } satisfies ILanguageModelChatMetadata, + identifier: 'copilot/auto', + }, + { + metadata: { + extension: nullExtensionDescription.identifier, + name: 'GPT-5', vendor: COPILOT_VENDOR_ID, family: 'gpt-5', version: '1.0', + id: 'gpt-5', maxInputTokens: 100, maxOutputTokens: 100, isDefaultForLocation: {}, + } satisfies ILanguageModelChatMetadata, + identifier: 'copilot/gpt-5', + }, + ], + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); }, + })); + await languageModels.selectLanguageModels({ vendor: COPILOT_VENDOR_ID }); + } + + test('model visibility — the default provider Auto model cannot be hidden, directly or via its group', async function () { + // Auto has no row in Manage Language Models, so hiding it would be unrecoverable. + await registerDefaultVendorWithAutoModel(); + + let fired = 0; + store.add(languageModels.onDidChangeModelVisibility(() => fired++)); + + languageModels.setModelHidden('copilot/auto', true); + assert.deepStrictEqual({ hiddenModels: languageModels.getHiddenModelIds(), events: fired }, { hiddenModels: [], events: 0 }); + + languageModels.setGroupHidden(COPILOT_VENDOR_ID, 'GitHub Copilot', true); + + assert.deepStrictEqual({ + hiddenModels: languageModels.getHiddenModelIds(), + autoHidden: languageModels.isModelHidden('copilot/auto'), + groupHidden: languageModels.isGroupHidden(COPILOT_VENDOR_ID, 'GitHub Copilot'), + events: fired, + }, { + hiddenModels: ['copilot/gpt-5'], + autoHidden: false, + groupHidden: true, + events: 1, + }); + }); + + test('model visibility — a non-default provider Auto model stays hideable', async function () { + // Copilot CLI's Auto gets a row, so it has a toggle that can bring it back. + languageModels.deltaLanguageModelChatProviderDescriptors([ + { vendor: 'copilotcli', displayName: 'Copilot CLI', configuration: undefined, managementCommand: undefined, when: undefined } + ], []); + store.add(languageModels.registerLanguageModelProvider('copilotcli', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async () => [ + { + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Auto', vendor: 'copilotcli', family: 'auto', version: '1.0', + id: 'auto', maxInputTokens: 100, maxOutputTokens: 100, isDefaultForLocation: {}, + targetChatSessionType: 'copilotcli', + } satisfies ILanguageModelChatMetadata, + identifier: 'copilotcli/auto', + }, + ], + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); }, + })); + await languageModels.selectLanguageModels({ vendor: 'copilotcli' }); + + languageModels.setModelHidden('copilotcli/auto', true); + + assert.deepStrictEqual({ + hiddenModels: languageModels.getHiddenModelIds(), + autoHidden: languageModels.isModelHidden('copilotcli/auto'), + }, { + hiddenModels: ['copilotcli/auto'], + autoHidden: true, + }); + }); + + test('model visibility — an Auto model hidden by an older version is visible again', function () { + // Recovery for users already stuck: no UI can clear the entry, so it is ignored + // on read. Asserted before models resolve, which is when the picker first asks. + // Only one service at a time can own the language model provider extension point. + languageModels.dispose(); + + const storageService = store.add(new TestStorageService()); + storageService.store( + 'chatModelVisibility', + JSON.stringify({ hiddenModels: ['copilot/auto', 'copilot/gpt-5'] }), + StorageScope.PROFILE, + StorageTarget.USER, + ); + + const service = store.add(createLanguageModelsService(storageService)); + + assert.deepStrictEqual({ + autoHidden: service.isModelHidden('copilot/auto'), + siblingHidden: service.isModelHidden('copilot/gpt-5'), + }, { + autoHidden: false, + siblingHidden: true, + }); + }); }); suite('LanguageModels - When Clause', function () { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationService.test.ts deleted file mode 100644 index 3059376385b7ce..00000000000000 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationService.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Codicon } from '../../../../../../../base/common/codicons.js'; -import { Event } from '../../../../../../../base/common/event.js'; -import { URI } from '../../../../../../../base/common/uri.js'; -import { mock } from '../../../../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../../common/customizationHarnessService.js'; -import { SessionType } from '../../../../common/chatSessionsService.js'; -import { PromptFileSource, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; -import { CustomizationMigrationService } from '../../../../common/promptSyntax/service/customizationMigrationServiceImpl.js'; -import { IPromptPath, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; -import { MockPromptsService } from './mockPromptsService.js'; - -class TestPromptsService extends MockPromptsService { - readonly requestedTypes: PromptsType[] = []; - - constructor(private readonly files: readonly IPromptPath[]) { - super(); - } - - override async listPromptFiles(type: PromptsType): Promise { - this.requestedTypes.push(type); - return this.files.filter(file => file.type === type); - } -} - -class TestCustomizationHarnessService extends mock() { - readonly requestedSourceFolderTypes: PromptsType[] = []; - - constructor( - private readonly sessionType = SessionType.AgentHostCopilot, - private readonly harnessLabel = 'Copilot', - ) { - super(); - } - - override findHarnessById(sessionType: string): IHarnessDescriptor | undefined { - if (sessionType !== this.sessionType) { - return undefined; - } - return { - id: sessionType, - label: this.harnessLabel, - icon: Codicon.copilot, - itemProvider: { - onDidChange: Event.None, - provideChatSessionCustomizations: async () => [], - provideSourceFolders: async (_sessionResource, type) => { - this.requestedSourceFolderTypes.push(type); - switch (type) { - case PromptsType.agent: - return [{ uri: URI.file('/copilot/agents'), label: 'Agents', source: PromptsStorage.user }]; - case PromptsType.skill: - return [ - { uri: URI.file('/workspace/.github/skills'), label: 'Workspace Skills', source: PromptsStorage.local }, - { uri: URI.file('/copilot/skills'), label: 'User Skills', source: PromptsStorage.user }, - ]; - default: - return []; - } - }, - }, - }; - } -} - -suite('CustomizationMigrationService', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - test('computes the dialog migration candidates for Agent Host sessions', async () => { - const promptsService = store.add(new TestPromptsService([ - { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace }, - { uri: URI.file('/user-data/prompts/release.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, source: PromptFileSource.UserData }, - { uri: URI.file('/user-data/prompts/reviewer.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, source: PromptFileSource.UserData }, - { uri: URI.file('/user-data/prompts/style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, source: PromptFileSource.UserData }, - { uri: URI.file('/home/test/.copilot/agents/planner.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, source: PromptFileSource.CopilotPersonal }, - { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, source: PromptFileSource.GitHubWorkspace }, - ])); - const harnessService = new TestCustomizationHarnessService(); - const service = new CustomizationMigrationService(promptsService, harnessService); - const agentHostSessionResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }); - const localSessionResource = URI.from({ scheme: SessionType.Local, path: '/session' }); - - const migrations = await service.computeMigrations(agentHostSessionResource); - const localMigrations = await service.computeMigrations(localSessionResource); - const hint = await service.computeMigrationHint(agentHostSessionResource); - const localHint = await service.computeMigrationHint(localSessionResource); - - assert.deepStrictEqual({ - migrations: migrations.map(migration => ({ - type: migration.type, - files: migration.files.map(file => file.path), - candidates: migration.candidates.map(candidate => candidate.uri.path), - })), - localMigrations, - hint, - localHint, - requestedTypes: promptsService.requestedTypes, - requestedSourceFolderTypes: harnessService.requestedSourceFolderTypes.toSorted(), - }, { - migrations: [ - { - type: 'userData', - files: [ - '/user-data/prompts/reviewer.agent.md', - ], - candidates: [ - '/user-data/prompts/reviewer.agent.md', - ], - }, - { - type: 'promptFiles', - files: [ - '/workspace/.github/prompts/review.prompt.md', - '/user-data/prompts/release.prompt.md', - ], - candidates: [ - '/workspace/.github/prompts/review.prompt.md', - '/user-data/prompts/release.prompt.md', - ], - }, - ], - localMigrations: [ - { type: 'userData', files: [], candidates: [] }, - { type: 'promptFiles', files: [], candidates: [] }, - ], - hint: 'Found 3 customization files that are present but not used by Copilot and could be migrated.', - localHint: undefined, - requestedTypes: [ - PromptsType.agent, PromptsType.instructions, PromptsType.prompt, - PromptsType.agent, PromptsType.instructions, PromptsType.prompt, - ], - requestedSourceFolderTypes: [ - PromptsType.agent, PromptsType.agent, PromptsType.instructions, - PromptsType.instructions, PromptsType.skill, PromptsType.skill, - ], - }); - }); - - test('uses the session harness label in migration hints', async () => { - const promptsService = store.add(new TestPromptsService([ - { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace }, - ])); - const harnessService = new TestCustomizationHarnessService(SessionType.AgentHostClaude, 'Claude'); - const service = new CustomizationMigrationService(promptsService, harnessService); - - const hint = await service.computeMigrationHint(URI.from({ scheme: SessionType.AgentHostClaude, path: '/session' })); - - assert.strictEqual(hint, 'Found 1 customization file that is present but not used by Claude and could be migrated.'); - }); -}); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 18f1eab4e027eb..63f77bf4783a97 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -915,6 +915,154 @@ suite('RunSubagentTool', () => { }); }); + suite('subagent allowlist', () => { + let callIdCounter = 0; + + function createAgent(name: string, agents?: readonly string[]): ICustomAgent { + const id = `file:///test/${name}.md`; + return { + id, + uri: URI.parse(id), + name, + description: `Agent ${name}`, + agents, + agentInstructions: { content: `${name} instructions`, toolReferences: [] }, + source: { storage: PromptsStorage.local }, + target: Target.Undefined, + visibility: { userInvocable: true, agentInvocable: true }, + enabled: true + }; + } + + function createAllowlistTool(opts: { + customAgents: ICustomAgent[]; + currentModeInstructions: IChatRequestModeInstructions; + capturedRequests?: IChatAgentRequest[]; + }) { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const promptsService = new MockPromptsService(); + promptsService.setCustomModes(opts.customAgents); + + const mockChatAgentService: Pick = { + getDefaultAgent() { + return { id: 'default-agent' } as IChatAgentService extends { getDefaultAgent(...args: infer _A): infer R } ? NonNullable : never; + }, + async invokeAgent(_id: string, request: IChatAgentRequest, _progress: (parts: IChatProgress[]) => void, _history: IChatAgentHistoryEntry[], _token: CancellationToken): Promise { + opts.capturedRequests?.push(request); + return {}; + }, + }; + + const mockChatService: Pick = { + getSession() { + return { + getRequests: () => [{ + id: 'req-1', + modeInfo: { + kind: undefined, + isBuiltin: false, + modeInstructions: opts.currentModeInstructions, + telemetryModeId: 'custom', + applyCodeBlockSuggestionId: undefined, + }, + }], + acceptResponseProgress: () => { }, + } as unknown as IChatModel; + }, + }; + + const mockInstantiationService: Pick = { + createInstance(..._args: never[]): { collect: () => Promise } { + return { collect: async () => { } }; + }, + }; + + return testDisposables.add(new RunSubagentTool( + mockChatAgentService as IChatAgentService, + mockChatService as IChatService, + mockToolsService, + {} as ILanguageModelsService, + new NullLogService(), + new TestConfigurationService(), + promptsService, + mockInstantiationService as IInstantiationService, + {} as IProductService, + )); + } + + function createInvocation(agentName: string): IToolInvocation { + return { + callId: `allowlist-call-${++callIdCounter}`, + toolId: 'runSubagent', + parameters: { prompt: 'do something', description: 'test', agentName }, + context: { sessionResource: URI.parse('test://session/allowlist') }, + userSelectedTools: { runSubagent: true }, + } as IToolInvocation; + } + + const countTokens = async () => 0; + const noProgress: ToolProgress = { report() { } }; + + test('prepareToolInvocation rejects a requested agent outside the current allowlist', async () => { + const tool = createAllowlistTool({ + customAgents: [createAgent('Allowed'), createAgent('Forbidden')], + currentModeInstructions: { name: 'Coordinator', content: 'Coordinate', toolReferences: [], allowedSubagents: ['Allowed'] }, + }); + + await assert.rejects( + () => tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'Forbidden' }, + toolCallId: 'allowlist-prepare', + chatSessionResource: URI.parse('test://session/allowlist'), + }, CancellationToken.None), + (err: Error) => { + assert.ok(err.message.includes('Requested agent \'Forbidden\' is not allowed')); + return true; + } + ); + }); + + test('invoke rejects a requested agent outside the current allowlist', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const tool = createAllowlistTool({ + customAgents: [createAgent('Allowed'), createAgent('Forbidden')], + currentModeInstructions: { name: 'Coordinator', content: 'Coordinate', toolReferences: [], allowedSubagents: ['Allowed'] }, + capturedRequests, + }); + + const result = await tool.invoke(createInvocation('Forbidden'), countTokens, noProgress, CancellationToken.None); + + assert.deepStrictEqual({ + requestCount: capturedRequests.length, + result: result.content[0].kind === 'text' ? result.content[0].value : undefined, + }, { + requestCount: 0, + result: 'Error invoking subagent: Requested agent \'Forbidden\' is not allowed by the current agent.', + }); + }); + + test('invoke forwards the selected subagent allowlist to nested requests', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const tool = createAllowlistTool({ + customAgents: [createAgent('Allowed', ['Nested']), createAgent('Nested')], + currentModeInstructions: { name: 'Coordinator', content: 'Coordinate', toolReferences: [], allowedSubagents: ['Allowed'] }, + capturedRequests, + }); + + await tool.invoke(createInvocation('Allowed'), countTokens, noProgress, CancellationToken.None); + + assert.deepStrictEqual({ + requestCount: capturedRequests.length, + subAgentName: capturedRequests[0]?.subAgentName, + allowedSubagents: capturedRequests[0]?.modeInstructions?.allowedSubagents, + }, { + requestCount: 1, + subAgentName: 'Allowed', + allowedSubagents: ['Nested'], + }); + }); + }); + suite('nested subagent depth tracking', () => { /** * Creates a RunSubagentTool with mocked services suitable for invoke() testing. diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionService.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionService.test.ts index 420530b1405354..0fa6cd7edfd6d2 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionService.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionService.test.ts @@ -44,6 +44,7 @@ import { IChatSlashCommandService } from '../../../chat/common/participants/chat import { ChatTransferService, IChatTransferService } from '../../../chat/common/model/chatTransferService.js'; import { IChatVariablesService } from '../../../chat/common/attachments/chatVariables.js'; import { ILanguageModelsService } from '../../../chat/common/languageModels.js'; +import { ICustomizationMigrationService } from '../../../chat/common/promptSyntax/service/customizationMigrationService.js'; import { IPromptsService } from '../../../chat/common/promptSyntax/service/promptsService.js'; import { MockChatVariablesService } from '../../../chat/test/common/mockChatVariables.js'; import { NullLanguageModelsService } from '../../../chat/test/common/languageModels.js'; @@ -170,6 +171,7 @@ suite('InlineChatSessionService', () => { collection.set(IEditorWorkerService, new SyncDescriptor(TestWorkerService)); collection.set(IChatService, new SyncDescriptor(ChatService)); collection.set(IMcpService, new TestMcpService()); + collection.set(ICustomizationMigrationService, new class extends mock() { }); collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); collection.set(IChatDebugService, store.add(new ChatDebugServiceImpl(new TestConfigurationService(), store.add(new MockContextKeyService())))); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.ts index 5de530d3f79cf4..b5a80bc722d8d9 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.ts @@ -37,7 +37,7 @@ import { IChatWidgetService } from '../../chat/browser/chat.js'; import { isAgentHostTarget } from '../../chat/common/chatSessionsService.js'; import { getChatSessionType } from '../../chat/common/model/chatUri.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; -import { allDiscoverySources, DiscoverySource, mcpDiscoverySection, mcpStdioServerSchema } from '../common/mcpConfiguration.js'; +import { allDiscoverySources, ExternalDiscoverySource, mcpDiscoverySection, mcpStdioServerSchema } from '../common/mcpConfiguration.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; import { IMcpService, McpConnectionState } from '../common/mcpTypes.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -196,7 +196,7 @@ export class McpAddConfigurationCommand { items.push({ type: 'separator' }); - const discovery = this._configurationService.getValue<{ [K in DiscoverySource]: boolean }>(mcpDiscoverySection); + const discovery = this._configurationService.getValue<{ [K in ExternalDiscoverySource]: boolean }>(mcpDiscoverySection); if (discovery && typeof discovery === 'object' && allDiscoverySources.some(d => !discovery[d])) { items.push({ kind: 'discovery', diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index 36ab12131eed76..3daeb4e8bc89fa 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -316,6 +316,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ // Source identity is intentionally trusted only in-process; IPC copies are re-verified. private readonly gallerySourceGenerations = new WeakMap(); private readonly registrySyncDelayer = this._register(new ThrottledDelayer(0)); + readonly whenInitialLocalMcpServersLoaded: Promise; get local(): readonly McpWorkbenchServer[] { return [...this._local]; } private readonly _onChange = this._register(new Emitter()); @@ -350,7 +351,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ this._register(this.mcpManagementService.onDidUpdateMcpServersInCurrentProfile(e => this.onDidUpdateMcpServers(e))); this._register(this.mcpManagementService.onDidUninstallMcpServerInCurrentProfile(e => this.onDidUninstallMcpServer(e))); this._register(this.mcpManagementService.onDidChangeProfile(e => this.onDidChangeProfile())); - this.queryLocal().then(() => { + this.whenInitialLocalMcpServersLoaded = this.queryLocal().then(() => { if (this._store.isDisposed) { return; } @@ -359,7 +360,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ this.scheduleRegistrySync(); })); this.scheduleRegistrySync(); - }); + }, error => this.logService.error(error)); urlService.registerHandler(this); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(mcpAccessConfig)) { diff --git a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts index 925edaf562f783..0a12308df7e759 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts @@ -15,7 +15,7 @@ import { IExtensionService } from '../../../../services/extensions/common/extens import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; import { mcpActivationEvent, mcpContributionPoint } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { extensionPrefixedIdentifier, McpCollectionSortOrder, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; +import { extensionPrefixedIdentifier, McpCollectionProvenance, McpCollectionSortOrder, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; const cacheKey = 'mcp.extCachedServers'; @@ -117,6 +117,7 @@ export class ExtensionMcpDiscovery extends Disposable implements IMcpDiscovery { const serverDefs = this.cachedServers.hasOwnProperty(id) ? this.cachedServers[id].servers : undefined; const dispo = this._mcpRegistry.registerCollection({ id, + provenance: McpCollectionProvenance.Extension, label: coll.label, remoteAuthority: null, trustBehavior: McpServerTrust.Kind.Trusted, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index 0debd5056efd87..c232c407332d38 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -18,7 +18,7 @@ import { IWorkbenchLocalMcpServer } from '../../../../services/mcp/common/mcpWor import { getMcpServerMapping } from '../mcpConfigFileUtils.js'; import { mcpConfigurationSection } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { IMcpConfigPath, IMcpWorkbenchService, MCP_CONFIGURATION_COLLECTION_ID_PREFIX, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; +import { getMcpCollectionProvenance, IMcpConfigPath, IMcpWorkbenchService, MCP_CONFIGURATION_COLLECTION_ID_PREFIX, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; interface CollectionState extends IDisposable { @@ -85,20 +85,11 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc collections.set(collectionId, definitions); } - const launch: McpServerLaunch = config.type === 'http' ? { - type: McpServerTransportType.HTTP, - uri: URI.parse(config.url), - headers: Object.entries(config.headers || {}), - oauth: config.oauth, - } : { - type: McpServerTransportType.Stdio, - command: config.command, - args: config.args || [], - env: config.env || {}, - envFile: config.envFile, - cwd: config.cwd, - sandbox: server.rootSandbox - }; + const launch = McpServerLaunch.fromServerConfiguration(config, server.rootSandbox); + if (!launch) { + this.logService.warn(`Skipping invalid MCP server configuration: ${server.name}`); + continue; + } const defaultCwd = config.type === 'http' ? undefined : mcpConfigPath?.workspaceFolder?.uri; definitions[1].push({ @@ -132,6 +123,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc const newServerDefinitions = observableValue(this, serverDefinitions); const newCollection: McpCollectionDefinition = { id, + provenance: getMcpCollectionProvenance(mcpConfigPath?.target), label: mcpConfigPath?.label ?? '', order: mcpConfigPath?.order ?? McpCollectionSortOrder.User, presentation: { diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts index d17fa5e9b22c6e..db00f608a2db2c 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts @@ -18,9 +18,9 @@ import { INativeMcpDiscoveryData } from '../../../../../platform/mcp/common/nati import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { StorageScope } from '../../../../../platform/storage/common/storage.js'; import { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; -import { DiscoverySource, discoverySourceLabel, mcpDiscoverySection } from '../mcpConfiguration.js'; +import { ExternalDiscoverySource, discoverySourceLabel, mcpDiscoverySection } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; +import { McpCollectionDefinition, McpCollectionProvenance, McpCollectionSortOrder, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; import { ClaudeDesktopMpcDiscoveryAdapter, CursorDesktopMpcDiscoveryAdapter, NativeMpcDiscoveryAdapter, WindsurfDesktopMpcDiscoveryAdapter } from './nativeMcpDiscoveryAdapters.js'; @@ -30,7 +30,7 @@ export abstract class FilesystemMcpDiscovery extends Disposable implements IMcpD readonly fromGallery: boolean = false; - protected readonly _fsDiscoveryEnabled: IObservable<{ [K in DiscoverySource]: boolean } | undefined>; + protected readonly _fsDiscoveryEnabled: IObservable<{ [K in ExternalDiscoverySource]: boolean } | undefined>; constructor( @IConfigurationService configurationService: IConfigurationService, @@ -42,7 +42,7 @@ export abstract class FilesystemMcpDiscovery extends Disposable implements IMcpD this._fsDiscoveryEnabled = observableConfigValue(mcpDiscoverySection, undefined, configurationService); } - protected _isDiscoveryEnabled(reader: IReader, discoverySource: DiscoverySource): boolean { + protected _isDiscoveryEnabled(reader: IReader, discoverySource: ExternalDiscoverySource): boolean { const fsDiscovery = this._fsDiscoveryEnabled.read(reader); if (typeof fsDiscovery === 'boolean') { return fsDiscovery; // old commands @@ -56,7 +56,7 @@ export abstract class FilesystemMcpDiscovery extends Disposable implements IMcpD protected watchFile( file: URI, collection: WritableMcpCollectionDefinition, - discoverySource: DiscoverySource, + discoverySource: ExternalDiscoverySource, adaptFile: (contents: VSBuffer) => Promise, ): IDisposable { const store = new DisposableStore(); @@ -145,6 +145,8 @@ export abstract class NativeFilesystemMcpDiscovery extends FilesystemMcpDiscover const collection: WritableMcpCollectionDefinition = { id: adapter.id, + provenance: McpCollectionProvenance.ExternalConfiguration, + discoverySource: adapter.discoverySource, label: discoverySourceLabel[adapter.discoverySource] + this.suffix, remoteAuthority: adapter.remoteAuthority, configTarget: ConfigurationTarget.USER, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts index c6a43be4837dc2..655666d6c6b378 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts @@ -8,14 +8,14 @@ import { Platform } from '../../../../../base/common/platform.js'; import { Mutable } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { INativeMcpDiscoveryData } from '../../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; -import { DiscoverySource } from '../mcpConfiguration.js'; +import { ExternalDiscoverySource } from '../mcpConfiguration.js'; import { McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType } from '../mcpTypes.js'; export interface NativeMpcDiscoveryAdapter { readonly remoteAuthority: string | null; readonly id: string; readonly order: number; - readonly discoverySource: DiscoverySource; + readonly discoverySource: ExternalDiscoverySource; getFilePath(details: INativeMcpDiscoveryData): URI | undefined; adaptFile(contents: VSBuffer, details: INativeMcpDiscoveryData): Promise; @@ -69,7 +69,7 @@ export async function claudeConfigToServerDefinition(idPrefix: string, contents: export class ClaudeDesktopMpcDiscoveryAdapter implements NativeMpcDiscoveryAdapter { public id: string; public readonly order = McpCollectionSortOrder.Filesystem; - public readonly discoverySource: DiscoverySource = DiscoverySource.ClaudeDesktop; + public readonly discoverySource: ExternalDiscoverySource = ExternalDiscoverySource.ClaudeDesktop; constructor(public readonly remoteAuthority: string | null) { this.id = `claude-desktop.${this.remoteAuthority}`; @@ -93,7 +93,7 @@ export class ClaudeDesktopMpcDiscoveryAdapter implements NativeMpcDiscoveryAdapt } export class WindsurfDesktopMpcDiscoveryAdapter extends ClaudeDesktopMpcDiscoveryAdapter { - public override readonly discoverySource: DiscoverySource = DiscoverySource.Windsurf; + public override readonly discoverySource: ExternalDiscoverySource = ExternalDiscoverySource.Windsurf; constructor(remoteAuthority: string | null) { super(remoteAuthority); @@ -106,7 +106,7 @@ export class WindsurfDesktopMpcDiscoveryAdapter extends ClaudeDesktopMpcDiscover } export class CursorDesktopMpcDiscoveryAdapter extends ClaudeDesktopMpcDiscoveryAdapter { - public override readonly discoverySource: DiscoverySource = DiscoverySource.CursorGlobal; + public override readonly discoverySource: ExternalDiscoverySource = ExternalDiscoverySource.CursorGlobal; constructor(remoteAuthority: string | null) { super(remoteAuthority); diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 8023623358dd3e..b128bae306c1b7 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -11,7 +11,6 @@ import { autorun } from '../../../../../base/common/observable.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; -import { IMcpServerConfiguration, McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IAgentPlugin, @@ -20,7 +19,7 @@ import { } from '../../../chat/common/plugins/agentPluginService.js'; import { isContributionEnabled } from '../../../chat/common/enablement.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { MCP_PLUGIN_COLLECTION_ID_PREFIX, McpCollectionProvenance, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; +import { MCP_PLUGIN_COLLECTION_ID_PREFIX, McpCollectionProvenance, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; /** @@ -97,7 +96,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { collectionId: string, { name, configuration, defaultCwd }: IAgentPluginMcpServerDefinition, ): McpServerDefinition | undefined { - const launch = this._toLaunch(configuration); + const launch = McpServerLaunch.fromServerConfiguration(configuration); if (!launch) { return undefined; } @@ -111,29 +110,4 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { cacheNonce: String(hash(launch)), }; } - - private _toLaunch(config: IMcpServerConfiguration): McpServerLaunch | undefined { - if (config.type === McpServerType.LOCAL) { - return { - type: McpServerTransportType.Stdio, - command: config.command, - args: config.args ? [...config.args] : [], - env: config.env ? { ...config.env } : {}, - envFile: config.envFile, - cwd: config.cwd, - sandbox: undefined, - }; - } - - try { - return { - type: McpServerTransportType.HTTP, - uri: URI.parse(config.url), - headers: Object.entries(config.headers ?? {}), - oauth: config.oauth, - }; - } catch { - return undefined; - } - } } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts index d296fb8440fa51..e0144cdc956186 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts @@ -13,7 +13,7 @@ import { StorageScope } from '../../../../../platform/storage/common/storage.js' import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { McpCollectionSortOrder, McpServerDefinition, McpServerTrust, WORKSPACE_DOT_MCP_COLLECTION_ID_PREFIX } from '../mcpTypes.js'; +import { McpCollectionProvenance, McpCollectionSortOrder, McpServerDefinition, McpServerTrust, WORKSPACE_DOT_MCP_COLLECTION_ID_PREFIX } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; import { claudeConfigToServerDefinition } from './nativeMcpDiscoveryAdapters.js'; @@ -57,6 +57,7 @@ export class WorkspaceDotMcpDiscovery extends Disposable implements IMcpDiscover const collection = { id: collectionId, + provenance: McpCollectionProvenance.WorkspaceDotMcp, label: `${folder.name}/.mcp.json`, remoteAuthority: this._remoteAgentService.getConnection()?.remoteAuthority || null, scope: StorageScope.WORKSPACE, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts b/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts index 15a80fcac125ce..0152c64f86a85e 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts @@ -12,9 +12,9 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; -import { DiscoverySource } from '../mcpConfiguration.js'; +import { ExternalDiscoverySource } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { McpCollectionSortOrder, McpServerTrust } from '../mcpTypes.js'; +import { CURSOR_WORKSPACE_MCP_COLLECTION_ID_PREFIX, McpCollectionProvenance, McpCollectionSortOrder, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; import { FilesystemMcpDiscovery, WritableMcpCollectionDefinition } from './nativeMcpDiscoveryAbstract.js'; import { claudeConfigToServerDefinition } from './nativeMcpDiscoveryAdapters.js'; @@ -50,7 +50,9 @@ export class CursorWorkspaceMcpDiscoveryAdapter extends FilesystemMcpDiscovery i private watchFolder(folder: IWorkspaceFolder) { const configFile = joinPath(folder.uri, '.cursor', 'mcp.json'); const collection: WritableMcpCollectionDefinition = { - id: `cursor-workspace.${folder.index}`, + id: `${CURSOR_WORKSPACE_MCP_COLLECTION_ID_PREFIX}${folder.index}`, + provenance: McpCollectionProvenance.ExternalConfiguration, + discoverySource: ExternalDiscoverySource.CursorWorkspace, label: `${folder.name}/.cursor/mcp.json`, remoteAuthority: this._remoteAgentService.getConnection()?.remoteAuthority || null, scope: StorageScope.WORKSPACE, @@ -66,7 +68,7 @@ export class CursorWorkspaceMcpDiscoveryAdapter extends FilesystemMcpDiscovery i this._collections.set(folder.uri.toString(), this.watchFile( URI.joinPath(folder.uri, '.cursor', 'mcp.json'), collection, - DiscoverySource.CursorWorkspace, + ExternalDiscoverySource.CursorWorkspace, async contents => { const defs = await claudeConfigToServerDefinition(collection.id, contents, { defaultCwd: folder.uri }); defs?.forEach(d => d.roots = [folder.uri]); diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index 265071cc6c7ac8..c42f18e940985a 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -24,7 +24,7 @@ const mcpActivationEventPrefix = 'onMcpCollection:'; export const mcpActivationEvent = (contributedCollectionId: string) => mcpActivationEventPrefix + contributedCollectionId; -export const enum DiscoverySource { +export const enum ExternalDiscoverySource { ClaudeDesktop = 'claude-desktop', Windsurf = 'windsurf', CursorGlobal = 'cursor-global', @@ -32,23 +32,23 @@ export const enum DiscoverySource { } export const allDiscoverySources = Object.keys({ - [DiscoverySource.ClaudeDesktop]: true, - [DiscoverySource.Windsurf]: true, - [DiscoverySource.CursorGlobal]: true, - [DiscoverySource.CursorWorkspace]: true, -} satisfies Record) as DiscoverySource[]; + [ExternalDiscoverySource.ClaudeDesktop]: true, + [ExternalDiscoverySource.Windsurf]: true, + [ExternalDiscoverySource.CursorGlobal]: true, + [ExternalDiscoverySource.CursorWorkspace]: true, +} satisfies Record) as ExternalDiscoverySource[]; -export const discoverySourceLabel: Record = { - [DiscoverySource.ClaudeDesktop]: localize('mcp.discovery.source.claude-desktop', "Claude Desktop"), - [DiscoverySource.Windsurf]: localize('mcp.discovery.source.windsurf', "Windsurf"), - [DiscoverySource.CursorGlobal]: localize('mcp.discovery.source.cursor-global', "Cursor (Global)"), - [DiscoverySource.CursorWorkspace]: localize('mcp.discovery.source.cursor-workspace', "Cursor (Workspace)"), +export const discoverySourceLabel: Record = { + [ExternalDiscoverySource.ClaudeDesktop]: localize('mcp.discovery.source.claude-desktop', "Claude Desktop"), + [ExternalDiscoverySource.Windsurf]: localize('mcp.discovery.source.windsurf', "Windsurf"), + [ExternalDiscoverySource.CursorGlobal]: localize('mcp.discovery.source.cursor-global', "Cursor (Global)"), + [ExternalDiscoverySource.CursorWorkspace]: localize('mcp.discovery.source.cursor-workspace', "Cursor (Workspace)"), }; -export const discoverySourceSettingsLabel: Record = { - [DiscoverySource.ClaudeDesktop]: localize('mcp.discovery.source.claude-desktop.config', "Claude Desktop configuration (`claude_desktop_config.json`)"), - [DiscoverySource.Windsurf]: localize('mcp.discovery.source.windsurf.config', "Windsurf configurations (`~/.codeium/windsurf/mcp_config.json`)"), - [DiscoverySource.CursorGlobal]: localize('mcp.discovery.source.cursor-global.config', "Cursor global configuration (`~/.cursor/mcp.json`)"), - [DiscoverySource.CursorWorkspace]: localize('mcp.discovery.source.cursor-workspace.config', "Cursor workspace configuration (`.cursor/mcp.json`)"), +export const discoverySourceSettingsLabel: Record = { + [ExternalDiscoverySource.ClaudeDesktop]: localize('mcp.discovery.source.claude-desktop.config', "Claude Desktop configuration (`claude_desktop_config.json`)"), + [ExternalDiscoverySource.Windsurf]: localize('mcp.discovery.source.windsurf.config', "Windsurf configurations (`~/.codeium/windsurf/mcp_config.json`)"), + [ExternalDiscoverySource.CursorGlobal]: localize('mcp.discovery.source.cursor-global.config', "Cursor global configuration (`~/.cursor/mcp.json`)"), + [ExternalDiscoverySource.CursorWorkspace]: localize('mcp.discovery.source.cursor-workspace.config', "Cursor workspace configuration (`.cursor/mcp.json`)"), }; export const mcpConfigurationSection = 'mcp'; @@ -390,4 +390,3 @@ Registry.as(Extensions.ExtensionFeaturesRegistry).re }, renderer: new SyncDescriptor(McpServerDefinitionsProviderRenderer), }); - diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 38efc38e01d854..38f3ccc63750fd 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -25,13 +25,13 @@ import { ExtensionIdentifier } from '../../../../platform/extensions/common/exte import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { McpGalleryManifestStatus } from '../../../../platform/mcp/common/mcpGalleryManifest.js'; import { IGalleryMcpServer, IGalleryMcpServerConfiguration, IInstallableMcpServer, IQueryOptions } from '../../../../platform/mcp/common/mcpManagement.js'; -import { IMcpDevModeConfig, IMcpSandboxConfiguration, IMcpServerConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { IMcpDevModeConfig, IMcpSandboxConfiguration, IMcpServerConfiguration, McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceFolder, IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchLocalMcpServer, IWorkbencMcpServerInstallOptions, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { ContributionEnablementState, IEnablementModel } from '../../chat/common/enablement.js'; import { ToolProgress } from '../../chat/common/tools/languageModelToolsService.js'; -import { IMcpServerSamplingConfiguration } from './mcpConfiguration.js'; +import { ExternalDiscoverySource, IMcpServerSamplingConfiguration } from './mcpConfiguration.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; import { MCP } from './modelContextProtocol.js'; import { UriTemplate } from '../../../../base/common/uriTemplate.js'; @@ -46,9 +46,34 @@ export const extensionMcpCollectionPrefix = 'ext.'; */ export const MCP_CONFIGURATION_COLLECTION_ID_PREFIX = 'mcp.config.'; export const MCP_PLUGIN_COLLECTION_ID_PREFIX = 'plugin.'; +export const CURSOR_WORKSPACE_MCP_COLLECTION_ID_PREFIX = 'cursor-workspace.'; export const enum McpCollectionProvenance { - Plugin = 'plugin', + UserProfile = 'userProfile', // User profile `mcp.json`. + RemoteUser = 'remoteUser', // Remote user profile `mcp.json`. + WorkspaceConfiguration = 'workspaceConfiguration', // The `settings.mcp` section of a `.code-workspace` file. + WorkspaceFolderConfiguration = 'workspaceFolderConfiguration', // `/.vscode/mcp.json`. + WorkspaceDotMcp = 'workspaceDotMcp', // `/.mcp.json`. + ExternalConfiguration = 'externalConfiguration', // Claude Desktop, Windsurf, or Cursor user/workspace configuration. + Extension = 'extension', // An extension-provided `McpServerDefinitionProvider`. + Plugin = 'plugin', // An agent plugin's `.mcp.json`. +} + +/** Returns the collection provenance represented by a VS Code configuration target. */ +export function getMcpCollectionProvenance(target: ConfigurationTarget | undefined): McpCollectionProvenance | undefined { + switch (target) { + case ConfigurationTarget.USER: + case ConfigurationTarget.USER_LOCAL: + return McpCollectionProvenance.UserProfile; + case ConfigurationTarget.USER_REMOTE: + return McpCollectionProvenance.RemoteUser; + case ConfigurationTarget.WORKSPACE: + return McpCollectionProvenance.WorkspaceConfiguration; + case ConfigurationTarget.WORKSPACE_FOLDER: + return McpCollectionProvenance.WorkspaceFolderConfiguration; + default: + return undefined; + } } /** @@ -71,7 +96,10 @@ export interface McpCollectionDefinition { readonly remoteAuthority: string | null; /** Globally-unique, stable ID for this definition */ readonly id: string; + /** Broad origin category; external product identity is recorded separately in {@link discoverySource}. */ readonly provenance?: McpCollectionProvenance; + /** Exact external product when {@link provenance} is `ExternalConfiguration`. */ + readonly discoverySource?: ExternalDiscoverySource; /** Human-readable label for the definition */ readonly label: string; /** Definitions this collection contains. */ @@ -138,6 +166,8 @@ export namespace McpCollectionDefinition { return a.id === b.id && a.remoteAuthority === b.remoteAuthority && a.label === b.label + && a.provenance === b.provenance + && a.discoverySource === b.discoverySource && a.trustBehavior === b.trustBehavior && objectsEqual(a.sandbox, b.sandbox); } @@ -683,6 +713,32 @@ export namespace McpServerLaunch { } } + /** Converts an MCP server configuration into the shared launch representation. */ + export function fromServerConfiguration(configuration: IMcpServerConfiguration, sandbox?: IMcpSandboxConfiguration): McpServerLaunch | undefined { + if (configuration.type === McpServerType.LOCAL) { + return { + type: McpServerTransportType.Stdio, + command: configuration.command, + args: configuration.args ? [...configuration.args] : [], + env: configuration.env ? { ...configuration.env } : {}, + envFile: configuration.envFile, + cwd: configuration.cwd, + sandbox, + }; + } + + try { + return { + type: McpServerTransportType.HTTP, + uri: URI.parse(configuration.url), + headers: Object.entries(configuration.headers ?? {}), + oauth: configuration.oauth, + }; + } catch { + return undefined; + } + } + export async function hash(launch: McpServerLaunch): Promise { const nonce = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(launch))); return encodeHex(VSBuffer.wrap(new Uint8Array(nonce))); @@ -912,6 +968,8 @@ export interface IMcpWorkbenchService { readonly onChange: Event; readonly onReset: Event; readonly local: readonly IWorkbenchMcpServer[]; + /** Resolves after the initial installed MCP server query attempt completes. Never rejects. */ + readonly whenInitialLocalMcpServersLoaded: Promise; getEnabledLocalMcpServers(): IWorkbenchLocalMcpServer[]; queryLocal(): Promise; queryGallery(options?: IQueryOptions, token?: CancellationToken): Promise>; diff --git a/src/vs/workbench/contrib/mcp/test/browser/mcpWorkbenchService.test.ts b/src/vs/workbench/contrib/mcp/test/browser/mcpWorkbenchService.test.ts index 036ea43e64c597..05ff3f06ca8b23 100644 --- a/src/vs/workbench/contrib/mcp/test/browser/mcpWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/mcp/test/browser/mcpWorkbenchService.test.ts @@ -128,6 +128,7 @@ class TestWorkbenchMcpManagementService extends mock | undefined; private readonly installedResults: Promise[] = []; + private installedError: Error | undefined; constructor(store: Pick) { super(); @@ -140,6 +141,11 @@ class TestWorkbenchMcpManagementService extends mock { + if (this.installedError) { + const error = this.installedError; + this.installedError = undefined; + throw error; + } return this.installedResults.shift() ?? this.installed; } @@ -183,6 +189,18 @@ class TestWorkbenchMcpManagementService extends mock): void { this.installedResults.push(result); } + + failNextInstalledQuery(error: Error): void { + this.installedError = error; + } +} + +class TestLogService extends NullLogService { + readonly errors: (string | Error)[] = []; + + override error(message: string | Error, ..._args: unknown[]): void { + this.errors.push(message); + } } function createGallery(name: string, remoteUrls: readonly string[] = []): IGalleryMcpServer { @@ -227,14 +245,18 @@ suite('McpWorkbenchService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - async function createFixture(installed: IWorkbenchLocalMcpServer[], accessValue: McpAccessValue = McpAccessValue.Registry) { + async function createFixture(installed: IWorkbenchLocalMcpServer[], accessValue: McpAccessValue = McpAccessValue.Registry, initialInstalledError?: Error) { const galleryService = new TestMcpGalleryService(store); const manifestService = new TestMcpGalleryManifestService(store); const managementService = new TestWorkbenchMcpManagementService(store); managementService.installed = [...installed]; + if (initialInstalledError) { + managementService.failNextInstalledQuery(initialInstalledError); + } const configurationService = new TestConfigurationService({ [mcpAccessConfig]: accessValue }); const allowedMcpServersEmitter = store.add(new Emitter()); const openedEditors: McpServerEditorInput[] = []; + const logService = store.add(new TestLogService()); const services = new ServiceCollection( [IMcpGalleryManifestService, manifestService], [IMcpGalleryService, galleryService], @@ -256,7 +278,7 @@ suite('McpWorkbenchService', () => { [IRemoteAgentService, upcastPartial({})], [IConfigurationService, configurationService], [ITelemetryService, NullTelemetryService], - [ILogService, store.add(new NullLogService())], + [ILogService, logService], [IExtensionsWorkbenchService, upcastPartial({})], [IAllowedMcpServersService, upcastPartial({ onDidChangeAllowedMcpServers: allowedMcpServersEmitter.event })], [IMcpService, upcastPartial({ servers: constObservable([]) })], @@ -265,8 +287,8 @@ suite('McpWorkbenchService', () => { ); const instantiationService = store.add(new TestInstantiationService(services)); const service = store.add(instantiationService.createInstance(McpWorkbenchService)); - await Event.toPromise(service.onChange); - return { service, galleryService, manifestService, managementService, allowedMcpServersEmitter, openedEditors }; + await service.whenInitialLocalMcpServersLoaded; + return { service, galleryService, manifestService, managementService, allowedMcpServersEmitter, openedEditors, logService }; } async function complete(request: IResolveRequest, result: Map): Promise { @@ -299,6 +321,20 @@ suite('McpWorkbenchService', () => { args: ['-c', 'open -a Calculator'], }, }); + + }); + + test('settles initial local server readiness after a failed query', async () => { + const error = new Error('Failed to query installed MCP servers'); + const { service, logService } = await createFixture([], McpAccessValue.Registry, error); + + assert.deepStrictEqual({ + local: service.local, + errors: logService.errors, + }, { + local: [], + errors: [error], + }); }); test('strips local and unknown properties from remote MCP server install URIs', async () => { diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts index 573ade7a2e2cb0..3f35d709dc8219 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { McpResourceURI, McpServerDefinition, McpServerTransportType } from '../../common/mcpTypes.js'; +import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { getMcpCollectionProvenance, McpCollectionProvenance, McpResourceURI, McpServerDefinition, McpServerLaunch, McpServerTransportType } from '../../common/mcpTypes.js'; import * as assert from 'assert'; import { URI } from '../../../../../base/common/uri.js'; @@ -143,4 +145,57 @@ suite('MCP Types', () => { deserialized: defaultCwd.toString(), }); }); + + test('McpServerLaunch converts persisted configurations', () => { + assert.deepStrictEqual({ + local: McpServerLaunch.fromServerConfiguration({ + type: McpServerType.LOCAL, + command: 'server', + args: ['--port', '3000'], + env: { TOKEN: 'value' }, + envFile: '.env', + cwd: '/workspace', + }, { network: { allowedDomains: ['example.com'] } }), + remote: McpServerLaunch.fromServerConfiguration({ + type: McpServerType.REMOTE, + url: 'https://example.com/mcp', + headers: { Authorization: 'Bearer token' }, + oauth: { clientId: 'client' }, + }), + }, { + local: { + type: McpServerTransportType.Stdio, + command: 'server', + args: ['--port', '3000'], + env: { TOKEN: 'value' }, + envFile: '.env', + cwd: '/workspace', + sandbox: { network: { allowedDomains: ['example.com'] } }, + }, + remote: { + type: McpServerTransportType.HTTP, + uri: URI.parse('https://example.com/mcp'), + headers: [['Authorization', 'Bearer token']], + oauth: { clientId: 'client' }, + }, + }); + }); + + test('maps configuration targets to collection provenance', () => { + assert.deepStrictEqual([ + getMcpCollectionProvenance(ConfigurationTarget.USER), + getMcpCollectionProvenance(ConfigurationTarget.USER_LOCAL), + getMcpCollectionProvenance(ConfigurationTarget.USER_REMOTE), + getMcpCollectionProvenance(ConfigurationTarget.WORKSPACE), + getMcpCollectionProvenance(ConfigurationTarget.WORKSPACE_FOLDER), + getMcpCollectionProvenance(undefined), + ], [ + McpCollectionProvenance.UserProfile, + McpCollectionProvenance.UserProfile, + McpCollectionProvenance.RemoteUser, + McpCollectionProvenance.WorkspaceConfiguration, + McpCollectionProvenance.WorkspaceFolderConfiguration, + undefined, + ]); + }); }); diff --git a/src/vs/workbench/contrib/snippets/browser/commands/configureSnippets.ts b/src/vs/workbench/contrib/snippets/browser/commands/configureSnippets.ts index 6add03050d1329..771c8891856bc0 100644 --- a/src/vs/workbench/contrib/snippets/browser/commands/configureSnippets.ts +++ b/src/vs/workbench/contrib/snippets/browser/commands/configureSnippets.ts @@ -113,8 +113,8 @@ async function computePicks(snippetService: ISnippetsService, userDataProfileSer const label = languageService.getLanguageName(languageId); if (label && !seen.has(languageId)) { future.push({ - label: languageId, - description: `(${label})`, + label: label, + description: `(${languageId})`, filepath: joinPath(dir, `${languageId}.json`), hint: true, iconClasses: getIconClassesForLanguageId(languageId) diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts index b6081f214ad891..688094c6839cb8 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts @@ -55,6 +55,7 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo private readonly _addon: MutableDisposable = new MutableDisposable(); private readonly _lspAddons: DisposableMap = this.add(new DisposableMap()); private readonly _lspModelProvider: MutableDisposable = new MutableDisposable(); + private readonly _lspResources = this.add(new MutableDisposable()); private readonly _terminalSuggestWidgetVisibleContextKey: IContextKey; get addon(): SuggestAddon | undefined { return this._addon.value; } @@ -82,6 +83,8 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo if (!completionsEnabled) { this._addon.clear(); this._lspAddons.clearAndDisposeAll(); + this._lspModelProvider.clear(); + this._lspResources.clear(); } const xtermRaw = this._ctx.instance.xterm?.raw; if (!!xtermRaw && completionsEnabled) { @@ -122,21 +125,28 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo } private async _loadLspCompletionAddon(xterm: RawXtermTerminal): Promise { + this._lspAddons.clearAndDisposeAll(); + this._lspModelProvider.clear(); + this._lspResources.clear(); + let lspTerminalObj = undefined; // TODO: Change to always load after settings update for terminal suggest provider if (!this._ctx.instance.shellType || !(lspTerminalObj = getTerminalLspSupportedLanguageObj(this._ctx.instance.shellType))) { - this._lspAddons.clearAndDisposeAll(); return; } + const resources = this._lspResources.value = new DisposableStore(); const virtualTerminalDocumentUri = createTerminalLanguageVirtualUri(this._ctx.instance.instanceId, lspTerminalObj.extension); // Load and register the LSP completion providers (one per language server) this._lspModelProvider.value = this._instantiationService.createInstance(LspTerminalModelContentProvider, this._ctx.instance.capabilities, this._ctx.instance.instanceId, virtualTerminalDocumentUri, this._ctx.instance.shellType); - this.add(this._lspModelProvider.value); const textVirtualModel = await this._textModelService.createModelReference(virtualTerminalDocumentUri); - this.add(textVirtualModel); + if (resources.isDisposed) { + textVirtualModel.dispose(); + return; + } + resources.add(textVirtualModel); const virtualProviders = this._languageFeaturesService.completionProvider.all(textVirtualModel.object.textEditorModel); const filteredProviders = virtualProviders.filter(p => p._debugDisplayName !== 'wordbasedCompletions'); @@ -146,7 +156,7 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo const lspCompletionProviderAddon = this._instantiationService.createInstance(LspCompletionProviderAddon, provider, textVirtualModel, this._lspModelProvider.value); this._lspAddons.set(provider._debugDisplayName, lspCompletionProviderAddon); xterm.loadAddon(lspCompletionProviderAddon); - this.add(this._terminalCompletionService.registerTerminalCompletionProvider( + resources.add(this._terminalCompletionService.registerTerminalCompletionProvider( 'lsp', lspCompletionProviderAddon.id, lspCompletionProviderAddon, diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index e090558115a80c..e1074c21e8adc3 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -95,7 +95,7 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA const createTransport = () => new AgentHostIpcChannelTransport(connection.getChannel(AgentHostIpcChannels.RemoteProxy), undefined, AgentHostClientConnectionKind.RemoteExtensionHost); const address = `vscode-remote://${connection.remoteAuthority}`; const clientInfo = environmentService.isSessionsWindow ? agentsWindowAgentHostClientInfo : editorWindowAgentHostClientInfo; - this._protocolClient = this._register(instantiationService.createInstance(AgentHostProtocolClient, address, createTransport, undefined, undefined, clientInfo)); + this._protocolClient = this._register(instantiationService.createInstance(AgentHostProtocolClient, address, createTransport, { clientInfo })); // Resources this client hands out (e.g. debug-log artifacts) are stamped with the // address-derived authority, so register it for reads. The ambient `local` authority // registered elsewhere covers a different URI namespace. diff --git a/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts b/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts index 800ea20614a771..7191dd601ab3d5 100644 --- a/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts +++ b/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts @@ -143,7 +143,7 @@ suite('EditorRemoteAgentHostServiceClient', () => { assert.deepStrictEqual({ beforeReady, afterReady: connectCalls, - clientInfo: protocolClientCall?.args[5], + clientInfo: protocolClientCall?.args[3]?.clientInfo, registeredAuthorities, }, { beforeReady: 0, diff --git a/src/vs/workbench/services/assignment/common/assignmentService.ts b/src/vs/workbench/services/assignment/common/assignmentService.ts index 30031408890e67..0cd95c3b6938a7 100644 --- a/src/vs/workbench/services/assignment/common/assignmentService.ts +++ b/src/vs/workbench/services/assignment/common/assignmentService.ts @@ -42,6 +42,27 @@ export interface IAssignmentFilter { export const IWorkbenchAssignmentService = createDecorator('assignmentService'); +/** + * Scope prefix that the new TAS assignments endpoint (`/api/v1/assignments`) prepends to the + * feature variable keys it returns (e.g. `/vscode/config.chat...`). The legacy endpoint and + * VS Code both query treatments by the bare name, so this prefix must be accounted for when a + * bare lookup misses. This is an interim workaround until tas-client strips the scope itself. + */ +const ASSIGNMENTS_SCOPE_PREFIX = '/vscode/'; + +/** + * Resolves a treatment value preferring the `/vscode/`-scoped key emitted by the new TAS + * assignments endpoint over the bare key used by the legacy endpoint, so the new endpoint wins + * when both assign a treatment (matching the behavior once tas-client strips the scope itself). + * Falls back to the bare key for treatments served only by the legacy endpoint. + * + * Exported for testing. + */ +export function resolveScopedTreatment(read: (name: string) => T | undefined, name: string): T | undefined { + const scoped = read(`${ASSIGNMENTS_SCOPE_PREFIX}${name}`); + return scoped !== undefined ? scoped : read(name); +} + export interface IWorkbenchAssignmentService extends IAssignmentService { getCurrentExperiments(): Promise; addTelemetryAssignmentFilter(filter: IAssignmentFilter): void; @@ -268,21 +289,22 @@ export class WorkbenchAssignmentService extends Disposable implements IAssignmen return undefined; } - let result: T | undefined; const client = await this.tasClient; - // The TAS client is initialized but we need to check if the initial fetch has completed yet - // If it is complete, return a cached value for the treatment - // If not, use the async call with `checkCache: true`. This will allow the module to return a cached value if it is present. - // Otherwise it will await the initial fetch to return the most up to date value. - if (this.networkInitialized) { - result = client.getTreatmentVariable('vscode', name); - } else { - result = await client.getTreatmentVariableAsync('vscode', name, true); + // Await the initial network fetch when it has not completed yet, so treatments are + // available before we read them from memory. `checkCache: true` returns immediately when a + // value is already cached, otherwise it awaits the initial fetch. + if (!this.networkInitialized) { + await client.getTreatmentVariableAsync('vscode', `${ASSIGNMENTS_SCOPE_PREFIX}${name}`, true); } - result = client.getTreatmentVariable('vscode', name); - return result; + // Interim workaround: the new TAS assignments endpoint (/api/v1/assignments) namespaces its + // returned feature variable keys with a `/vscode/` scope, whereas the legacy endpoint and + // VS Code query treatments by the bare name. Read the scoped key first so the new endpoint + // wins over the legacy (bare) key when both assign a treatment - matching the behavior once + // tas-client strips the scope itself. Fall back to the bare key for treatments served only + // by the legacy endpoint. + return resolveScopedTreatment(readName => client.getTreatmentVariable('vscode', readName), name); } /** diff --git a/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts b/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts new file mode 100644 index 00000000000000..18d92c7817d9dd --- /dev/null +++ b/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { resolveScopedTreatment } from '../../common/assignmentService.js'; + +suite('resolveScopedTreatment', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const BARE = 'config.chat.agentHost.copilot.multiTurnContextRouting.enabled'; + const SCOPED = `/vscode/${BARE}`; + + function readFrom(values: Record): (name: string) => string | number | boolean | undefined { + return name => values[name]; + } + + test('prefers the /vscode/ scoped value (new endpoint) over the bare value on collision', () => { + const read = readFrom({ [BARE]: 'legacy', [SCOPED]: 'new' }); + assert.strictEqual(resolveScopedTreatment(read, BARE), 'new'); + }); + + test('falls back to the bare value when only the legacy endpoint assigns it', () => { + const read = readFrom({ [BARE]: 'legacy' }); + assert.strictEqual(resolveScopedTreatment(read, BARE), 'legacy'); + }); + + test('uses the scoped value when only the new endpoint assigns it', () => { + const read = readFrom({ [SCOPED]: 'new' }); + assert.strictEqual(resolveScopedTreatment(read, BARE), 'new'); + }); + + test('returns undefined when neither endpoint assigns it', () => { + const read = readFrom({}); + assert.strictEqual(resolveScopedTreatment(read, BARE), undefined); + }); + + test('preserves a defined falsy scoped value instead of falling back to bare', () => { + const read = readFrom({ [BARE]: true, [SCOPED]: false }); + assert.strictEqual(resolveScopedTreatment(read, BARE), false); + }); +}); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 4e8d24e62eb520..5d6b067b489a90 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -33,6 +33,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { IObservable, observableFromEvent } from '../../../../base/common/observable.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IDefaultAccount, IEntitlementsData } from '../../../../base/common/defaultAccount.js'; +import { isInternalAccount } from '../../../../platform/assignment/common/assignment.js'; export namespace ChatEntitlementContextKeys { @@ -60,7 +61,7 @@ export namespace ChatEntitlementContextKeys { planEnterprise: new RawContextKey('chatPlanEnterprise', false, true), // True when user is a chat enterprise user. organisations: new RawContextKey('chatEntitlementOrganisations', undefined, true), // The organizations the user belongs to. - internal: new RawContextKey('chatEntitlementInternal', false, true), // True when user belongs to internal organisation. + internal: new RawContextKey('chatEntitlementInternal', false, true), // True when the user is GitHub/Microsoft staff or belongs to an internal organisation. sku: new RawContextKey('chatEntitlementSku', undefined, true), // The SKU of the user. }; @@ -208,6 +209,8 @@ export interface IChatEntitlementService { readonly hasByokModels: boolean; readonly organisations: string[] | undefined; + + /** True for GitHub/Microsoft staff or members of an internal organisation. */ readonly isInternal: boolean; readonly sku: string | undefined; readonly copilotTrackingId: string | undefined; @@ -795,6 +798,7 @@ type EntitlementEvent = { interface IEntitlements { readonly entitlement: ChatEntitlement; readonly organisations?: string[]; + readonly isStaff?: boolean; readonly sku?: string; readonly copilotTrackingId?: string; readonly quotas?: IQuotas; @@ -1125,6 +1129,7 @@ export class ChatEntitlementRequests extends Disposable { const entitlements: IEntitlements = { entitlement, organisations: entitlementsData.organization_login_list, + isStaff: entitlementsData.is_staff, quotas: this.toQuotas(entitlementsData), sku: entitlementsData.access_type_sku, copilotTrackingId: entitlementsData.analytics_tracking_id @@ -1202,7 +1207,7 @@ export class ChatEntitlementRequests extends Disposable { private update(state: IEntitlements): void { this.state = state; - this.context.update({ entitlement: this.state.entitlement, organisations: this.state.organisations, sku: this.state.sku, copilotTrackingId: this.state.copilotTrackingId }); + this.context.update({ entitlement: this.state.entitlement, organisations: this.state.organisations, isStaff: this.state.isStaff, sku: this.state.sku, copilotTrackingId: this.state.copilotTrackingId }); if (state.quotas) { this.chatQuotasAccessor.acceptQuotas(state.quotas); @@ -1378,6 +1383,11 @@ export interface IChatEntitlementContextState extends IChatSentiment { */ organisations: string[] | undefined; + /** + * Whether the user's last known or resolved account is a GitHub or Microsoft staff account. + */ + isStaff: boolean | undefined; + /** * User's Copilot tracking ID from the entitlement API. */ @@ -1458,6 +1468,7 @@ export class ChatEntitlementContext extends Disposable { this._state = this.storageService.getObject(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, StorageScope.PROFILE) ?? { entitlement: ChatEntitlement.Unknown, organisations: undefined, + isStaff: undefined, sku: undefined, copilotTrackingId: undefined }; @@ -1508,8 +1519,8 @@ export class ChatEntitlementContext extends Disposable { update(context: { completed: true }): Promise; update(context: { hidden: false }): Promise; // legacy UI state from before we had a setting to hide, keep around to still support users who used this update(context: { later: boolean }): Promise; - update(context: { entitlement: ChatEntitlement; organisations: string[] | undefined; sku: string | undefined; copilotTrackingId: string | undefined }): Promise; - async update(context: { completed?: boolean; installed?: boolean; disabled?: boolean; untrusted?: boolean; disabledInWorkspace?: boolean; hidden?: false; later?: boolean; entitlement?: ChatEntitlement; organisations?: string[]; sku?: string; copilotTrackingId?: string }): Promise { + update(context: { entitlement: ChatEntitlement; organisations: string[] | undefined; isStaff: boolean | undefined; sku: string | undefined; copilotTrackingId: string | undefined }): Promise; + async update(context: { completed?: boolean; installed?: boolean; disabled?: boolean; untrusted?: boolean; disabledInWorkspace?: boolean; hidden?: false; later?: boolean; entitlement?: ChatEntitlement; organisations?: string[]; isStaff?: boolean; sku?: string; copilotTrackingId?: string }): Promise { this.logService.trace(`[chat entitlement context] update(): ${JSON.stringify(context)}`); const oldState = JSON.stringify(this._state); @@ -1540,6 +1551,7 @@ export class ChatEntitlementContext extends Disposable { if (typeof context.entitlement === 'number') { this._state.entitlement = context.entitlement; this._state.organisations = context.organisations; + this._state.isStaff = context.isStaff; this._state.sku = context.sku; this._state.copilotTrackingId = context.copilotTrackingId; @@ -1587,7 +1599,7 @@ export class ChatEntitlementContext extends Disposable { this.enterpriseContextKey.set(state.entitlement === ChatEntitlement.Enterprise); this.organisationsContextKey.set(state.organisations); - this.isInternalContextKey.set(Boolean(state.organisations?.some(org => org === 'github' || org === 'microsoft' || org === 'ms-copilot' || org === 'MicrosoftCopilot'))); + this.isInternalContextKey.set(isInternalAccount(state.isStaff, state.organisations)); this.skuContextKey.set(state.sku); this.completedContext.set(!!state.completed); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 7c06fb799aaf76..9e190b023a0322 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -48,7 +48,7 @@ import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemPro import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; import { getChatSessionType, LocalChatSessionUri } from '../../../../contrib/chat/common/model/chatUri.js'; import { ICustomizationMigrationService } from '../../../../contrib/chat/common/promptSyntax/service/customizationMigrationService.js'; -import { CustomizationMigrationService } from '../../../../contrib/chat/common/promptSyntax/service/customizationMigrationServiceImpl.js'; +import { CustomizationMigrationService } from '../../../../contrib/chat/browser/aiCustomization/customizationMigrationServiceImpl.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IAgentSkill, IChatPromptSlashCommand, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { IResolvedPromptSourceFolder } from '../../../../contrib/chat/common/promptSyntax/config/promptFileLocations.js'; import { ParsedPromptFile, PromptFileParser } from '../../../../contrib/chat/common/promptSyntax/promptFileParser.js'; @@ -56,6 +56,7 @@ import { PromptFileSource, PromptsType } from '../../../../contrib/chat/common/p import { IAgentPluginService, IAgentPlugin } from '../../../../contrib/chat/common/plugins/agentPluginService.js'; import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource } from '../../../../contrib/chat/common/tools/languageModelToolsService.js'; import { IAgentHostToolSetEnablementService, IToolEnablementState } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; +import { IAgentHostActiveClientService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { ExtensionState, IExtension, IExtensionsWorkbenchService } from '../../../../contrib/extensions/common/extensions.js'; import { IPluginMarketplaceService, IMarketplacePlugin, MarketplaceType, PluginSourceKind } from '../../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; import { MarketplaceReferenceKind } from '../../../../contrib/chat/common/plugins/marketplaceReference.js'; @@ -855,7 +856,15 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor }()); const promptsService = createMockPromptsService(fixtureFiles, agentInstructions, fileContents, promptFilesDidChangeEmitter.event); reg.defineInstance(IPromptsService, promptsService); - reg.defineInstance(ICustomizationMigrationService, new CustomizationMigrationService(promptsService, harnessService)); + const agentHostCustomizationService = createMockAgentHostCustomizationService(options.activeSessionMcpServers); + reg.defineInstance(ICustomizationMigrationService, new CustomizationMigrationService( + promptsService, + harnessService, + new class extends mock() { + override acquireMcpServerSupportScope() { return undefined; } + }(), + agentHostCustomizationService, + )); reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { override readonly isSessionsWindow = isSessionsWindow; override readonly welcomePageFeatures = { @@ -871,7 +880,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override getSkillUIIntegrations() { return skillUIIntegrations; } }()); reg.defineInstance(ICustomizationHarnessService, harnessService); - reg.defineInstance(IAgentHostCustomizationService, createMockAgentHostCustomizationService(options.activeSessionMcpServers)); + reg.defineInstance(IAgentHostCustomizationService, agentHostCustomizationService); // AICustomizationItemsModel is the single source of truth for items // in the editor. Register the real implementation — it will resolve // items via the mock prompts service / harness service above. diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts index 0225c813b04982..d0028c6a387714 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts @@ -29,6 +29,8 @@ import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../../sessions/common/age import { ChatOriginKind, ISessionArtifact, ISessionChangeset, ISessionChatCustomization, ISessionTurnFileChange, ISessionWorkspace, IChat, ISessionCapabilities, ISessionFileChange, SessionArtifactKind, SessionCustomizationKind, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import { registerChatFixtureServices } from '../chat/chatFixtureUtils.js'; import { IFixtureMessage, renderChatWidget } from '../chat/chatWidget.fixture.js'; @@ -138,6 +140,9 @@ function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndC // services) the artifact pill needs, on top of the base editor services // (which register a partial ISessionsService). registerChatFixtureServices(reg); + reg.defineInstance(ISessionsProvidersService, new class extends mock() { + override getProvider() { return undefined; } + }()); reg.defineInstance(IBrowserViewWorkbenchService, createBrowserViewService(sessionMock.browsers)); if (options?.debugData) { reg.defineInstance(IGitHubService, new class extends mock() { diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index bb80b086e45a85..d1f369e30e91c3 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -25,7 +25,7 @@ import { InMemoryFileSystemProvider } from '../../../platform/files/common/inMem import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ISharedProcessService } from '../../../platform/ipc/electron-browser/services.js'; import { NullLogService } from '../../../platform/log/common/log.js'; -import { INativeHostOptions, INativeHostService, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, IOSProperties, IOSStatistics, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../../../platform/native/common/native.js'; +import { IApplicationBadge, INativeHostOptions, INativeHostService, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, IOSProperties, IOSStatistics, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../../../platform/native/common/native.js'; import { IProductService } from '../../../platform/product/common/productService.js'; import { AuthInfo, Credentials } from '../../../platform/request/common/request.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; @@ -145,6 +145,7 @@ export class TestNativeHostService implements INativeHostService { async getProcessId(): Promise { throw new Error('Method not implemented.'); } async killProcess(): Promise { } async setDocumentEdited(edited: boolean): Promise { } + async setApplicationBadge(badge: IApplicationBadge | undefined): Promise { } async openExternal(url: string, defaultApplication?: string): Promise { return false; } async updateTouchBar(): Promise { } async moveItemToTrash(): Promise { } diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index a1d3bfa5756e83..b5bec484243876 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -73,22 +73,22 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/53ddc34360c336aba42bc527fced457fad6503ea7ed7c5739df13114e3d608b7) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9b08554c2be3dc91914ddd8575f848f8ecdd0a12c585456d12ab393d5a13ab16) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d816d432f1c51513810871127ce8475d572ffd439dc86667d164e713b06042c2) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/243178e6c1365f1a924d8a302f748d2616f009fee9fbb8ba18b21306f1d442bb) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/da6d99ac0360b16cdcc8ff845ff379d160987658b28d9ddd89fb25b5fa012bb1) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/2a9c427e7535bcd815a0c7f5d9b31edc068c9cd27662d3a037ba35478b31537d) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1e100b00beb07383f488c73fcc79c855b4098ae24e46db01072f2be1ea954d88) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/46decd9bfa89d5a109c774be82bf9ddf033708f775b0ec59f3dff4caa548375c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/43638fae9595c059725b43c1dd8db047e9f521c1f32e8bb06a7768c6d451ab36) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7604d5a063bcc2fc5b931d10e0b0846ed1002fd8d7103d1032bbedece2990e6a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/233475eebb4de7d555a05635a4576371075d85f0ea599cbc3b3dd7c1bc3015dc) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e4902a982f70ddf296118fb5b9d3f604494ac895e7ef46aa2f2c9cba8a0204a6) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d9596cabc6fedef4660ac86b2861ff18ebb3a67da723b7014f05fea915059919) #### chat/aiCustomizations/aiCustomizationManagementEditor/WelcomePage/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/bb845f60651850b28b0c3e5ca0b8ed12910a706c00e6bb71c1715bf61a646f2c) @@ -181,25 +181,25 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8c8eb6b803b02923698fc074ae21adba8ab9d19f0456c03b2a2702ef237608ba) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f3db1a2e75f5320f7047aac91b148f6600fea29ce8135d370a39fa964e64e873) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8f4a4dc0bd65c797d58dec40dc5b0a739152687e9192683e5560835c04728f1a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/284dc3b398fe35444af49cd1726f3efdf071835f9a12b173c624b16f329dc738) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/3a93997e6f3e6ef7b63dcce185f571b7e048f217013fda13e7b6622c4bb4e9b1) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d65a5c982df3188ca688e5b0c317ecc5f0be1451a0061f56dab251a43d708c21) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/0baa16342facc6b2bbfec277ba966e283bcb5cc8522feadedbb0551d11b37af6) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a80dd54eb5c270bb59ec6f3830a107812b4cf7bc93371451af520cdef978da30) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9ce99acb0597d337c3629946531ad90143c739de94866b759c749e4c91dc1327) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5a8e493276778f6d2a65a208b9fa0cb9f7bf7abafcf78d5adf8151b3459f0b9f) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/154e56d8556400167418dce9e0b5ae4355644be560a94f91ba5fbf502ef3696f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8858fb38373092200aee6675c0f6cbcb30ad4dce7c62acd8300d70ff64f8ae6b) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/fac59f2e6a520bf03f063f35f83af3ddc575cf95cec659e7f4cdef963ff4e090) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/496be935d13b3be52e1e3ba5471317475a5fa1694a58daf9134e749c0fbe0acb) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a6b3c1c792dd0804bcbc2fb3819ba09bbdbe15f653e4b7f89efeb799d9cc11c9) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1cba4648260e466818fe0c914cd29516cc465f322a112be65de67e2b49a20284)