//! Anthropic Messages API streaming provider. //! //! Wire format reference: //! https://platform.claude.com/docs/en/build-with-claude/streaming //! //! Responsibilities: //! - Convert `Conversation` → request JSON (delegated to anthropic_messages_json.zig) //! - POST to `{base_url}/v1/messages` with `stream: true` //! - Read the chunked body, feed bytes through SSEParser //! - Parse each event payload, drive a thin assembly loop, and emit Receiver //! callbacks. Anthropic gives us explicit block boundaries, so no //! state-machine inference is needed. //! - Assemble the final Message and emit onMessageComplete. 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("anthropic_messages_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; /// A single Anthropic Messages streaming request. Transient: constructed /// per `streamStep`, holds only borrowed state (allocator, io, the global /// HTTP client, and the active config). Carries nothing across requests. pub const AnthropicMessagesRequest = struct { allocator: Allocator, io: Io, config: *const config_mod.AnthropicMessagesConfig, http_client: *http.Client, /// Optional diagnostic side-channel; see `OpenAIChatRequest.diag`. diag: ?*provider_mod.ProviderDiagnostic = null, /// Open the streaming HTTP request and return a heap-allocated resumable /// response. Reads response headers (classifying any >=400 status) but /// does not pump the body — that happens lazily in /// `ResumableResponse.produce`. On success the caller owns the returned /// `*ResumableResponse` and must `deinit` it. pub fn open( self: *AnthropicMessagesRequest, 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}/v1/messages", .{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); // Build headers. The four base headers are always present; the // interleaved-thinking beta header is added only when the config // explicitly requests manual extended thinking with interleaving. // It is intentionally NOT sent for `.adaptive` (interleaving is // automatic there and the header causes 400s on some backends) or // `.disabled`. var headers_buf: [5]http.Header = .{ .{ .name = "content-type", .value = "application/json" }, .{ .name = "accept", .value = "text/event-stream" }, .{ .name = "x-api-key", .value = self.config.api_key }, .{ .name = "anthropic-version", .value = self.config.api_version }, undefined, // slot reserved for the optional beta header }; const send_interleaved = self.config.thinking == .enabled and self.config.thinking_interleaved; if (send_interleaved) { headers_buf[4] = .{ .name = "anthropic-beta", .value = "interleaved-thinking-2025-05-14", }; } const base_headers = headers_buf[0 .. if (send_interleaved) @as(usize, 5) else @as(usize, 4)]; // Merge any provider `extra_headers` onto the base set. Freed at the // end of `open` — after the request body has been flushed. 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, // Disable compression: gzip buffers small SSE frames, defeating // the streaming property we paid for `stream: true` to get. .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) { // `head.bytes` (which `iterateHeaders` walks) points into the // connection read buffer and is invalidated the moment the body // stream is initialized below. Capture Retry-After first. 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("anthropic_messages HTTP {d}: {s}", .{ status, err_buf.items }); // Anthropic rejects oversized requests with HTTP 400 and a // "prompt is too long" message; `classifyHttpStatus` maps that to // ContextOverflow so the caller can compact and retry. Other // statuses map to retryable/terminal provider errors. 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; } }; /// A resumable Anthropic Messages streaming response. Owns the pinned HTTP /// request/response, the body reader's transfer buffer, the `SSEParser`, and /// the block-assembly `StreamState`. Must be heap-allocated and never moved: /// `body_reader` borrows `&self.response`. 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; /// Wrap this response in the provider-agnostic `ProviderStream` the agent /// loop drives. 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 produceVT(ptr: *anyopaque, out: *EventQueue) anyerror!ProduceStatus { const self: *ResumableResponse = @ptrCast(@alignCast(ptr)); return self.produce(out); } fn lastErrorVT(ptr: *anyopaque) ?[]const u8 { const self: *ResumableResponse = @ptrCast(@alignCast(ptr)); return self.state.stream_error_message; } 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); } /// Pump the response: read one chunk, feed it through the SSE parser, and /// decode each SSE event into zero or more `Event`s appended to `out`. /// Returns `.more` if the caller should pump again, or /// `.response_complete` once `message_stop` (or EOF) is reached and the /// assistant message has been committed + a final `message_complete` /// pushed. 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) { // Stream ended without an explicit message_stop. Finalize anyway. error.EndOfStream => { try self.finishStream(out); return .response_complete; }, // Transport failure before the message completed: retryable. 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("anthropic_messages <= {s}", .{ev_payload}); try handleEvent(self.allocator, ev_payload, &self.state, out); if (self.state.end_of_stream) { 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); } }; /// State maintained across the streaming response. /// /// Anthropic gives us explicit block boundaries (`content_block_start` / /// `content_block_stop`), so we don't need to infer transitions like /// `provider_openai_chat` does. We just track the currently-open block. const StreamState = struct { allocator: Allocator, started: bool = false, end_of_stream: bool = false, finalized: bool = false, /// The block currently being assembled (if any). active: ?ActiveBlock = null, /// Assembled blocks for the final message, in stream order. blocks: std.ArrayList(conversation.ContentBlock) = .empty, /// Accumulated token counts. Anthropic reports the input-side counts /// on `message_start.usage` and the final `output_tokens` (plus /// possibly updated input-side counts) on `message_delta.usage`. /// `usage_seen` distinguishes "genuinely all zero" from "never /// reported" — the former stamps a real `Usage` on /// `onMessageComplete`, the latter stamps `null`. usage: provider_mod.Usage = .{}, usage_seen: bool = false, stop_reason: ?[]u8 = null, /// Owned, human-readable description of a mid-stream `error` event /// (e.g. `"overloaded_error: Overloaded"`), surfaced to the agent via /// `ProviderStream.lastError` so the retry notice can show *why*. stream_error_message: ?[]u8 = null, const ActiveBlock = struct { /// Index reported on the wire (Anthropic's content-array index). wire_index: usize, kind: BlockKind, text_buf: conversation.TextualBlock = .empty, signature: ?[]const u8 = null, /// Populated for `.tool_use` blocks. Owned by this state until the /// block closes, at which point ownership transfers to the /// ToolUseBlock. tool_id: ?[]u8 = null, tool_name: ?[]u8 = null, }; const BlockKind = enum { text, thinking, tool_use, unsupported }; fn init(allocator: Allocator) StreamState { return .{ .allocator = allocator }; } fn deinit(self: *StreamState) void { if (self.active) |*a| { a.text_buf.deinit(self.allocator); if (a.signature) |sig| self.allocator.free(sig); if (a.tool_id) |s| self.allocator.free(s); if (a.tool_name) |s| self.allocator.free(s); } for (self.blocks.items) |*b| b.deinit(self.allocator); self.blocks.deinit(self.allocator); if (self.stop_reason) |s| self.allocator.free(s); if (self.stream_error_message) |s| self.allocator.free(s); } fn ensureStarted(self: *StreamState, out: *EventQueue) !void { if (self.started) return; self.started = true; try out.push(.{ .message_start = .assistant }); } /// Merge a wire-level usage snapshot into the accumulated counts. /// Missing fields mean "unchanged," not "reset to zero." Marks /// `usage_seen` so `finalize` delivers a non-null `Usage` to /// `onMessageComplete`. fn mergeUsage(self: *StreamState, partial: json_mod.StreamUsage) void { if (partial.input_tokens) |v| self.usage.input = v; if (partial.output_tokens) |v| self.usage.output = v; if (partial.cache_creation_input_tokens) |v| self.usage.cache_write = v; if (partial.cache_read_input_tokens) |v| self.usage.cache_read = v; if (partial.input_tokens != null or partial.output_tokens != null or partial.cache_creation_input_tokens != null or partial.cache_read_input_tokens != null) { self.usage_seen = true; } } fn openBlock( self: *StreamState, out: *EventQueue, wire_index: usize, kind: BlockKind, tool_id: ?[]const u8, tool_name: ?[]const u8, ) !void { // Defensive: if a prior block didn't get an explicit stop, drop it. if (self.active != null) { self.discardActive(); } var ab: ActiveBlock = .{ .wire_index = wire_index, .kind = kind, }; // For tool_use blocks, capture the identity fields. Anthropic // delivers both whole on content_block_start. The wire name is // encoded (`__` for `.`); decode it here so everything downstream // — onToolDetails, the stored ContentBlock, session logs, and // dispatch — sees the internal (dotted) name. The decoded form is // never longer than the wire form. if (kind == .tool_use) { if (tool_id) |id| ab.tool_id = try self.allocator.dupe(u8, id); if (tool_name) |n| { // Decode `__` -> `.` into an exact-size owned buffer so the // stored slice is freeable as a whole allocation. const owned = try self.allocator.alloc(u8, n.len); errdefer self.allocator.free(owned); const decoded = tool_registry_mod.decodeName(owned, n); if (decoded.len == n.len) { ab.tool_name = owned; } else { ab.tool_name = try self.allocator.realloc(owned, decoded.len); } } } self.active = ab; const block_type: ?provider_mod.ContentBlockType = switch (kind) { .text => .Text, .thinking => .Thinking, .tool_use => .ToolUse, .unsupported => null, }; if (block_type) |bt| { try out.push(.{ .block_start = .{ .block_type = bt, .index = wire_index } }); // Anthropic delivers tool id+name whole on content_block_start, // so we can fire tool_details immediately — before any arg // deltas. If the wire was malformed and either field is // missing, skip: closeBlock will drop the block defensively. // id/name are owned by `ab` (stable) but we dupe into the queue // arena for a uniform borrow lifetime. if (kind == .tool_use) { if (ab.tool_id != null and ab.tool_name != null) { try out.push(.{ .tool_details = .{ .index = wire_index, .id = try out.dupeBytes(ab.tool_id.?), .name = try out.dupeBytes(ab.tool_name.?), } }); } } } } fn appendTextDelta( self: *StreamState, out: *EventQueue, delta: []const u8, ) !void { const a = &(self.active orelse return); if (a.kind == .unsupported) return; try a.text_buf.appendSlice(self.allocator, delta); // Dupe into the queue arena: `delta` borrows the transient SSE/JSON // payload that `produce` frees before `next()` reads the queue. try out.push(.{ .content_delta = .{ .index = a.wire_index, .delta = try out.dupeBytes(delta), } }); } /// Append a chunk of the streamed JSON arguments for the active /// tool_use block. No-op if the active block isn't a tool_use. fn appendInputJsonDelta( self: *StreamState, out: *EventQueue, delta: []const u8, ) !void { const a = &(self.active orelse return); if (a.kind != .tool_use) return; try a.text_buf.appendSlice(self.allocator, delta); try out.push(.{ .content_delta = .{ .index = a.wire_index, .delta = try out.dupeBytes(delta), } }); } fn setSignature(self: *StreamState, sig: []const u8) !void { const a = &(self.active orelse return); if (a.signature) |old| self.allocator.free(old); a.signature = try self.allocator.dupe(u8, sig); } fn setStopReason(self: *StreamState, reason: ?[]const u8) !void { if (self.stop_reason) |old| self.allocator.free(old); self.stop_reason = if (reason) |r| try self.allocator.dupe(u8, r) else null; } /// Record a readable description of a mid-stream `error` event, combining /// the error `kind` and `message` into one owned string (either may be /// absent). Replaces any previous value. fn setStreamErrorMessage(self: *StreamState, kind: ?[]const u8, message: ?[]const u8) !void { if (self.stream_error_message) |old| self.allocator.free(old); self.stream_error_message = null; self.stream_error_message = if (kind != null and message != null) try std.fmt.allocPrint(self.allocator, "{s}: {s}", .{ kind.?, message.? }) else if (kind) |k| try self.allocator.dupe(u8, k) else if (message) |m| try self.allocator.dupe(u8, m) else null; } /// Close the active block: append it to `blocks` and emit block_complete. fn closeBlock( self: *StreamState, out: *EventQueue, ) !void { var a = self.active orelse return; self.active = null; if (a.kind == .unsupported) { a.text_buf.deinit(self.allocator); if (a.signature) |sig| self.allocator.free(sig); if (a.tool_id) |s| self.allocator.free(s); if (a.tool_name) |s| self.allocator.free(s); return; } // tool_use blocks require both id and name. If either is missing // (malformed stream), drop the block defensively. if (a.kind == .tool_use and (a.tool_id == null or a.tool_name == null)) { a.text_buf.deinit(self.allocator); if (a.tool_id) |s| self.allocator.free(s); if (a.tool_name) |s| self.allocator.free(s); return; } const block: conversation.ContentBlock = switch (a.kind) { .text => blk: { if (a.signature) |sig| self.allocator.free(sig); break :blk .{ .Text = a.text_buf }; }, .thinking => .{ .Thinking = .{ .text = a.text_buf, .signature = a.signature, } }, // An interrupted/malformed tool_use (incomplete or non-object // input JSON) is preserved as-is. The agent's dispatch path // detects invalid input and answers it with a synthetic error // ToolResult in the *following user message* — emitting a // ToolResult here would wrongly place it in this assistant // message, which Anthropic rejects. .tool_use => blk: { break :blk .{ .ToolUse = .{ .id = a.tool_id.?, .name = a.tool_name.?, .input = a.text_buf, } }; }, .unsupported => unreachable, }; try self.blocks.append(self.allocator, block); try out.push(.{ .block_complete = .{ .index = a.wire_index, .block = self.blocks.items[self.blocks.items.len - 1], } }); } /// Drop the active block without emitting a completion callback. /// Used when an unexpected `content_block_start` arrives before the /// previous block closed. fn discardActive(self: *StreamState) void { if (self.active) |*a| { a.text_buf.deinit(self.allocator); if (a.signature) |sig| self.allocator.free(sig); if (a.tool_id) |s| self.allocator.free(s); if (a.tool_name) |s| self.allocator.free(s); self.active = null; } } fn finalize( self: *StreamState, out: *EventQueue, conv: *conversation.Conversation, ) !void { if (self.finalized) return; self.finalized = true; if (self.active != null) { // Preserve an interrupted tool call so the agent can answer it // with a synthetic error ToolResult instead of invoking it. try self.closeBlock(out); } const moved_blocks = try self.blocks.toOwnedSlice(self.allocator); defer self.allocator.free(moved_blocks); const usage: ?provider_mod.Usage = if (self.usage_seen) self.usage else null; try conv.addAssistantMessage(moved_blocks, usage); const msg = conv.messages.items[conv.messages.items.len - 1]; try out.push(.{ .message_complete = .{ .message = msg, .usage = usage } }); } }; fn handleEvent( allocator: Allocator, payload: []const u8, state: *StreamState, out: *EventQueue, ) !void { var parsed = try json_mod.parseStreamEvent(allocator, payload); defer parsed.deinit(); switch (parsed.event) { .message_start => |s| { try state.ensureStarted(out); state.mergeUsage(s.usage); }, .content_block_start => |s| { try state.ensureStarted(out); const kind: StreamState.BlockKind = switch (s.kind) { .text => .text, .thinking => .thinking, .tool_use => .tool_use, .unknown => .unsupported, }; try state.openBlock(out, s.index, kind, s.tool_id, s.tool_name); }, .content_block_delta => |d| { if (d.text_delta) |t| try state.appendTextDelta(out, t); if (d.thinking_delta) |t| try state.appendTextDelta(out, t); if (d.signature_delta) |sig| try state.setSignature(sig); if (d.input_json_delta) |j| try state.appendInputJsonDelta(out, j); }, .content_block_stop => |s| { if (state.active) |a| { if (a.wire_index == s.index) try state.closeBlock(out); } }, .message_delta => |d| { try state.setStopReason(d.stop_reason); state.mergeUsage(d.usage); }, .message_stop => { state.end_of_stream = true; }, .ping => {}, .@"error" => |e| { if (!@import("builtin").is_test) { std.log.err("anthropic stream error: {?s}: {?s}", .{ e.kind, e.message }); } // Stash a readable description so the agent's retry notice can // explain *why* the stream failed instead of only showing the // bare error name. Owned by `state`; freed in `deinit`. state.setStreamErrorMessage(e.kind, e.message) catch {}; // Mid-stream error event before the message was committed. Map // the common overload case to a dedicated retryable error so the // UI can say "overloaded" rather than "malformed stream"; other // kinds stay as the generic retryable malformed-stream error. if (e.kind) |k| { if (std.mem.eql(u8, k, "overloaded_error")) return error.ProviderOverloaded; } return error.ProviderStreamMalformed; }, .unknown => { // Forward-compatible: ignore unknown event types per Anthropic's // versioning policy. }, } } // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- const testing = std.testing; /// Records the decoded pull `Event`s for assertions. Keeps the same typed /// schema the old RecordingReceiver exposed (message_start / block_start / /// delta / block_complete / message_complete), so the existing assertions /// are preserved verbatim. The recorder owns copies of all byte payloads /// (the queue arena is reset on full drain). const EventRecorder = struct { allocator: Allocator, events: std.ArrayList(Rec) = .empty, const Rec = union(enum) { message_start: conversation.MessageRole, block_start: struct { kind: provider_mod.ContentBlockType, index: usize, }, delta: struct { index: usize, bytes: []const u8, // owned copy }, block_complete: struct { index: usize, kind: provider_mod.ContentBlockType, text: []const u8, // owned copy signature: ?[]const u8 = null, // owned copy when present }, message_complete: ?provider_mod.Usage, }; fn init(allocator: Allocator) EventRecorder { return .{ .allocator = allocator }; } fn deinit(self: *EventRecorder) void { for (self.events.items) |ev| { switch (ev) { .delta => |d| self.allocator.free(d.bytes), .block_complete => |b| { self.allocator.free(b.text); if (b.signature) |s| self.allocator.free(s); }, else => {}, } } self.events.deinit(self.allocator); } /// Translate one pull `Event` into the recorder's schema. Tool identity /// (`tool_details`) is dropped: the anthropic tests assert tool-use /// blocks via the ContentBlock in conv after finalize, not via the /// event stream. Tool-arg `content_delta`s are also dropped here because /// the old recorder only recorded text/thinking deltas (it routed tool /// args through a separate path that didn't call onContentDelta in a way /// these tests observe) — we preserve that by only recording deltas for /// the currently text/thinking block. Since the recorder can't see block /// kind from a bare delta, we record every delta; the existing tests /// only assert delta bytes for text/thinking turns, so this is /// equivalent for them. fn record(self: *EventRecorder, ev: Event) !void { switch (ev) { .message_start => |role| try self.events.append(self.allocator, .{ .message_start = role }), .block_start => |b| try self.events.append(self.allocator, .{ .block_start = .{ .kind = b.block_type, .index = b.index, } }), .content_delta => |d| { const copy = try self.allocator.dupe(u8, d.delta); try self.events.append(self.allocator, .{ .delta = .{ .index = d.index, .bytes = copy } }); }, .block_complete => |bc| switch (bc.block) { .Text => |tb| { const txt = try self.allocator.dupe(u8, tb.items); try self.events.append(self.allocator, .{ .block_complete = .{ .kind = .Text, .index = bc.index, .text = txt, } }); }, .Thinking => |tb| { const txt = try self.allocator.dupe(u8, tb.text.items); const sig = if (tb.signature) |s| try self.allocator.dupe(u8, s) else null; try self.events.append(self.allocator, .{ .block_complete = .{ .kind = .Thinking, .index = bc.index, .text = txt, .signature = sig, } }); }, else => {}, }, .message_complete => |m| try self.events.append(self.allocator, .{ .message_complete = m.usage }), 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(); for (events) |payload| { try handleEvent(allocator, payload, &state, &queue); if (state.end_of_stream) break; } try state.finalize(&queue, conv); while (queue.pop()) |ev| { if (rec) |r| try r.record(ev); } } /// Test helper: append a single-text user message. `addUserMessage` now /// takes a block slice (symmetric with `addAssistantMessage`); this wraps /// the common plain-text case the tests below use. 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 "streams a text-only turn end-to-end" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "hello"); var rec = EventRecorder.init(allocator); defer rec.deinit(); const events = [_][]const u8{ \\{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude"}} , \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}} , \\{"type":"content_block_stop","index":0} , \\{"type":"message_delta","delta":{"stop_reason":"end_turn"}} , \\{"type":"message_stop"} , }; try runStreamedTurn(allocator, &conv, &rec, &events); // Conversation now holds the assistant reply. try testing.expectEqual(@as(usize, 2), conv.messages.items.len); try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[1].role); try testing.expectEqual(@as(usize, 1), conv.messages.items[1].content.items.len); try testing.expectEqualStrings( "Hello!", conv.messages.items[1].content.items[0].Text.items, ); // Callback sequence: msg_start, block_start, delta, delta, block_complete, msg_complete. try testing.expectEqual(@as(usize, 6), rec.events.items.len); try testing.expectEqual(conversation.MessageRole.assistant, rec.events.items[0].message_start); try testing.expectEqual(provider_mod.ContentBlockType.Text, rec.events.items[1].block_start.kind); try testing.expectEqualStrings("Hello", rec.events.items[2].delta.bytes); try testing.expectEqualStrings("!", rec.events.items[3].delta.bytes); try testing.expectEqualStrings("Hello!", rec.events.items[4].block_complete.text); try testing.expect(rec.events.items[5] == .message_complete); // No usage on the wire — the assertion is structural. try testing.expect(rec.events.items[5].message_complete == null); } test "anthropic: captures usage from message_start and message_delta on message_complete" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); const events = [_][]const u8{ // Initial input-side counts on message_start. \\{"type":"message_start","message":{"id":"m","type":"message","role":"assistant","content":[],"model":"claude","usage":{"input_tokens":100,"cache_creation_input_tokens":50,"cache_read_input_tokens":200}}} , \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}} , \\{"type":"content_block_stop","index":0} , // Final output count on message_delta. \\{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":42}} , \\{"type":"message_stop"} , }; try runStreamedTurn(allocator, &conv, &rec, &events); // Find the message_complete event and check its usage payload. var found: ?provider_mod.Usage = null; for (rec.events.items) |ev| { if (ev == .message_complete) found = ev.message_complete; } try testing.expect(found != null); const u = found.?; try testing.expectEqual(@as(u64, 100), u.input); try testing.expectEqual(@as(u64, 42), u.output); try testing.expectEqual(@as(u64, 200), u.cache_read); try testing.expectEqual(@as(u64, 50), u.cache_write); try testing.expectEqual(@as(u64, 0), u.reasoning); // Anthropic doesn't split reasoning separately. } test "anthropic: message_complete carries null usage when wire omits it" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); const events = [_][]const u8{ \\{"type":"message_start","message":{"id":"m","role":"assistant","content":[],"model":"claude"}} , \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} , \\{"type":"content_block_stop","index":0} , \\{"type":"message_delta","delta":{"stop_reason":"end_turn"}} , \\{"type":"message_stop"} , }; try runStreamedTurn(allocator, &conv, &rec, &events); var saw_complete = false; for (rec.events.items) |ev| { if (ev == .message_complete) { saw_complete = true; try testing.expect(ev.message_complete == null); } } try testing.expect(saw_complete); } test "captures thinking signature for round-trip" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "solve"); var rec = EventRecorder.init(allocator); defer rec.deinit(); const events = [_][]const u8{ \\{"type":"message_start","message":{"role":"assistant"}} , \\{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"step one"}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" step two"}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqQBabc"}} , \\{"type":"content_block_stop","index":0} , \\{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} , \\{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"answer"}} , \\{"type":"content_block_stop","index":1} , \\{"type":"message_stop"} , }; try runStreamedTurn(allocator, &conv, &rec, &events); // The assistant message has Thinking + Text, with signature on the Thinking. const asst = conv.messages.items[1]; try testing.expectEqual(@as(usize, 2), asst.content.items.len); try testing.expectEqualStrings("step one step two", asst.content.items[0].Thinking.text.items); try testing.expectEqualStrings("EqQBabc", asst.content.items[0].Thinking.signature.?); try testing.expectEqualStrings("answer", asst.content.items[1].Text.items); } test "signature-only thinking block (display omitted)" { // Anthropic emits a thinking block with only a signature_delta when // `display: "omitted"` is configured. Verify we still capture the // signature with empty thinking text. const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); const events = [_][]const u8{ \\{"type":"message_start","message":{"role":"assistant"}} , \\{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig123"}} , \\{"type":"content_block_stop","index":0} , \\{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} , \\{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"hi back"}} , \\{"type":"content_block_stop","index":1} , \\{"type":"message_stop"} , }; 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("", asst.content.items[0].Thinking.text.items); try testing.expectEqualStrings("sig123", asst.content.items[0].Thinking.signature.?); } test "ping and unknown events are ignored" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); const events = [_][]const u8{ \\{"type":"message_start","message":{"role":"assistant"}} , \\{"type":"ping"} , \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} , \\{"type":"ping"} , \\{"type":"future_event_type","whatever":true} , \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}} , \\{"type":"content_block_stop","index":0} , \\{"type":"message_stop"} , }; try runStreamedTurn(allocator, &conv, &rec, &events); try testing.expectEqualStrings( "ok", conv.messages.items[1].content.items[0].Text.items, ); } test "tool_use blocks are captured with id, name, and assembled input" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "use a tool"); var rec = EventRecorder.init(allocator); defer rec.deinit(); const events = [_][]const u8{ \\{"type":"message_start","message":{"role":"assistant"}} , \\{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tu_1","name":"calc","input":{}}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"x\":"}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"1}"}} , \\{"type":"content_block_stop","index":0} , \\{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} , \\{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"done"}} , \\{"type":"content_block_stop","index":1} , \\{"type":"message_stop"} , }; try runStreamedTurn(allocator, &conv, &rec, &events); const asst = conv.messages.items[1]; try testing.expectEqual(@as(usize, 2), asst.content.items.len); const tu = asst.content.items[0].ToolUse; try testing.expectEqualStrings("tu_1", tu.id); try testing.expectEqualStrings("calc", tu.name); try testing.expectEqualStrings("{\"x\":1}", tu.input.items); try testing.expectEqualStrings("done", asst.content.items[1].Text.items); } test "inbound wire tool name is decoded to dotted form" { // Anthropic delivers the (wire-encoded) name whole at // content_block_start; it is decoded to the internal dotted form for // the conversation, session logs, and dispatch. const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "use a tool"); var rec = EventRecorder.init(allocator); defer rec.deinit(); const events = [_][]const u8{ \\{"type":"message_start","message":{"role":"assistant"}} , \\{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t1","name":"calc__sum","input":{}}} , \\{"type":"content_block_stop","index":0} , \\{"type":"message_stop"} , }; try runStreamedTurn(allocator, &conv, &rec, &events); const tu = conv.messages.items[1].content.items[0].ToolUse; try testing.expectEqualStrings("calc.sum", tu.name); } test "error event propagates as Zig error" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "hi"); var queue = EventQueue.init(allocator); defer queue.deinit(); var state: StreamState = .init(allocator); defer state.deinit(); try handleEvent( allocator, \\{"type":"message_start","message":{"role":"assistant"}} , &state, &queue, ); const result = handleEvent( allocator, \\{"type":"error","error":{"type":"overloaded_error","message":"too busy"}} , &state, &queue, ); // `overloaded_error` maps to the dedicated retryable error, and the // provider's diagnostic is stashed for the agent's retry notice. try testing.expectError(error.ProviderOverloaded, result); try testing.expectEqualStrings("overloaded_error: too busy", state.stream_error_message.?); } test "non-overloaded error event stays malformed and stashes message" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "hi"); var queue = EventQueue.init(allocator); defer queue.deinit(); var state: StreamState = .init(allocator); defer state.deinit(); const result = handleEvent( allocator, \\{"type":"error","error":{"type":"api_error","message":"boom"}} , &state, &queue, ); try testing.expectError(error.ProviderStreamMalformed, result); try testing.expectEqualStrings("api_error: boom", state.stream_error_message.?); } test "two streamed turns persist assistant replies in the conversation" { // Same regression scenario as the openai_chat test, adapted to Anthropic. const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try conv.addSystemMessage("Be brief."); try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); const turn1 = [_][]const u8{ \\{"type":"message_start","message":{"role":"assistant"}} , \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi!"}} , \\{"type":"content_block_stop","index":0} , \\{"type":"message_stop"} , }; try runStreamedTurn(allocator, &conv, &rec, &turn1); try addUserText(&conv, "what did you say?"); const turn2 = [_][]const u8{ \\{"type":"message_start","message":{"role":"assistant"}} , \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} , \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I said: Hi!"}} , \\{"type":"content_block_stop","index":0} , \\{"type":"message_stop"} , }; try runStreamedTurn(allocator, &conv, &rec, &turn2); // system + user + assistant + user + assistant = 5 try testing.expectEqual(@as(usize, 5), conv.messages.items.len); try testing.expectEqualStrings( "I said: Hi!", conv.messages.items[4].content.items[0].Text.items, ); } /// Helper: build the header slice exactly as `open` does, given a config, /// and return whether the interleaved beta header is present. /// This lets us test the header-selection logic without a live HTTP connection. fn headerSliceIncludesInterleaved(cfg: *const config_mod.AnthropicMessagesConfig) bool { const send_interleaved = cfg.thinking == .enabled and cfg.thinking_interleaved; return send_interleaved; } test "interleaved beta header: enabled when thinking=.enabled and interleaved=true" { const cfg: config_mod.AnthropicMessagesConfig = .{ .api_key = "k", .base_url = "u", .model = "m", .thinking = .enabled, .thinking_interleaved = true, }; try testing.expect(headerSliceIncludesInterleaved(&cfg)); } test "interleaved beta header: absent when thinking=.enabled and interleaved=false" { const cfg: config_mod.AnthropicMessagesConfig = .{ .api_key = "k", .base_url = "u", .model = "m", .thinking = .enabled, .thinking_interleaved = false, }; try testing.expect(!headerSliceIncludesInterleaved(&cfg)); } test "interleaved beta header: absent when thinking=.adaptive even if interleaved=true" { const cfg: config_mod.AnthropicMessagesConfig = .{ .api_key = "k", .base_url = "u", .model = "m", .thinking = .adaptive, .thinking_interleaved = true, }; // .adaptive does not send the header; interleaving is automatic there. try testing.expect(!headerSliceIncludesInterleaved(&cfg)); } test "interleaved beta header: absent when thinking=.disabled" { const cfg: config_mod.AnthropicMessagesConfig = .{ .api_key = "k", .base_url = "u", .model = "m", .thinking = .disabled, .thinking_interleaved = true, }; try testing.expect(!headerSliceIncludesInterleaved(&cfg)); }