microsoft / microsoft/vscode

Agent host: the external-harness chat surface has two root causes, not sixteen defects

Open
#333,174 5 comments 0 reactions 1 assignee Claimed by @roblourens View on GitHub
Dominant language
TypeScript
Stars
193k
Forks
42.4k
PR merge metrics
PR metrics pending

Description

Over the past few weeks I have been using the built-in chat against an external agent harness (Claude via the agent host) as my daily driver, in both local and dev container windows, and filing defects one at a time. That produced a long tail of issues that individually look like small papercuts.

I have now gone back over the whole set against the source rather than against symptoms. **They are not sixteen defects. Nearly all of them are two.** I am rewriting this issue around that, because the original framing (a map grouped by surface area) hid the structure that actually matters.

This issue is still a map, not a new defect report. Everything below is already filed.

## The two causes

### Cause one: values are compared across two URI namespaces

The agent host runs inside the container and serves its own disk as `file:`. The window addresses that same disk as `vscode-remote://dev-container+/...`. Values cross that boundary constantly, and today the conversion is done by hand at whichever call site somebody remembered.

What makes this pervasive rather than merely annoying is that **it cannot fail loudly**. `ExtUri.isEqualOrParent` gates on scheme before comparing anything:

```ts
// src/vs/base/common/resources.ts:172
isEqualOrParent(base: URI, parentCandidate: URI, ignoreFragment = false): boolean {
if (base.scheme === parentCandidate.scheme) {
...
}
return false; // every cross-namespace call lands here
}
```

So a namespace mismatch is not an error. It is a confident `false`. And on the read side, `readJsonFile` and `pathExists` both swallow the missing-provider rejection and return "absent", so a filesystem the host cannot address is indistinguishable from an empty directory.

Four separate reported symptoms are this one comparison:

