Skip to content

Commit 6e8ee8b

Browse files
committed
fix(codex): streamTextResponse handles output_item.done + completed events
Previously the streaming SSE parser only fired on \`response.output_text.delta\` events. For short prompts the backend sometimes delivers the whole reply as a single \`response.output_item.done\` (no per-token deltas), so the AsyncThrowingStream finished without yielding any text. The demo's chat bubble stayed at "..." forever. Rewrite the streaming loop to inspect every event type and yield text from whichever path the server chose: - response.output_text.delta -> yield each delta as it arrives - response.output_item.done -> if no deltas were yielded yet, stash the assistant text for fallback - response.completed -> if no deltas were yielded, yield the full text from the response (or the stashed item text) - response.failed / .incomplete -> throw CodexError.backendError Also surface the SSE event decoder as decodeSSEEvent(dataLines:) so the delta extractor and the new fallback path share one parser. No public API change. 46 kit tests still pass; demo's chat bubble now fills in for both delta-streaming and one-shot replies.
1 parent 0bb95f9 commit 6e8ee8b

1 file changed

Lines changed: 55 additions & 15 deletions

File tree

Sources/CodingPlanCodex/OpenAICodexClient.swift

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -270,22 +270,65 @@ public struct OpenAICodexClient: Sendable {
270270

271271
// SSE events are separated by blank lines. Accumulate
272272
// each event's `data:` lines, then decode at the boundary.
273+
// Yield text from any of the events the backend might use:
274+
// - response.output_text.delta (the usual streaming case)
275+
// - response.output_item.done (short replies sometimes arrive in one item)
276+
// - response.completed (final fallback)
273277
var current: [String] = []
274-
for try await line in bytes.lines {
275-
if line.isEmpty {
276-
if let delta = Self.delta(fromDataLines: current) {
278+
var anyDeltaYielded = false
279+
var pendingItemText: String?
280+
281+
func flushEvent() throws {
282+
defer { current.removeAll(keepingCapacity: true) }
283+
guard let event = Self.decodeSSEEvent(dataLines: current) else { return }
284+
switch event["type"] as? String {
285+
case "response.output_text.delta":
286+
if let delta = event["delta"] as? String, !delta.isEmpty {
277287
continuation.yield(delta)
288+
anyDeltaYielded = true
289+
}
290+
case "response.output_item.done":
291+
if !anyDeltaYielded,
292+
let item = event["item"] as? [String: Any],
293+
(item["role"] as? String) == "assistant",
294+
let text = Self.outputText(from: item),
295+
!text.isEmpty {
296+
pendingItemText = text
278297
}
279-
current.removeAll(keepingCapacity: true)
298+
case "response.completed":
299+
if !anyDeltaYielded {
300+
if let response = event["response"] as? [String: Any],
301+
let text = Self.outputText(from: response), !text.isEmpty {
302+
continuation.yield(text)
303+
anyDeltaYielded = true
304+
} else if let text = pendingItemText {
305+
continuation.yield(text)
306+
anyDeltaYielded = true
307+
}
308+
}
309+
case "response.failed", "response.incomplete":
310+
throw CodexError.backendError(
311+
statusCode: nil,
312+
message: Self.backendErrorMessage(from: event)
313+
)
314+
default:
315+
break
316+
}
317+
}
318+
319+
for try await line in bytes.lines {
320+
if line.isEmpty {
321+
try flushEvent()
280322
} else if line.hasPrefix("data:") {
281323
current.append(
282324
String(line.dropFirst("data:".count))
283325
.trimmingCharacters(in: .whitespaces)
284326
)
285327
}
286328
}
287-
if !current.isEmpty, let delta = Self.delta(fromDataLines: current) {
288-
continuation.yield(delta)
329+
try flushEvent()
330+
if !anyDeltaYielded, let text = pendingItemText {
331+
continuation.yield(text)
289332
}
290333
continuation.finish()
291334
} catch {
@@ -296,17 +339,14 @@ public struct OpenAICodexClient: Sendable {
296339
}
297340
}
298341

299-
private static func delta(fromDataLines lines: [String]) -> String? {
300-
guard !lines.isEmpty else { return nil }
301-
let payload = lines.joined(separator: "\n")
302-
guard let payloadData = payload.data(using: .utf8),
303-
let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
304-
let type = json["type"] as? String,
305-
type == "response.output_text.delta",
306-
let chunk = json["delta"] as? String else {
342+
private static func decodeSSEEvent(dataLines: [String]) -> [String: Any]? {
343+
guard !dataLines.isEmpty else { return nil }
344+
let payload = dataLines.joined(separator: "\n")
345+
guard let data = payload.data(using: .utf8),
346+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
307347
return nil
308348
}
309-
return chunk
349+
return json
310350
}
311351

312352
private static func outputText(from json: [String: Any]) -> String? {

0 commit comments

Comments
 (0)