






















@@ -128,8 +128,35 @@ async function collectEvents(stream: StreamReturn): Promise<AssistantMessageEven
128128129129function expectDone(events: AssistantMessageEvent[]): AssistantMessage {
130130const done = events.find((event) => event.type === "done")?.message;
131-expect(done).toBeDefined();
132-return done!;
131+if (!done) {
132+throw new MissingDoneEventError(events);
133+}
134+return done;
135+}
136+137+class MissingDoneEventError extends Error {
138+constructor(events: AssistantMessageEvent[]) {
139+super(
140+`OpenAI WebSocket stream ended without a done event; event types: ${events.map((event) => event.type).join(", ") || "<none>"}`,
141+);
142+this.name = "MissingDoneEventError";
143+}
144+}
145+146+function isTransientWebSocketLiveError(error: unknown): boolean {
147+if (error instanceof MissingDoneEventError) {
148+return true;
149+}
150+if (!(error instanceof Error)) {
151+return false;
152+}
153+const message = error.message.toLowerCase();
154+return (
155+message.includes("websocket closed") ||
156+message.includes("websocket stream ended") ||
157+message.includes("timeout") ||
158+message.includes("aborted")
159+);
133160}
134161135162function assistantText(message: AssistantMessage): string {
@@ -305,76 +332,89 @@ describe("OpenAI WebSocket e2e", () => {
305332testFn(
306333"surfaces replay-safe reasoning metadata on websocket tool turns",
307334async () => {
308-const sid = freshSession("tool-reasoning");
309-const completedResponses: ResponseObject[] = [];
310-openAIWsStreamModule.__testing.setDepsForTest({
311-createManager: (options) => {
312-const manager = new openAIWsConnectionModule.OpenAIWebSocketManager(options);
313-manager.onMessage((event) => {
314-if (event.type === "response.completed") {
315-completedResponses.push(event.response);
316-}
335+let lastError: unknown;
336+for (let attempt = 0; attempt < 2; attempt += 1) {
337+try {
338+const sid = freshSession(`tool-reasoning-${attempt}`);
339+const completedResponses: ResponseObject[] = [];
340+openAIWsStreamModule.__testing.setDepsForTest({
341+createManager: (options) => {
342+const manager = new openAIWsConnectionModule.OpenAIWebSocketManager(options);
343+manager.onMessage((event) => {
344+if (event.type === "response.completed") {
345+completedResponses.push(event.response);
346+}
347+});
348+return manager;
349+},
350+});
351+const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid);
352+const firstContext = makeToolContext(
353+"Think carefully, call the tool `noop` with {} first, then after the tool result reply with exactly TOOL_OK.",
354+);
355+const firstDone = expectDone(
356+await collectEvents(
357+streamFn(model, firstContext, {
358+transport: "websocket",
359+toolChoice: "required",
360+reasoningEffort: "high",
361+reasoningSummary: "detailed",
362+maxTokens: 256,
363+} as unknown as StreamFnParams[2]),
364+),
365+);
366+367+const firstResponse = completedResponses[0];
368+expect(firstResponse).toBeDefined();
369+370+const rawReasoningItems = (firstResponse?.output ?? []).filter(
371+(item): item is Extract<OutputItem, { type: "reasoning" | `reasoning.${string}` }> =>
372+item.type === "reasoning" || item.type.startsWith("reasoning."),
373+);
374+const replayableReasoningItems = rawReasoningItems.filter(
375+(item) => extractReasoningText(item).length > 0,
376+);
377+const thinkingBlocks = extractThinkingBlocks(firstDone);
378+expect(thinkingBlocks).toHaveLength(replayableReasoningItems.length);
379+expect(thinkingBlocks.map((block) => block.thinking)).toEqual(
380+replayableReasoningItems.map((item) => extractReasoningText(item)),
381+);
382+expect(
383+thinkingBlocks.map((block) => parseReasoningSignature(block.thinkingSignature)),
384+).toEqual(replayableReasoningItems.map((item) => toExpectedReasoningSignature(item)));
385+386+const rawToolCall = firstResponse?.output.find(
387+(item): item is Extract<OutputItem, { type: "function_call" }> =>
388+item.type === "function_call",
389+);
390+expect(rawToolCall).toBeDefined();
391+const toolCall = extractToolCall(firstDone);
392+expect(toolCall?.name).toBe(rawToolCall?.name);
393+expect(toolCall?.id).toBe(
394+rawToolCall ? `${rawToolCall.call_id}|${rawToolCall.id}` : undefined,
395+);
396+397+const secondDone = await runWebsocketToolFollowupTurn({
398+ streamFn,
399+context: firstContext,
400+ firstDone,
401+toolCallId: toolCall!.id,
402+output: "TOOL_OK",
317403});
318-return manager;
319-},
320-});
321-const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid);
322-const firstContext = makeToolContext(
323-"Think carefully, call the tool `noop` with {} first, then after the tool result reply with exactly TOOL_OK.",
324-);
325-const firstDone = expectDone(
326-await collectEvents(
327-streamFn(model, firstContext, {
328-transport: "websocket",
329-toolChoice: "required",
330-reasoningEffort: "high",
331-reasoningSummary: "detailed",
332-maxTokens: 256,
333-} as unknown as StreamFnParams[2]),
334-),
335-);
336-337-const firstResponse = completedResponses[0];
338-expect(firstResponse).toBeDefined();
339-340-const rawReasoningItems = (firstResponse?.output ?? []).filter(
341-(item): item is Extract<OutputItem, { type: "reasoning" | `reasoning.${string}` }> =>
342-item.type === "reasoning" || item.type.startsWith("reasoning."),
343-);
344-const replayableReasoningItems = rawReasoningItems.filter(
345-(item) => extractReasoningText(item).length > 0,
346-);
347-const thinkingBlocks = extractThinkingBlocks(firstDone);
348-expect(thinkingBlocks).toHaveLength(replayableReasoningItems.length);
349-expect(thinkingBlocks.map((block) => block.thinking)).toEqual(
350-replayableReasoningItems.map((item) => extractReasoningText(item)),
351-);
352-expect(
353-thinkingBlocks.map((block) => parseReasoningSignature(block.thinkingSignature)),
354-).toEqual(replayableReasoningItems.map((item) => toExpectedReasoningSignature(item)));
355-356-const rawToolCall = firstResponse?.output.find(
357-(item): item is Extract<OutputItem, { type: "function_call" }> =>
358-item.type === "function_call",
359-);
360-expect(rawToolCall).toBeDefined();
361-const toolCall = extractToolCall(firstDone);
362-expect(toolCall?.name).toBe(rawToolCall?.name);
363-expect(toolCall?.id).toBe(
364-rawToolCall ? `${rawToolCall.call_id}|${rawToolCall.id}` : undefined,
365-);
366-367-const secondDone = await runWebsocketToolFollowupTurn({
368- streamFn,
369-context: firstContext,
370- firstDone,
371-toolCallId: toolCall!.id,
372-output: "TOOL_OK",
373-});
374404375-expect(assistantText(secondDone)).toMatch(/TOOL_OK/);
405+expect(assistantText(secondDone)).toMatch(/TOOL_OK/);
406+return;
407+} catch (error) {
408+lastError = error;
409+openAIWsStreamModule.__testing.setDepsForTest();
410+if (!isTransientWebSocketLiveError(error) || attempt === 1) {
411+throw error;
412+}
413+}
414+}
415+throw lastError;
376416},
377-60_000,
417+120_000,
378418);
379419380420testFn(
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。