src/vs/platform/agentHost/node/claude/claudeSubagentResolver.ts

355 LOC · 321 covered · 34 uncovered · 71 ranges · 384 concepts · 24 introducers · 213 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- claudeSubagentResolver.ts ×15
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import type { CancellationToken } from '../../../../base/common/cancellation.js';
7 > import { URI } from '../../../../base/common/uri.js';
8 > import { vObjAny, vString as vStringValidator } from '../../../../base/common/validation.js';
9 > import { ILogService } from '../../../log/common/log.js';
10 > import { AgentSession } from '../../common/agentService.js';
11 > import { parseSubagentSessionUri } from '../../common/state/sessionState.js';
12 > import {
13 > ResponsePartKind,
14 > ToolCallStatus,
15 > type Turn,
16 > } from '../../common/state/protocol/state.js';
17 > import { IClaudeAgentSdkService } from './claudeAgentSdkService.js';
18 > import { mapSessionMessagesToTurns } from './claudeReplayMapper.js';
19 > import { scanTranscriptForAgentIds, SUBAGENT_TOOL_NAMES, type SubagentRegistry } from './claudeSubagentRegistry.js';
20 >
21 > /**
22 > * One link in the resolver chain. Each strategy consults a different
23 > * SDK primitive to map a `toolCallId` back to an `agentId`. Strategies
24 > * are ordered cheapest-first inside {@link getSubagentTranscript}
25 > * (TextSuffix → PromptMatch → Native).
26 > */
27 > export interface ISubagentLookupStrategy {
28 > /** Short label, used as a category in any future telemetry counter. */
29 > readonly name: string;
30 > /** Returns the agentId or `undefined` if this strategy cannot resolve the input. */
31 > lookup(toolCallId: string, ctx: ISubagentLookupContext): Promise<string | undefined>;
32 > }
33 >
34 > export interface ISubagentLookupContext {
35 > readonly parentUri: URI;
36 > /** SDK session id (`AgentSession.id(parentUri)`). Precomputed so strategies don't repeat the work. */
37 > readonly parentSessionId: string;
38 > /** Pre-fetched parent transcript when available; otherwise the strategy must fetch its own. */
39 > readonly parentTranscript?: readonly Turn[];
40 > readonly token: CancellationToken;
41 > }
42 >
43 > /**
44 > * Strategy 1 — scan the parent transcript for the synthetic agentId
45 > * suffix. Cheapest path: the transcript is usually already in memory
46 > * from the live session or the parent's own replay fetch.
47 > */
48 > export class TextSuffixStrategy implements ISubagentLookupStrategy {
49 > readonly name = 'text_suffix';
50 >
51 > constructor(
52 > private readonly _sdk: IClaudeAgentSdkService, claudeSubagentResolver.ts ×1
53 > private readonly _logService: ILogService,
54 > ) { }
56 > async lookup(toolCallId: string, ctx: ISubagentLookupContext): Promise<string | undefined> {
57 > const transcript = await fetchParentTurns(this._sdk, this._logService, ctx, 'TextSuffix'); claudeSubagentResolver.ts ×2
58 > if (!transcript) {
59 return undefined;
60 }
61 > return scanTranscriptForAgentIds(transcript).get(toolCallId); claudeSubagentResolver.ts ×2
62 > }
64 >
65 > /**
66 > * Shared transcript-fetch helper for strategies that need the parent
67 > * transcript and weren't given one via {@link ISubagentLookupContext.parentTranscript}.
68 > * Returns `undefined` (and logs a warning tagged with `strategyLabel`)
69 > * on any SDK error so callers can short-circuit cleanly.
70 > */
71 > export async function fetchParentTurns( claudeSubagentResolver.ts ×2
72 > sdk: IClaudeAgentSdkService,
73 > logService: ILogService,
74 > ctx: ISubagentLookupContext,
75 > strategyLabel: string,
76 > ): Promise<readonly Turn[] | undefined> {
77 > if (ctx.parentTranscript) {
78 > return ctx.parentTranscript; claudeSubagentResolver.ts ×1
79 > }
81 > const messages = await sdk.getSessionMessages(ctx.parentSessionId, { includeSystemMessages: true });
82 > return mapSessionMessagesToTurns(messages, ctx.parentUri, logService);
83 > } catch (err) {
84 > logService.warn(`[claudeSubagentResolver] ${strategyLabel}: parent transcript fetch failed: ${err}`); claudeSubagentResolver.ts ×1
85 > return undefined;
86 > }
89 > // #region locally-defined validator adapters
90 >
91 > /**
92 > * Thin convenience wrappers over the base validators in
93 > * `src/vs/base/common/validation.ts`. The base API returns a
94 > * `{content, error}` discriminated union (built for full schema
95 > * validation); these wrappers collapse that to `T | undefined` for the
96 > * narrow `is-it-this-type?` checks below where we just need a
97 > * structural narrowing of arbitrary SDK JSON payloads.
98 > */
99 > function vString(input: unknown): string | undefined { claudeSubagentResolver.ts ×10
100 > const r = vStringValidator().validate(input);
101 > return r.error ? undefined : r.content;
102 > }
104 > function vObj(input: unknown): Record<string, unknown> | undefined { claudeSubagentResolver.ts ×10
105 > const r = vObjAny().validate(input);
106 > if (r.error || r.content === null || Array.isArray(r.content)) {
107 return undefined;
108 }
109 > return r.content as Record<string, unknown>; claudeSubagentResolver.ts ×10
110 > }
112 > // #endregion
113 >
114 > /**
115 > * Strategy 2 — list every subagent the SDK knows for this parent and
116 > * compare each one's first user message against the parent's
117 > * `Agent.tool_use.input.prompt`. Bulletproof when TextSuffix misses
118 > * (e.g. SDK reformats the suffix) at the cost of two extra SDK calls.
119 > */
120 > export class PromptMatchStrategy implements ISubagentLookupStrategy {
121 > readonly name = 'prompt_match';
122 >
123 > constructor(
124 > private readonly _sdk: IClaudeAgentSdkService, claudeSubagentResolver.ts ×1
125 > private readonly _logService: ILogService,
126 > ) { }
128 > async lookup(toolCallId: string, ctx: ISubagentLookupContext): Promise<string | undefined> {
129 > const prompt = await this._loadParentPrompt(toolCallId, ctx); claudeSubagentResolver.ts ×4
130 > if (!prompt) {
131 > return undefined;
132 > }
133 > let agentIds: readonly string[]; claudeSubagentResolver.ts ×11
134 > try {
135 > agentIds = await this._sdk.listSubagents(ctx.parentSessionId);
136 > } catch (err) {
137 this._logService.warn(`[claudeSubagentResolver] PromptMatch: listSubagents failed: ${err}`);
138 return undefined;
139 }
140 > for (const agentId of agentIds) { claudeSubagentResolver.ts ×11
141 > if (ctx.token.isCancellationRequested) {
142 return undefined;
143 }
144 > let messages; claudeSubagentResolver.ts ×11
145 > try {
146 > messages = await this._sdk.getSubagentMessages(ctx.parentSessionId, agentId);
147 > } catch (err) {
148 this._logService.warn(`[claudeSubagentResolver] PromptMatch: getSubagentMessages(${agentId}) failed: ${err}`);
149 continue;
150 }
151 > const firstMessage = extractFirstUserText(messages); claudeSubagentResolver.ts ×11
152 > if (firstMessage === undefined) {
153 continue;
154 }
155 > if (firstMessage === prompt) { claudeSubagentResolver.ts ×11
156 > return agentId;
157 > }
158 > }
159 return undefined;
162 > private async _loadParentPrompt(toolCallId: string, ctx: ISubagentLookupContext): Promise<string | undefined> {
163 > const transcript = await fetchParentTurns(this._sdk, this._logService, ctx, 'PromptMatch'); claudeSubagentResolver.ts ×4
164 > if (!transcript) {
165 return undefined;
166 }
167 > return extractSpawningPromptFromTranscript(transcript, toolCallId); claudeSubagentResolver.ts ×4
168 > }
170 >
171 > /**
172 > * Pure transcript scan: locate the spawning subagent tool call by id
173 > * and return its `prompt` input field, or `undefined` if not a
174 > * subagent tool, still streaming, or input is malformed. Exported so
175 > * it can be tested independently of SDK fetch behavior.
176 > */
177 > export function extractSpawningPromptFromTranscript(transcript: readonly Turn[], toolCallId: string): string | undefined {
178 > for (const turn of transcript) { claudeSubagentResolver.ts ×2
179 > for (const part of turn.responseParts) { claudeSubagentResolver.ts ×10
180 > if (part.kind !== ResponsePartKind.ToolCall) {
181 continue;
182 }
183 > const state = part.toolCall; claudeSubagentResolver.ts ×10
184 > if (state.toolCallId !== toolCallId) {
185 > continue;
186 > }
187 > if (!SUBAGENT_TOOL_NAMES.has(state.toolName)) {
188 > return undefined; claudeSubagentResolver.ts ×3
189 > }
190 > if (state.status === ToolCallStatus.Streaming) { claudeSubagentResolver.ts ×10
191 > return undefined; claudeSubagentResolver.ts ×3
192 > }
193 > const inputRaw = state.toolInput; claudeSubagentResolver.ts ×10
194 > if (typeof inputRaw !== 'string') {
195 > return undefined; claudeSubagentResolver.ts ×11
196 > }
197 > let parsed: unknown; claudeSubagentResolver.ts ×10
198 > try {
199 > parsed = JSON.parse(inputRaw);
200 > } catch {
201 > return undefined; claudeSubagentResolver.ts ×3
202 > }
203 > const bag = vObj(parsed); claudeSubagentResolver.ts ×10
204 > if (!bag) {
205 return undefined;
206 }
207 > return vString(bag.prompt); claudeSubagentResolver.ts ×10
208 > }
209 > }
210 > return undefined; claudeSubagentResolver.ts ×2
211 > }
213 > function extractFirstUserText(messages: readonly { readonly type?: string; readonly message?: unknown }[]): string | undefined { claudeSubagentResolver.ts ×11
214 > for (const msg of messages) {
215 > if (msg.type !== 'user') {
216 continue;
217 }
218 > const inner = vObj(msg.message); claudeSubagentResolver.ts ×11
219 > if (!inner) {
220 continue;
221 }
222 > const content = inner.content; claudeSubagentResolver.ts ×11
223 > if (typeof content === 'string') {
224 > return content;
225 > }
226 > if (!Array.isArray(content)) {
227 continue;
228 }
229 > for (const block of content) { claudeSubagentResolver.ts ×11
230 > const obj = vObj(block);
231 > if (!obj || obj.type !== 'text') {
232 continue;
233 }
234 > const text = vString(obj.text); claudeSubagentResolver.ts ×11
235 > if (text !== undefined) {
236 > return text;
237 > }
238 > }
239 }
240 return undefined;
241 }
243 > /**
244 > * Strategy 3 — placeholder for a future SDK primitive that returns the
245 > * spawning `tool_use_id` alongside each subagent id. Currently the SDK
246 > * exposes no such API; this class returns `undefined` and the resolver
247 > * falls back to the other strategies.
248 > */
249 > export class NativeStrategy implements ISubagentLookupStrategy {
250 > readonly name = 'native'; claudeSubagentResolver.ts ×1
251 > async lookup(): Promise<string | undefined> { claudeSubagentResolver.ts ×15
252 > return undefined; claudeSubagentResolver.ts ×1
253 > }
255 >
256 > /**
257 > * Side-effecting collaborators the strategy chain uses. Pulled out so
258 > * {@link resolveAgentIdViaChain} is a pure orchestrator: tests can
259 > * drive it with in-memory stubs without standing up a full SDK or
260 > * registry.
261 > */
262 > interface IResolveChainDeps {
263 > readonly strategies: readonly ISubagentLookupStrategy[];
264 > readonly cacheGet: (toolCallId: string) => string | undefined;
265 > readonly cacheSet: (toolCallId: string, agentId: string) => void;
266 > }
267 >
268 > /**
269 > * Chain orchestration extracted so it can be tested in isolation. Cache
270 > * hit short-circuits the chain; otherwise strategies run in order, the
271 > * first non-undefined hit wins, the cache is populated.
272 > *
273 > * Cancellation is checked between strategies — a cancelled token resolves
274 > * to `undefined`.
275 > */
276 > export async function resolveAgentIdViaChain( claudeSubagentResolver.ts ×1
277 > toolCallId: string,
278 > ctx: ISubagentLookupContext,
279 > deps: IResolveChainDeps,
280 > ): Promise<string | undefined> {
281 > const cached = deps.cacheGet(toolCallId);
282 > if (cached) {
283 > return cached; claudeSubagentResolver.ts ×1
284 > }
285 > for (const strategy of deps.strategies) { claudeSubagentResolver.ts ×3
286 > if (ctx.token.isCancellationRequested) {
287 > return undefined; claudeSubagentResolver.ts ×1
288 > }
289 > const hit = await strategy.lookup(toolCallId, ctx); claudeSubagentResolver.ts ×3
290 > if (hit) {
291 > deps.cacheSet(toolCallId, hit); claudeSubagentResolver.ts ×1
292 > return hit;
293 > }
295 > return undefined; claudeSubagentResolver.ts ×1
296 > }
298 > /**
299 > * Build the production strategy chain. Pulled out so callers
300 > * ({@link getSubagentTranscript}, tests, etc.) construct a single
301 > * canonical ordering instead of inlining it.
302 > */
303 > function buildDefaultStrategies(sdk: IClaudeAgentSdkService, logService: ILogService): readonly ISubagentLookupStrategy[] { claudeSubagentResolver.ts ×4
304 > return [
305 > new TextSuffixStrategy(sdk, logService),
306 > new PromptMatchStrategy(sdk, logService),
307 > new NativeStrategy(),
308 > ];
309 > }
311 > /**
312 > * Phase 12 — fetch a subagent's transcript by URI. Cache lookup goes
313 > * through the parent session's {@link SubagentRegistry} (each spawn
314 > * carries its `agentId`); on a miss, the strategy chain runs and the
315 > * resolved agentId is recorded back onto the spawn (first-writer-wins)
316 > * for future calls.
317 > *
318 > * Resilient: returns `[]` on any unresolvable agentId or SDK error
319 > * after warn-logging. Throws only on a malformed (non-subagent) URI,
320 > * which indicates a programming error in the caller.
321 > */
322 > export async function getSubagentTranscript( claudeSubagentResolver.ts ×4
323 > subagentUri: URI,
324 > parentRegistry: SubagentRegistry,
325 > sdk: IClaudeAgentSdkService,
326 > logService: ILogService,
327 > token: CancellationToken,
328 > ): Promise<readonly Turn[]> {
329 > const parsed = parseSubagentSessionUri(subagentUri);
330 > if (!parsed) {
331 throw new Error(`getSubagentTranscript: not a subagent URI: ${subagentUri.toString()}`);
332 }
333 > const { parentSession, toolCallId } = parsed; claudeSubagentResolver.ts ×4
334 > const parentSessionId = AgentSession.id(parentSession);
335 > const agentId = await resolveAgentIdViaChain(toolCallId, {
336 > parentUri: parentSession,
337 > parentSessionId,
338 > token,
339 > }, {
340 > strategies: buildDefaultStrategies(sdk, logService),
341 > cacheGet: id => parentRegistry.getSpawn(id)?.agentId,
342 > cacheSet: (id, resolved) => { parentRegistry.recordSpawn(id, { agentId: resolved }); },
343 > });
344 > if (!agentId) {
346 > }
347 > let messages; claudeSubagentResolver.ts ×4
348 > try {
349 > messages = await sdk.getSubagentMessages(parentSessionId, agentId);
350 > } catch (err) {
351 > logService.warn(`[getSubagentTranscript] getSubagentMessages(${agentId}) failed: ${err}`); claudeSubagentResolver.ts ×2
352 > return [];
353 > }
354 > return mapSessionMessagesToTurns(messages, subagentUri, logService); claudeSubagentResolver.ts ×1
355 > }