diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 677ee37a3bc..c84dfdf5c1b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -700,6 +700,7 @@ export class CopilotAgentSession extends Disposable { private readonly _parentToolCallIdsByAgentId = new Map(); /** Maps SDK root-agent turn ids to their owning host protocol turn ids. */ private readonly _hostTurnIdsBySdkTurnId = new Map(); + private _activeRootSdkTurnId: string | undefined; private readonly _activeSubagentAgentIds = new Set(); private readonly _unroutableSubagentToolCallIds = new Set(); private readonly _autoApprovals = new Map(); @@ -1163,6 +1164,9 @@ export class CopilotAgentSession extends Disposable { turn.messageCharLen = steering.message.text.length; turn.markRunning(); } + if (this._activeRootSdkTurnId) { + this._hostTurnIdsBySdkTurnId.set(this._activeRootSdkTurnId, newTurnId); + } return newTurnId; } @@ -5562,7 +5566,9 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onTurnStart(e => { this._currentTurn.value?.markRunning(); this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); + this._resumeSubagentForEvent(e); if (!e.agentId) { + this._activeRootSdkTurnId = e.data.turnId; if (this._currentTurn.value) { this._hostTurnIdsBySdkTurnId.set(e.data.turnId, this._currentTurn.value.id); } @@ -5605,6 +5611,9 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onTurnEnd(e => { this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`); + if (!e.agentId && this._activeRootSdkTurnId === e.data.turnId) { + this._activeRootSdkTurnId = undefined; + } })); this._register(wrapper.onAbort(e => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 0d84d283e00..20c9e3b3184 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -5005,6 +5005,35 @@ suite('CopilotAgentSession', () => { assert.strictEqual(steeringCompletions.length, 0, 'an aborted steering turn must not be completed'); }); + test('maps model-call lifecycle events to a promoted steering turn', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-original'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); + + await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); + mockSession.fire('user.message', { + content: 'focus on tests', + interactionId: 'interaction-steer', + } as SessionEventPayload<'user.message'>['data']); + const steeringTurnId = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)?.turnId; + assert.ok(steeringTurnId); + + mockSession.fireRaw({ + type: 'model.call_finished', + ephemeral: true, + id: 'model-call-steering', + data: { + turnId: 'sdk-0', + dispatchDurationMs: 250, + outcome: 'success', + containsBuiltInFileEditRequest: true, + editClassifierVersion: 1, + }, + }); + + assert.strictEqual(signals.find(signal => signal.kind === 'model_call_finished')?.turnId, steeringTurnId); + }); + test('does not signal cleanup when send fails', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); @@ -5308,6 +5337,47 @@ suite('CopilotAgentSession', () => { }]); }); + test('resumes a subagent on turn start before mapping model.call_finished', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('host-turn-1'); + mockSession.fire('subagent.started', { + toolCallId: 'subagent-tool-call', + agentName: 'helper', + agentDisplayName: 'Helper', + agentDescription: 'Helps', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); + mockSession.fire('subagent.completed', { + toolCallId: 'subagent-tool-call', + agentName: 'helper', + agentDisplayName: 'Helper', + durationMs: 1, + totalTokens: 0, + totalToolCalls: 0, + } as SessionEventPayload<'subagent.completed'>['data'], { agentId: 'agent-1' }); + + mockSession.fire('assistant.turn_start', { turnId: 'sdk-subagent-turn' }, { agentId: 'agent-1' }); + mockSession.fireRaw({ + type: 'model.call_finished', + ephemeral: true, + id: 'subagent-model-call', + agentId: 'agent-1', + data: { + turnId: 'sdk-subagent-turn', + dispatchDurationMs: 125, + outcome: 'error', + editClassifierVersion: 1, + }, + }); + + assert.deepStrictEqual(signals.filter(signal => signal.kind === 'subagent_resumed' || signal.kind === 'model_call_finished').map(signal => ({ + kind: signal.kind, + parentToolCallId: signal.kind === 'model_call_finished' ? signal.parentToolCallId : signal.toolCallId, + })), [ + { kind: 'subagent_resumed', parentToolCallId: 'subagent-tool-call' }, + { kind: 'model_call_finished', parentToolCallId: 'subagent-tool-call' }, + ]); + }); + test('tool_start event is mapped for non-hidden tools', async () => { const { mockSession, signals } = await createAgentSession(disposables); mockSession.fire('tool.execution_start', {