fix(session): retried bare Request was aborted error-stop turns

A stalled or dropped provider stream that surfaces as stopReason:"error"
carrying the bare "Request was aborted" sentinel fell through both retry
gates: #isRetryableReasonlessAbort required stopReason:"aborted", and
#isRetryableError's classifier returns no retriable kinds for the generic
sentinel. The turn died immediately despite retry.enabled.

- Relaxed #isRetryableReasonlessAbort to accept an empty generic-abort
  sentinel turn under stopReason "aborted" or "error", tagging it Abort so
  #handleRetryableError retries it without model fallback.
- Kept the deliberate-abort guards intact: user interrupts and silent aborts
  carry their own markers (not the generic sentinel), and #abortInProgress /
  #isDisposed / #streamingEditAbortTriggered still settle without retry.
- Rewrote the stale fallback test that froze the buggy no-retry behavior to
  assert retry-and-recover for the error-stop sentinel.

Fixes #5375
This commit is contained in:
roboomp
2026-07-14 19:39:15 +00:00
parent 3047c27c33
commit 5e71fac653
3 changed files with 37 additions and 19 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed a bare `Request was aborted` provider abort that arrives as `stopReason: "error"` (a stalled or dropped stream reported as an error rather than an abort) never being auto-retried despite `retry.enabled`. The reason-less-abort retry gate now recognizes the empty generic-abort sentinel under either `stopReason: "aborted"` or `"error"`, while deliberate user interrupts, dispose-driven aborts, and streaming-edit guard aborts still settle without retry ([#5375](https://github.com/can1357/oh-my-pi/issues/5375)).
## [16.5.0] - 2026-07-13
### Breaking Changes
@@ -13829,19 +13829,23 @@ export class AgentSession {
}
/**
* Retry an empty, reason-less provider abort: a turn that ended `aborted`
* with no content and the generic sentinel (bare `abort()`), but only while
* the session is neither aborting nor tearing down. A user/lifecycle abort
* (`#abortInProgress`), a dispose-driven abort (`#isDisposed`), or a
* session-induced streaming-edit guard abort (`#streamingEditAbortTriggered` —
* auto-generated-file guard or failed-patch preview) is deliberate and MUST
* settle the turn instead: routing it through retry would orphan
* `#retryPromise` on a continuation the guard skips (hanging the in-flight
* `prompt()`) or silently undo the guard's intended abort.
* Retry an empty, reason-less provider abort: a turn with no content that
* carries the generic sentinel (bare `abort()`), whether the provider
* finalized it as `stopReason: "aborted"` or leaked it as `stopReason:
* "error"` (a stalled/dropped stream reported as an error rather than an
* abort — issue #5375). Only fires while the session is neither aborting nor
* tearing down. A user/lifecycle abort (`#abortInProgress`), a dispose-driven
* abort (`#isDisposed`), or a session-induced streaming-edit guard abort
* (`#streamingEditAbortTriggered` — auto-generated-file guard or failed-patch
* preview) is deliberate and MUST settle the turn instead: routing it through
* retry would orphan `#retryPromise` on a continuation the guard skips
* (hanging the in-flight `prompt()`) or silently undo the guard's intended
* abort. Deliberate user interrupts (`UserInterrupt`) and silent aborts carry
* their own marker, not the generic sentinel, so they never match here.
*/
#isRetryableReasonlessAbort(message: AssistantMessage): boolean {
if (
message.stopReason !== "aborted" ||
(message.stopReason !== "aborted" && message.stopReason !== "error") ||
message.content.length !== 0 ||
this.#abortInProgress ||
this.#isDisposed ||
@@ -13851,7 +13855,7 @@ export class AgentSession {
}
const id = this.#classifyRetryMessage(message);
if (AIError.is(id, AIError.Flag.Abort)) return true;
if (message.stopReason === "aborted" && AIError.is(id, AIError.Flag.Abort)) return true;
if (!this.#isGenericAbortSentinel(message)) return false;
message.errorId = AIError.create(AIError.Flag.Abort);
@@ -1602,14 +1602,18 @@ describe("AgentSession retry fallback", () => {
expect(lastAssistant.errorMessage).toBe(envelopeError);
});
it("does not auto-retry generic Request was aborted. errors", async () => {
it("auto-retries a bare Request was aborted error-stop turn (issue #5375)", async () => {
const model = getBundledModel("openai", "gpt-4o-mini");
if (!model) {
throw new Error("Expected bundled OpenAI test model to exist");
}
const requestedModels: string[] = [];
const mock = createMockModel({ handler: () => ({ throw: "Request was aborted." }) });
// A stalled/dropped stream that the provider surfaces as stopReason:"error"
// carrying the bare abort sentinel, then a clean recovery on the retry.
const mock = createMockModel({
responses: [{ throw: "Request was aborted." }, { content: ["recovered after bare abort error"] }],
});
const agent = new Agent({
getApiKey: model => `${model.provider}-test-key`,
initialState: {
@@ -1637,17 +1641,23 @@ describe("AgentSession retry fallback", () => {
settings,
modelRegistry,
});
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
await session.prompt("Do not retry generic abort text");
await session.prompt("Retry the bare abort error");
await session.waitForIdle();
expect(requestedModels).toEqual([`${model.provider}/${model.id}`]);
expect(retryStartEvents).toHaveLength(0);
expect(retryEndEvents).toHaveLength(0);
// Same model, retried once (no model fallback for a reason-less abort).
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
expect(retryStartEvents).toHaveLength(1);
expect(retryEndEvents).toHaveLength(1);
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
const lastAssistant = getLastAssistantMessage(session);
expect(lastAssistant.stopReason).toBe("error");
expect(lastAssistant.errorMessage).toBe("Request was aborted.");
expect(lastAssistant.stopReason).toBe("stop");
expect(lastAssistant.content).toContainEqual({
type: "text",
text: "recovered after bare abort error",
});
});
it("matches plain fallback roles for compat-routed primary models", async () => {