summaryrefslogtreecommitdiff
path: root/libpanto/src/session.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-03 09:11:36 -0600
committert <t@tjp.lol>2026-06-03 12:34:13 -0600
commitac5c4898dfa0a9e57424336774893dfc72b132e9 (patch)
tree03eb8e32a001845dbaec163dfd4905ffc23d9fa6 /libpanto/src/session.zig
parent2416a308c0e467f8dbdbe09942aa903bca751c0e (diff)
image uploads
Diffstat (limited to 'libpanto/src/session.zig')
-rw-r--r--libpanto/src/session.zig181
1 files changed, 169 insertions, 12 deletions
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig
index 21193c8..daa6cfa 100644
--- a/libpanto/src/session.zig
+++ b/libpanto/src/session.zig
@@ -186,12 +186,32 @@ pub const DiskToolUseBlock = struct {
}
};
+/// One on-disk tool-result part: either text or an inline base64 media
+/// attachment (no sidecar files).
+pub const DiskResultPart = union(enum) {
+ text: []const u8, // owned
+ media: struct {
+ media_type: []const u8, // owned
+ data: []const u8, // owned (base64)
+ },
+ pub fn deinit(self: DiskResultPart, alloc: Allocator) void {
+ switch (self) {
+ .text => |t| alloc.free(t),
+ .media => |m| {
+ alloc.free(m.media_type);
+ alloc.free(m.data);
+ },
+ }
+ }
+};
+
pub const DiskToolResultBlock = struct {
tool_use_id: []const u8, // owned
- content: []const u8, // owned
+ parts: []DiskResultPart, // owned
pub fn deinit(self: DiskToolResultBlock, alloc: Allocator) void {
alloc.free(self.tool_use_id);
- alloc.free(self.content);
+ for (self.parts) |p| p.deinit(alloc);
+ alloc.free(self.parts);
}
};
@@ -382,8 +402,33 @@ fn writeDiskBlock(s: *std.json.Stringify, block: DiskContentBlock) !void {
try s.write("toolResult");
try s.objectField("toolUseId");
try s.write(b.tool_use_id);
- try s.objectField("content");
- try s.write(b.content);
+ // `parts` is an array of {type:"text",text} and
+ // {type:"image",mimeType,data} (data = inline base64).
+ try s.objectField("parts");
+ try s.beginArray();
+ for (b.parts) |part| {
+ switch (part) {
+ .text => |t| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("text");
+ try s.objectField("text");
+ try s.write(t);
+ try s.endObject();
+ },
+ .media => |m| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("image");
+ try s.objectField("mimeType");
+ try s.write(m.media_type);
+ try s.objectField("data");
+ try s.write(m.data);
+ try s.endObject();
+ },
+ }
+ }
+ try s.endArray();
try s.endObject();
},
.compaction_summary => |b| {
@@ -567,8 +612,8 @@ fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Disk
} else if (std.mem.eql(u8, t, "toolResult")) {
const tuid = try dupeStringField(allocator, obj, "toolUseId");
errdefer allocator.free(tuid);
- const content = try dupeStringField(allocator, obj, "content");
- return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } };
+ const parts = try parseDiskResultParts(allocator, obj);
+ return .{ .tool_result = .{ .tool_use_id = tuid, .parts = parts } };
} else if (std.mem.eql(u8, t, "compactionSummary")) {
const text = try dupeStringField(allocator, obj, "text");
return .{ .compaction_summary = .{ .text = text } };
@@ -577,6 +622,43 @@ fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Disk
}
}
+/// Parse the `parts` array of a `toolResult` disk block. Falls back to a
+/// legacy single `content` string field (older session logs) -> one text
+/// part. Each element is {type:"text",text} or {type:"image",mimeType,data}.
+fn parseDiskResultParts(allocator: Allocator, obj: std.json.ObjectMap) ParseError![]DiskResultPart {
+ var list: std.ArrayList(DiskResultPart) = .empty;
+ errdefer {
+ for (list.items) |p| p.deinit(allocator);
+ list.deinit(allocator);
+ }
+ const parts_v = obj.get("parts");
+ if (parts_v == null or parts_v.? == .null) {
+ // Legacy: a single `content` string.
+ const content = try dupeStringField(allocator, obj, "content");
+ try list.append(allocator, .{ .text = content });
+ return list.toOwnedSlice(allocator);
+ }
+ if (parts_v.? != .array) return error.MissingField;
+ for (parts_v.?.array.items) |item| {
+ if (item != .object) return error.MissingField;
+ const po = item.object;
+ const pt_v = po.get("type") orelse return error.MissingField;
+ if (pt_v != .string) return error.MissingField;
+ if (std.mem.eql(u8, pt_v.string, "text")) {
+ const text = try dupeStringField(allocator, po, "text");
+ try list.append(allocator, .{ .text = text });
+ } else if (std.mem.eql(u8, pt_v.string, "image")) {
+ const mt = try dupeStringField(allocator, po, "mimeType");
+ errdefer allocator.free(mt);
+ const data = try dupeStringField(allocator, po, "data");
+ try list.append(allocator, .{ .media = .{ .media_type = mt, .data = data } });
+ } else {
+ return error.UnknownBlockType;
+ }
+ }
+ return list.toOwnedSlice(allocator);
+}
+
fn readU64(obj: std.json.ObjectMap, name: []const u8) u64 {
const v = obj.get(name) orelse return 0;
if (v != .integer) return 0;
@@ -630,8 +712,24 @@ pub fn contentBlockToDisk(
.ToolResult => |tr| {
const tuid = try allocator.dupe(u8, tr.tool_use_id);
errdefer allocator.free(tuid);
- const content = try allocator.dupe(u8, tr.content.items);
- return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } };
+ var parts: std.ArrayList(DiskResultPart) = .empty;
+ errdefer {
+ for (parts.items) |p| p.deinit(allocator);
+ parts.deinit(allocator);
+ }
+ try parts.ensureTotalCapacity(allocator, tr.parts.items.len);
+ for (tr.parts.items) |src| {
+ switch (src) {
+ .text => |tb| parts.appendAssumeCapacity(.{ .text = try allocator.dupe(u8, tb.items) }),
+ .media => |m| {
+ const mt = try allocator.dupe(u8, m.media_type);
+ errdefer allocator.free(mt);
+ const data = try allocator.dupe(u8, m.data.items);
+ parts.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = data } });
+ },
+ }
+ }
+ return .{ .tool_result = .{ .tool_use_id = tuid, .parts = try parts.toOwnedSlice(allocator) } };
},
// A `.System` block becomes a disk text block; its mode rides on
// the enclosing `DiskMessage.mode` (set by the session manager),
@@ -679,8 +777,24 @@ pub fn diskContentBlockToInternal(
.tool_result => |b| {
const tuid = try allocator.dupe(u8, b.tool_use_id);
errdefer allocator.free(tuid);
- const content = try conversation.textualBlockFromSlice(allocator, b.content);
- return .{ .ToolResult = .{ .tool_use_id = tuid, .content = content } };
+ var parts: std.ArrayList(conversation.ResultPartStored) = .empty;
+ errdefer {
+ for (parts.items) |*p| p.deinit(allocator);
+ parts.deinit(allocator);
+ }
+ try parts.ensureTotalCapacity(allocator, b.parts.len);
+ for (b.parts) |src| {
+ switch (src) {
+ .text => |t| parts.appendAssumeCapacity(.{ .text = try conversation.textualBlockFromSlice(allocator, t) }),
+ .media => |m| {
+ const mt = try allocator.dupe(u8, m.media_type);
+ errdefer allocator.free(mt);
+ const data = try conversation.textualBlockFromSlice(allocator, m.data);
+ parts.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = data } });
+ },
+ }
+ }
+ return .{ .ToolResult = .{ .tool_use_id = tuid, .parts = parts } };
},
.compaction_summary => |b| {
const tb = try conversation.textualBlockFromSlice(allocator, b.text);
@@ -812,9 +926,11 @@ test "serialize/parse tool result message entry" {
const a = testing.allocator;
var content = try a.alloc(DiskContentBlock, 1);
+ var trp = try a.alloc(DiskResultPart, 1);
+ trp[0] = .{ .text = try dupe(a, "file1.txt\nfile2.txt") };
content[0] = .{ .tool_result = .{
.tool_use_id = try dupe(a, "tool_abc"),
- .content = try dupe(a, "file1.txt\nfile2.txt"),
+ .parts = trp,
} };
const entry: SessionEntry = .{ .message = .{
@@ -840,10 +956,51 @@ test "serialize/parse tool result message entry" {
const got = fe.entry.message;
try testing.expectEqual(DiskMessageRole.user, got.message.role);
try testing.expectEqualStrings("tool_abc", got.message.content[0].tool_result.tool_use_id);
- try testing.expectEqualStrings("file1.txt\nfile2.txt", got.message.content[0].tool_result.content);
+ try testing.expectEqual(@as(usize, 1), got.message.content[0].tool_result.parts.len);
+ try testing.expectEqualStrings("file1.txt\nfile2.txt", got.message.content[0].tool_result.parts[0].text);
try testing.expectEqualStrings("anthropic", got.provider.?);
}
+test "serialize/parse tool result with text + image part round-trips" {
+ const a = testing.allocator;
+
+ var content = try a.alloc(DiskContentBlock, 1);
+ var trp = try a.alloc(DiskResultPart, 2);
+ trp[0] = .{ .text = try dupe(a, "here is the image") };
+ trp[1] = .{ .media = .{
+ .media_type = try dupe(a, "image/png"),
+ .data = try dupe(a, "iVBORw0KGgo="),
+ } };
+ content[0] = .{ .tool_result = .{
+ .tool_use_id = try dupe(a, "tool_img"),
+ .parts = trp,
+ } };
+
+ const entry: SessionEntry = .{ .message = .{
+ .base = .{
+ .id = try dupe(a, "img00001"),
+ .parent_id = try dupe(a, "img00000"),
+ .timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"),
+ },
+ .provider = try dupe(a, "anthropic"),
+ .model = try dupe(a, "claude-sonnet-4-20250514"),
+ .message = .{ .role = .user, .content = content },
+ } };
+ defer entry.deinit(a);
+
+ const line = try serializeEntry(a, entry);
+ defer a.free(line);
+
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ const tr = fe.entry.message.message.content[0].tool_result;
+ try testing.expectEqualStrings("tool_img", tr.tool_use_id);
+ try testing.expectEqual(@as(usize, 2), tr.parts.len);
+ try testing.expectEqualStrings("here is the image", tr.parts[0].text);
+ try testing.expectEqualStrings("image/png", tr.parts[1].media.media_type);
+ try testing.expectEqualStrings("iVBORw0KGgo=", tr.parts[1].media.data);
+}
+
test "system message mode round-trips; absent mode defaults to append" {
const a = testing.allocator;