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
1 change: 1 addition & 0 deletions extensions/copilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,7 @@
"resolveMemoryFileUri",
"runCommand",
"switchAgent",
"toolSearch",
"vscodeAPI"
]
},
Expand Down
131 changes: 129 additions & 2 deletions extensions/copilot/src/extension/chatSessions/claude/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,18 @@ All interactions are displayed through VS Code's native chat UI, providing a sea
- Loads and manages persisted Claude Code sessions from disk
- Reads `.jsonl` session files from `~/.claude/projects/<workspace-slug>/`
- Builds message chains from leaf nodes to reconstruct full conversations
- Discovers and parses subagent sessions from `{session-id}/subagents/agent-*.jsonl`
- Loads subagent sessions via SDK APIs (`listSubagents` + `getSubagentMessages`) and correlates them with their spawning tool use via `parent_tool_use_id` (stored as `ISubagentSession.parentToolUseId`)
- Provides session caching with mtime-based invalidation
- Used to resume previous Claude Code conversations
- See `node/sessionParser/README.md` for detailed documentation

### `node/sessionParser/sdkSessionAdapter.ts`

Adapts raw SDK session data into the internal `IClaudeCodeSession` / `ISubagentSession` schemas:
- **`buildClaudeCodeSession()`**: Assembles a full `IClaudeCodeSession` from session info, messages, and subagents
- **`sdkSubagentMessagesToSubagentSession()`**: Converts raw SDK `SessionMessage[]` into an `ISubagentSession`
- **`extractParentToolUseId()`**: Helper that scans a `SessionMessage[]` array until it finds a string `parent_tool_use_id`, used to correlate a subagent session with the Agent/Task tool_use block that spawned it

### `node/claudeSkills.ts`

**IClaudePluginService / ClaudePluginService**
Expand All @@ -150,7 +157,7 @@ All interactions are displayed through VS Code's native chat UI, providing a sea
### `common/claudeTools.ts`

Defines Claude Code's tool interface:
- **ClaudeToolNames**: Enum of all supported tool names (Bash, Read, Edit, Write, etc.)
- **ClaudeToolNames**: Enum of all supported tool names (Bash, Read, Edit, Write, etc.). `Agent` is the current name (SDK v2.1.63+); `Task` is kept for backward compatibility with older sessions.
- **Tool input interfaces**: Type definitions for each tool's input parameters
- **claudeEditTools**: List of tools that modify files (Edit, MultiEdit, Write, NotebookEdit)
- **getAffectedUrisForEditTool**: Extracts file URIs that will be modified by edit operations
Expand All @@ -162,6 +169,12 @@ Formats tool invocations for display in VS Code's chat UI:
- Handles tool-specific formatting (Bash commands, file reads, searches, etc.)
- Suppresses certain tools from display (TodoWrite, Edit, Write) where other UI handles them

### `../../chatSessions/vscode-node/chatHistoryBuilder.ts`

Converts a persisted `IClaudeCodeSession` into VS Code `ChatResponsePart[]` for replay in the chat UI:
- Reconstructs assistant text, thinking blocks, tool invocations, and tool results into chat response parts
- Matches subagent sessions to their spawning Agent/Task tool_use blocks using `ISubagentSession.parentToolUseId`, injecting the subagent's tool calls inline under the parent tool invocation

## Message Flow

1. **User sends message** in VS Code Chat
Expand Down Expand Up @@ -236,6 +249,120 @@ In multi-root and empty workspaces, a folder picker option appears in the chat s
- **`node/claudeCodeAgent.ts`**: Consumes `ClaudeFolderInfo` in `ClaudeCodeSession._startSession()`
- **`node/sessionParser/claudeCodeSessionService.ts`**: `_getProjectSlugs()` generates slugs for all folders

## Input State Reactive Pipeline

The chat session input controls (permission mode picker, folder picker) are driven by a reactive observable pipeline, not by imperative setter calls. Understanding this pipeline is important when modifying input state behavior.

### Overview

VS Code calls `getChatSessionInputState` to get a `ChatSessionInputState` object whose `.groups` array drives the UI. Rather than computing groups once and returning them, the pipeline keeps `groups` live: shared observables push changes into each state object whenever relevant configuration changes.

### Key Types

```
InputStateReactivePipeline {
permissionMode: ISettableObservable<PermissionMode>
folderUri: ISettableObservable<URI | undefined>
folderItems: ISettableObservable<readonly vscode.ChatSessionProviderOptionItem[]>
isSessionStarted: ISettableObservable<boolean>
store: DisposableStore // owns all autoruns for this pipeline
}
```

### Seeding: Extracting Initial Values

