fix(coding-agent): close terminal retry lifecycle

This commit is contained in:
Mathews-Tom
2026-07-15 22:53:47 +05:30
parent fde3722fc3
commit ea3882f8a3
3 changed files with 66 additions and 6 deletions
@@ -2232,8 +2232,8 @@ export class AgentSession {
const prewalk = this.#prewalk;
if (!prewalk || context?.message.role !== "assistant") return;
const todoCalledThisTurn = context.toolResults.some(result => result.toolName === "todo");
if (todoCalledThisTurn) {
const todoSucceededThisTurn = context.toolResults.some(result => result.toolName === "todo" && !result.isError);
if (todoSucceededThisTurn) {
this.#prewalkTodoSeen = true;
}
@@ -2268,9 +2268,6 @@ export class AgentSession {
// not establish the list. The gate keys on the ACTIVE tool set, not the
// registry: a registered-but-deactivated todo (e.g. a restricted
// active-tool slate) is uncallable and would deadlock the switch.
if (context.toolResults.some(result => result.toolName === "todo" && !result.isError)) {
this.#prewalkTodoSeen = true;
}
const todoGateOpen = this.#prewalkTodoSeen || !this.getActiveToolNames().includes("todo");
const action = todoGateOpen
? context.toolResults.find(result => PREWALK_ACTION_TOOLS[result.toolName])
@@ -4584,6 +4581,17 @@ export class AgentSession {
if (this.#isClassifierRefusal(msg)) {
this.#removeAssistantMessageFromActiveContext(msg);
}
if (msg.stopReason === "error" && this.#retryAttempt > 0) {
const attempt = this.#retryAttempt;
this.#retryAttempt = 0;
await this.#emitSessionEvent({
type: "auto_retry_end",
success: false,
attempt,
finalError: msg.errorMessage,
});
this.#clearPendingRecoveredRetryErrors();
}
this.#resolveRetry();
if (!checkedCompaction) {
@@ -241,7 +241,11 @@ describe("AgentSession prewalk", () => {
const failingTodoTool: AgentTool<typeof todoToolSchema, undefined> = {
...todoTool,
async execute() {
throw new Error("todo update failed");
return {
content: [{ type: "text", text: "todo update failed" }],
details: undefined,
isError: true,
};
},
};
const mock = createMockModel({
@@ -1683,6 +1683,54 @@ describe("AgentSession retry fallback", () => {
expect(lastAssistant.errorMessage).toBe(envelopeError);
});
it("closes the retry lifecycle when a retried turn ends with a non-retryable error", async () => {
const model = getBundledModel("openai", "gpt-4o-mini");
if (!model) throw new Error("Expected bundled OpenAI test model to exist");
const retryableError = "rate limit exceeded retry-after-ms=5";
const terminalError = "invalid request: schema violation";
const requestedModels: string[] = [];
const mock = createMockModel();
const agent = new Agent({
getApiKey: requestedModel => `${requestedModel.provider}-test-key`,
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
streamFn: (requestedModel, context, options) => {
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
mock.push({ throw: requestedModels.length === 1 ? retryableError : terminalError });
return mock.stream(requestedModel, context, options);
},
});
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.baseDelayMs": 5,
"retry.maxRetries": 1,
});
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
await session.prompt("Retry once, then surface a terminal validation failure");
await session.waitForIdle();
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
expect(retryStartEvents).toHaveLength(1);
expect(retryEndEvents).toEqual([
expect.objectContaining({ success: false, attempt: 1, finalError: terminalError }),
]);
expect(session.retryAttempt).toBe(0);
expect(getLastAssistantMessage(session).stopReason).toBe("error");
});
it("auto-retries a bare Request was aborted error-stop turn (issue #5375)", async () => {
const model = getBundledModel("openai", "gpt-4o-mini");
if (!model) {