compaction & branch summarization follow retry policy

fixes #6647

compaction (auto & manual) and branch summarization retry on transient failures.
use the same retry policy from settings.
emit events for the tui to show indication of retries
This commit is contained in:
David Brailovsky
2026-07-21 10:07:20 +00:00
parent 890b3547af
commit 8e53e0e49c
7 changed files with 546 additions and 16 deletions
+120 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { fauxAssistantMessage } from "../src/providers/faux.ts";
import { isRetryableAssistantError } from "../src/utils/retry.ts";
import { isRetryableAssistantError, type RetryPolicy, retryAssistantCall } from "../src/utils/retry.ts";
const openAIExplicitRetryMessage =
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_******** in your message.";
@@ -66,3 +66,121 @@ describe("provider retry classification", () => {
expect(isRetryableAssistantError(fauxAssistantMessage("not an error"))).toBe(false);
});
});
describe("retryAssistantCall", () => {
const disabled: RetryPolicy = { enabled: false, maxRetries: 3, baseDelayMs: 0 };
const enabled: RetryPolicy = { enabled: true, maxRetries: 3, baseDelayMs: 0 };
it("returns a successful response immediately without retrying", async () => {
const produce = vi.fn(async () => fauxAssistantMessage("ok"));
const res = await retryAssistantCall(produce, enabled, undefined);
expect(res.content).toEqual([{ type: "text", text: "ok" }]);
expect(produce).toHaveBeenCalledTimes(1);
});
it("does not retry an aborted message", async () => {
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "aborted" }));
const onRetry = vi.fn();
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry });
expect(res.stopReason).toBe("aborted");
expect(produce).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("does not retry a non-retryable error (quota/billing)", async () => {
const produce = vi.fn(async () =>
fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }),
);
const onRetry = vi.fn();
const onRetryEnd = vi.fn();
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry, onRetryEnd });
expect(res.stopReason).toBe("error");
expect(produce).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
expect(onRetryEnd).not.toHaveBeenCalled();
});
it("retries a transient error up to maxRetries then returns the final error", async () => {
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
const onRetry = vi.fn();
const onRetryEnd = vi.fn();
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry, onRetryEnd });
expect(res.stopReason).toBe("error");
expect(produce).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
expect(onRetry).toHaveBeenCalledTimes(3);
expect(onRetryEnd).toHaveBeenCalledWith(false, 3, "terminated");
});
it("stops retrying once a call succeeds", async () => {
let n = 0;
const produce = vi.fn(async () => {
n++;
return n < 3
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
: fauxAssistantMessage("recovered");
});
const onRetryEnd = vi.fn();
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryEnd });
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
expect(produce).toHaveBeenCalledTimes(3);
expect(onRetryEnd).toHaveBeenCalledWith(true, 2);
});
it("does not retry when policy is disabled", async () => {
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
const onRetry = vi.fn();
const onRetryEnd = vi.fn();
const res = await retryAssistantCall(produce, disabled, undefined, { onRetry, onRetryEnd });
expect(res.stopReason).toBe("error");
expect(produce).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
expect(onRetryEnd).not.toHaveBeenCalled();
});
it("emits onRetryAttemptStart after backoff before each retried call", async () => {
const events: string[] = [];
let n = 0;
const produce = vi.fn(async () => {
events.push(`produce:${n}`);
n++;
return n < 3
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
: fauxAssistantMessage("recovered");
});
const onRetry = vi.fn((attempt: number) => {
events.push(`retry:${attempt}`);
});
const onRetryAttemptStart = vi.fn(() => {
events.push("attempt-start");
});
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry, onRetryAttemptStart });
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
expect(onRetry).toHaveBeenCalledTimes(2);
expect(onRetryAttemptStart).toHaveBeenCalledTimes(2);
expect(events).toEqual([
"produce:0",
"retry:1",
"attempt-start",
"produce:1",
"retry:2",
"attempt-start",
"produce:2",
]);
});
it("aborts backoff sleep via signal, returns an aborted message, and emits onRetryEnd(false)", async () => {
const controller = new AbortController();
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
const policy: RetryPolicy = { enabled: true, maxRetries: 5, baseDelayMs: 10_000 };
const onRetryEnd = vi.fn();
const p = retryAssistantCall(produce, policy, controller.signal, { onRetryEnd });
// Let one error call resolve and the first backoff sleep start, then abort.
await vi.waitFor(() => expect(produce).toHaveBeenCalled());
controller.abort();
const res = await p;
expect(res.stopReason).toBe("aborted");
expect(res.errorMessage).toBeUndefined();
expect(produce).toHaveBeenCalledTimes(1);
expect(onRetryEnd).toHaveBeenCalledWith(false, 1, "terminated");
});
});