Before attaching any autoruns, `_createInputStateReactivePipeline` calls `_computeSeedValues(state.groups)` to extract the current groups into typed values. This must happen *before* the first autorun runs, because the first autorun pass immediately reads `allGroups` and writes to `state.groups` — if the per-state observables were left at defaults, that write would discard the carefully-constructed initial groups.

`_computeSeedValues` extracts four values:

| Value | Source | Fallback |
|---|---|---|
| `permissionMode` | Selected item id in the `permissionMode` group | `lastUsedPermissionMode` |
| `folderUri` | Selected item id in the `folder` group | `undefined` |
| `folderItems` | Full item list of the `folder` group | `[]` |
| `isSessionStarted` | `locked: true` on any folder item or the selected item | `false` |

The `isSessionStarted` recovery from `locked` items is important for the `previousInputState` path: the previous state's groups encode the lock signal via `locked: true` on their items. If `_computeSeedValues` did not recover this, the pipeline would start with `isSessionStarted = false` and the `folderGroup` derived would re-render all items as unlocked.

### Shared vs. Per-State Observables

`ClaudeChatSessionItemController` holds two **shared** observables (one instance per controller, not per session):

| Observable | Source | Purpose |
|---|---|---|
| `_bypassPermissionsEnabled` | `IConfigurationService` event | Controls which permission mode items are available |
| `_workspaceFolders` | `IWorkspaceService` event | Controls folder picker items and visibility |

Each call to `getChatSessionInputState` creates a **per-state** pipeline with `_createInputStateReactivePipeline(state)`. The per-state observables are seeded via `_computeSeedValues`.

`folderItems` is a settable per-state observable (not a pure `derived`) because of an async edge case: when the workspace has no folders, the items come from an async MRU fetch (`IFolderRepositoryManager`). An autorun watches `_workspaceFolders` and updates `folderItems` synchronously when folders exist, or kicks off the async MRU fetch when the workspace is empty.

### Derived Computation and Autorun

Inside `_createInputStateReactivePipeline`, `derived` observables combine shared and per-state inputs:

```
permissionModeGroup = derived(bypassEnabled, permissionMode)
folderGroup = derived(folderItems, workspaceFolders, folderUri, isSessionStarted)
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)

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)
```

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

### 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:

```typescript
const externalPermissionMode = observableFromEvent(
this,
Event.filter(sessionStateService.onDidChangeSessionState,
e => e.sessionId === sessionId && e.permissionMode !== undefined),
() => sessionStateService.getPermissionModeForSession(sessionId),
);
pipeline.store.add(autorun(reader => {
pipeline.permissionMode.set(externalPermissionMode.read(reader), undefined);
}));
```

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

### 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.

`_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`.

### Critical Invariant: Subscribe After Both Branches

`_setupInputState` creates `state` and `pipeline` in one of two branches:
- **`context.previousInputState` path** — VS Code already has a state for this session and is asking for a fresh one; seed from the old groups.
- **New-state path** — first call for this session; fetch groups from disk or defaults.

**The external permission mode subscription must run after both branches.** If it only runs in the new-state path, permission mode changes from `EnterPlanMode`/`ExitPlanMode` are silently dropped for every session after the first `getChatSessionInputState` call. Guard against this regression by ensuring the subscription is placed outside the `if/else` block.

## Session Metadata and Git Commands

### Session Metadata Enrichment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ Claude has access to a comprehensive set of tools for coding tasks:

| Tool | Description |
|------|-------------|
| **Task** | Delegate work to a subagent |
| **Agent** | Delegate work to a subagent (previously called "Task") |
| **AskUserQuestion** | Ask the user a question with optional choices |

### IDE Integration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface EnterPlanModeInput {

