Skip to content

Commit 95d9f12

Browse files
roblourensCopilot
andauthored
agentHost: re-enable retryable errors (#333260)
* Revert "agentHost: temporarily disable retryable errors" Reverts #332839 now that the retry issue is fixed. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: preserve non-resumable error handling Keep subagent errors non-resumable when their parent mapping is missing, and retain dispatcher coverage for rejecting non-resumable retry requests. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f8b5627 commit 95d9f12

8 files changed

Lines changed: 32 additions & 37 deletions

File tree

src/vs/platform/agentHost/node/agentSideEffects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1593,7 +1593,7 @@ export class AgentSideEffects extends Disposable {
15931593
type: ActionType.ChatError,
15941594
turnId: action.turnId,
15951595
duration: execution.duration + execution.stopWatch.elapsed(),
1596-
part: createErrorResponsePart(failure.error),
1596+
part: createErrorResponsePart(failure.error, true),
15971597
});
15981598
this._completeTurn(channel, action.turnId, 'error', failure);
15991599
this._toolCallTracker.clearSession(channel);

src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,7 +1594,7 @@ export class CopilotAgentSession extends Disposable {
15941594
type: ActionType.ChatError,
15951595
turnId: turn.id,
15961596
duration: turn.duration,
1597-
part: createErrorResponsePart(error),
1597+
part: createErrorResponsePart(error, true),
15981598
});
15991599
this._clearActiveTurn();
16001600
return turn.id;
@@ -2644,7 +2644,7 @@ export class CopilotAgentSession extends Disposable {
26442644
part: createErrorResponsePart({
26452645
errorType: 'developmentRecoverableError',
26462646
message: localize('copilotAgent.developmentRecoverableError', "Injected recoverable development error ({0}/{1}).", attempt, totalFailures),
2647-
}),
2647+
}, true),
26482648
});
26492649
this._clearActiveTurn();
26502650
}
@@ -5003,7 +5003,7 @@ export class CopilotAgentSession extends Disposable {
50035003
type: ActionType.ChatError,
50045004
turnId: this._turnId,
50055005
duration: turn?.duration ?? 0,
5006-
part: createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data)),
5006+
part: createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data), !e.agentId && turn !== undefined),
50075007
}, parentToolCallId);
50085008
if (!parentToolCallId) {
50095009
this._clearActiveTurn();

src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,7 @@ export async function mapSessionEvents(
672672
rootRequestActive = false;
673673
parentTurnState = TurnState.Error;
674674
parentTurnTerminated = true;
675-
parentBuilder.responseParts.push(createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data)));
675+
parentBuilder.responseParts.push(createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data), true));
676676
parentBuilder.waitingStartedAt = currentEventTimestamp;
677677
touch(parentBuilder);
678678
}
@@ -784,7 +784,7 @@ export async function mapSessionEvents(
784784
}
785785

786786
if (options && !(options instanceof URI) && options.interruptedTurnError && parentBuilder && rootRequestActive && parentTurnState !== TurnState.Error) {
787-
parentBuilder.responseParts.push(createErrorResponsePart(options.interruptedTurnError));
787+
parentBuilder.responseParts.push(createErrorResponsePart(options.interruptedTurnError, true));
788788
parentTurnState = TurnState.Error;
789789
}
790790
flushParent();

src/vs/platform/agentHost/test/node/agentService.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,7 @@ suite('AgentService (node dispatcher)', () => {
791791
});
792792
});
793793

