From d1362b377201b3a713927673952b072306601047 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 24 Jun 2026 16:39:53 +1000 Subject: [PATCH 1/3] Enhance subagent session handling: add eager registration and memoization for improved performance --- .../platform/agentHost/common/agentService.ts | 27 ++++ .../platform/agentHost/node/agentService.ts | 44 ++++++- .../agentHost/node/copilot/copilotAgent.ts | 24 +++- .../node/copilot/copilotAgentSession.ts | 115 ++++++++++++++-- .../agentHost/test/node/agentService.test.ts | 56 +++++++- .../test/node/copilotAgentSession.test.ts | 19 +++ .../agentHost/agentHostSessionHandler.ts | 124 +++++++++++------- 7 files changed, 347 insertions(+), 62 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index f1d771be12fd23..967186cfada2ea 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -841,6 +841,22 @@ export interface IMcpNotification { readonly params?: Record; } +/** + * A subagent child session discovered in a parent session's event log, + * returned by {@link IAgent.getSubagentSessions} so a parent restore can + * register the child's state up-front. + */ +export interface IRestoredSubagentSession { + /** Child subagent session URI (subscribable by clients). */ + readonly resource: URI; + /** Parent tool call id that spawned the subagent. */ + readonly toolCallId: string; + /** Display title for the subagent session. */ + readonly title: string; + /** Reconstructed turns for the subagent's transcript. */ + readonly turns: readonly Turn[]; +} + /** * Implemented by each agent backend (e.g. Copilot SDK). * The {@link IAgentService} dispatches to the appropriate agent based on @@ -929,6 +945,17 @@ export interface IAgent { */ getSessionMessages(session: URI): Promise; + /** + * Returns the subagent child sessions discoverable in a session's event + * log so a parent restore can eagerly register them in a single pass. + * Without this, every child is restored separately by re-fetching and + * re-reconstructing the full parent event log (one pass per subagent). + * Agents that serve this from the same reconstruction they already + * produced for the parent turns avoid that redundant work entirely. + * Optional; agents without subagents omit it. + */ + getSubagentSessions?(session: URI): Promise; + /** Dispose a session, freeing resources. */ disposeSession(session: URI): Promise; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 57d7d82fbe210a..e34d5a0302f726 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -20,7 +20,7 @@ import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProv import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agentService.js'; +import { AgentProvider, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { buildDefaultChangesetCatalogue, parseChangesetUri } from '../common/changesetUri.js'; import { ActionType, ActionEnvelope, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js'; @@ -1554,6 +1554,23 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.restoreSession(summary, [...turns]); + // Eagerly register subagent child sessions discovered in the event log + // so the client's per-subagent subscriptions resolve from in-memory + // state (hitting `restoreSubagent skipped existing`) instead of each + // re-fetching and re-reconstructing the full parent event log. The + // agent serves these from the same reconstruction it already produced + // for the parent turns above, so this adds no extra event-log reads. + if (agent.getSubagentSessions) { + try { + const children = await agent.getSubagentSessions(session); + for (const child of children) { + this._registerRestoredSubagent(child, summary); + } + } catch (err) { + this._logService.warn(`[AgentService] restoreSession failed to eagerly register subagents session=${sessionStr}`, err); + } + } + // Restore any additional (non-default) peer chats the provider has // persisted for this session, seeding each with its own history and // persisted title so they reappear after a process restart. @@ -2239,6 +2256,31 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`); } + /** + * Registers a subagent child session's state up-front from data the agent + * already reconstructed for the parent, so a later subscribe-driven + * {@link _restoreSubagentSession} finds it present and returns early + * instead of re-reading the parent event log. No-op if already registered. + */ + private _registerRestoredSubagent(child: IRestoredSubagentSession, parentSummary: SessionSummary): void { + const resourceStr = child.resource.toString(); + if (this._stateManager.getSessionState(resourceStr)) { + return; + } + this._stateManager.restoreSession( + { + resource: resourceStr, + provider: 'subagent', + title: child.title, + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + ...(parentSummary.project ? { project: parentSummary.project } : {}), + }, + [...child.turns], + ); + } + private _findProviderForSession(session: URI | string): IAgent | undefined { const key = typeof session === 'string' ? session : session.toString(); const providerId = this._sessionToProvider.get(key); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 9c0b938bad1029..d0c5ca9bfae286 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -32,7 +32,7 @@ import { createAgentModelPricingMeta } from '../../common/agentModelPricing.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostMcpServersConfigKey, AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IMcpNotification } from '../../common/agentService.js'; +import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IMcpNotification, IRestoredSubagentSession } from '../../common/agentService.js'; import { getEffectiveAgents } from '../../common/customAgents.js'; import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; @@ -1584,6 +1584,28 @@ export class CopilotAgent extends Disposable implements IAgent { return prependAnnouncementToFirstTurn(rawTurns, buildWorktreeAnnouncementText(worktreeMeta.branchName)); } + async getSubagentSessions(session: URI): Promise { + // Only the root SDK session entry owns the event log; peer-chat and + // subagent URIs are derived from it and have no subagents of their own. + const chatInfo = parseChatUri(session); + if (chatInfo && !isDefaultChatUri(session)) { + return []; + } + if (parseSubagentSessionUri(session)) { + return []; + } + const sessionId = AgentSession.id(session); + // Provisional sessions have no SDK history (and thus no subagents) yet. + if (this._provisionalSessions.has(sessionId)) { + return []; + } + const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => { + this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for subagent lookup`, err); + return undefined; + }); + return entry ? entry.getSubagentSessions() : []; + } + async disposeSession(session: URI): Promise { const sessionId = AgentSession.id(session); await this._sessionSequencer.queue(sessionId, async () => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 4307a136aca996..bb8f4e0d9a2792 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -27,7 +27,7 @@ import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../co import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { platformSessionSchema } from '../../common/agentHostSchema.js'; -import { AgentSignal, IMcpNotification } from '../../common/agentService.js'; +import { AgentSignal, IMcpNotification, IRestoredSubagentSession } from '../../common/agentService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; @@ -37,7 +37,7 @@ import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAtt import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, getToolSubagentContent, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; @@ -73,6 +73,8 @@ const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state'); const EMPTY_TOOL_RESULT_TEXT = ''; const RUNTIME_SLASH_COMMAND_CACHE_TTL_MS = 30_000; +type IMappedSessionEvents = { turns: Turn[]; subagentTurnsByToolCallId: ReadonlyMap }; + function getEmptyToolResultText(binaryResults: readonly { readonly type: 'image' | 'resource' }[] | undefined): string { if (!binaryResults?.length) { return EMPTY_TOOL_RESULT_TEXT; @@ -985,6 +987,7 @@ export class CopilotAgentSession extends Disposable { this._wrapper = this._register(wrapper); this._subscribeToEvents(); this._subscribeForLogging(); + this._subscribeForMemoInvalidation(); // Advertise the agent host's server tools for this session so clients // see them as server-provided. Execution happens in-process via the SDK @@ -1268,18 +1271,80 @@ export class CopilotAgentSession extends Disposable { } async getMessages(): Promise { - const events = await this._wrapper.session.getEvents(); - let db: ISessionDatabase | undefined; - try { - db = this._databaseRef.object; - } catch { - // Database may not exist yet — that's fine - } - const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); + const result = await this._getMappedEvents(); return result.turns; } async getSubagentMessages(parentToolCallId: string): Promise { + const result = await this._getMappedEvents(); + const turns = result.subagentTurnsByToolCallId.get(parentToolCallId) ?? []; + return turns; + } + + /** + * Returns the subagent child sessions discoverable in this session's event + * log, derived from the same {@link mapSessionEvents} reconstruction used + * for {@link getMessages}/{@link getSubagentMessages}. Lets a parent + * restore register every child up-front instead of each child re-fetching + * and re-reconstructing the full parent event log. + */ + async getSubagentSessions(): Promise { + const result = await this._getMappedEvents(); + if (result.subagentTurnsByToolCallId.size === 0) { + return []; + } + const parentSessionStr = this.sessionUri.toString(); + const out: IRestoredSubagentSession[] = []; + for (const turn of result.turns) { + for (const rp of turn.responseParts) { + if (rp.kind !== ResponsePartKind.ToolCall) { + continue; + } + const tc = rp.toolCall; + const childTurns = result.subagentTurnsByToolCallId.get(tc.toolCallId); + if (!childTurns || childTurns.length === 0) { + continue; + } + const content = (tc as { content?: readonly ToolResultContent[] }).content; + const subagentContent = content ? getToolSubagentContent({ content }) : undefined; + out.push({ + resource: URI.parse(buildSubagentSessionUri(parentSessionStr, tc.toolCallId)), + toolCallId: tc.toolCallId, + title: subagentContent?.title ?? 'Subagent', + turns: childTurns, + }); + } + } + return out; + } + + /** + * Memoized `getEvents()` + {@link mapSessionEvents} result, shared by + * {@link getMessages}, {@link getSubagentMessages} and + * {@link getSubagentSessions}. A single session open reads and + * reconstructs the full parent event log once instead of once per + * subagent. The memo is scoped to the resume/restore wave: it is dropped + * whenever the persisted event log could change (see + * {@link _invalidateMappedEvents}) and on dispose, so it never serves + * stale turns for an actively-running session. + */ + private _mappedEventsMemo: Promise | undefined; + + private _getMappedEvents(): Promise { + if (!this._mappedEventsMemo) { + const pending = this._computeMappedEvents(); + this._mappedEventsMemo = pending; + // Don't cache a rejected reconstruction — let the next caller retry. + pending.catch(() => { + if (this._mappedEventsMemo === pending) { + this._mappedEventsMemo = undefined; + } + }); + } + return this._mappedEventsMemo; + } + + private async _computeMappedEvents(): Promise { const events = await this._wrapper.session.getEvents(); let db: ISessionDatabase | undefined; try { @@ -1288,7 +1353,12 @@ export class CopilotAgentSession extends Disposable { // Database may not exist yet — that's fine } const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); - return result.subagentTurnsByToolCallId.get(parentToolCallId) ?? []; + return result; + } + + /** Drop the memoized event reconstruction; the next read rebuilds it. */ + private _invalidateMappedEvents(): void { + this._mappedEventsMemo = undefined; } async abort(): Promise { @@ -2826,6 +2896,29 @@ export class CopilotAgentSession extends Disposable { } } + /** + * Drop the memoized event reconstruction whenever the persisted event log + * could have changed, so {@link _getMappedEvents} never serves stale turns + * once the session resumes activity. While the session is idle (e.g. during + * a historical session open) none of these fire, so the whole restore wave + * coalesces to a single reconstruction. + */ + private _subscribeForMemoInvalidation(): void { + const wrapper = this._wrapper; + const invalidate = () => this._invalidateMappedEvents(); + // New content appended to the log. + this._register(wrapper.onUserMessage(invalidate)); + this._register(wrapper.onTurnStart(invalidate)); + this._register(wrapper.onTurnEnd(invalidate)); + this._register(wrapper.onToolComplete(invalidate)); + this._register(wrapper.onSubagentCompleted(invalidate)); + this._register(wrapper.onSubagentFailed(invalidate)); + // In-place rewrites of the persisted log. + this._register(wrapper.onSessionCompactionComplete(invalidate)); + this._register(wrapper.onSessionTruncation(invalidate)); + this._register(wrapper.onSessionSnapshotRewind(invalidate)); + } + private _subscribeForLogging(): void { const wrapper = this._wrapper; const sessionId = this.sessionId; diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 387d4f4e3ce1d9..af6c6384bd1fdf 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -22,7 +22,7 @@ import { hasKey } from '../../../../base/common/types.js'; import { NullLogService } from '../../../log/common/log.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; +import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IRestoredSubagentSession } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; @@ -2131,6 +2131,60 @@ suite('AgentService (node dispatcher)', () => { assert.ok(mdParts.length > 0, 'Should have markdown content'); }); + test('eagerly registers subagent child sessions during parent restore', async () => { + // An agent that surfaces its subagent children from the parent's + // reconstructed history, exercising the eager-registration path. + class EagerSubagentMockAgent extends MockAgent { + async getSubagentSessions(session: URI): Promise { + if (parseSubagentSessionUri(session)) { + return []; + } + const parent = session.toString(); + const out: IRestoredSubagentSession[] = []; + const seen = new Set(); + for (const rec of this.sessionMessages) { + if (rec.type === 'subagent_started' && !seen.has(rec.toolCallId)) { + seen.add(rec.toolCallId); + const childUri = buildSubagentSessionUri(parent, rec.toolCallId); + const turns = await this.getSessionMessages(URI.parse(childUri)); + if (turns.length > 0) { + out.push({ resource: URI.parse(childUri), toolCallId: rec.toolCallId, title: rec.agentDisplayName, turns }); + } + } + } + return out; + } + } + + const agent = new EagerSubagentMockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + service.registerProvider(agent); + const { session } = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session; + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review this code', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] }, + { type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, subagentDescription: 'Find related files', subagentAgentName: 'explore' }, + { type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' }, + { type: 'tool_start', session, toolCallId: 'tc-inner-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' }, + { type: 'tool_complete', session, toolCallId: 'tc-inner-1', result: { success: true, pastTenseMessage: 'Ran ls', content: [{ type: ToolResultContentType.Text, text: 'file1.ts' }] }, parentToolCallId: 'tc-sub' }, + { type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'Delegated task', content: [{ type: ToolResultContentType.Text, text: 'Found 3 issues' }] } }, + ]; + + await service.restoreSession(sessionResource); + + // The subagent child state must already exist WITHOUT any client + // subscribing to it: parent restore registered it eagerly. + const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub'); + const childState = service.stateManager.getSessionState(childSessionUri); + assert.ok(childState, 'subagent child should be eagerly registered during parent restore'); + assert.strictEqual(childState!.turns.length, 1, 'child should have its reconstructed turn'); + const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-1'), 'child should contain the inner tool call'); + }); + test('inner assistant messages from subagent route via envelope agentId (fixture)', async () => { // Regression for the SDK migration away from the deprecated // `data.parentToolCallId` to the envelope-level `agentId`. Newer diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 19fb2bbaa94ba5..64750d249e9e75 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -466,6 +466,25 @@ suite('CopilotAgentSession', () => { }]); }); + test('memoizes the event reconstruction across getMessages/getSubagentMessages and invalidates on log changes', async () => { + const { session, mockSession } = await createAgentSession(disposables); + let getEventsCalls = 0; + mockSession.getEvents = async () => { getEventsCalls++; return mockSession.messages; }; + + // A single resume wave reads + reconstructs the event log once, shared + // by the parent turns and every subagent lookup. + await session.getMessages(); + await session.getSubagentMessages('tc-x'); + await session.getMessages(); + assert.strictEqual(getEventsCalls, 1, 'event log should be read once for the whole resume wave'); + + // A log-mutating event drops the memo so a later read rebuilds from + // fresh events instead of serving stale turns. + mockSession.fire('assistant.turn_end', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_end'>['data']); + await session.getMessages(); + assert.strictEqual(getEventsCalls, 2, 'memo should be invalidated after the event log changes'); + }); + test('falls back to file reference when reading a symbol Resource attachment fails', async () => { const symbolUri = URI.file('/workspace/missing.ts'); const { session, mockSession } = await createAgentSession(disposables, { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 1c9d1e55c044a0..915b66ac20fa14 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2645,72 +2645,100 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC parentSession: URI, ): Promise { const parentSessionStr = parentSession.toString(); + const subagentInsertions: { item: Extract; index: number; toolCallId: string }[] = []; for (const item of history) { if (item.type !== 'response') { continue; } - // Collect subagent tool calls from this response's parts - const subagentInsertions: { index: number; toolCallId: string }[] = []; for (let i = 0; i < item.parts.length; i++) { const part = item.parts[i]; if (part.kind === 'toolInvocationSerialized' && part.toolSpecificData?.kind === 'subagent') { - subagentInsertions.push({ index: i, toolCallId: part.toolCallId }); + subagentInsertions.push({ item, index: i, toolCallId: part.toolCallId }); } } + } - // Process insertions in reverse order so indices remain valid - for (let j = subagentInsertions.length - 1; j >= 0; j--) { - const { index, toolCallId } = subagentInsertions[j]; - const childSessionUri = buildSubagentSessionUri(parentSessionStr, toolCallId); + if (subagentInsertions.length === 0) { + return; + } - try { - const childSub = this._ensureSessionSubscription(childSessionUri); - let childState = this._getSessionState(childSessionUri); - if (!childState) { - if (childSub.value instanceof Error) { - throw childSub.value; - } - await new Promise(resolve => { - const d = childSub.onDidChange(() => { d.dispose(); resolve(); }); - }); - if (childSub.value instanceof Error) { - throw childSub.value; - } - childState = this._getSessionState(childSessionUri); - } - if (childState) { - const innerParts: IChatProgress[] = []; - for (const turn of childState.turns) { - for (const rp of turn.responseParts) { - if (rp.kind === ResponsePartKind.ToolCall) { - const tc = rp.toolCall; - if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { - const completedTc = tc as ICompletedToolCall; - const fileEditParts = completedToolCallToEditParts(completedTc, this._config.connectionAuthority); - const serialized = completedToolCallToSerialized(completedTc, toolCallId, URI.parse(childSessionUri), this._config.connectionAuthority); - if (fileEditParts.length > 0) { - serialized.presentation = ToolInvocationPresentation.Hidden; - } - innerParts.push(serialized); - innerParts.push(...fileEditParts); - } - } - } - } - if (innerParts.length > 0) { - // Insert inner tool calls right after the subagent tool call - item.parts.splice(index + 1, 0, ...innerParts); + const childStateByUri = new Map>(); + const getChildState = (childSessionUri: string): Promise => { + let existing = childStateByUri.get(childSessionUri); + if (!existing) { + existing = this._loadSubagentState(childSessionUri); + childStateByUri.set(childSessionUri, existing); + } + return existing; + }; + + const enrichedInsertions = await Promise.all(subagentInsertions.map(async ({ item, index, toolCallId }) => { + const childSessionUri = buildSubagentSessionUri(parentSessionStr, toolCallId); + try { + const childState = await getChildState(childSessionUri); + return { item, index, innerParts: childState ? this._getSubagentInnerParts(childSessionUri, toolCallId, childState) : [] }; + } catch (err) { + this._logService.warn(`[AgentHost] Failed to enrich history with subagent calls: ${childSessionUri}`, err); + return { item, index, innerParts: [] }; + } + })); + + for (const { item, index, innerParts } of enrichedInsertions.sort((a, b) => b.index - a.index)) { + if (innerParts.length > 0) { + item.parts.splice(index + 1, 0, ...innerParts); + } + } + } + + private async _loadSubagentState(childSessionUri: string): Promise { + const childSub = this._ensureSessionSubscription(childSessionUri); + // `_ensureSessionSubscription` already subscribes to the child's default + // chat in lockstep; grab a handle so we can await it too. After the + // multi-chat protocol split, `turns` live on the chat channel, so we + // must wait for BOTH the session summary and its default-chat state to + // hydrate. Awaiting only the session subscription would read the merged + // state while `turns` is still empty, yielding no subagent inner tool + // calls. This mirrors the main `provideChatSessionContent` path. + const childChatSub = this._ensureDefaultChatSubscription(childSessionUri); + try { + await Promise.all([ + this._whenSubscriptionHydrated(childSub, CancellationToken.None), + this._whenSubscriptionHydrated(childChatSub, CancellationToken.None), + ]); + if (childSub.value instanceof Error) { + throw childSub.value; + } + if (childChatSub.value instanceof Error) { + throw childChatSub.value; + } + return this._getSessionState(childSessionUri); + } finally { + this._releaseSessionSubscription(childSessionUri); + } + } + + private _getSubagentInnerParts(childSessionUri: string, toolCallId: string, childState: ISessionWithDefaultChat): IChatProgress[] { + const innerParts: IChatProgress[] = []; + for (const turn of childState.turns) { + for (const rp of turn.responseParts) { + if (rp.kind === ResponsePartKind.ToolCall) { + const tc = rp.toolCall; + if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { + const completedTc = tc as ICompletedToolCall; + const fileEditParts = completedToolCallToEditParts(completedTc, this._config.connectionAuthority); + const serialized = completedToolCallToSerialized(completedTc, toolCallId, URI.parse(childSessionUri), this._config.connectionAuthority); + if (fileEditParts.length > 0) { + serialized.presentation = ToolInvocationPresentation.Hidden; } + innerParts.push(serialized); + innerParts.push(...fileEditParts); } - } catch (err) { - this._logService.warn(`[AgentHost] Failed to enrich history with subagent calls: ${childSessionUri}`, err); - } finally { - this._releaseSessionSubscription(childSessionUri); } } } + return innerParts; } /** From 4bd25ffd453f29682bc606ab49a8851be4021c39 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 24 Jun 2026 17:01:47 +1000 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/copilot/copilotAgentSession.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index bb8f4e0d9a2792..9f6ab595af8fbf 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2909,10 +2909,13 @@ export class CopilotAgentSession extends Disposable { // New content appended to the log. this._register(wrapper.onUserMessage(invalidate)); this._register(wrapper.onTurnStart(invalidate)); - this._register(wrapper.onTurnEnd(invalidate)); + this._register(wrapper.onMessage(invalidate)); + this._register(wrapper.onToolStart(invalidate)); this._register(wrapper.onToolComplete(invalidate)); + this._register(wrapper.onSubagentStarted(invalidate)); this._register(wrapper.onSubagentCompleted(invalidate)); this._register(wrapper.onSubagentFailed(invalidate)); + this._register(wrapper.onTurnEnd(invalidate)); // In-place rewrites of the persisted log. this._register(wrapper.onSessionCompactionComplete(invalidate)); this._register(wrapper.onSessionTruncation(invalidate)); From 71f190b1430f8ce86412b5ec4461cba758930b53 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 25 Jun 2026 11:07:23 +1000 Subject: [PATCH 3/3] fix jsdoc --- src/vs/platform/agentHost/common/agentService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 317ec5993c713f..0d43da5accadf6 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -879,6 +879,7 @@ export interface IRestoredSubagentSession { readonly turns: readonly Turn[]; } +/** * A per-session handle for one active client's contributions (tools and * plugin customizations) to an agent session, obtained via * {@link IAgent.getOrCreateActiveClient}.