agentHost: preserve edit timing across resumed turns

Remap the active SDK turn when steering promotes a new host turn and resume subagents from turn-start events so terminal attempts without later content are still attributed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Munger
2026-08-21 15:53:24 -07:00
co-authored by Copilot
parent 10f1857a8a
commit 35f9e462ae
2 changed files with 79 additions and 0 deletions
@@ -700,6 +700,7 @@ export class CopilotAgentSession extends Disposable {
private readonly _parentToolCallIdsByAgentId = new Map<string, string>();
/** Maps SDK root-agent turn ids to their owning host protocol turn ids. */
private readonly _hostTurnIdsBySdkTurnId = new Map<string, string>();
private _activeRootSdkTurnId: string | undefined;
private readonly _activeSubagentAgentIds = new Set<string>();
private readonly _unroutableSubagentToolCallIds = new Set<string>();
private readonly _autoApprovals = new Map<string, PermissionAutoApproval | null>();
@@ -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 => {
@@ -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', {