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
7 changes: 6 additions & 1 deletion src/vs/platform/agentHost/node/claude/claudeAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,12 @@ export class ClaudeAgent extends Disposable implements IAgent {
}

onClientToolCallComplete(_session: URI, _toolCallId: string, _result: ToolCallResult): void {
throw new Error('TODO: Phase 10');
// Phase 10 — client (MCP) tool completion routing. Until then, every
// SDK-owned tool that completes also fires this hook via
// `AgentSideEffects` listening on `SessionToolCallComplete` envelopes,
// so the body must be a benign no-op rather than throw. Once client
// tools are registered via `setClientTools`, this should resolve the
// matching pending promise on the SDK session.
}

setClientCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (results: ISyncedCustomization[]) => void): Promise<ISyncedCustomization[]> {
Expand Down
10 changes: 6 additions & 4 deletions src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { SessionInputResponseKind, ToolCallPendingConfirmationState, ToolCallSta
import { IAgentConfigurationService } from '../agentConfigurationService.js';
import { ClaudeAgentSession } from './claudeAgentSession.js';
import { buildAskUserSessionInputQuestions, buildExitPlanModeConfirmationState, flattenAskUserAnswers, parseAskUserQuestionInput } from './claudeInteractiveTools.js';
import { getClaudeConfirmationTitle, getClaudePermissionKind, getClaudeToolDisplayName, getClaudeToolPath, INTERACTIVE_CLAUDE_TOOLS } from './claudeToolDisplay.js';
import { getClaudeConfirmationTitle, getClaudeInvocationMessage, getClaudePermissionKind, getClaudeToolDisplayName, getClaudeToolInputString, getClaudeToolPath, INTERACTIVE_CLAUDE_TOOLS, buildClaudeToolMeta } from './claudeToolDisplay.js';

/**
* Dependencies for {@link handleCanUseTool}. Kept narrow: a session
Expand Down Expand Up @@ -123,15 +123,17 @@ async function dispatchCanUseTool(
const permissionKind = getClaudePermissionKind(toolName);
const displayName = getClaudeToolDisplayName(toolName);
const permissionPath = options.blockedPath ?? getClaudeToolPath(toolName, input);
const toolInputJson = JSON.stringify(input);
const toolInputString = getClaudeToolInputString(toolName, input);
const meta = buildClaudeToolMeta(toolName);
Comment thread
TylerLeonhardt marked this conversation as resolved.
const state: ToolCallPendingConfirmationState = {
status: ToolCallStatus.PendingConfirmation,
toolCallId: options.toolUseID,
toolName,
displayName,
invocationMessage: displayName,
toolInput: toolInputJson,
invocationMessage: getClaudeInvocationMessage(toolName, displayName, input),
toolInput: toolInputString,
confirmationTitle: getClaudeConfirmationTitle(toolName),
...(meta ? { _meta: meta } : {}),
};

const parentToolCallId = resolveSubagentParent(session, options);
Expand Down
124 changes: 92 additions & 32 deletions src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { ActionType } from '../../common/state/sessionActions.js';
import { ResponsePartKind, ToolResultContentType, type ToolResultContent, type ToolResultFileEditContent } from '../../common/state/sessionState.js';
import { buildTopLevelSubagentReadyAction, emitInnerAssistantSignals, mapSubagentSystemMessage, SUBAGENT_SPAWNING_TOOL_NAMES, tagWithParent } from './claudeSubagentSignals.js';
import type { SubagentRegistry } from './claudeSubagentRegistry.js';
import { getClaudeToolDisplayName } from './claudeToolDisplay.js';
import { buildClaudeToolMeta, getClaudePastTenseMessage, getClaudeToolDisplayName } from './claudeToolDisplay.js';
import { ClaudeToolCallRegistry } from './claudeToolCallRegistry.js';
import { ToolCallConfirmationReason, type StringOrMarkdown } from '../../common/state/protocol/state.js';

/**
* Cross-call state for {@link mapSDKMessageToAgentSignals}. One instance
Expand Down Expand Up @@ -43,8 +45,14 @@ import { getClaudeToolDisplayName } from './claudeToolDisplay.js';
*/
export class ClaudeMapperState {
private readonly _activeToolBlocks = new Map<number, { toolUseId: string; toolName: string }>();
private readonly _toolCallTurnIds = new Map<string, string>();
private readonly _toolCallNames = new Map<string, string>();
/**
* Phase 8.5 — cross-message tool-call attribution + input
* accumulation + computed start-info, encapsulated as its own
* collaborator class so it can be unit-tested independently.
* Public so mapper functions can call its lifecycle methods
* directly without forwarding through this class.
*/
readonly toolCalls = new ClaudeToolCallRegistry();
private _currentMessageId: string | undefined;

/**
Expand Down Expand Up @@ -79,8 +87,7 @@ export class ClaudeMapperState {
*/
startToolBlock(index: number, toolUseId: string, toolName: string, turnId: string): void {
this._activeToolBlocks.set(index, { toolUseId, toolName });
this._toolCallTurnIds.set(toolUseId, turnId);
this._toolCallNames.set(toolUseId, toolName);
this.toolCalls.begin(toolUseId, toolName, turnId);
}

getActiveToolBlock(index: number): { toolUseId: string; toolName: string } | undefined {
Expand All @@ -91,24 +98,46 @@ export class ClaudeMapperState {
this._activeToolBlocks.delete(index);
}

/**
* Phase 8.5 — forward an `input_json_delta.partial_json` chunk
* to the registry. Resolves the index → `tool_use_id` mapping
* locally (the registry is keyed by id, not by index) and is a
* no-op when the index is unknown.
*/
appendToolBlockInputDelta(index: number, partialJson: string): void {
const tracked = this._activeToolBlocks.get(index);
if (!tracked) {
return;
}
this.toolCalls.appendInputDelta(tracked.toolUseId, partialJson);
}

/**
* Phase 8.5 — forward the `content_block_stop` signal to the
* registry, which parses the buffer and stashes the computed
* start-info.
*/
finalizeToolBlock(index: number): void {
const tracked = this._activeToolBlocks.get(index);
if (!tracked) {
return;
}
this.toolCalls.finalize(tracked.toolUseId);
}

/**
* Cross-message lookup for `tool_result` handling. Returns
* `undefined` if the `tool_use_id` is unknown (defense-in-depth
* against transport drift / replay).
*/
lookupToolCall(toolUseId: string): { turnId: string; toolName: string } | undefined {
const turnId = this._toolCallTurnIds.get(toolUseId);
const toolName = this._toolCallNames.get(toolUseId);
if (turnId === undefined || toolName === undefined) {
return undefined;
}
return { turnId, toolName };
const entry = this.toolCalls.lookup(toolUseId);
return entry ? { turnId: entry.turnId, toolName: entry.toolName } : undefined;
}

/** Drain cross-message tracking once a `tool_result` is delivered. */
completeToolCall(toolUseId: string): void {
this._toolCallTurnIds.delete(toolUseId);
this._toolCallNames.delete(toolUseId);
this.toolCalls.complete(toolUseId);
}

/**
Expand Down Expand Up @@ -147,15 +176,7 @@ export class ClaudeMapperState {
* `registry.drainForegroundSpawns()` from {@link mapResult}.
*/
clearPendingToolCalls(logService: ILogService): void {
if (this._toolCallTurnIds.size === 0) {
return;
}
for (const [toolUseId, turnId] of this._toolCallTurnIds) {
const toolName = this._toolCallNames.get(toolUseId) ?? '<unknown>';
logService.warn(`[claudeMapSessionEvents] turn ${turnId} ended with pending tool_use ${toolUseId} (${toolName}); dropping cross-message state`);
}
this._toolCallTurnIds.clear();
this._toolCallNames.clear();
this.toolCalls.clearPending(logService);
}
}

Expand Down Expand Up @@ -319,6 +340,10 @@ function mapUserMessage(
if (fileEdit) {
content.push(fileEdit);
}
const info = state.toolCalls.lookup(block.tool_use_id)?.info;
const pastTenseMessage: StringOrMarkdown = info
? getClaudePastTenseMessage(info.toolName, info.displayName, info.parsedInput, !isError)
: `${getClaudeToolDisplayName(tracked.toolName)} finished`;
signals.push({
kind: 'action',
session,
Expand All @@ -329,7 +354,7 @@ function mapUserMessage(
toolCallId: block.tool_use_id,
result: {
success: !isError,
pastTenseMessage: `${getClaudeToolDisplayName(tracked.toolName)} finished`,
pastTenseMessage,
content: content.length > 0 ? content : undefined,
},
},
Expand Down Expand Up @@ -494,6 +519,13 @@ function mapStreamEvent(
} else {
registry.noteInnerTool(block.id, parentToolUseId);
}
// Phase 8.5 — `_meta.toolKind` drives the workbench's terminal /
// search / subagent renderers. Single write at the tool-open
// seam; the reducer carries `_meta` forward to all subsequent
// state transitions (D6). Subagent meta from Phase 12 is now
// produced by `buildClaudeToolMeta` because
// `getClaudeToolKind('Task') === 'subagent'`.
const meta = buildClaudeToolMeta(block.name);
return [{
kind: 'action',
session,
Expand All @@ -504,13 +536,7 @@ function mapStreamEvent(
toolCallId: block.id,
toolName: block.name,
displayName: getClaudeToolDisplayName(block.name),
// Phase 12 — `_meta.toolKind` is read by the workbench
// renderer (`isSubagentTool` in stateToProgressAdapter)
// to recognise this tool call as a subagent spawner
// and render it via `ChatSubagentContentPart` instead
// of the generic tool-call view. Without this the UI
// shows "Running [tool]" with no subagent details.
...(isSubagentSpawn ? { _meta: { toolKind: 'subagent' } } : {}),
...(meta ? { _meta: meta } : {}),
},
}];
}
Expand Down Expand Up @@ -550,6 +576,7 @@ function mapStreamEvent(
logService.warn(`[claudeMapSessionEvents] input_json_delta for unknown content-block index ${event.index}`);
return [];
}
state.appendToolBlockInputDelta(event.index, event.delta.partial_json);
return [{
kind: 'action',
session,
Expand All @@ -565,9 +592,42 @@ function mapStreamEvent(
return [];
}

case 'content_block_stop':
case 'content_block_stop': {
const tracked = state.getActiveToolBlock(event.index);
state.finalizeToolBlock(event.index);
state.endToolBlock(event.index);
return [];
if (!tracked) {
return [];
}
// Phase 8.5 — emit `SessionToolCallReady` so the tool transitions
// out of `Streaming` even when the Claude SDK auto-allows without
// calling `canUseTool` (no `claudeCanUseTool` round-trip means
// `sessionPermissions` never emits Ready, the state stays in
// `Streaming`, and the subsequent `SessionToolCallComplete` is
// dropped by the reducer — leaving the tool widget empty).
// When `canUseTool` DOES fire, `sessionPermissions` emits a second
// Ready that re-transitions Running → PendingConfirmation as needed.
const entry = state.toolCalls.lookup(tracked.toolUseId);
const info = entry?.info;
if (!info) {
return [];
}
const meta = buildClaudeToolMeta(tracked.toolName);
return [{
kind: 'action',
session,
action: {
type: ActionType.SessionToolCallReady,
session: sessionStr,
turnId,
toolCallId: tracked.toolUseId,
invocationMessage: info.invocationMessage,
...(info.toolInput !== undefined ? { toolInput: info.toolInput } : {}),
confirmed: ToolCallConfirmationReason.NotNeeded,
...(meta ? { _meta: meta } : {}),
},
}];
}

case 'message_delta':
case 'message_stop':
Expand Down
42 changes: 22 additions & 20 deletions src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
type Turn,
} from '../../common/state/protocol/state.js';
import { buildSubagentSessionUri } from '../../common/state/sessionState.js';
import { getClaudeToolDisplayName } from './claudeToolDisplay.js';
import { buildClaudeToolMeta, getClaudeInvocationMessage, getClaudePastTenseMessage, getClaudeToolDisplayName, getClaudeToolInputString } from './claudeToolDisplay.js';

/**
* Phase 13 — replay mapper. Reduces a flat `SessionMessage[]` (the SDK's
Expand Down Expand Up @@ -127,15 +127,6 @@ function parseSystemMessage(msg: SessionMessage): ParsedSessionMessage | undefin

// #region Builder

/**
* Subagent-spawning tool names recognised by both `Task` (built-in,
* see [`sdk.d.ts:95`](node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts))
* and `Agent` (custom subagents,
* see [`sdk.d.ts:36`](node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts)).
* The production extension matches both at `claudeMessageDispatch.ts:194`.
*/
const SUBAGENT_TOOL_NAMES: ReadonlySet<string> = new Set(['Task', 'Agent']);

/**
* Allowlist of `system` subtypes that survive replay as
* {@link ResponsePartKind.SystemNotification} parts on the active turn.
Expand Down Expand Up @@ -183,8 +174,17 @@ interface InProgressTurn {
class ReplayBuilder {
private readonly _turns: Turn[] = [];
private _active: InProgressTurn | undefined;
/** Cross-turn: tool_use_id → turnId of the announcing turn. */
private readonly _toolUseToTurnId = new Map<string, string>();
/**
* Cross-turn tool-use tracking. Keyed by `tool_use_id`:
* - `turnId` — the announcing turn (so a late `tool_result` in a
* later `user` envelope can attach back to the right turn per M7).
* - `parsedInput` — the original `tool_use.input`, looked up at
* `_attachToolResult` so the past-tense message can include the
* original parameters. Mirrors the live mapper's `_toolCallInfo`
* pattern but simpler (replay has the full input synchronously on
* the `tool_use` block).
*/
private readonly _toolUses = new Map<string, { readonly turnId: string; readonly parsedInput: Record<string, unknown> | undefined }>();

constructor(private readonly _session: URI, private readonly _logService: ILogService) { }

Expand Down Expand Up @@ -258,18 +258,19 @@ class ReplayBuilder {
if (this._active === undefined) {
return;
}
const isSubagent = SUBAGENT_TOOL_NAMES.has(toolName);
const displayName = getClaudeToolDisplayName(toolName);
const parsedInput = input !== null && typeof input === 'object' ? input as Record<string, unknown> : undefined;
const meta = buildClaudeToolMeta(toolName);
// Build a placeholder Cancelled state by default; replaced with Completed when the tool_result lands.
const placeholder: ToolCallCancelledState = {
status: ToolCallStatus.Cancelled,
toolCallId: toolUseId,
toolName,
displayName,
invocationMessage: displayName,
toolInput: typeof input === 'string' ? input : input !== undefined ? safeStringify(input) : undefined,
invocationMessage: getClaudeInvocationMessage(toolName, displayName, parsedInput),
toolInput: parsedInput !== undefined ? getClaudeToolInputString(toolName, parsedInput) : (typeof input === 'string' ? input : input !== undefined ? safeStringify(input) : undefined),
reason: ToolCallCancellationReason.Skipped,
...(isSubagent ? { _meta: { toolKind: 'subagent' as const } } : {}),
...(meta ? { _meta: meta } : {}),
};
const part: ToolCallResponsePart = {
kind: ResponsePartKind.ToolCall,
Expand All @@ -278,15 +279,16 @@ class ReplayBuilder {
this._active.responseParts.push(part);
this._active.toolCallParts.set(toolUseId, part);
this._active.pendingToolUseIds.add(toolUseId);
this._toolUseToTurnId.set(toolUseId, this._active.id);
this._toolUses.set(toolUseId, { turnId: this._active.id, parsedInput });
}

private _attachToolResult(block: UserToolResultBlock): void {
const announcingTurnId = this._toolUseToTurnId.get(block.tool_use_id);
if (announcingTurnId === undefined) {
const entry = this._toolUses.get(block.tool_use_id);
if (entry === undefined) {
this._logService.warn(`[claudeReplayMapper] tool_result for unknown tool_use_id ${block.tool_use_id}`);
return;
}
const announcingTurnId = entry.turnId;
// Find the part — it lives on the announcing turn (which may be `_active` or one already pushed to `_turns`).
const part = this._findToolCallPart(announcingTurnId, block.tool_use_id);
if (part === undefined) {
Expand All @@ -312,7 +314,7 @@ class ReplayBuilder {
toolInput: previousState.status === ToolCallStatus.Streaming ? undefined : previousState.toolInput,
confirmed: ToolCallConfirmationReason.NotNeeded,
success: !isError,
pastTenseMessage: `${previousState.displayName} finished`,
pastTenseMessage: getClaudePastTenseMessage(previousState.toolName, previousState.displayName, entry.parsedInput, !isError),
content: content.length > 0 ? content : undefined,
...(previousState._meta ? { _meta: previousState._meta } : {}),
};
Expand Down
Loading
Loading