From e4c855bda69843152badc8180a5f434b1bd4fb15 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 16 Nov 2025 15:54:52 -0600 Subject: [PATCH] fix: drop invalid previousResponseId on server errors --- src/node/services/streamManager.test.ts | 54 +++++++++++++++++++++++++ src/node/services/streamManager.ts | 47 +++++++++++++++------ 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 2a23ed0ae1..d93778bdf0 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -455,4 +455,58 @@ describe("StreamManager - previousResponseId recovery", () => { const errorWithoutId = new Error("Some other error"); expect(extractMethod.call(streamManager, errorWithoutId)).toBeUndefined(); }); + + test("recordLostResponseIdIfApplicable records IDs for explicit OpenAI errors", () => { + const mockHistoryService = createMockHistoryService(); + const mockPartialService = createMockPartialService(); + const streamManager = new StreamManager(mockHistoryService, mockPartialService); + + const recordMethod = Reflect.get(streamManager, "recordLostResponseIdIfApplicable") as ( + error: unknown, + streamInfo: unknown + ) => void; + expect(typeof recordMethod).toBe("function"); + + const apiError = new APICallError({ + message: "Previous response with id 'resp_deadbeef' not found.", + url: "https://api.openai.com/v1/responses", + requestBodyValues: {}, + statusCode: 400, + responseHeaders: {}, + responseBody: "Previous response with id 'resp_deadbeef' not found.", + isRetryable: false, + data: { error: { code: "previous_response_not_found" } }, + }); + + recordMethod.call(streamManager, apiError, { messageId: "msg-1", model: "openai:gpt-mini" }); + + expect(streamManager.isResponseIdLost("resp_deadbeef")).toBe(true); + }); + + test("recordLostResponseIdIfApplicable records IDs for 500 errors referencing previous responses", () => { + const mockHistoryService = createMockHistoryService(); + const mockPartialService = createMockPartialService(); + const streamManager = new StreamManager(mockHistoryService, mockPartialService); + + const recordMethod = Reflect.get(streamManager, "recordLostResponseIdIfApplicable") as ( + error: unknown, + streamInfo: unknown + ) => void; + expect(typeof recordMethod).toBe("function"); + + const apiError = new APICallError({ + message: "Internal error: Previous response with id 'resp_cafebabe' not found.", + url: "https://api.openai.com/v1/responses", + requestBodyValues: {}, + statusCode: 500, + responseHeaders: {}, + responseBody: "Internal error: Previous response with id 'resp_cafebabe' not found.", + isRetryable: false, + data: { error: { code: "server_error" } }, + }); + + recordMethod.call(streamManager, apiError, { messageId: "msg-2", model: "openai:gpt-mini" }); + + expect(streamManager.isResponseIdLost("resp_cafebabe")).toBe(true); + }); }); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index d412fef810..0faebea56f 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -1207,23 +1207,29 @@ export class StreamManager extends EventEmitter { * Frontend will automatically retry, and buildProviderOptions will filter it out */ private recordLostResponseIdIfApplicable(error: unknown, streamInfo: WorkspaceStreamInfo): void { - const errorCode = this.extractErrorCode(error); - if (errorCode !== "previous_response_not_found") { + const responseId = this.extractPreviousResponseIdFromError(error); + if (!responseId) { return; } - // Extract previousResponseId from the stream's initial provider options - // We need to check streamInfo.streamResult.providerOptions, but that's not exposed - // Instead, we can extract it from the error response body if it contains it - const responseId = this.extractPreviousResponseIdFromError(error); - if (responseId) { - log.info("Recording lost previousResponseId for future filtering", { - previousResponseId: responseId, - workspaceId: streamInfo.messageId, - model: streamInfo.model, - }); - this.lostResponseIds.add(responseId); + const errorCode = this.extractErrorCode(error); + const statusCode = this.extractStatusCode(error); + const shouldRecord = + errorCode === "previous_response_not_found" || statusCode === 404 || statusCode === 500; + + if (!shouldRecord || this.lostResponseIds.has(responseId)) { + return; } + + log.info("Recording lost previousResponseId for future filtering", { + previousResponseId: responseId, + messageId: streamInfo.messageId, + model: streamInfo.model, + statusCode, + errorCode, + }); + + this.lostResponseIds.add(responseId); } /** @@ -1280,6 +1286,21 @@ export class StreamManager extends EventEmitter { return undefined; } + private extractStatusCode(error: unknown): number | undefined { + if (APICallError.isInstance(error) && typeof error.statusCode === "number") { + return error.statusCode; + } + + if (typeof error === "object" && error !== null && "statusCode" in error) { + const candidate = (error as { statusCode?: unknown }).statusCode; + if (typeof candidate === "number") { + return candidate; + } + } + + return undefined; + } + private getStructuredErrorCode(candidate: unknown): string | undefined { if (typeof candidate === "object" && candidate !== null && "error" in candidate) { const withError = candidate as { error?: unknown };