mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-23 06:33:26 +01:00
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:
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user