| Symptom | Where |
|---|---|
| [#330979](https://github.com/microsoft/vscode/issues/330979) sessions filtered out of the list in a remote window | `agentHostSessionListStore.ts` compares wrapped `vscode-agent-host://` working directories against `vscode-remote://` workspace folders. The file contains no `fromAgentHostUri` call, so nothing unwraps them first. |
| [#333665](https://github.com/microsoft/vscode/issues/333665) file watches fail with ENOPRO | `_watchCustomizations` is handed window-namespace roots. It has three callers; converting them individually is what let one drift. |
| [#331526](https://github.com/microsoft/vscode/issues/331526) project customizations unreadable | Same roots, read instead of watched, with the failure swallowed by the helpers above. |
| Not yet filed | Auto-approval containment in `sessionPermissions.ts` compares client-namespace roots against host-local targets. It fails closed, so nothing unsafe is approved, but every read, write and shell redirect inside the workspace prompts forever in a container. |

There is also a hard failure worth separating out: `resolveSessionWorkingDirectoryAction` throws `Working directory must be a file URI` for any non-`file:` scheme, while the `createSession` path accepts whatever the client sends. The reducer asserts an invariant that the creation path does not enforce, so dynamic multi-root add, remove and replace all throw from a container window.

**Suggested direction.** Convert once at the protocol boundary rather than per call site: a mapper on egress so host values reach the client in the client's namespace, and normalization on ingress where client working directories first become host state. Doing it at call sites is what produced the drift in the first place. As a much cheaper first step that is independently useful: stop swallowing `ERR_NO_PROVIDER` in the read helpers. That single change would have surfaced most of this cluster in one session.

### Cause two: one harness was never brought to parity with the others

Three harnesses implement the same protocol. Codex and Copilot solved several of these problems; the Claude path did not, and a number of the issues below are that gap rather than a novel defect.

| Capability | Codex | Copilot | Claude |
|---|---|---|---|
| Idempotency guard on repeated `setPendingMessages` | yes | yes | **no** |
| Promote a steering message into its own turn | yes | yes | **no** |
| `respondOrBuffer` for tool results that arrive early | yes | yes | **no** |
| Interrupted-turn detection on restore | n/a | yes | **no** |

The steering row is the clearest. `QueueDrainContribution._syncPendingMessages` re-delivers the current steering message to the provider on **every** pending-message action, so `setPendingMessages` is a declarative state sync, not an imperative command. Codex already guards, and its comment names the cause exactly:

```ts
// src/vs/platform/agentHost/node/codex/codexAgent.ts
// `_syncPendingMessages` re-sends the current steering message on every
// pending-state change; ignore a steering message already in flight.
if (session.pendingSteeringFlips.has(steeringMessage.id)) {
return;
}
```

Copilot has the same guard. `ClaudeAgent.setPendingMessages` injects unconditionally. In practice that means one user steering message can reach the model twice, and the second copy can leave a turn open that no work will ever fill.

Relatedly, `IAgent.setPendingMessages` does not document that implementations must be idempotent per `PendingMessage.id`. Three implementations, two guards, one omission is at least partly a documentation gap.

## What this changes about the existing set

**Some of it is already fixed.** The turn-scope work appears superseded upstream by `_completeSessionTurn`, which guards on the live active turn and is routed to from every turn-ending site. I am re-checking my own patches against that before pressing any of them.

**Some of it should be one change, not several.** The three session-changeset counting attempts ([#330941](https://github.com/microsoft/vscode/issues/330941)) are the clearest example: the value has four independent producers and every one of them is branch-scoped, so each fix reveals the next writer. The invariant worth stating is that the summary must have exactly one producer, sourced from the session changeset.

**One reframing.** [#330899](https://github.com/microsoft/vscode/issues/330899) is already the right diagnosis, and I want to restate it in the strongest form: there is no admission event for a client tool. A display signal derived from the stream is being used as the dispatch signal, while the SDK's own runtime invocation, which carries the authoritative arguments, is discarded. The two deduplication fixes in this area are the same symptom caught at two different layers, which is itself the evidence that deduplication is not the right shape.

## Two further defects found while doing this

Both now have pull requests, listed in the map below.

1. **`chat/truncated` is a worse instance of [#332087](https://github.com/microsoft/vscode/issues/332087).** It is client-dispatchable, and its reducer clears `turns` entirely when `turnId` is undefined. Stranded in the optimistic overlay it blanks the whole transcript for as long as it stays pending, by the same mechanism as the streaming bug.

2. **Nothing ever closes an abandoned turn.** A `chat/turnStarted` with no terminal action leaves the response spinning indefinitely. The only thing resembling a watchdog derives a hang reason for telemetry and dispatches nothing, so every escape is user-driven.

## Suggested order

If any of this is worth taking, this is the order I would take it in, cheapest and most independently useful first.

1. Stop swallowing the missing-provider error in the read helpers. One file, and it makes the rest diagnosable.
2. Unwrap before comparing at the three verified sites: session list, permission containment, and the working-directory reducer.
3. Bring the Claude provider to parity on the four rows above, reusing the Codex implementations rather than inventing new ones.
4. Reconstruct turn attribution on restore instead of remembering it. `Turn.id` is documented as a locked invariant equal to the SDK message uuid, and `ReplayBuilder` already builds the required index on every restore and then discards it.
5. Make the optimistic overlay safe: retire pending actions by observing confirmed state, and make client-dispatchable reducer cases idempotent.

## Method, and what is not established

This was a source review against `main`, not a reproduction. Every claim above was traced in code; none of the new findings were observed failing at runtime, and I have said where that distinction matters. My own environment runs 1.135.0 with a local patch set, so some symptoms I originally measured there may already differ on main.

Three things I could not settle and would welcome an answer on from anyone who knows the harness internals:

- Whether the Claude SDK echoes a steering message back on the live stream. The repository contradicts itself: `CONTEXT.md` and a test comment say it does, one code path assumes it does not. The correct steering fix depends on which is true.
- Whether the SDK re-invokes in-process MCP tool handlers on resume for transcript-dangling `tool_use` blocks. The whole replay cluster rests on this and it is asserted nowhere.
- Whether `permissionMode: bypassPermissions` skips `canUseTool` for in-process client tools, which would change the client tool analysis materially.

## The full map

Regrouped by cause. Rows fixed upstream are marked as such, and the pull requests they superseded are closed.

### Cause one, namespace

| Issue | Pull request |
|---|---|
| [#330979](https://github.com/microsoft/vscode/issues/330979) sessions filtered out of the list in a remote window | [#331349](https://github.com/microsoft/vscode/pull/331349) |
| [#331526](https://github.com/microsoft/vscode/issues/331526) project customizations unreadable in a remote window | Fixed upstream by [#333296](https://github.com/microsoft/vscode/pull/333296). [#331527](https://github.com/microsoft/vscode/pull/331527) closed as superseded |
| [#333665](https://github.com/microsoft/vscode/issues/333665) file watches fail with ENOPRO | Cause fixed upstream by [#333296](https://github.com/microsoft/vscode/pull/333296). [#333683](https://github.com/microsoft/vscode/pull/333683) closed as superseded |
| | [#331871](https://github.com/microsoft/vscode/pull/331871) resolve agent host file paths against the window's remote |
| | [#334145](https://github.com/microsoft/vscode/pull/334145) brand the agent host URI namespaces and convert the project root at the boundary |
| | [#334005](https://github.com/microsoft/vscode/pull/334005) accept remote workspace folders in working-directory actions |
| | [#334048](https://github.com/microsoft/vscode/pull/334048) keep remote workspace folders when creating a session through the handler |
| | [#334034](https://github.com/microsoft/vscode/pull/334034) surface unreadable files in plugin discovery instead of treating them as absent |

### Cause two, provider parity and lifecycle

| Issue | Pull request |
|---|---|
| [#332087](https://github.com/microsoft/vscode/issues/332087) responses render only at turn end | [#332122](https://github.com/microsoft/vscode/pull/332122) |
| | [#334011](https://github.com/microsoft/vscode/pull/334011) make the chat/turnStarted reducer idempotent, upstream in https://github.com/microsoft/agent-host-protocol/pull/433 |
| | [#334040](https://github.com/microsoft/vscode/pull/334040) retire a stranded optimistic chat truncation once the host has applied it |
| [#332073](https://github.com/microsoft/vscode/issues/332073) nothing more shown once the parent resumes after a subagent | [#332075](https://github.com/microsoft/vscode/pull/332075) |
| [#331595](https://github.com/microsoft/vscode/issues/331595) a replayed client tool from a subagent lands on the parent | |
| [#333931](https://github.com/microsoft/vscode/issues/333931) a subagent's tool call is never executed and its turn is never closed | [#334072](https://github.com/microsoft/vscode/pull/334072) |
| | [#334127](https://github.com/microsoft/vscode/pull/334127) close abandoned turns deterministically |
| | [#334066](https://github.com/microsoft/vscode/pull/334066) report a cancelled subscribe as cancelled, not as a missing resource |
| | [#331872](https://github.com/microsoft/vscode/pull/331872) a background subagent settling after its turn never completes |
| | [#330785](https://github.com/microsoft/vscode/pull/330785) surface a steering message as its own turn |
| | [#331885](https://github.com/microsoft/vscode/pull/331885) replay the user's prompt without the host's added context |
| | [#334008](https://github.com/microsoft/vscode/pull/334008) keep tool attribution across a steering preempt |
| | [#334049](https://github.com/microsoft/vscode/pull/334049) mark a turn interrupted by a host crash on restore |
| | [#334047](https://github.com/microsoft/vscode/pull/334047) expose the Claude replay attribution index |

### Client tool execution

| Issue | Pull request |
|---|---|
| [#330899](https://github.com/microsoft/vscode/issues/330899) client tools execute off the stream ready, not the runtime invocation | [#334146](https://github.com/microsoft/vscode/pull/334146). The [#330933](https://github.com/microsoft/vscode/pull/330933) proposal it grew from is closed |
| [#331289](https://github.com/microsoft/vscode/issues/331289) calls still routed to a disconnected client | [#331300](https://github.com/microsoft/vscode/pull/331300) |
| [#331987](https://github.com/microsoft/vscode/issues/331987) a client tool is cancelled when the derived collection reads empty | |
| | [#330683](https://github.com/microsoft/vscode/pull/330683) a tool call readied twice runs twice, closed as superseded by [#334146](https://github.com/microsoft/vscode/pull/334146) |
| | [#330684](https://github.com/microsoft/vscode/pull/330684) client tool input is not validated against the schema |
| | [#330709](https://github.com/microsoft/vscode/pull/330709) a tool error is swallowed when the tool returns no content |
| | [#330730](https://github.com/microsoft/vscode/pull/330730) a result arriving before the SDK asks for it is dropped |
| | [#334146](https://github.com/microsoft/vscode/pull/334146) admit a client tool call once, instead of racing two triggers |
| | [#334037](https://github.com/microsoft/vscode/pull/334037) let the workbench own confirmation for Claude client tools |
| | [#334038](https://github.com/microsoft/vscode/pull/334038) answer client-tool handler rejections as tool errors |
| | [#334044](https://github.com/microsoft/vscode/pull/334044) fail Claude client tool calls that no connected client provides |

### Surfaces with their own root cause

| Issue | Pull request |
|---|---|
| [#335546](https://github.com/microsoft/vscode/issues/335546) Codex steering with attached context remains pending after consumption | [#335547](https://github.com/microsoft/vscode/pull/335547) matches the resolved input echo; [source and backport](https://github.com/RyanEwen/vscode-patches/blob/bb83e8c6c1b186919b58d1a8d45d02fb7eafc00a/docs/patches/vscode-335547.md) |
| [#331138](https://github.com/microsoft/vscode/issues/331138) slash commands never submit when the command has no readable content | Fixed upstream by [#334278](https://github.com/microsoft/vscode/pull/334278). [#331145](https://github.com/microsoft/vscode/pull/331145) closed as superseded |
| [#332047](https://github.com/microsoft/vscode/issues/332047) restored sessions lose slash commands and skills | [#332055](https://github.com/microsoft/vscode/pull/332055) |
| [#330941](https://github.com/microsoft/vscode/issues/330941) change counts show branch divergence, not the session's changes | [#331345](https://github.com/microsoft/vscode/pull/331345) |
| [#331568](https://github.com/microsoft/vscode/issues/331568) a pasted image renders as a broken thumbnail | [#331569](https://github.com/microsoft/vscode/pull/331569) |
| [#331347](https://github.com/microsoft/vscode/issues/331347) pasted images labelled unsupported in Claude sessions | [#331396](https://github.com/microsoft/vscode/pull/331396) |
| [#333180](https://github.com/microsoft/vscode/issues/333180) Codex auto-approval rationale floods the transcript | Fixed upstream by [#333956](https://github.com/microsoft/vscode/pull/333956) |
| | [#334053](https://github.com/microsoft/vscode/pull/334053) keep Codex MCP tool progress out of the tool result |
| | [#331408](https://github.com/microsoft/vscode/pull/331408) tell the caller a consumed queued message was sent |
| | [#334002](https://github.com/microsoft/vscode/pull/334002) settle the promise of a pending request the host dropped |
| | [#330698](https://github.com/microsoft/vscode/pull/330698) delete the Claude transcript when a chat is disposed |
| | [#334010](https://github.com/microsoft/vscode/pull/334010) reclaim orphaned session data directories |
| | [#334311](https://github.com/microsoft/vscode/pull/334311) report a browser tool failure with an empty message as a failure |
| [#334423](https://github.com/microsoft/vscode/issues/334423) a slash command is never executed when the turn carries host context | |
| | [#334549](https://github.com/microsoft/vscode/pull/334549) do not mark a denied subagent tool call as a subagent |
| | [#334559](https://github.com/microsoft/vscode/pull/334559) spare a background subagent's in-flight tool call from the turn-end wipe |

I am happy to consolidate the pull requests to match this structure, or to close the ones a root fix would subsume, if that would make the set easier to review. I would rather do that than keep twenty-two separate threads open.

### Public patches and patcher scripts

[Public patch catalog and patcher scripts](https://github.com/RyanEwen/vscode-patches/blob/main/CATALOG.md) · [Source patch index](https://github.com/RyanEwen/vscode-patches/blob/main/SOURCE-PATCHES.md). The [public collection](https://github.com/RyanEwen/vscode-patches) includes the maintained patchers, rollback instructions, regression scripts, and historical snapshots. Build restrictions and exact installer coverage are documented there.

*AI disclosure: this comment and the related code were written with the assistance of AI.*

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.