summaryrefslogtreecommitdiff
path: root/libpanto/src/session.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/session.zig')
-rw-r--r--libpanto/src/session.zig72
1 files changed, 69 insertions, 3 deletions
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig
index daa6cfa..0491157 100644
--- a/libpanto/src/session.zig
+++ b/libpanto/src/session.zig
@@ -208,6 +208,7 @@ pub const DiskResultPart = union(enum) {
pub const DiskToolResultBlock = struct {
tool_use_id: []const u8, // owned
parts: []DiskResultPart, // owned
+ is_error: bool = false,
pub fn deinit(self: DiskToolResultBlock, alloc: Allocator) void {
alloc.free(self.tool_use_id);
for (self.parts) |p| p.deinit(alloc);
@@ -402,6 +403,12 @@ fn writeDiskBlock(s: *std.json.Stringify, block: DiskContentBlock) !void {
try s.write("toolResult");
try s.objectField("toolUseId");
try s.write(b.tool_use_id);
+ // Persist the error marker only when set, so existing
+ // (success) tool-result logs serialize byte-identically.
+ if (b.is_error) {
+ try s.objectField("isError");
+ try s.write(true);
+ }
// `parts` is an array of {type:"text",text} and
// {type:"image",mimeType,data} (data = inline base64).
try s.objectField("parts");
@@ -613,7 +620,9 @@ fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Disk
const tuid = try dupeStringField(allocator, obj, "toolUseId");
errdefer allocator.free(tuid);
const parts = try parseDiskResultParts(allocator, obj);
- return .{ .tool_result = .{ .tool_use_id = tuid, .parts = parts } };
+ // Missing `isError` in older logs defaults to false.
+ const is_err = readBool(obj, "isError");
+ return .{ .tool_result = .{ .tool_use_id = tuid, .parts = parts, .is_error = is_err } };
} else if (std.mem.eql(u8, t, "compactionSummary")) {
const text = try dupeStringField(allocator, obj, "text");
return .{ .compaction_summary = .{ .text = text } };
@@ -659,6 +668,12 @@ fn parseDiskResultParts(allocator: Allocator, obj: std.json.ObjectMap) ParseErro
return list.toOwnedSlice(allocator);
}
+fn readBool(obj: std.json.ObjectMap, name: []const u8) bool {
+ const v = obj.get(name) orelse return false;
+ if (v != .bool) return false;
+ return v.bool;
+}
+
fn readU64(obj: std.json.ObjectMap, name: []const u8) u64 {
const v = obj.get(name) orelse return 0;
if (v != .integer) return 0;
@@ -729,7 +744,11 @@ pub fn contentBlockToDisk(
},
}
}
- return .{ .tool_result = .{ .tool_use_id = tuid, .parts = try parts.toOwnedSlice(allocator) } };
+ return .{ .tool_result = .{
+ .tool_use_id = tuid,
+ .parts = try parts.toOwnedSlice(allocator),
+ .is_error = tr.is_error,
+ } };
},
// A `.System` block becomes a disk text block; its mode rides on
// the enclosing `DiskMessage.mode` (set by the session manager),
@@ -794,7 +813,7 @@ pub fn diskContentBlockToInternal(
},
}
}
- return .{ .ToolResult = .{ .tool_use_id = tuid, .parts = parts } };
+ return .{ .ToolResult = .{ .tool_use_id = tuid, .parts = parts, .is_error = b.is_error } };
},
.compaction_summary => |b| {
const tb = try conversation.textualBlockFromSlice(allocator, b.text);
@@ -959,6 +978,53 @@ test "serialize/parse tool result message entry" {
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.?);
+ // Unset is_error defaults to false and serializes without the field.
+ try testing.expect(!got.message.content[0].tool_result.is_error);
+ try testing.expect(std.mem.indexOf(u8, line, "isError") == null);
+}
+
+test "serialize/parse tool result preserves is_error = true" {
+ const a = testing.allocator;
+
+ var content = try a.alloc(DiskContentBlock, 1);
+ var trp = try a.alloc(DiskResultPart, 1);
+ trp[0] = .{ .text = try dupe(a, "file not found") };
+ content[0] = .{ .tool_result = .{
+ .tool_use_id = try dupe(a, "tool_err"),
+ .parts = trp,
+ .is_error = true,
+ } };
+
+ const entry: SessionEntry = .{ .message = .{
+ .base = .{
+ .id = try dupe(a, "e1"),
+ .parent_id = try dupe(a, "e0"),
+ .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);
+ try testing.expect(std.mem.indexOf(u8, line, "\"isError\":true") != null);
+
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ try testing.expect(fe.entry.message.message.content[0].tool_result.is_error);
+}
+
+test "parse tool result without isError defaults to false" {
+ const a = testing.allocator;
+ // A legacy line predating the is_error field.
+ const line =
+ \\{"type":"message","id":"x","parentId":"y","timestamp":"t","provider":"anthropic","model":"m","message":{"role":"user","content":[{"type":"toolResult","toolUseId":"t1","parts":[{"type":"text","text":"ok"}]}]}}
+ ;
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ try testing.expect(!fe.entry.message.message.content[0].tool_result.is_error);
}
test "serialize/parse tool result with text + image part round-trips" {