794-
test('finalizes the same turn with a non-resumable error when continuation fails immediately', async () => {
794+
test('finalizes the same turn with another resumable error when continuation fails immediately', async () => {
795795
const { chat } = await createErroredTurn();
796796
copilotAgent.chats.resumeTurn = async () => {
797797
throw new Error('continuation failed');
@@ -814,7 +814,7 @@ suite('AgentService (node dispatcher)', () => {
814814
state: TurnState.Error,
815815
errors: [
816816
createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true),
817-
createErrorResponsePart({ errorType: 'sendFailed', message: 'Error: continuation failed' }),
817+
createErrorResponsePart({ errorType: 'sendFailed', message: 'Error: continuation failed' }, true),
818818
],
819819
durationAtLeastInitial: true,
820820
});

src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,7 @@ suite('CopilotAgentSession', () => {
14641464
errorType: 'executionInterrupted',
14651465
message: 'The agent was interrupted before this request finished.',
14661466
},
1467+
resumable: true,
14671468
});
14681469
});
14691470

@@ -5704,7 +5705,7 @@ Use the attached image as context.
57045705
});
57055706
});
57065707

5707-
test('the development $error-ui path emits an error even with attachments', async () => {
5708+
test('the development $error-ui path emits a resumable error even with attachments', async () => {
57085709
const { session, mockSession, signals } = await createAgentSession(disposables);
57095710

57105711
await session.send('$error-ui', [{
@@ -5730,6 +5731,7 @@ Use the attached image as context.
57305731
errorType: 'developmentRecoverableError',
57315732
message: 'Injected recoverable development error (1/1).',
57325733
},
5734+
resumable: true,
57335735
},
57345736
}],
57355737
});

src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -776,25 +776,18 @@ Use the affected provider command with `--grep "<exact test title>"` and tempora
776776
--grep "accepted steering followed by abort"
777777
```
778778

779-
### Retryable Copilot errors are temporarily disabled
779+
### Mid-turn host shutdown recovery is record-only
780780

781-
Copilot errors currently end a turn without offering an in-place retry. The retry protocol remains implemented, but the host intentionally omits the `resumable` marker from live, restored, and repeated errors until the feature is re-enabled.
781+
A user can lose the Agent Host process while a model response is still streaming. Reopening the session should restore the unfinished request as a resumable error, and retrying should continue that same turn without adding another user message.
782782

783-
- Tests:
784-
- `resumes a failed turn in place`
785-
- `resumes the same turn after repeated failures`
786-
- `restores and resumes a turn interrupted by host shutdown`
787-
- Scope: Copilot on all platforms and execution modes.
788-
- Expected when enabled: a failed turn is marked resumable, and retrying continues the same turn without adding another user message. An unfinished request restored after host shutdown has the same behavior.
789-
- Observed: Copilot errors intentionally omit the `resumable` marker, so clients cannot request an in-place retry.
790-
- Gate: all three scenarios are unconditionally skipped. The host-shutdown scenario additionally requires direct `AGENT_HOST_REPLAY_RECORD=1` mode because replay has no active streaming window to terminate.
791-
- Run after removing the temporary gate:
783+
- Test: `restores and resumes a turn interrupted by host shutdown`.
784+
- Scope: deterministic replay for Copilot.
785+
- Expected: the host dies after streaming starts but before any terminal turn action; restoration synthesizes a resumable `executionInterrupted` error, and a zero-message continuation completes the same turn.
786+
- Observed: replay serves the full recorded response immediately, leaving no active streaming window in which to kill the host before turn completion.
787+
- Gate: direct `AGENT_HOST_REPLAY_RECORD=1` mode only.
788+
- Run:
792789

793790
```bash
794-
./scripts/test-integration.sh --run \
795-
src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \
796-
--grep "resumes a failed turn in place|resumes the same turn after repeated failures"
797-
798791
AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \
799792
src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \
800793
--grep "restores and resumes a turn interrupted by host shutdown"

src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () {
181181
90_000,
182182
);
183183
const liveErrorPart = (getActionEnvelope(liveNotification).action as ChatErrorAction).part;
184-
assert.strictEqual(liveErrorPart.resumable, undefined);
184+
assert.strictEqual(liveErrorPart.resumable, true);
185185

186186
client = await lease.restart();
187187
client.setWorkingDirectory(workingDirectory);
@@ -201,12 +201,11 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () {
201201
}, {
202202
state: TurnState.Error,
203203
error: liveErrorPart.error,
204-
resumable: undefined,
204+
resumable: true,
205205
});
206206
});
207207

