Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 8 additions & 20 deletions extensions/copilot/src/extension/chatSessions/claude/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,25 +309,16 @@ allGroups = derived(permissionModeGroup, folderGroup)

An `autorun` reads `allGroups` and writes to `state.groups`. This is the only place `state.groups` is written — the pipeline is the single source of truth for the UI.

### Lifetime Management (WeakRef + FinalizationRegistry)
### Lifetime Management (onDidDispose)

The `autorun`'s closure holds a `WeakRef<ChatSessionInputState>` rather than a direct reference. This is required because the shared observables (`_workspaceFolders`, `_bypassPermissionsEnabled`) hold strong references to the autorun's observer. Without the `WeakRef`, each `state` object would be transitively reachable through the shared observable → autorun → closure → state chain, and would never be garbage collected.

When VS Code discards a `ChatSessionInputState`, the `WeakRef` lets the GC collect it. The `FinalizationRegistry` (`_stateAutorunRegistry`) then fires and calls `store.dispose()`, which unsubscribes all autoruns for that state.

```
SharedObservable ──strong──► autorun observer
WeakRef ← allows GC of state
state.groups (written on change)
```
Each pipeline's `store` is disposed via `state.onDidDispose`:

```typescript
_stateAutorunRegistry = new FinalizationRegistry<DisposableStore>(store => store.dispose())
// registered as: _stateAutorunRegistry.register(state, pipeline.store)
pipeline.store.add(state.onDidDispose(() => pipeline.store.dispose()));
```

When VS Code discards a `ChatSessionInputState`, the `onDidDispose` event fires and deterministically cleans up all autoruns for that state. The `onDidDispose` subscription is itself registered on the pipeline store, so it is cleaned up as part of disposal.

### External Permission Mode Updates

