Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/node/services/streamManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
47 changes: 34 additions & 13 deletions src/node/services/streamManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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 };
Expand Down