// TODO: How can we verify these when we bump the SDK version?
export enum ClaudeToolNames {
Agent = 'Agent',
Task = 'Task',
Bash = 'Bash',
Glob = 'Glob',
Expand Down Expand Up @@ -72,6 +73,7 @@ export interface LSInput {
* Maps ClaudeToolNames to their SDK input types
*/
export interface ClaudeToolInputMap {
[ClaudeToolNames.Agent]: AgentInput;
[ClaudeToolNames.Task]: AgentInput;
[ClaudeToolNames.Bash]: BashInput;
[ClaudeToolNames.Glob]: GlobInput;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,20 @@ describe('createFormattedToolInvocation', () => {

expect(result).toBeDefined();
});

it('formats Agent tool name (renamed from Task in Claude Code v2.1.63)', () => {
const toolUse = createToolUseBlock(ClaudeToolNames.Agent, {
description: 'Search for files',
prompt: 'find all TypeScript files'
});

const result = createFormattedToolInvocation(toolUse);

expect(result).toBeDefined();
expect(result!.toolName).toBe(ClaudeToolNames.Agent);
const message = result!.invocationMessage as { value: string };
expect(message.value).toContain('Search for files');
});
});

describe('TodoWrite tool', () => {
Expand Down Expand Up @@ -255,6 +269,7 @@ describe('createFormattedToolInvocation', () => {
ClaudeToolNames.Grep,
ClaudeToolNames.LS,
ClaudeToolNames.ExitPlanMode,
ClaudeToolNames.Agent,
ClaudeToolNames.Task
];

Expand All @@ -273,6 +288,7 @@ describe('createFormattedToolInvocation', () => {
ClaudeToolNames.Grep,
ClaudeToolNames.LS,
ClaudeToolNames.ExitPlanMode,
ClaudeToolNames.Agent,
ClaudeToolNames.Task
];

Expand Down Expand Up @@ -505,6 +521,25 @@ describe('completeToolInvocation', () => {
expect(data.description).toBe('Empty result task');
expect(data.result).toBe('');
});

it('completes Agent tool invocation same as Task', () => {
const toolUse = createToolUseBlock(ClaudeToolNames.Agent, {
description: 'Search codebase',
subagent_type: 'Explore',
prompt: 'find all tests'
});
const toolResult = createToolResultBlock('test-tool-id-456', 'Found 15 test files');
const invocation = createFormattedToolInvocation(toolUse)!;

completeToolInvocation(toolUse, toolResult, invocation);

expect(invocation.toolSpecificData).toBeInstanceOf(ChatSubagentToolInvocationData);
const data = invocation.toolSpecificData as ChatSubagentToolInvocationData;
expect(data.description).toBe('Search codebase');
expect(data.agentName).toBe('Explore');
expect(data.prompt).toBe('find all tests');
expect(data.result).toBe('Found 15 test files');
});
});

describe('Generic/unknown tools', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function completeToolInvocation(
case ClaudeToolNames.TodoWrite:
// These tools have their own UI handling (edit diffs, todo list)
break;
case ClaudeToolNames.Agent:
case ClaudeToolNames.Task:
completeTaskInvocation(invocation, resultContent);
break;
Expand Down Expand Up @@ -226,6 +227,7 @@ export function createFormattedToolInvocation(
case ClaudeToolNames.ExitPlanMode:
formatExitPlanModeInvocation(invocation, toolUse);
break;
case ClaudeToolNames.Agent:
case ClaudeToolNames.Task:
formatTaskInvocation(invocation, toolUse);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ export interface IClaudeCodeSdkService {
* @param dir Workspace/project directory path (the SDK resolves this to the session storage location internally)
* @returns Session info object, or undefined if not found
*/
getSessionInfo(sessionId: string, dir: string): Promise<SDKSessionInfo | undefined>;
getSessionInfo(sessionId: string, dir?: string): Promise<SDKSessionInfo | undefined>;

/**
* Gets all messages for a specific session
* @param sessionId Session ID
* @param dir Workspace/project directory path (the SDK resolves this to the session storage location internally)
* @returns Array of session messages
*/
getSessionMessages(sessionId: string, dir: string): Promise<SessionMessage[]>;
getSessionMessages(sessionId: string, dir?: string): Promise<SessionMessage[]>;

/**
* Renames a session by setting a custom title
Expand Down Expand Up @@ -100,17 +100,17 @@ export class ClaudeCodeSdkService implements IClaudeCodeSdkService {

public async listSessions(dir?: string): Promise<SDKSessionInfo[]> {
const { listSessions } = await this._loadSdk();
return listSessions({ dir });
return listSessions(dir !== undefined ? { dir } : undefined);
}

public async getSessionInfo(sessionId: string, dir: string): Promise<SDKSessionInfo | undefined> {
public async getSessionInfo(sessionId: string, dir?: string): Promise<SDKSessionInfo | undefined> {
const { getSessionInfo } = await this._loadSdk();
return getSessionInfo(sessionId, { dir });
return getSessionInfo(sessionId, dir !== undefined ? { dir } : undefined);
}

public async getSessionMessages(sessionId: string, dir: string): Promise<SessionMessage[]> {
public async getSessionMessages(sessionId: string, dir?: string): Promise<SessionMessage[]> {
const { getSessionMessages } = await this._loadSdk();
return getSessionMessages(sessionId, { dir });
return getSessionMessages(sessionId, dir !== undefined ? { dir } : undefined);
}

public async renameSession(sessionId: string, title: string): Promise<void> {
Expand Down
Loading
Loading