When Claude executes `EnterPlanMode` or `ExitPlanMode` tools, `claudeMessageDispatch.ts` calls `IClaudeSessionStateService.setPermissionModeForSession()`, which fires `onDidChangeSessionState`. The pipeline subscribes to this event via a second autorun:
Expand All @@ -344,16 +335,13 @@ pipeline.store.add(autorun(reader => {
}));
```

This autorun is registered on `pipeline.store`, so it is disposed along with all other pipeline autoruns when the state is GC'd.
This autorun is registered on `pipeline.store`, so it is disposed along with all other pipeline autoruns when the state is disposed.

### Session-Started Signal

The `isSessionStarted` observable controls whether folder items carry `locked: true`. It is set in two places:

- **Restoring an existing session** (new-state path): `pipeline.isSessionStarted.set(true, undefined)` in `_setupInputState` when `isExistingSession` is true.
- **First message sent** (new-untitled session): `ClaudeChatSessionContentProvider.createHandler()` calls `markSessionStarted(inputState)`, which looks up the pipeline from `_statePipelines` and sets `isSessionStarted` to `true`. This is how the folder gets locked after the user submits their first prompt.
The `isSessionStarted` observable controls whether folder items carry `locked: true`. It is set to `true` when `getChatSessionInputState` is called with a `sessionResource` — i.e., whenever VS Code provides a resource for the session. This covers both existing on-disk sessions and sessions that have been started (where a resource has been assigned).

`_statePipelines` is a `WeakMap<ChatSessionInputState, InputStateReactivePipeline>` that enables these external mutations. The `WeakMap` does not prevent GC of state objects (WeakMap keys are held weakly), so it complements rather than interferes with the `FinalizationRegistry`.
For the `previousInputState` path, the lock state is recovered from the items themselves: `_computeSeedValues` checks for `locked: true` on folder items and restores `isSessionStarted` accordingly.

### Critical Invariant: Subscribe After Both Branches

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,6 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
const existingSession = await this.sessionService.getSession(sessionUri, token);
const isNewSession = !existingSession;

// Lock the folder group when starting a new session (permission mode stays editable)
if (isNewSession) {
this._controller.markSessionStarted(chatSessionContext.inputState);
}

const modelId = parseClaudeModelId(request.model.id);
const selectedPermissionId = chatSessionContext.inputState.groups.find(group => group.id === PERMISSION_MODE_OPTION_ID)?.selected?.id;
if (!selectedPermissionId || !isPermissionMode(selectedPermissionId)) {
Expand Down Expand Up @@ -207,13 +202,6 @@ export class ClaudeChatSessionItemController extends Disposable {
/** Current workspace folders — controls folder group items and visibility. */
private readonly _workspaceFolders: IObservable<URI[]>;

/** Disposes per-state autoruns when the state object is garbage collected. */
private readonly _stateAutorunRegistry = new FinalizationRegistry<DisposableStore>(
store => store.dispose()
);

/** Maps input state objects to their reactive pipelines for external updates. */
private readonly _statePipelines = new WeakMap<vscode.ChatSessionInputState, InputStateReactivePipeline>();

// #endregion

Expand Down Expand Up @@ -357,9 +345,8 @@ export class ClaudeChatSessionItemController extends Disposable {
* into derived group computations. An autorun reads the derived groups and pushes
* the result to `state.groups`, which is the "UI".
*
* The `state` is only held weakly by the autoruns so it can be garbage-collected
* while the shared observables still reference the pipeline's observers. When the
* state is collected, the finalization registry disposes the store and unsubscribes.
* The returned `DisposableStore` owns the autorun lifecycle and is disposed via
* `state.onDidDispose` in the caller.
*
* Returns the per-state observables so callers can drive external updates, plus a
* `DisposableStore` that owns the autorun lifecycle.
Expand Down Expand Up @@ -439,18 +426,9 @@ export class ClaudeChatSessionItemController extends Disposable {
return groups;
});

// Hold `state` via a WeakRef so the autorun's closure does not retain it.
// Shared observables (`_workspaceFolders`, `_bypassPermissionsEnabled`) hold
// strong references to autoruns; without the WeakRef, `state` would transitively
// stay reachable forever and `_stateAutorunRegistry` could never fire.
const stateRef = new WeakRef(state);
store.add(autorun(reader => {
/** @description syncInputStateGroups */
const groups = allGroups.read(reader);
const currentState = stateRef.deref();
if (currentState) {
currentState.groups = groups;
}
state.groups = allGroups.read(reader);
}));

return { permissionMode, folderUri, folderItems, isSessionStarted, store };
Expand All @@ -471,16 +449,14 @@ export class ClaudeChatSessionItemController extends Disposable {
: await this._optionBuilder.buildNewSessionGroups();
state = this._controller.createChatSessionInputState(initialGroups);
pipeline = this._createInputStateReactivePipeline(state);

if (isExistingSession) {
pipeline.isSessionStarted.set(true, undefined);
}
}

// React to external permission mode changes for this session.
// Runs for both previousInputState and new-state paths so that
// EnterPlanMode / ExitPlanMode tool calls always update the input UI.
if (sessionResource) {
pipeline.isSessionStarted.set(true, undefined);

// React to external permission mode changes for this session.
// Runs for both previousInputState and new-state paths so that
// EnterPlanMode / ExitPlanMode tool calls always update the input UI.
const sessionId = ClaudeSessionUri.getSessionId(sessionResource);
const externalPermissionMode = observableFromEvent(
this,
Expand All @@ -494,8 +470,7 @@ export class ClaudeChatSessionItemController extends Disposable {
}));
}

this._statePipelines.set(state, pipeline);
this._stateAutorunRegistry.register(state, pipeline.store);
pipeline.store.add(state.onDidDispose(() => pipeline.store.dispose()));
return state;
};
}
Expand Down Expand Up @@ -542,17 +517,6 @@ export class ClaudeChatSessionItemController extends Disposable {
return { permissionMode, folderUri, folderItems, isSessionStarted };
}

/**
* Marks the input state as "started", which locks the folder group.
* Called by the content provider when a new session begins.
*/
markSessionStarted(inputState: vscode.ChatSessionInputState): void {
const pipeline = this._statePipelines.get(inputState);
if (pipeline) {
pipeline.isSessionStarted.set(true, undefined);
}
}

private async _buildExistingSessionGroups(sessionResource: vscode.Uri): Promise<vscode.ChatSessionProviderOptionGroup[]> {
const sessionId = ClaudeSessionUri.getSessionId(sessionResource);
const permissionMode = this._sessionStateService.getPermissionModeForSession(sessionId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1128,41 +1128,26 @@ describe('ChatSessionContentProvider', () => {
expect(getGroup(restoredState, 'permissionMode')!.selected?.id).toBe('acceptEdits');
});

it('markSessionStarted locks the folder group mid-session', async () => {
it('sessionResource locks the folder group for existing sessions', async () => {
const mocks = createDefaultMocks();
createProviderWithServices(store, [folderA, folderB], mocks);

const state = await getInputState();
let folderGroup = getGroup(state, 'folder')!;
// New session (no sessionResource) — folder is unlocked
const newState = await getInputState();
let folderGroup = getGroup(newState, 'folder')!;
expect(folderGroup.items.every(i => !i.locked)).toBe(true);
expect(folderGroup.selected?.locked).toBeUndefined();

// Simulate a new session starting by invoking the handler (which calls markSessionStarted)
// The handler is owned by the content provider — we go through it via createHandler.
// Easier: reach through via the exported accessor pattern — call markSessionStarted through the controller.
// The content provider does not export the controller, but the handler path covers it.
vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(undefined);
seedSessionItem('new-session');

const { provider: handlerProvider } = createProviderWithServices(store, [folderA, folderB], mocks);
const handler = handlerProvider.createHandler();
// The state we want to observe must be the one passed into the handler
const newState = await getInputState();
const context: vscode.ChatContext = {
history: [],
yieldRequested: false,
chatSessionContext: {
isUntitled: false,
chatSessionItem: {
resource: ClaudeSessionUri.forSessionId('new-session'),
label: 'New',
},
inputState: newState,
},
} as vscode.ChatContext;
await handler(createTestRequest('hello'), context, new MockChatResponseStream(), CancellationToken.None);
// Existing session (sessionResource provided) — folder is locked
vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue({
id: 'started-session',
messages: [{ type: 'user', message: { role: 'user', content: 'Hello' } }],
subagents: [],
} as any);
const sessionUri = createClaudeSessionUri('started-session');
const startedState = await getInputState(sessionUri);

folderGroup = getGroup(newState, 'folder')!;
folderGroup = getGroup(startedState, 'folder')!;
expect(folderGroup.items.every(i => i.locked === true)).toBe(true);
expect(folderGroup.selected?.locked).toBe(true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
assert.strictEqual(output.trim(), marker);
});

test('network requests to allowlisted domains succeed in sandbox', async function () {
test.skip('network requests to allowlisted domains succeed in sandbox', async function () {
this.timeout(60000);

const configuration = vscode.workspace.getConfiguration();
Expand All @@ -332,7 +332,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
}
});

test('requestUnsandboxedExecution preserves sandbox $TMPDIR', async function () {
test.skip('requestUnsandboxedExecution preserves sandbox $TMPDIR', async function () {
this.timeout(60000);

const marker = `SANDBOX_UNSANDBOX_${Date.now()}`;
Expand Down Expand Up @@ -370,7 +370,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
assert.ok(trimmed.endsWith(marker), `Unexpected output: ${JSON.stringify(trimmed)}`);
});

test('can read files outside the workspace', async function () {
test.skip('can read files outside the workspace', async function () {
this.timeout(60000);

const output = await invokeRunInTerminal('head -1 /etc/shells');
Expand All @@ -397,7 +397,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
assert.strictEqual(output.trim(), marker);
});

test('$TMPDIR is writable inside the sandbox', async function () {
test.skip('$TMPDIR is writable inside the sandbox', async function () {
this.timeout(60000);

const marker = `SANDBOX_TMPDIR_${Date.now()}`;
Expand All @@ -411,7 +411,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
assert.strictEqual(lastLine, marker, `Unexpected output: ${JSON.stringify(trimmed)}`);
});

test('non-allowlisted domains trigger unsandboxed confirmation flow', async function () {
test.skip('non-allowlisted domains trigger unsandboxed confirmation flow', async function () {
this.timeout(60000);

const marker = `SANDBOX_DOMAIN_${Date.now()}`;
Expand Down
2 changes: 1 addition & 1 deletion src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export interface IAgentSessionConfigCompletionsParams extends IAgentResolveSessi
/** Serializable attachment passed alongside a message to the agent host. */
export interface IAgentAttachment {
readonly type: AttachmentType;
readonly path: string;
readonly uri: URI;
readonly displayName?: string;
/** For selections: the selected text. */
readonly text?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3948d65
8611f76
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// Generated from types/actions.ts — do not edit
// Run `npm run generate` to regenerate.

import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionDiffsChangedAction, type SessionConfigChangedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js';
import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionDiffsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js';


// ─── Root vs Session vs Terminal Action Unions ───────────────────────────────
Expand Down Expand Up @@ -57,8 +57,10 @@ export type SessionAction =
| SessionTruncatedAction
| SessionIsReadChangedAction
| SessionIsArchivedChangedAction
| SessionActivityChangedAction
| SessionDiffsChangedAction
| SessionConfigChangedAction
| SessionMetaChangedAction
;

/** Union of session actions that clients may dispatch. */
Expand Down Expand Up @@ -101,7 +103,9 @@ export type ServerSessionAction =
| SessionServerToolsChangedAction
| SessionInputRequestedAction
| SessionCustomizationsChangedAction
| SessionActivityChangedAction
| SessionDiffsChangedAction
| SessionMetaChangedAction
;

/** Union of all terminal-scoped actions. */
Expand Down Expand Up @@ -182,8 +186,10 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool
[ActionType.SessionTruncated]: true,
[ActionType.SessionIsReadChanged]: true,
[ActionType.SessionIsArchivedChanged]: true,
[ActionType.SessionActivityChanged]: false,
[ActionType.SessionDiffsChanged]: false,
[ActionType.SessionConfigChanged]: true,
[ActionType.SessionMetaChanged]: false,
[ActionType.TerminalData]: false,
[ActionType.TerminalInput]: true,
[ActionType.TerminalResized]: true,
Expand Down
Loading
Loading