summaryrefslogtreecommitdiff
path: root/libpanto/src/provider_openai_responses.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/provider_openai_responses.zig')
-rw-r--r--libpanto/src/provider_openai_responses.zig656
1 files changed, 656 insertions, 0 deletions
diff --git a/libpanto/src/provider_openai_responses.zig b/libpanto/src/provider_openai_responses.zig
new file mode 100644
index 0000000..92a309b
--- /dev/null
+++ b/libpanto/src/provider_openai_responses.zig
@@ -0,0 +1,656 @@
+//! OpenAI Responses API streaming provider (ChatGPT-subscription Codex).
+//!
+//! Mirrors `provider_openai_chat.zig` in shape — a transient request object
+//! that opens the HTTP stream and a heap-pinned `ResumableResponse` that pumps
+//! SSE bytes into `Event`s — but speaks the Responses streaming protocol
+//! (typed `response.*` events) instead of Chat Completions `choices[].delta`.
+//!
+//! Event → block mapping:
+//! - `response.output_text.delta` → Text block deltas
+//! - `response.reasoning_summary_text.delta` → Thinking block deltas
+//! - `response.output_item.added` (function_call) → opens a ToolUse block
+//! - `response.function_call_arguments.delta` → ToolUse input deltas
+//! - `response.output_item.done` (function_call) → closes the ToolUse
+//! - `response.completed` → usage + finalize
+//! - `error` / `response.failed` → malformed-stream error
+//!
+//! NOTE: the Responses-backed Codex path could not be verified against live
+//! ChatGPT-subscription credentials; the request/stream shapes follow the
+//! OpenAI Responses API docs and the open-source Codex client. Fixture tests
+//! exercise the state machine; live verification is still required.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+const http = std.http;
+const Uri = std.Uri;
+
+const conversation = @import("conversation.zig");
+const provider_mod = @import("provider.zig");
+const stream_mod = @import("stream.zig");
+const sse_mod = @import("sse.zig");
+const json_mod = @import("openai_responses_json.zig");
+const config_mod = @import("config.zig");
+const tool_registry_mod = @import("tool_registry.zig");
+
+const Event = stream_mod.Event;
+const EventQueue = stream_mod.EventQueue;
+
+fn decodeNameInPlace(name_buf: *conversation.TextualBlock) void {
+ const decoded = tool_registry_mod.decodeName(name_buf.items, name_buf.items);
+ name_buf.items.len = decoded.len;
+}
+
+pub const OpenAIResponsesRequest = struct {
+ allocator: Allocator,
+ io: Io,
+ config: *const config_mod.OpenAIResponsesConfig,
+ http_client: *http.Client,
+ diag: ?*provider_mod.ProviderDiagnostic = null,
+
+ pub fn open(
+ self: *OpenAIResponsesRequest,
+ conv: *conversation.Conversation,
+ tools: *const provider_mod.ToolRegistry,
+ ) !*ResumableResponse {
+ const rr = try self.allocator.create(ResumableResponse);
+ errdefer self.allocator.destroy(rr);
+ rr.* = .{
+ .allocator = self.allocator,
+ .conv = conv,
+ .parser = sse_mod.SSEParser.init(self.allocator),
+ .state = .init(self.allocator),
+ };
+ errdefer {
+ rr.parser.deinit();
+ rr.state.deinit();
+ }
+
+ const url = try std.fmt.allocPrint(self.allocator, "{s}/responses", .{self.config.base_url});
+ defer self.allocator.free(url);
+ const uri = try Uri.parse(url);
+
+ const body = try json_mod.serializeRequest(self.allocator, self.config, conv, tools);
+ defer self.allocator.free(body);
+
+ const auth_value = try std.fmt.allocPrint(self.allocator, "Bearer {s}", .{self.config.api_key});
+ defer self.allocator.free(auth_value);
+
+ const base_headers = [_]http.Header{
+ .{ .name = "content-type", .value = "application/json" },
+ .{ .name = "accept", .value = "text/event-stream" },
+ .{ .name = "authorization", .value = auth_value },
+ };
+ const extra_headers = try provider_mod.mergeHeaders(
+ self.allocator,
+ &base_headers,
+ self.config.extra_headers,
+ );
+ defer self.allocator.free(extra_headers);
+
+ rr.req = try self.http_client.request(.POST, uri, .{
+ .extra_headers = extra_headers,
+ .headers = .{ .accept_encoding = .{ .override = "identity" } },
+ .keep_alive = false,
+ .redirect_behavior = .not_allowed,
+ });
+ rr.req_open = true;
+ errdefer {
+ rr.req.deinit();
+ rr.req_open = false;
+ }
+
+ rr.req.transfer_encoding = .{ .content_length = body.len };
+ var send_buf: [4096]u8 = undefined;
+ var bw = try rr.req.sendBodyUnflushed(&send_buf);
+ try bw.writer.writeAll(body);
+ try bw.end();
+ try rr.req.connection.?.flush();
+
+ var redirect_buf: [1024]u8 = undefined;
+ rr.response = try rr.req.receiveHead(&redirect_buf);
+
+ if (@intFromEnum(rr.response.head.status) >= 400) {
+ const retry_after_ms = provider_mod.retryAfterFromHead(rr.response.head);
+ const body_reader = rr.response.reader(&rr.transfer_buf);
+ var err_buf: std.ArrayList(u8) = .empty;
+ defer err_buf.deinit(self.allocator);
+ var tmp: [1024]u8 = undefined;
+ while (true) {
+ const n = body_reader.readSliceShort(&tmp) catch break;
+ if (n == 0) break;
+ try err_buf.appendSlice(self.allocator, tmp[0..n]);
+ if (err_buf.items.len > 16 * 1024) break;
+ }
+ const status: u16 = @intFromEnum(rr.response.head.status);
+ std.log.err("openai_responses HTTP {d}: {s}", .{ status, err_buf.items });
+ const classified = provider_mod.classifyHttpStatus(status, err_buf.items);
+ if (self.diag) |d| {
+ d.status_code = status;
+ d.retry_after_ms = retry_after_ms;
+ }
+ return classified;
+ }
+
+ rr.body_reader = rr.response.reader(&rr.transfer_buf);
+ return rr;
+ }
+};
+
+pub const ResumableResponse = struct {
+ allocator: Allocator,
+ conv: *conversation.Conversation,
+ parser: sse_mod.SSEParser,
+ state: StreamState,
+
+ req: http.Client.Request = undefined,
+ response: http.Client.Response = undefined,
+ transfer_buf: [4096]u8 = undefined,
+ body_reader: *std.Io.Reader = undefined,
+ chunk: [4096]u8 = undefined,
+
+ req_open: bool = false,
+ done: bool = false,
+
+ pub const ProduceStatus = provider_mod.ProviderStream.ProduceStatus;
+
+ pub fn providerStream(self: *ResumableResponse) provider_mod.ProviderStream {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+
+ const vtable: provider_mod.ProviderStream.VTable = .{
+ .produce = produceVT,
+ .deinit = deinitVT,
+ .last_error = lastErrorVT,
+ };
+
+ fn lastErrorVT(ptr: *anyopaque) ?[]const u8 {
+ const self: *ResumableResponse = @ptrCast(@alignCast(ptr));
+ return self.state.stream_error_message;
+ }
+ fn produceVT(ptr: *anyopaque, out: *EventQueue) anyerror!ProduceStatus {
+ const self: *ResumableResponse = @ptrCast(@alignCast(ptr));
+ return self.produce(out);
+ }
+ fn deinitVT(ptr: *anyopaque) void {
+ const self: *ResumableResponse = @ptrCast(@alignCast(ptr));
+ self.deinit();
+ }
+
+ pub fn deinit(self: *ResumableResponse) void {
+ if (self.req_open) self.req.deinit();
+ self.parser.deinit();
+ self.state.deinit();
+ self.allocator.destroy(self);
+ }
+
+ pub fn produce(self: *ResumableResponse, out: *EventQueue) !ProduceStatus {
+ if (self.done) return .response_complete;
+
+ var vecs: [1][]u8 = .{&self.chunk};
+ const n = self.body_reader.readVec(&vecs) catch |err| switch (err) {
+ error.EndOfStream => {
+ try self.finishStream(out);
+ return .response_complete;
+ },
+ else => return error.ProviderStreamMalformed,
+ };
+ if (n == 0) return .more;
+
+ const events = try self.parser.feed(self.chunk[0..n]);
+ defer self.parser.freeEvents(events);
+
+ for (events) |ev_payload| {
+ std.log.debug("openai_responses <= {s}", .{ev_payload});
+ // The Responses stream has no `[DONE]` sentinel; `response.completed`
+ // is the terminal event.
+ if (std.mem.eql(u8, ev_payload, "[DONE]")) {
+ try self.finishStream(out);
+ return .response_complete;
+ }
+ const terminal = try handleEvent(self.allocator, ev_payload, &self.state, out);
+ if (terminal) {
+ try self.finishStream(out);
+ return .response_complete;
+ }
+ }
+ return .more;
+ }
+
+ fn finishStream(self: *ResumableResponse, out: *EventQueue) !void {
+ if (self.done) return;
+ self.done = true;
+ try self.state.finalize(out, self.conv);
+ }
+};
+
+const ActiveBlock = enum { none, text, thinking };
+
+const StreamState = struct {
+ allocator: Allocator,
+ started: bool = false,
+ finalized: bool = false,
+ active: ActiveBlock = .none,
+ block_index: usize = 0,
+ current_buf: conversation.TextualBlock = .empty,
+ blocks: std.ArrayList(conversation.ContentBlock) = .empty,
+ /// In-progress tool calls keyed by their streaming `item_id`.
+ tools: std.StringArrayHashMapUnmanaged(ToolUseInProgress) = .empty,
+ usage: ?provider_mod.Usage = null,
+ stream_error_message: ?[]u8 = null,
+
+ const ToolUseInProgress = struct {
+ block_index: usize,
+ id_buf: conversation.TextualBlock = .empty, // call_id
+ name_buf: conversation.TextualBlock = .empty,
+ arguments: conversation.TextualBlock = .empty,
+
+ fn deinit(self: *ToolUseInProgress, allocator: Allocator) void {
+ self.id_buf.deinit(allocator);
+ self.name_buf.deinit(allocator);
+ self.arguments.deinit(allocator);
+ }
+ };
+
+ fn init(allocator: Allocator) StreamState {
+ return .{ .allocator = allocator };
+ }
+
+ fn deinit(self: *StreamState) void {
+ self.current_buf.deinit(self.allocator);
+ for (self.blocks.items) |*b| b.deinit(self.allocator);
+ self.blocks.deinit(self.allocator);
+ var it = self.tools.iterator();
+ while (it.next()) |e| {
+ self.allocator.free(e.key_ptr.*);
+ e.value_ptr.deinit(self.allocator);
+ }
+ self.tools.deinit(self.allocator);
+ if (self.stream_error_message) |s| self.allocator.free(s);
+ }
+
+ fn setStreamErrorMessage(self: *StreamState, message: []const u8) void {
+ if (self.stream_error_message) |old| self.allocator.free(old);
+ self.stream_error_message = self.allocator.dupe(u8, message) catch null;
+ }
+
+ fn ensureStarted(self: *StreamState, out: *EventQueue) !void {
+ if (self.started) return;
+ self.started = true;
+ try out.push(.{ .message_start = .assistant });
+ }
+
+ fn closeActive(self: *StreamState, out: *EventQueue) !void {
+ if (self.active == .none) return;
+ const block: conversation.ContentBlock = switch (self.active) {
+ .text => .{ .Text = self.current_buf },
+ .thinking => .{ .Thinking = .{ .text = self.current_buf } },
+ .none => unreachable,
+ };
+ self.current_buf = .empty;
+ try self.blocks.append(self.allocator, block);
+ try out.push(.{ .block_complete = .{
+ .index = self.block_index,
+ .block = self.blocks.items[self.blocks.items.len - 1],
+ } });
+ self.active = .none;
+ }
+
+ fn openBlock(self: *StreamState, new_active: ActiveBlock, out: *EventQueue) !void {
+ std.debug.assert(new_active == .text or new_active == .thinking);
+ if (self.active == new_active) return;
+ if (self.active != .none) {
+ try self.closeActive(out);
+ self.block_index += 1;
+ }
+ self.active = new_active;
+ const block_type: provider_mod.ContentBlockType = switch (new_active) {
+ .text => .Text,
+ .thinking => .Thinking,
+ .none => unreachable,
+ };
+ try out.push(.{ .block_start = .{ .block_type = block_type, .index = self.block_index } });
+ }
+
+ fn appendDelta(self: *StreamState, out: *EventQueue, delta: []const u8) !void {
+ try self.current_buf.appendSlice(self.allocator, delta);
+ try out.push(.{ .content_delta = .{
+ .index = self.block_index,
+ .delta = try out.dupeBytes(delta),
+ } });
+ }
+
+ /// Open a new ToolUse block for a `function_call` output item.
+ fn openToolUse(
+ self: *StreamState,
+ out: *EventQueue,
+ item_id: []const u8,
+ call_id: ?[]const u8,
+ name: ?[]const u8,
+ ) !void {
+ // Close any active text/thinking block so the tool gets its own index.
+ if (self.active != .none) {
+ try self.closeActive(out);
+ self.block_index += 1;
+ }
+ var tu: ToolUseInProgress = .{ .block_index = self.block_index };
+ if (call_id) |c| try tu.id_buf.appendSlice(self.allocator, c);
+ if (name) |nm| try tu.name_buf.appendSlice(self.allocator, nm);
+ const key = try self.allocator.dupe(u8, item_id);
+ try self.tools.put(self.allocator, key, tu);
+
+ try out.push(.{ .block_start = .{ .block_type = .ToolUse, .index = self.block_index } });
+ if (call_id != null and name != null) {
+ try out.push(.{ .tool_details = .{
+ .index = self.block_index,
+ .id = try out.dupeBytes(call_id.?),
+ .name = try out.dupeBytes(name.?),
+ } });
+ }
+ self.block_index += 1;
+ }
+
+ fn appendToolArgs(self: *StreamState, out: *EventQueue, item_id: []const u8, delta: []const u8) !void {
+ const tu = self.tools.getPtr(item_id) orelse return;
+ try tu.arguments.appendSlice(self.allocator, delta);
+ try out.push(.{ .content_delta = .{
+ .index = tu.block_index,
+ .delta = try out.dupeBytes(delta),
+ } });
+ }
+
+ /// Close a ToolUse block on `output_item.done`. `final_args` (when the
+ /// done event carries the full arguments) overrides the accumulated ones.
+ fn closeToolUse(
+ self: *StreamState,
+ out: *EventQueue,
+ item_id: []const u8,
+ final_args: ?[]const u8,
+ ) !void {
+ const entry = self.tools.fetchSwapRemove(item_id) orelse return;
+ self.allocator.free(entry.key);
+ var tu = entry.value;
+
+ if (tu.id_buf.items.len == 0 or tu.name_buf.items.len == 0) {
+ tu.deinit(self.allocator);
+ return;
+ }
+ if (final_args) |fa| {
+ tu.arguments.clearRetainingCapacity();
+ try tu.arguments.appendSlice(self.allocator, fa);
+ }
+ decodeNameInPlace(&tu.name_buf);
+
+ const id_owned = try tu.id_buf.toOwnedSlice(self.allocator);
+ const name_owned = try tu.name_buf.toOwnedSlice(self.allocator);
+ const block: conversation.ContentBlock = .{ .ToolUse = .{
+ .id = id_owned,
+ .name = name_owned,
+ .input = tu.arguments,
+ } };
+ tu.arguments = .empty;
+ try self.blocks.append(self.allocator, block);
+ try out.push(.{ .block_complete = .{
+ .index = tu.block_index,
+ .block = self.blocks.items[self.blocks.items.len - 1],
+ } });
+ }
+
+ fn finalize(self: *StreamState, out: *EventQueue, conv: *conversation.Conversation) !void {
+ if (self.finalized) return;
+ self.finalized = true;
+ try self.closeActive(out);
+ // Close any tool calls that never received an explicit done event.
+ var it = self.tools.iterator();
+ var leftover: std.ArrayList([]const u8) = .empty;
+ defer leftover.deinit(self.allocator);
+ while (it.next()) |e| leftover.append(self.allocator, e.key_ptr.*) catch {};
+ for (leftover.items) |k| try self.closeToolUse(out, k, null);
+
+ const moved_blocks = try self.blocks.toOwnedSlice(self.allocator);
+ defer self.allocator.free(moved_blocks);
+ try conv.addAssistantMessage(moved_blocks, self.usage);
+ const msg = conv.messages.items[conv.messages.items.len - 1];
+ try out.push(.{ .message_complete = .{ .message = msg, .usage = self.usage } });
+ }
+};
+
+/// Handle one parsed event. Returns true when the stream is terminal
+/// (`response.completed`) so the caller can finalize.
+fn handleEvent(
+ allocator: Allocator,
+ payload: []const u8,
+ state: *StreamState,
+ out: *EventQueue,
+) !bool {
+ var ev = try json_mod.parseStreamEvent(allocator, payload);
+ defer ev.deinit();
+
+ switch (ev.kind) {
+ .output_text_delta => {
+ if (ev.delta) |d| {
+ try state.ensureStarted(out);
+ try state.openBlock(.text, out);
+ try state.appendDelta(out, d);
+ }
+ },
+ .reasoning_summary_delta => {
+ if (ev.delta) |d| {
+ try state.ensureStarted(out);
+ try state.openBlock(.thinking, out);
+ try state.appendDelta(out, d);
+ }
+ },
+ .output_item_added => {
+ if (ev.item_type) |it| {
+ if (std.mem.eql(u8, it, "function_call")) {
+ try state.ensureStarted(out);
+ if (ev.item_id) |id| try state.openToolUse(out, id, ev.call_id, ev.name);
+ }
+ }
+ },
+ .function_call_arguments_delta => {
+ if (ev.item_id) |id| {
+ if (ev.delta) |d| try state.appendToolArgs(out, id, d);
+ }
+ },
+ .output_item_done => {
+ if (ev.item_type) |it| {
+ if (std.mem.eql(u8, it, "function_call")) {
+ if (ev.item_id) |id| try state.closeToolUse(out, id, ev.arguments);
+ }
+ }
+ },
+ .completed => {
+ if (ev.usage) |u| {
+ const cached = u.cached_tokens;
+ const total_in = u.input_tokens;
+ const fresh = if (cached > total_in) 0 else total_in - cached;
+ state.usage = .{
+ .input = fresh,
+ .output = u.output_tokens,
+ .cache_read = cached,
+ .cache_write = 0,
+ .reasoning = u.reasoning_tokens,
+ };
+ }
+ return true;
+ },
+ .failed, .err => {
+ if (ev.error_message) |m| {
+ std.log.err("openai_responses stream error: {s}", .{m});
+ state.setStreamErrorMessage(m);
+ }
+ return error.ProviderStreamMalformed;
+ },
+ .other => {},
+ }
+ return false;
+}
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+
+const EventRecorder = struct {
+ allocator: Allocator,
+ events: std.ArrayList([]const u8) = .empty,
+
+ fn deinit(self: *EventRecorder) void {
+ for (self.events.items) |e| self.allocator.free(e);
+ self.events.deinit(self.allocator);
+ }
+ fn push(self: *EventRecorder, comptime fmt: []const u8, args: anytype) !void {
+ try self.events.append(self.allocator, try std.fmt.allocPrint(self.allocator, fmt, args));
+ }
+ fn record(self: *EventRecorder, ev: Event) !void {
+ switch (ev) {
+ .message_start => try self.push("msg_start", .{}),
+ .block_start => |b| try self.push("block_start[{d}]:{s}", .{ b.index, @tagName(b.block_type) }),
+ .tool_details => |t| try self.push("tool_details[{d}]:{s}:{s}", .{ t.index, t.id, t.name }),
+ .content_delta => |d| try self.push("delta[{d}]:{s}", .{ d.index, d.delta }),
+ .block_complete => |b| try self.push("block_complete[{d}]", .{b.index}),
+ .message_complete => |m| {
+ if (m.usage) |u| {
+ try self.push("msg_complete[in={d},out={d},cr={d},rsn={d}]", .{ u.input, u.output, u.cache_read, u.reasoning });
+ } else try self.push("msg_complete[null]", .{});
+ },
+ else => {},
+ }
+ }
+};
+
+fn runStreamedTurn(
+ allocator: Allocator,
+ conv: *conversation.Conversation,
+ rec: ?*EventRecorder,
+ events: []const []const u8,
+) !void {
+ var state: StreamState = .init(allocator);
+ defer state.deinit();
+ var queue = EventQueue.init(allocator);
+ defer queue.deinit();
+
+ var terminal = false;
+ for (events) |payload| {
+ terminal = try handleEvent(allocator, payload, &state, &queue);
+ if (terminal) break;
+ }
+ try state.finalize(&queue, conv);
+ while (queue.pop()) |ev| {
+ if (rec) |r| try r.record(ev);
+ }
+}
+
+fn addUserText(conv: *conversation.Conversation, text: []const u8) !void {
+ const tb = try conversation.textualBlockFromSlice(conv.allocator, text);
+ var block: conversation.ContentBlock = .{ .Text = tb };
+ errdefer block.deinit(conv.allocator);
+ try conv.addUserMessage(&.{block});
+}
+
+test "responses stream: reasoning then text then completed" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var rec = EventRecorder{ .allocator = allocator };
+ defer rec.deinit();
+
+ const events = [_][]const u8{
+ \\{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","delta":"thinking…"}
+ ,
+ \\{"type":"response.output_text.delta","item_id":"msg_1","delta":"Hello"}
+ ,
+ \\{"type":"response.output_text.delta","item_id":"msg_1","delta":" there"}
+ ,
+ \\{"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":5,"output_tokens_details":{"reasoning_tokens":2}}}}
+ ,
+ };
+ try runStreamedTurn(allocator, &conv, &rec, &events);
+
+ const asst = conv.messages.items[1];
+ try testing.expectEqual(@as(usize, 2), asst.content.items.len);
+ try testing.expectEqualStrings("thinking…", asst.content.items[0].Thinking.text.items);
+ try testing.expectEqualStrings("Hello there", asst.content.items[1].Text.items);
+
+ // Usage stamped.
+ try testing.expect(asst.usage != null);
+ try testing.expectEqual(@as(u64, 5), asst.usage.?.output);
+ try testing.expectEqual(@as(u64, 2), asst.usage.?.reasoning);
+}
+
+test "responses stream: function call assembles a ToolUse" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "call it");
+
+ var rec = EventRecorder{ .allocator = allocator };
+ defer rec.deinit();
+
+ const events = [_][]const u8{
+ \\{"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_9","name":"std__read"}}
+ ,
+ \\{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"path\":"}
+ ,
+ \\{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\"a\"}"}
+ ,
+ \\{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_9","name":"std__read","arguments":"{\"path\":\"a\"}"}}
+ ,
+ \\{"type":"response.completed","response":{"usage":{"input_tokens":3,"output_tokens":1}}}
+ ,
+ };
+ try runStreamedTurn(allocator, &conv, &rec, &events);
+
+ const asst = conv.messages.items[1];
+ try testing.expectEqual(@as(usize, 1), asst.content.items.len);
+ const tu = asst.content.items[0].ToolUse;
+ try testing.expectEqualStrings("call_9", tu.id);
+ // Wire name `std__read` decoded to internal dotted form.
+ try testing.expectEqualStrings("std.read", tu.name);
+ try testing.expectEqualStrings("{\"path\":\"a\"}", tu.input.items);
+
+ // Callback order: start → details → deltas → complete.
+ const expect = [_][]const u8{
+ "msg_start",
+ "block_start[0]:ToolUse",
+ "tool_details[0]:call_9:std__read",
+ "delta[0]:{\"path\":",
+ "delta[0]:\"a\"}",
+ "block_complete[0]",
+ "msg_complete[in=3,out=1,cr=0,rsn=0]",
+ };
+ try testing.expectEqual(expect.len, rec.events.items.len);
+ for (expect, rec.events.items) |w, g| try testing.expectEqualStrings(w, g);
+}
+
+test "responses stream: text then tool call keeps block order" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "go");
+
+ const events = [_][]const u8{
+ \\{"type":"response.output_text.delta","item_id":"msg_1","delta":"working"}
+ ,
+ \\{"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"c1","name":"ping"}}
+ ,
+ \\{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{}"}
+ ,
+ \\{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"c1","name":"ping","arguments":"{}"}}
+ ,
+ \\{"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}
+ ,
+ };
+ try runStreamedTurn(allocator, &conv, null, &events);
+
+ const asst = conv.messages.items[1];
+ try testing.expectEqual(@as(usize, 2), asst.content.items.len);
+ try testing.expectEqualStrings("working", asst.content.items[0].Text.items);
+ try testing.expectEqualStrings("ping", asst.content.items[1].ToolUse.name);
+ try testing.expectEqualStrings("{}", asst.content.items[1].ToolUse.input.items);
+}