208-
// Retryable errors are temporarily disabled.
209-
test.skip('resumes a failed turn in place', async function () {
208+
test('resumes a failed turn in place', async function () {
210209
this.timeout(180_000);
211210
const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-failed-turn-resume-'));
212211
tempDirs.push(workingDirectory);
@@ -345,7 +344,7 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () {
345344
}
346345
});
347346

348-
test.skip('resumes the same turn after repeated failures', async function () {
347+
test('resumes the same turn after repeated failures', async function () {
349348
this.timeout(180_000);
350349
const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-repeated-failed-turn-resume-'));
351350
tempDirs.push(workingDirectory);
@@ -426,8 +425,8 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () {
426425
});
427426
});
428427

429-
// Retryable errors are temporarily disabled.
430-
test.skip('restores and resumes a turn interrupted by host shutdown', async function () {
428+
// Replay serves the full recorded response immediately, so it has no active streaming window to terminate.
429+
(RECORD_ONLY ? test : test.skip)('restores and resumes a turn interrupted by host shutdown', async function () {
431430
this.timeout(240_000);
432431
const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-host-shutdown-resume-'));
433432
tempDirs.push(workingDirectory);

src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ suite('mapSessionEvents — history replay', () => {
146146
});
147147
});
148148

149-
test('restores an unfinished request as an error on the same turn', async () => {
149+
test('restores an unfinished request as a resumable error on the same turn', async () => {
150150
const events: ISessionEvent[] = [
151151
{ type: 'user.message', id: 'interrupted-turn', data: { interactionId: 'm1', content: 'Keep working' } },
152152
{ type: 'assistant.turn_start', data: { turnId: 'sdk-turn' } },
@@ -171,6 +171,7 @@ suite('mapSessionEvents — history replay', () => {
171171
errorPart: {
172172
kind: ResponsePartKind.Error,
173173
error: interruptedTurnError,
174+
resumable: true,
174175
},
175176
});
176177
});
@@ -232,11 +233,11 @@ suite('mapSessionEvents — history replay', () => {
232233
{ kind: ResponsePartKind.Markdown, content: 'Second segment' },
233234
{ kind: ResponsePartKind.Error },
234235
],
235-
resumable: undefined,
236+
resumable: true,
236237
});
237238
});
238239

239-
test('keeps an error terminal when a later notification starts another turn', async () => {
240+
test('keeps a resumable error terminal when a later notification starts another turn', async () => {
240241
const events: ISessionEvent[] = [
241242
{ type: 'user.message', id: 'failed-turn', timestamp: '2026-08-11T00:00:00.000Z', data: { interactionId: 'm1', content: 'Start the background agent' } },
242243
{ type: 'assistant.turn_start', timestamp: '2026-08-11T00:00:00.100Z', data: { turnId: 'sdk-turn-1' } },
@@ -273,10 +274,10 @@ suite('mapSessionEvents — history replay', () => {
273274
state: TurnState.Complete,
274275
parts: [{ kind: ResponsePartKind.Markdown, content: 'The background agent finished.' }],
275276
}]);
276-
assert.strictEqual(getErrorResponsePart(turns[0])?.resumable, undefined);
277+
assert.strictEqual(getErrorResponsePart(turns[0])?.resumable, true);
277278
});
278279

279-
test('keeps an error as the final part when a late tool completion arrives', async () => {
280+
test('keeps a resumable error as the final part when a late tool completion arrives', async () => {
280281
const events: ISessionEvent[] = [
281282
{ type: 'user.message', id: 'failed-turn', data: { interactionId: 'm1', content: 'Run a command' } },
282283
{ type: 'assistant.turn_start', data: { turnId: 'sdk-turn-1' } },
@@ -294,7 +295,7 @@ suite('mapSessionEvents — history replay', () => {
294295
}, {
295296
state: TurnState.Error,
296297
parts: [{ kind: ResponsePartKind.Error }],
297-
resumable: undefined,
298+
resumable: true,
298299
});
299300
});
300301

0 commit comments

Comments
 (0)