summaryrefslogtreecommitdiff
path: root/libpanto/src
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-10 10:06:07 -0600
committert <t@tjp.lol>2026-06-10 12:17:27 -0600
commitbb85e867bc938138f29fb45230169af1ba60761c (patch)
tree5ad1cf50274c61ccfffab7048b4a4e28003d82b5 /libpanto/src
parentd26890bbc52e9f0050db40f208763a7f2b714ddd (diff)
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.
Diffstat (limited to 'libpanto/src')
-rw-r--r--libpanto/src/agent.zig35
-rw-r--r--libpanto/src/anthropic_messages_json.zig36
-rw-r--r--libpanto/src/conversation.zig45
-rw-r--r--libpanto/src/file_system_jsonl_store.zig11
-rw-r--r--libpanto/src/openai_chat_json.zig30
-rw-r--r--libpanto/src/provider_anthropic_messages.zig32
-rw-r--r--libpanto/src/provider_openai_chat.zig30
-rw-r--r--libpanto/src/public.zig5
8 files changed, 156 insertions, 68 deletions
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;