Skip to content

Commit 679eda0

Browse files
authored
Merge pull request #793 from Wibias/restore/773-openai-chat-eof
fix(openai-chat): complete streams that end after output without terminal
2 parents e03bf35 + 7bb72e3 commit 679eda0

3 files changed

Lines changed: 72 additions & 14 deletions

File tree

src/adapters/openai-chat.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
697697
const pendingToolCalls: PendingToolCall[] = [];
698698
let toolCallSeq = 0;
699699
const flushToolCalls = function* (): Generator<AdapterEvent> {
700+
// Do not treat flushed tool calls as user-facing output for the finish-less EOF
701+
// fallback — incomplete tool args must stay on the truncation path.
700702
for (const call of pendingToolCalls) {
701703
if (!call.id) call.id = `call_${++toolCallSeq}`;
702704
yield { type: "tool_call_start", id: call.id, name: call.name };
@@ -711,6 +713,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
711713
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
712714
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
713715
let finishReason: string | undefined;
716+
// Only answer text enables the finish-less EOF fallback. Reasoning-only streams can be
717+
// suppressed by hideThinkingSummary and must not complete as empty successful turns.
718+
let sawUserFacingOutput = false;
714719

715720
// Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
716721
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
@@ -780,6 +785,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
780785
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
781786
}
782787
if (typeof delta.content === "string" && delta.content.length > 0) {
788+
sawUserFacingOutput = true;
783789
yield { type: "text_delta", text: delta.content };
784790
}
785791

@@ -837,10 +843,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
837843
if (buffer.length > 0) {
838844
if ((yield* handleDataLine(buffer)) === "terminate") return;
839845
}
840-
// Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
841-
// a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
842-
// at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
843-
// closed so the bridge emits a classified response.failed rather than a silent truncation.
846+
// Reader EOF. Prefer failing closed before flushing pending tool calls so the bridge
847+
// never sees a fabricated tool_call_end on a truncated mid-assembly stream.
844848
//
845849
// Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
846850
// taking it back: a half-assembled argument string would reach the client as a completed
@@ -856,16 +860,19 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
856860
yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
857861
return;
858862
}
859-
yield* flushToolCalls();
860-
if (!sawFinish && pendingUsage === undefined) {
863+
// Finish-less EOF is only safe when answer text was emitted. Reasoning-only / usage-only
864+
// truncations must stay on the error path (hideThinkingSummary can suppress reasoning).
865+
// Trailing usage alone is not a terminal signal for this adapter (#735 / restore #773).
866+
if (!sawFinish && !sawUserFacingOutput) {
861867
debugProviderDiagnostic("openai-chat", "stream-truncated", {
862868
finishReason: finishReason ?? null,
863-
hadUsage: false,
869+
hadUsage: pendingUsage !== undefined,
864870
});
865871
yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
866872
return;
867873
}
868-
// Graceful close that omitted [DONE] but delivered finish_reason and/or final usage.
874+
yield* flushToolCalls();
875+
// Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
869876
const stopReason = finishReason === "length"
870877
? "max_tokens"
871878
: finishReason === "content_filter"

tests/openai-chat-eof.test.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@ async function collect(gen: AsyncGenerator<AdapterEvent>): Promise<AdapterEvent[
1212
}
1313

1414
describe("openai-chat stream EOF fail-closed", () => {
15-
test("truncated stream (no [DONE], no finish_reason) yields a terminal error, not a clean done", async () => {
15+
test("truncated stream (no [DONE], no finish_reason) yields done when content was emitted", async () => {
1616
const response = new Response('data: {"choices":[{"delta":{"content":"par"}}]}\n\n');
1717
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
1818
const last = events[events.length - 1];
19-
expect(last.type).toBe("error");
19+
expect(last.type).toBe("done");
20+
expect(events.some(e => e.type === "error")).toBe(false);
21+
});
22+
23+
test("empty EOF without content still errors", async () => {
24+
const response = new Response("");
25+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
26+
expect(events.at(-1)?.type).toBe("error");
2027
expect(events.some(e => e.type === "done")).toBe(false);
2128
});
2229

@@ -128,10 +135,49 @@ describe("openai-chat stream EOF fail-closed", () => {
128135
expect(events.some(e => e.type === "error")).toBe(false);
129136
});
130137

131-
test("genuinely truncated stream WITHOUT a trailing newline still fails closed", async () => {
132-
// Mid-content frame, no terminator, no newline — must remain a terminal error.
138+
test("genuinely truncated stream WITHOUT a trailing newline completes when content was emitted", async () => {
139+
// Mid-content frame, no terminator, no newline — content was yielded, so accept done.
133140
const response = new Response('data: {"choices":[{"delta":{"content":"par"}}]}');
134141
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
142+
expect(events.at(-1)?.type).toBe("done");
143+
expect(events.some(e => e.type === "error")).toBe(false);
144+
});
145+
146+
test("EOF with pending tool calls and no finish_reason fails closed", async () => {
147+
const response = new Response(
148+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\\"a\\":"}}]}}]}\n\n',
149+
);
150+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
151+
expect(events.at(-1)?.type).toBe("error");
152+
expect(events.some(e => e.type === "done")).toBe(false);
153+
expect(events.some(e => e.type === "tool_call_end")).toBe(false);
154+
});
155+
156+
test("reasoning-only EOF without finish_reason fails closed", async () => {
157+
const response = new Response(
158+
'data: {"choices":[{"delta":{"reasoning_content":"thinking..."}}]}\n\n',
159+
);
160+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
161+
expect(events.at(-1)?.type).toBe("error");
162+
expect(events.some(e => e.type === "done")).toBe(false);
163+
});
164+
165+
test("usage-only EOF with pending tool calls fails closed", async () => {
166+
const response = new Response(
167+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{}"}}]}}]}\n\n' +
168+
'data: {"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}\n\n',
169+
);
170+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
171+
expect(events.at(-1)?.type).toBe("error");
172+
expect(events.some(e => e.type === "done")).toBe(false);
173+
expect(events.some(e => e.type === "tool_call_end")).toBe(false);
174+
});
175+
176+
test("usage-only EOF without answer text fails closed", async () => {
177+
const response = new Response(
178+
'data: {"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}\n\n',
179+
);
180+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
135181
expect(events.at(-1)?.type).toBe("error");
136182
expect(events.some(e => e.type === "done")).toBe(false);
137183
});

tests/sidebar-routes.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ describe("GET /api/update/badge", () => {
5151
});
5252

5353
describe("GET /api/github/star", () => {
54+
// These hit a real `gh` spawn (auth then api). AUTH_TIMEOUT_MS is 5s and API_TIMEOUT_MS
55+
// is 10s in star-state.ts — Bun's default 5s test budget races the auth kill on a slow
56+
// Windows credential helper and flakes as a timeout even when spawnGh is correct.
57+
const STAR_ROUTE_TIMEOUT_MS = 20_000;
58+
5459
test("is routed and reports one of the three known states", async () => {
5560
invalidateStarStatusCache();
5661
const { status, body } = await call("GET", "/api/github/star");
@@ -59,7 +64,7 @@ describe("GET /api/github/star", () => {
5964
expect(["starred", "not-starred", "unauthenticated"]).toContain(star.state);
6065
expect(star.repo).toBe("lidge-jun/opencodex");
6166
expect(star.url).toBe("https://github.com/lidge-jun/opencodex");
62-
});
67+
}, { timeout: STAR_ROUTE_TIMEOUT_MS });
6368

6469
test("never serializes gh output, tokens, or account identifiers", async () => {
6570
invalidateStarStatusCache();
@@ -71,7 +76,7 @@ describe("GET /api/github/star", () => {
7176
expect(raw.toLowerCase()).not.toContain("scope");
7277
expect(raw).not.toContain("gho_");
7378
expect(raw).not.toContain("ghp_");
74-
});
79+
}, { timeout: STAR_ROUTE_TIMEOUT_MS });
7580
});
7681

7782
describe("route surface", () => {

0 commit comments

Comments
 (0)