From 983e523ca9f8e6a7db56b4aca23a962be95e7051 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 6 Jul 2026 08:34:35 -0600 Subject: rendering & chat fixes for panto cli - resiliency for tool ids that come back from some openai_chat providers - fixing codepoint width recognition - implement proper rendering for full markdown as seen from ai models --- libpanto/src/provider_openai_chat.zig | 84 ++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 6 deletions(-) (limited to 'libpanto/src') diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig index a05c7ba..4ac1e11 100644 --- a/libpanto/src/provider_openai_chat.zig +++ b/libpanto/src/provider_openai_chat.zig @@ -466,12 +466,12 @@ const StreamState = struct { } const tu = &self.active_tool.?; - // Append identity fragments. Most providers send id+name whole on - // the first delta and never repeat them, but appending is the only - // correct behavior across the full range of OpenAI-compatible - // backends — some chunk these strings. - if (d.id) |s| try tu.id_buf.appendSlice(self.allocator, s); - if (d.name) |s| try tu.name_buf.appendSlice(self.allocator, s); + // Some OpenAI-compatible backends send identity as fragments + // (`call_` then `xyz`), while others resend the cumulative/full value + // on later argument chunks. Accept both without turning repeats into + // runaway ids like `call_xyzcall_xyz...`. + if (d.id) |s| try mergeStreamingField(self.allocator, &tu.id_buf, s); + if (d.name) |s| try mergeStreamingField(self.allocator, &tu.name_buf, s); // Defer `onBlockStart` until args begin. The first argument // fragment is our signal that identity is likely settled enough @@ -601,6 +601,24 @@ const StreamState = struct { try out.push(.{ .block_start = .{ .block_type = .ToolUse, .index = tu.block_index } }); } + fn mergeStreamingField( + allocator: Allocator, + buf: *conversation.TextualBlock, + piece: []const u8, + ) !void { + if (piece.len == 0) return; + if (buf.items.len == 0) { + try buf.appendSlice(allocator, piece); + } else if (std.mem.eql(u8, buf.items, piece)) { + return; + } else if (std.mem.startsWith(u8, piece, buf.items)) { + buf.clearRetainingCapacity(); + try buf.appendSlice(allocator, piece); + } else { + try buf.appendSlice(allocator, piece); + } + } + /// End the stream: close any open text/thinking block, close the still- /// active tool_use (if any), then commit the assembled assistant /// Message to the conversation and push the terminal `message_complete`. @@ -988,6 +1006,60 @@ test "fragmented tool_call id and name are reassembled" { try testing.expectEqualStrings("{\"host\":\"a.com\"}", tu.input.items); } +test "repeated full tool_call id and name do not accumulate" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try addUserText(&conv, "read it"); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"chatcmpl-tool-abc","type":"function","function":{"name":"std__read","arguments":"{\"path\""}}]}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"chatcmpl-tool-abc","function":{"name":"std__read","arguments":":\"a\"}"}}]}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]} + , + "[DONE]", + }; + + try runStreamedTurn(allocator, &conv, null, &events); + + const tu = conv.messages.items[1].content.items[0].ToolUse; + try testing.expectEqualStrings("chatcmpl-tool-abc", tu.id); + try testing.expectEqualStrings("std.read", tu.name); + try testing.expectEqualStrings("{\"path\":\"a\"}", tu.input.items); +} + +test "cumulative tool_call id and name replace their prefix" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try addUserText(&conv, "read it"); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"chatcmpl-tool-","type":"function","function":{"name":"std_","arguments":"{\"path\""}}]}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"chatcmpl-tool-abc","function":{"name":"std__read","arguments":":\"a\"}"}}]}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]} + , + "[DONE]", + }; + + try runStreamedTurn(allocator, &conv, null, &events); + + const tu = conv.messages.items[1].content.items[0].ToolUse; + try testing.expectEqualStrings("chatcmpl-tool-abc", tu.id); + try testing.expectEqualStrings("std.read", tu.name); + try testing.expectEqualStrings("{\"path\":\"a\"}", tu.input.items); +} + test "inbound wire tool name is decoded to dotted form (even split across __)" { // The model echoes the wire name it was given (`std__read`). It is // decoded to the internal `std.read` for the conversation/session/ -- cgit v1.3