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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user