diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 0d4a1dcbe41aa..da814dff103eb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -6098,14 +6098,26 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`); })); + const subagentIdsByStopHook = new Map(); this._register(wrapper.onHookStart(e => { this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`); + if (e.data.hookType === 'subagentStop') { + // Some SDK stop hooks identify the subagent only in the start event's input. + const input = e.data.input; + const inputAgentId = input !== null && typeof input === 'object' && !Array.isArray(input) ? input.agentId : undefined; + const agentId = e.agentId ?? (isString(inputAgentId) ? inputAgentId : undefined); + if (agentId) { + subagentIdsByStopHook.set(e.data.hookInvocationId, agentId); + } + } })); this._register(wrapper.onHookEnd(e => { this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`); - if (e.data.hookType === 'agentStop') { - this._completeSubagentTurn(e.agentId); + const agentId = e.agentId ?? subagentIdsByStopHook.get(e.data.hookInvocationId); + subagentIdsByStopHook.delete(e.data.hookInvocationId); + if (e.data.hookType === 'agentStop' || e.data.hookType === 'subagentStop') { + this._completeSubagentTurn(agentId); } })); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index fb2f47119a59e..6f6a3740c8d55 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -8541,6 +8541,135 @@ Use the attached image as context. ]); }); + test('completes a resumed subagent when its stop hook identifies the agent only in the input', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-parent'); + + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + }, { agentId: 'agent-1' }); + const completion = { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + durationMs: 1, + totalTokens: 0, + totalToolCalls: 0, + }; + mockSession.fire('subagent.completed', completion, { agentId: 'agent-1' }); + mockSession.fire('user.message', { + content: 'Review the follow-up', + source: 'agent-parent', + }, { agentId: 'agent-1' }); + mockSession.fire('hook.end', { + hookInvocationId: 'agent-stop', + hookType: 'agentStop', + success: true, + }); + mockSession.fire('hook.start', { + hookInvocationId: 'subagent-stop', + hookType: 'subagentStop', + input: { agentId: 'agent-1' }, + }); + + const completions = () => signals + .filter(signal => signal.kind === 'subagent_completed') + .map(signal => signal.toolCallId); + const beforeHookEnd = completions(); + + mockSession.fire('hook.end', { + hookInvocationId: 'subagent-stop', + hookType: 'subagentStop', + success: true, + }); + const afterHookEnd = completions(); + mockSession.fire('subagent.completed', completion, { agentId: 'agent-1' }); + + assert.deepStrictEqual({ + beforeHookEnd, + afterHookEnd, + afterDuplicateCompletion: completions(), + resumed: signals.filter(signal => signal.kind === 'subagent_resumed').map(signal => signal.toolCallId), + parentCompleted: signals.some(signal => isAction(signal, ActionType.ChatTurnComplete)), + }, { + beforeHookEnd: ['tc-subagent'], + afterHookEnd: ['tc-subagent', 'tc-subagent'], + afterDuplicateCompletion: ['tc-subagent', 'tc-subagent'], + resumed: ['tc-subagent'], + parentCompleted: false, + }); + }); + + test('ignores subagent stop hook inputs without a string agent ID', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-parent'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + }, { agentId: 'agent-1' }); + + const inputs: SessionEventPayload<'hook.start'>['data']['input'][] = [ + undefined, null, true, 1, 'agent-1', [], {}, { agentId: 1 }, { agentId: null }, { agentId: ['agent-1'] }, + ]; + for (const input of inputs) { + mockSession.fire('hook.start', { + hookInvocationId: 'subagent-stop', + hookType: 'subagentStop', + input, + }); + mockSession.fire('hook.end', { + hookInvocationId: 'subagent-stop', + hookType: 'subagentStop', + success: true, + }); + } + + assert.deepStrictEqual(signals.filter(signal => signal.kind === 'subagent_completed'), []); + }); + + test('matches overlapping subagent stop hooks to their own agents', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-parent'); + + for (const id of ['first', 'second']) { + mockSession.fire('subagent.started', { + toolCallId: `tc-${id}`, + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + }, { agentId: `agent-${id}` }); + mockSession.fire('hook.start', { + hookInvocationId: `stop-${id}`, + hookType: 'subagentStop', + input: { agentId: `agent-${id}` }, + }); + } + + for (const id of ['second', 'first']) { + mockSession.fire('hook.end', { + hookInvocationId: `stop-${id}`, + hookType: 'subagentStop', + success: true, + }); + } + mockSession.fire('user.message', { content: 'Another turn' }, { agentId: 'agent-first' }); + mockSession.fire('hook.end', { + hookInvocationId: 'stop-first', + hookType: 'subagentStop', + success: true, + }); + + assert.deepStrictEqual( + signals.filter(signal => signal.kind === 'subagent_completed').map(signal => signal.toolCallId), + ['tc-second', 'tc-first'], + ); + }); + test('history replay seeds turn id from the SDK envelope id, matching `turns.event_id`', async () => { // Regression test: fork / truncate look up the SDK boundary // event id via `getNextTurnEventId(turnId)`, which keys on