From bb85e867bc938138f29fb45230169af1ba60761c Mon Sep 17 00:00:00 2001 From: t Date: Wed, 10 Jun 2026 10:06:07 -0600 Subject: Add addUserText helper; update all call sites Conversation.addUserMessage now takes a []ContentBlock (symmetric with addAssistantMessage). Introduce a thin addUserText wrapper in agent.zig for the plain-text case and update every call site in agent.zig and anthropic_messages_json.zig accordingly. --- libpanto/src/agent.zig | 35 ++++++++++++++-------- libpanto/src/anthropic_messages_json.zig | 36 ++++++++++++++-------- libpanto/src/conversation.zig | 45 +++++++++++++++++++++------- libpanto/src/file_system_jsonl_store.zig | 11 ++++++- libpanto/src/openai_chat_json.zig | 30 ++++++++++++------- libpanto/src/provider_anthropic_messages.zig | 32 +++++++++++++------- libpanto/src/provider_openai_chat.zig | 30 ++++++++++++------- libpanto/src/public.zig | 5 ++++ 8 files changed, 156 insertions(+), 68 deletions(-) (limited to 'libpanto/src') diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 378205a..0252af6 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -55,6 +55,17 @@ pub const Config = config_mod.Config; /// usage per message, used for retention sizing). pub const conversation_Usage = @import("session.zig").Usage; +/// Append a single-text user message. `Conversation.addUserMessage` now +/// takes a block slice (symmetric with `addAssistantMessage`); this wraps +/// the common plain-text case used by the agent's interactive turn and the +/// compaction prompt. +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}); +} + /// Deep-copy a message (role + all content blocks) into fresh owned /// allocations. Used when rebuilding the conversation after compaction. fn cloneMessage(alloc: Allocator, msg: conversation.Message) !conversation.Message { @@ -373,7 +384,7 @@ pub const Agent = struct { // Append + persist the user prompt up front (the dangling-prompt // recovery guarantee). const user_start = self.conversation.messages.items.len; - try self.conversation.addUserMessage(message.text); + try addUserText(&self.conversation, message.text); try turn_persist.persistTurn( self._allocator, &self._session, @@ -816,7 +827,7 @@ pub const Agent = struct { var conv = conversation.Conversation.init(alloc); defer conv.deinit(); try conv.addSystemMessage(system_prompt); - try conv.addUserMessage(body); + try addUserText(&conv, body); // Drive one provider response to completion, ignoring every event. // Compaction doesn't need incremental output — the assembled message @@ -2681,11 +2692,11 @@ test "compact: summarizes prefix, keeps suffix, system survives" { const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); - try conv.addUserMessage("first question here with several words"); + try addUserText(conv, "first question here with several words"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, }, null); - try conv.addUserMessage("second recent question"); + try addUserText(conv, "second recent question"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "second recent answer") }, }, null); @@ -2746,21 +2757,21 @@ test "compact: restated suffix usage reconstructs a fresh cumulative chain" { // Prefix turn (will be summarized). Cumulative footprint = 500+40+10+50 // = 600. Its real usage (with cache buckets) is irrelevant // post-compaction. - try conv.addUserMessage("first question here with several words"); + try addUserText(conv, "first question here with several words"); try conv.addAssistantMessage( &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with words") }}, .{ .input = 500, .output = 50, .cache_read = 40, .cache_write = 10 }, ); // Kept turn 1: cumulative 608 (delta 8 over prefix). Reasoning is a // subset of output. - try conv.addUserMessage("kept question"); // 2 words => ceil(2*1.3)=3 tokens + try addUserText(conv, "kept question"); // 2 words => ceil(2*1.3)=3 tokens try conv.addAssistantMessage( &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "kept answer") }}, .{ .input = 600, .output = 8, .reasoning = 5 }, ); // Kept turn 2: cumulative 614 (delta 6). Cache buckets present to prove // they collapse into `input` on restatement. - try conv.addUserMessage("two more words here"); // 4 words => ceil(4*1.3)=6 + try addUserText(conv, "two more words here"); // 4 words => ceil(4*1.3)=6 try conv.addAssistantMessage( &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "final answer") }}, .{ .input = 600, .output = 4, .cache_read = 8, .cache_write = 2 }, @@ -2818,7 +2829,7 @@ test "compact: no-op when conversation already fits the budget" { const conv = &agent.conversation; try conv.addSystemMessage("sys"); - try conv.addUserMessage("hi"); + try addUserText(conv, "hi"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "hello") }, }, null); @@ -2855,11 +2866,11 @@ test "compact: extra instructions are appended to the system prompt" { agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try conv.addUserMessage("question one two three"); + try addUserText(conv, "question one two three"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer one two three") }, }, null); - try conv.addUserMessage("question two"); + try addUserText(conv, "question two"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer two") }, }, null); @@ -2897,7 +2908,7 @@ test "runStep: auto-compacts on context overflow and retries once" { const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); - try conv.addUserMessage("first question with several words here"); + try addUserText(conv, "first question with several words here"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, }, null); @@ -3277,7 +3288,7 @@ test "runStep: context-overflow compaction fires a compaction retry notification const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); - try conv.addUserMessage("first question with several words here"); + try addUserText(conv, "first question with several words here"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, }, null); diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index 78f4b5f..3ad6069 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -606,6 +606,16 @@ fn testConfig(model: []const u8) config_mod.AnthropicMessagesConfig { }; } +/// 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 "serializeRequest - system extracted into top-level field" { const allocator = testing.allocator; @@ -613,7 +623,7 @@ test "serializeRequest - system extracted into top-level field" { defer conv.deinit(); try conv.addSystemMessage("You are helpful."); - try conv.addUserMessage("Hello!"); + try addUserText(&conv, "Hello!"); const cfg = testConfig("claude-sonnet-4-20250514"); var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); @@ -650,7 +660,7 @@ test "serializeRequest - multiple system messages joined with horizontal rule" { try conv.addSystemMessage("Be terse."); try conv.addSystemMessage("Be accurate."); - try conv.addUserMessage("Hi"); + try addUserText(&conv, "Hi"); const cfg = testConfig("claude-x"); var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); @@ -676,7 +686,7 @@ test "serializeRequest - replace-mode system block wipes prior system text" { try conv.addSystemMessage("original append"); try conv.replaceSystemMessage("fresh seed"); try conv.addSystemMessage("fresh append"); - try conv.addUserMessage("Hi"); + try addUserText(&conv, "Hi"); const cfg = testConfig("claude-x"); var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); @@ -700,7 +710,7 @@ test "serializeRequest - trailing newlines stripped before the rule join" { try conv.addSystemMessage("line one\n\n"); try conv.addSystemMessage("line two\n"); - try conv.addUserMessage("Hi"); + try addUserText(&conv, "Hi"); const cfg = testConfig("claude-x"); var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); @@ -721,7 +731,7 @@ test "serializeRequest - no system messages omits the system field" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("Hi"); + try addUserText(&conv, "Hi"); const cfg = testConfig("claude-x"); var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); @@ -966,7 +976,7 @@ test "serializeRequest - emits tools array when registry non-empty" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("call something"); + try addUserText(&conv, "call something"); var tools = tool_registry_mod.ToolRegistry.init(allocator); defer tools.deinit(); @@ -1173,7 +1183,7 @@ test "serializeRequest - thinking disabled omits thinking and effort fields" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var cfg = testConfig("claude-x"); cfg.thinking = .disabled; @@ -1193,7 +1203,7 @@ test "serializeRequest - thinking enabled explicit budget" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var cfg = testConfig("claude-x"); // max_tokens = 1024 cfg.thinking = .enabled; @@ -1217,7 +1227,7 @@ test "serializeRequest - thinking enabled null budget falls back to max_tokens - const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var cfg = testConfig("claude-x"); // max_tokens = 1024 cfg.thinking = .enabled; @@ -1237,7 +1247,7 @@ test "serializeRequest - thinking enabled budget clamped when >= max_tokens" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var cfg = testConfig("claude-x"); // max_tokens = 1024 cfg.thinking = .enabled; @@ -1258,7 +1268,7 @@ test "serializeRequest - thinking adaptive default effort" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var cfg = testConfig("claude-x"); cfg.thinking = .adaptive; @@ -1290,7 +1300,7 @@ test "serializeRequest - thinking adaptive explicit effort levels" { for (efforts) |entry| { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var cfg = testConfig("claude-x"); cfg.thinking = .adaptive; @@ -1310,7 +1320,7 @@ test "serializeRequest - thinking adaptive ignores budget_tokens" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var cfg = testConfig("claude-x"); cfg.thinking = .adaptive; diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig index a436378..1942a0c 100644 --- a/libpanto/src/conversation.zig +++ b/libpanto/src/conversation.zig @@ -265,10 +265,24 @@ pub const Conversation = struct { }); } - pub fn addUserMessage(self: *Conversation, text: []const u8) !void { - const tb = try textualBlockFromSlice(self.allocator, text); + /// Append a `user`-role message from a slice of content blocks. + /// Symmetric with `addAssistantMessage`: ownership of the blocks (and + /// every byte they reference) transfers to the conversation; the caller + /// must not deinit them after this call. This is the general user-side + /// builder — a user turn may carry plain text, multiple text blocks + /// (e.g. messages queued while the agent was mid-turn), one or more + /// `.ToolResult` blocks, or any mix. For the common single-text case, + /// build a one-element `.{ .Text = ... }` slice (see the `addUserText` + /// test helpers across the codebase). + /// + /// Blocks must use this conversation's allocator for any owned bytes, + /// since the conversation will free them with that allocator. + pub fn addUserMessage(self: *Conversation, blocks: []const ContentBlock) !void { var content: std.ArrayList(ContentBlock) = .empty; - try content.append(self.allocator, .{ .Text = tb }); + try content.ensureTotalCapacity(self.allocator, blocks.len); + for (blocks) |block| { + content.appendAssumeCapacity(block); + } try self.messages.append(self.allocator, .{ .role = .user, .content = content, @@ -401,6 +415,15 @@ pub fn activeMessageWindow(messages: []const Message) []const Message { return messages; } +/// Test helper: append a single-text user message. `addUserMessage` takes +/// a block slice; this wraps the plain-text case the tests below use. +fn addUserText(conv: *Conversation, text: []const u8) !void { + const tb = try textualBlockFromSlice(conv.allocator, text); + var block: ContentBlock = .{ .Text = tb }; + errdefer block.deinit(conv.allocator); + try conv.addUserMessage(&.{block}); +} + test "Conversation - add messages and verify content" { const allocator = std.testing.allocator; @@ -408,7 +431,7 @@ test "Conversation - add messages and verify content" { defer conv.deinit(); try conv.addSystemMessage("You are a helpful assistant."); - try conv.addUserMessage("Hello!"); + try addUserText(&conv, "Hello!"); try conv.addAssistantMessage(&.{ .{ .Text = try textualBlockFromSlice(allocator, "Hi there!") }, }, null); @@ -448,7 +471,7 @@ test "Conversation - deinit frees without leaks" { var conv = Conversation.init(allocator); try conv.addSystemMessage("system"); - try conv.addUserMessage("user message"); + try addUserText(&conv, "user message"); try conv.addAssistantMessage(&.{ .{ .Text = try textualBlockFromSlice(allocator, "response") }, }, null); @@ -494,7 +517,7 @@ test "effectiveSystemBlocks - append accumulates in order" { try conv.addSystemMessage("a"); try conv.addSystemMessage("b"); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); try conv.addSystemMessage("c"); var blocks = try effectiveSystemBlocks(allocator, conv.messages.items); @@ -548,7 +571,7 @@ test "addCompactionSummary - sits alone in a user message" { var conv = Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); try conv.addCompactionSummary("summary of earlier history"); const m = conv.messages.items[1]; @@ -567,7 +590,7 @@ test "latestCompactionIndex - null when never compacted" { defer conv.deinit(); try conv.addSystemMessage("sys"); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); try conv.addAssistantMessage(&.{ .{ .Text = try textualBlockFromSlice(allocator, "hello") }, }, null); @@ -582,11 +605,11 @@ test "latestCompactionIndex - returns the latest summary anchor" { defer conv.deinit(); try conv.addSystemMessage("sys"); - try conv.addUserMessage("old"); + try addUserText(&conv, "old"); try conv.addCompactionSummary("S1"); - try conv.addUserMessage("mid"); + try addUserText(&conv, "mid"); try conv.addCompactionSummary("S2"); // index 4 - try conv.addUserMessage("recent"); + try addUserText(&conv, "recent"); try std.testing.expectEqual(@as(?usize, 4), latestCompactionIndex(conv.messages.items)); } diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig index c77d7d2..979b35e 100644 --- a/libpanto/src/file_system_jsonl_store.zig +++ b/libpanto/src/file_system_jsonl_store.zig @@ -1435,6 +1435,15 @@ fn anStamp() session_mod.WireStamp { return .{ .api_style = .anthropic_messages, .base_url = "https://api.anthropic.com", .model = "claude-sonnet-4-20250514" }; } +/// Test helper: append a single-text user message (see the `addUserMessage` +/// signature change to a block slice). +fn addUserText(conv: *conversation_mod.Conversation, text: []const u8) !void { + const tb = try conversation_mod.textualBlockFromSlice(conv.allocator, text); + var block: conversation_mod.ContentBlock = .{ .Text = tb }; + errdefer block.deinit(conv.allocator); + try conv.addUserMessage(&.{block}); +} + test "newUuidV7: produces 36-char hyphenated string with version 7" { const io = testing.io; const id = try newUuidV7(testing.allocator, io); @@ -2058,7 +2067,7 @@ test "FileSystemJSONLStore catalog: create → append → load round-trips" { // messages owned here). var conv = conversation_mod.Conversation.init(testing.allocator); defer conv.deinit(); - try conv.addUserMessage("ping"); + try addUserText(&conv, "ping"); try conv.addAssistantMessage(&.{}, null); const id: session_store_mod.WireIdentity = .{ .api_style = .openai_chat, .base_url = "u", .model = "m" }; diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig index b30b424..8af939f 100644 --- a/libpanto/src/openai_chat_json.zig +++ b/libpanto/src/openai_chat_json.zig @@ -535,6 +535,16 @@ fn emptyTools() tool_registry_mod.ToolRegistry { return tool_registry_mod.ToolRegistry.init(testing.allocator); } +/// 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 "serializeRequest - system + user" { const allocator = testing.allocator; @@ -542,7 +552,7 @@ test "serializeRequest - system + user" { defer conv.deinit(); try conv.addSystemMessage("You are helpful."); - try conv.addUserMessage("Hello!"); + try addUserText(&conv, "Hello!"); const cfg = testConfig("gpt-4o"); var tools = emptyTools(); @@ -576,9 +586,9 @@ test "serializeRequest - multiple system blocks hoisted as separate leading mess defer conv.deinit(); try conv.addSystemMessage("seed"); - try conv.addUserMessage("Hello!"); + try addUserText(&conv, "Hello!"); try conv.addSystemMessage("mid-conversation append"); - try conv.addUserMessage("again"); + try addUserText(&conv, "again"); const cfg = testConfig("gpt-4o"); var tools = emptyTools(); @@ -611,7 +621,7 @@ test "serializeRequest - replace-mode system block wipes prior system messages" try conv.addSystemMessage("original"); try conv.replaceSystemMessage("fresh seed"); try conv.addSystemMessage("fresh append"); - try conv.addUserMessage("Hi"); + try addUserText(&conv, "Hi"); const cfg = testConfig("gpt-4o"); var tools = emptyTools(); @@ -663,7 +673,7 @@ test "serializeRequest - reasoning effort level included when set" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("Hi"); + try addUserText(&conv, "Hi"); var cfg = testConfig("gpt-4o"); cfg.reasoning = .high; @@ -687,7 +697,7 @@ test "serializeRequest - reasoning .off sends \"none\"" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("Hi"); + try addUserText(&conv, "Hi"); var cfg = testConfig("gpt-4o"); cfg.reasoning = .off; @@ -798,7 +808,7 @@ test "serializeRequest - emits tools array when registry non-empty" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("call something"); + try addUserText(&conv, "call something"); var tools = emptyTools(); defer tools.deinit(); @@ -833,7 +843,7 @@ test "serializeRequest - dotted tool name is wire-encoded with __" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("go"); + try addUserText(&conv, "go"); var tools = emptyTools(); defer tools.deinit(); @@ -1050,14 +1060,14 @@ test "serializeRequest - compaction summary trims superseded prefix" { // System prompt survives compaction. try conv.addSystemMessage("you are helpful"); // Old prefix that must be dropped. - try conv.addUserMessage("ancient question"); + try addUserText(&conv, "ancient question"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "ancient answer") }, }, null); // Compaction summary resets conversation context. try conv.addCompactionSummary("summary of the ancient exchange"); // Kept-verbatim suffix replayed after the summary. - try conv.addUserMessage("recent question"); + try addUserText(&conv, "recent question"); var tools = emptyTools(); defer tools.deinit(); diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig index 8e41695..37ce818 100644 --- a/libpanto/src/provider_anthropic_messages.zig +++ b/libpanto/src/provider_anthropic_messages.zig @@ -707,12 +707,22 @@ fn runStreamedTurn( } } +/// 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 conv.addUserMessage("hello"); + try addUserText(&conv, "hello"); var rec = EventRecorder.init(allocator); defer rec.deinit(); @@ -762,7 +772,7 @@ test "anthropic: captures usage from message_start and message_delta on message_ var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); @@ -805,7 +815,7 @@ test "anthropic: message_complete carries null usage when wire omits it" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); @@ -842,7 +852,7 @@ test "captures thinking signature for round-trip" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("solve"); + try addUserText(&conv, "solve"); var rec = EventRecorder.init(allocator); defer rec.deinit(); @@ -888,7 +898,7 @@ test "signature-only thinking block (display omitted)" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); @@ -925,7 +935,7 @@ test "ping and unknown events are ignored" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); @@ -962,7 +972,7 @@ test "tool_use blocks are captured with id, name, and assembled input" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("use a tool"); + try addUserText(&conv, "use a tool"); var rec = EventRecorder.init(allocator); defer rec.deinit(); @@ -1009,7 +1019,7 @@ test "inbound wire tool name is decoded to dotted form" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("use a tool"); + try addUserText(&conv, "use a tool"); var rec = EventRecorder.init(allocator); defer rec.deinit(); @@ -1036,7 +1046,7 @@ test "error event propagates as Zig error" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var queue = EventQueue.init(allocator); defer queue.deinit(); @@ -1069,7 +1079,7 @@ test "two streamed turns persist assistant replies in the conversation" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try conv.addSystemMessage("Be brief."); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var rec = EventRecorder.init(allocator); defer rec.deinit(); @@ -1088,7 +1098,7 @@ test "two streamed turns persist assistant replies in the conversation" { }; try runStreamedTurn(allocator, &conv, &rec, &turn1); - try conv.addUserMessage("what did you say?"); + try addUserText(&conv, "what did you say?"); const turn2 = [_][]const u8{ \\{"type":"message_start","message":{"role":"assistant"}} diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig index ed646e8..8319d9d 100644 --- a/libpanto/src/provider_openai_chat.zig +++ b/libpanto/src/provider_openai_chat.zig @@ -794,6 +794,16 @@ const EventRecorder = struct { } }; +/// 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 "two streamed turns persist assistant replies in the conversation" { // Regression test for the bug where `finish_reason` arrived before // `[DONE]` and `finalize` early-returned without appending the assistant @@ -805,7 +815,7 @@ test "two streamed turns persist assistant replies in the conversation" { defer conv.deinit(); try conv.addSystemMessage("You are a helpful assistant."); - try conv.addUserMessage("hello!"); + try addUserText(&conv, "hello!"); const turn1 = [_][]const u8{ \\{"choices":[{"delta":{"role":"assistant"}}]} @@ -828,7 +838,7 @@ test "two streamed turns persist assistant replies in the conversation" { ); // Second user turn: the assistant must still see its prior response. - try conv.addUserMessage("how did you respond to my greeting just now?"); + try addUserText(&conv, "how did you respond to my greeting just now?"); const turn2 = [_][]const u8{ \\{"choices":[{"delta":{"role":"assistant"}}]} @@ -855,7 +865,7 @@ test "openai_chat: terminating usage chunk lands on message_complete with split var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var rec = EventRecorder{ .allocator = allocator }; defer rec.deinit(); @@ -888,7 +898,7 @@ test "openai_chat: omitted stream usage yields null on message_complete" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("hi"); + try addUserText(&conv, "hi"); var rec = EventRecorder{ .allocator = allocator }; defer rec.deinit(); @@ -921,7 +931,7 @@ test "fragmented tool_call id and name are reassembled" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("call something"); + try addUserText(&conv, "call something"); const events = [_][]const u8{ \\{"choices":[{"delta":{"role":"assistant"}}]} @@ -956,7 +966,7 @@ test "inbound wire tool name is decoded to dotted form (even split across __)" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("read a file"); + try addUserText(&conv, "read a file"); const events = [_][]const u8{ \\{"choices":[{"delta":{"role":"assistant"}}]} @@ -991,7 +1001,7 @@ test "parallel tool_calls emit one complete start/delta/complete cycle per block var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("ping four hosts"); + try addUserText(&conv, "ping four hosts"); var rec: EventRecorder = .{ .allocator = allocator }; defer rec.deinit(); @@ -1057,7 +1067,7 @@ test "non-contiguous tool_call deltas: re-emission of a closed index is dropped" var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("go"); + try addUserText(&conv, "go"); var rec: EventRecorder = .{ .allocator = allocator }; defer rec.deinit(); @@ -1105,7 +1115,7 @@ test "onToolDetails fires after id+name complete, even mid-arg-stream" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("go"); + try addUserText(&conv, "go"); var rec: EventRecorder = .{ .allocator = allocator }; defer rec.deinit(); @@ -1171,7 +1181,7 @@ test "tool_call with no arguments still finalizes a well-formed ToolUse" { var conv = conversation.Conversation.init(allocator); defer conv.deinit(); - try conv.addUserMessage("ring it"); + try addUserText(&conv, "ring it"); const events = [_][]const u8{ \\{"choices":[{"delta":{"role":"assistant"}}]} diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig index f9c2830..277b4ea 100644 --- a/libpanto/src/public.zig +++ b/libpanto/src/public.zig @@ -78,6 +78,11 @@ pub const ContentBlock = conversation_mod.ContentBlock; pub const Message = conversation_mod.Message; pub const MessageRole = conversation_mod.MessageRole; pub const TextualBlock = conversation_mod.TextualBlock; +/// Build a `TextualBlock` (the streaming text buffer used inside content +/// blocks) from a slice, copying the bytes with `alloc`. The canonical way +/// for binding code to construct `.Text`/`.Thinking`/tool-result text when +/// assembling `ContentBlock`s by hand (e.g. for conversation resumption). +pub const textualBlockFromSlice = conversation_mod.textualBlockFromSlice; pub const ThinkingBlock = conversation_mod.ThinkingBlock; pub const ToolUseBlock = conversation_mod.ToolUseBlock; pub const ToolResultBlock = conversation_mod.ToolResultBlock; -- cgit v1.3