summaryrefslogtreecommitdiff
path: root/libpanto/src/openai_chat_json.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/openai_chat_json.zig')
-rw-r--r--libpanto/src/openai_chat_json.zig484
1 files changed, 467 insertions, 17 deletions
diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig
index 465d951..7ca9546 100644
--- a/libpanto/src/openai_chat_json.zig
+++ b/libpanto/src/openai_chat_json.zig
@@ -10,6 +10,7 @@ const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const conversation = @import("conversation.zig");
const config_mod = @import("config.zig");
+const tool_registry_mod = @import("tool_registry.zig");
/// A single parsed streaming chunk. Fields are populated only when present
/// in the wire payload; null fields signal "not in this chunk".
@@ -21,6 +22,26 @@ pub const StreamDelta = struct {
content: ?[]const u8 = null,
reasoning_content: ?[]const u8 = null,
finish_reason: ?[]const u8 = null,
+ tool_calls: []const ToolCallDelta = &.{},
+ /// Mid-stream error reported by the provider. OpenAI-compatible
+ /// endpoints sometimes return HTTP 200 with `data: {"error":{...}}`
+ /// embedded in the SSE stream instead of a non-2xx response (notably
+ /// some OpenRouter / MiniMax / DeepSeek paths, and occasionally OpenAI
+ /// itself on transient overload). When present, the provider must
+ /// treat the turn as failed.
+ error_message: ?[]const u8 = null,
+ error_type: ?[]const u8 = null,
+};
+
+/// A single entry from a streaming `tool_calls` array. Multiple parallel
+/// tool calls are distinguished by their `index`; identity fields (`id`,
+/// `name`) typically arrive only on the first delta for each index, while
+/// `arguments` arrives incrementally across many deltas.
+pub const ToolCallDelta = struct {
+ index: usize,
+ id: ?[]const u8 = null,
+ name: ?[]const u8 = null,
+ arguments: ?[]const u8 = null,
};
/// Serialize a Conversation into a `chat/completions` request body.
@@ -30,6 +51,7 @@ pub fn serializeRequest(
allocator: Allocator,
cfg: *const config_mod.OpenAIChatConfig,
conv: *const conversation.Conversation,
+ tools: *const tool_registry_mod.ToolRegistry,
) ![]u8 {
var aw: Writer.Allocating = .init(allocator);
errdefer aw.deinit();
@@ -56,6 +78,29 @@ pub fn serializeRequest(
},
}
+ if (tools.count() > 0) {
+ try s.objectField("tools");
+ try s.beginArray();
+ var it = tools.iterator();
+ while (it.next()) |t| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("function");
+ try s.objectField("function");
+ try s.beginObject();
+ try s.objectField("name");
+ try s.write(t.name);
+ try s.objectField("description");
+ try s.write(t.description);
+ try s.objectField("parameters");
+ // Emit the tool's JSON Schema verbatim.
+ try writeRawJson(&s, t.schema_json);
+ try s.endObject();
+ try s.endObject();
+ }
+ try s.endArray();
+ }
+
try s.objectField("messages");
try s.beginArray();
for (conv.messages.items) |msg| {
@@ -68,24 +113,130 @@ pub fn serializeRequest(
return try aw.toOwnedSlice();
}
+/// Emit a pre-encoded JSON value into the current stringifier position.
+///
+/// `std.json.Stringify.write` can only emit Zig values; we need to splice
+/// arbitrary user-supplied JSON (tool schemas, parsed tool inputs) into
+/// the output. We do that by parsing the bytes into `std.json.Value` and
+/// asking the stringifier to write them. If parsing fails, fall back to
+/// emitting an empty object so we never produce invalid JSON on the wire.
+fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void {
+ // Use the stringifier's own allocator path via a scratch arena.
+ var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
+ defer arena.deinit();
+ const parsed = std.json.parseFromSlice(
+ std.json.Value,
+ arena.allocator(),
+ raw,
+ .{},
+ ) catch {
+ try s.beginObject();
+ try s.endObject();
+ return;
+ };
+ try s.write(parsed.value);
+}
+
+/// Emit one `Conversation.Message` as one or more wire-level messages.
+///
+/// OpenAI's wire format is awkward here: a single logical `user` turn that
+/// contains ToolResult blocks must be split into separate top-level
+/// `{"role":"tool",...}` messages (one per ToolResult). A single assistant
+/// turn that mixes Text and ToolUse becomes one assistant message with both
+/// a `content` string and a `tool_calls` array.
fn writeMessage(s: *std.json.Stringify, msg: conversation.Message, allocator: Allocator) !void {
- try s.beginObject();
+ // User messages that carry ToolResult blocks fan out into one
+ // `role:"tool"` message per block. Any plain Text blocks in the same
+ // user message become a separate user message after the tool messages.
+ if (msg.role == .user) {
+ var has_tool_result = false;
+ for (msg.content.items) |b| {
+ if (b == .ToolResult) {
+ has_tool_result = true;
+ break;
+ }
+ }
+ if (has_tool_result) {
+ for (msg.content.items) |block| {
+ if (block != .ToolResult) continue;
+ const tr = block.ToolResult;
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write("tool");
+ try s.objectField("tool_call_id");
+ try s.write(tr.tool_use_id);
+ try s.objectField("content");
+ try s.write(tr.content.items);
+ try s.endObject();
+ }
+ // Trailing plain Text blocks (rare in practice) ride along as
+ // a follow-up user message so we don't lose them.
+ var has_text = false;
+ for (msg.content.items) |b| {
+ if (b == .Text) {
+ has_text = true;
+ break;
+ }
+ }
+ if (!has_text) return;
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write("user");
+ try s.objectField("content");
+ var buf: std.ArrayList(u8) = .empty;
+ defer buf.deinit(allocator);
+ try concatTextBlocks(msg.content.items, &buf, allocator);
+ try s.write(buf.items);
+ try s.endObject();
+ return;
+ }
+ }
+ try s.beginObject();
try s.objectField("role");
try s.write(@tagName(msg.role));
- // All roles flatten to a plain `content` string in outbound requests.
- // Thinking blocks are intentionally dropped from history: the openai_chat
- // dialect has no portable way to round-trip them (OpenAI/DeepSeek strip;
- // OpenRouter/NanoGPT want a `reasoning` field instead of an inline block;
- // none accept the `{"type":"thinking",...}` shape). Preserving reasoning
- // across turns will land with tool-use in a later phase.
+ // Assistant messages may carry ToolUse blocks. The wire shape is a
+ // `tool_calls` array alongside `content`. OpenAI requires `content`
+ // to be either a string or null — we always emit a string (possibly
+ // empty) so JSON shape is predictable.
try s.objectField("content");
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(allocator);
try concatTextBlocks(msg.content.items, &buf, allocator);
try s.write(buf.items);
+ if (msg.role == .assistant) {
+ var n_tool_uses: usize = 0;
+ for (msg.content.items) |b| if (b == .ToolUse) {
+ n_tool_uses += 1;
+ };
+ if (n_tool_uses > 0) {
+ try s.objectField("tool_calls");
+ try s.beginArray();
+ for (msg.content.items) |block| {
+ if (block != .ToolUse) continue;
+ const tu = block.ToolUse;
+ try s.beginObject();
+ try s.objectField("id");
+ try s.write(tu.id);
+ try s.objectField("type");
+ try s.write("function");
+ try s.objectField("function");
+ try s.beginObject();
+ try s.objectField("name");
+ try s.write(tu.name);
+ try s.objectField("arguments");
+ // `arguments` is a string carrying JSON, per the OpenAI
+ // wire format — not a nested object.
+ try s.write(tu.input.items);
+ try s.endObject();
+ try s.endObject();
+ }
+ try s.endArray();
+ }
+ }
+
try s.endObject();
}
@@ -97,7 +248,7 @@ fn concatTextBlocks(
for (blocks) |block| {
switch (block) {
.Text => |tb| try out.appendSlice(allocator, tb.items),
- // Thinking: dropped. ToolUse/ToolResult: phase 3+.
+ // Thinking, ToolUse, ToolResult: handled elsewhere or dropped.
else => {},
}
}
@@ -111,8 +262,13 @@ fn concatTextBlocks(
pub const ParsedDelta = struct {
parsed: std.json.Parsed(std.json.Value),
delta: StreamDelta,
+ /// Owned buffer holding the per-call deltas referenced by
+ /// `delta.tool_calls`. Freed by `deinit` along with `parsed`.
+ tool_calls_buf: ?[]ToolCallDelta = null,
+ allocator: Allocator,
pub fn deinit(self: *ParsedDelta) void {
+ if (self.tool_calls_buf) |b| self.allocator.free(b);
self.parsed.deinit();
}
};
@@ -124,19 +280,44 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedDelta
var delta: StreamDelta = .{};
const root = parsed.value;
- if (root != .object) return .{ .parsed = parsed, .delta = delta };
+ if (root != .object) return .{ .parsed = parsed, .delta = delta, .allocator = allocator };
+
+ // Top-level `error` field. Some providers (and OpenAI itself on rare
+ // mid-stream failures) emit `data: {"error":{"message":...,"type":...}}`
+ // with HTTP 200, so we look for this BEFORE the choices array.
+ if (root.object.get("error")) |e| {
+ switch (e) {
+ .object => |obj| {
+ if (obj.get("message")) |m| if (m == .string) {
+ delta.error_message = m.string;
+ };
+ if (obj.get("type")) |t| if (t == .string) {
+ delta.error_type = t.string;
+ };
+ },
+ .string => |s| {
+ // Some providers send a bare string. Surface it as the
+ // message so callers can still report something useful.
+ delta.error_message = s;
+ },
+ else => {},
+ }
+ }
- const choices_v = root.object.get("choices") orelse return .{ .parsed = parsed, .delta = delta };
+ const choices_v = root.object.get("choices") orelse return .{ .parsed = parsed, .delta = delta, .allocator = allocator };
if (choices_v != .array or choices_v.array.items.len == 0) {
- return .{ .parsed = parsed, .delta = delta };
+ return .{ .parsed = parsed, .delta = delta, .allocator = allocator };
}
const choice = choices_v.array.items[0];
- if (choice != .object) return .{ .parsed = parsed, .delta = delta };
+ if (choice != .object) return .{ .parsed = parsed, .delta = delta, .allocator = allocator };
if (choice.object.get("finish_reason")) |fr| {
if (fr == .string) delta.finish_reason = fr.string;
}
+ var tool_calls_buf: ?[]ToolCallDelta = null;
+ errdefer if (tool_calls_buf) |b| allocator.free(b);
+
if (choice.object.get("delta")) |d| {
if (d == .object) {
if (d.object.get("role")) |r| {
@@ -152,10 +333,46 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedDelta
} else if (d.object.get("reasoning")) |rc| {
if (rc == .string) delta.reasoning_content = rc.string;
}
+ if (d.object.get("tool_calls")) |tcs| {
+ if (tcs == .array and tcs.array.items.len > 0) {
+ const buf = try allocator.alloc(ToolCallDelta, tcs.array.items.len);
+ tool_calls_buf = buf;
+ for (tcs.array.items, 0..) |tc, i| {
+ var entry: ToolCallDelta = .{ .index = 0 };
+ if (tc == .object) {
+ if (tc.object.get("index")) |iv| {
+ if (iv == .integer and iv.integer >= 0) {
+ entry.index = @intCast(iv.integer);
+ }
+ }
+ if (tc.object.get("id")) |idv| {
+ if (idv == .string) entry.id = idv.string;
+ }
+ if (tc.object.get("function")) |fnv| {
+ if (fnv == .object) {
+ if (fnv.object.get("name")) |nv| {
+ if (nv == .string) entry.name = nv.string;
+ }
+ if (fnv.object.get("arguments")) |av| {
+ if (av == .string) entry.arguments = av.string;
+ }
+ }
+ }
+ }
+ buf[i] = entry;
+ }
+ delta.tool_calls = buf;
+ }
+ }
}
}
- return .{ .parsed = parsed, .delta = delta };
+ return .{
+ .parsed = parsed,
+ .delta = delta,
+ .tool_calls_buf = tool_calls_buf,
+ .allocator = allocator,
+ };
}
// -----------------------------------------------------------------------------
@@ -172,6 +389,11 @@ fn testConfig(model: []const u8) config_mod.OpenAIChatConfig {
};
}
+/// Caller deinits.
+fn emptyTools() tool_registry_mod.ToolRegistry {
+ return tool_registry_mod.ToolRegistry.init(testing.allocator);
+}
+
test "serializeRequest - system + user" {
const allocator = testing.allocator;
@@ -182,7 +404,9 @@ test "serializeRequest - system + user" {
try conv.addUserMessage("Hello!");
const cfg = testConfig("gpt-4o");
- const body = try serializeRequest(allocator, &cfg, &conv);
+ var tools = emptyTools();
+ defer tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools);
defer allocator.free(body);
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
@@ -193,6 +417,8 @@ test "serializeRequest - system + user" {
try testing.expect(root.get("stream").?.bool);
// reasoning_effort is omitted when set to .default.
try testing.expect(root.get("reasoning_effort") == null);
+ // No tools registered — the `tools` field must be omitted entirely.
+ try testing.expect(root.get("tools") == null);
const msgs = root.get("messages").?.array.items;
try testing.expectEqual(@as(usize, 2), msgs.len);
@@ -214,7 +440,9 @@ test "serializeRequest - assistant Thinking blocks are stripped from outbound hi
});
const cfg = testConfig("gpt-4o");
- const body = try serializeRequest(allocator, &cfg, &conv);
+ var tools = emptyTools();
+ defer tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools);
defer allocator.free(body);
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
@@ -237,7 +465,9 @@ test "serializeRequest - reasoning effort level included when set" {
var cfg = testConfig("gpt-4o");
cfg.reasoning = .high;
- const body = try serializeRequest(allocator, &cfg, &conv);
+ var tools = emptyTools();
+ defer tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools);
defer allocator.free(body);
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
@@ -259,7 +489,9 @@ test "serializeRequest - reasoning .off sends \"none\"" {
var cfg = testConfig("gpt-4o");
cfg.reasoning = .off;
- const body = try serializeRequest(allocator, &cfg, &conv);
+ var tools = emptyTools();
+ defer tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools);
defer allocator.free(body);
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
@@ -321,3 +553,221 @@ test "parseStreamEvent - reasoning_content" {
try testing.expectEqualStrings("hmm", pd.delta.reasoning_content.?);
}
+
+// -----------------------------------------------------------------------------
+// Phase 3: tools serialization, tool_calls parsing
+// -----------------------------------------------------------------------------
+
+const tool_mod = @import("tool.zig");
+
+/// Minimal in-test tool: borrows its name/description/schema slices from
+/// the test's stack. The vtable's deinit is a no-op since nothing is owned.
+const StaticToolVT = struct {
+ fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror![]u8 {
+ return error.NotImplementedInTest;
+ }
+ fn deinit_(_: *anyopaque, _: Allocator) void {}
+
+ const v: tool_mod.Tool.VTable = .{
+ .invoke = invoke,
+ .deinit = deinit_,
+ };
+};
+var static_tool_ctx_sentinel: u8 = 0;
+fn makeStaticTool(
+ name: []const u8,
+ description: []const u8,
+ schema: []const u8,
+) tool_mod.Tool {
+ return .{
+ .name = name,
+ .description = description,
+ .schema_json = schema,
+ .ctx = &static_tool_ctx_sentinel,
+ .vtable = &StaticToolVT.v,
+ };
+}
+
+test "serializeRequest - emits tools array when registry non-empty" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("call something");
+
+ var tools = emptyTools();
+ defer tools.deinit();
+
+ try tools.register(makeStaticTool(
+ "echo",
+ "Echo a message back.",
+ \\{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}
+ ));
+
+ const cfg = testConfig("gpt-4o");
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const arr = parsed.value.object.get("tools").?.array.items;
+ try testing.expectEqual(@as(usize, 1), arr.len);
+ try testing.expectEqualStrings("function", arr[0].object.get("type").?.string);
+
+ const f = arr[0].object.get("function").?.object;
+ try testing.expectEqualStrings("echo", f.get("name").?.string);
+ try testing.expectEqualStrings("Echo a message back.", f.get("description").?.string);
+
+ const params = f.get("parameters").?.object;
+ try testing.expectEqualStrings("object", params.get("type").?.string);
+ try testing.expect(params.get("properties").? == .object);
+ try testing.expect(params.get("required").? == .array);
+}
+
+test "serializeRequest - assistant ToolUse becomes tool_calls" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const id = try allocator.dupe(u8, "call_1");
+ const name = try allocator.dupe(u8, "echo");
+ var args: conversation.TextualBlock = .empty;
+ try args.appendSlice(allocator, "{\"message\":\"hi\"}");
+
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "calling tool") },
+ .{ .ToolUse = .{ .id = id, .name = name, .input = args } },
+ });
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-4o");
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const msg = parsed.value.object.get("messages").?.array.items[0].object;
+ try testing.expectEqualStrings("assistant", msg.get("role").?.string);
+ try testing.expectEqualStrings("calling tool", msg.get("content").?.string);
+
+ const tcs = msg.get("tool_calls").?.array.items;
+ try testing.expectEqual(@as(usize, 1), tcs.len);
+ try testing.expectEqualStrings("call_1", tcs[0].object.get("id").?.string);
+ try testing.expectEqualStrings("function", tcs[0].object.get("type").?.string);
+
+ const fn_obj = tcs[0].object.get("function").?.object;
+ try testing.expectEqualStrings("echo", fn_obj.get("name").?.string);
+ // `arguments` is a string (JSON-as-string) per the OpenAI wire format.
+ try testing.expectEqualStrings("{\"message\":\"hi\"}", fn_obj.get("arguments").?.string);
+}
+
+test "serializeRequest - user ToolResult fans out into tool messages" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const id1 = try allocator.dupe(u8, "call_a");
+ var c1: conversation.TextualBlock = .empty;
+ try c1.appendSlice(allocator, "42");
+
+ const id2 = try allocator.dupe(u8, "call_b");
+ var c2: conversation.TextualBlock = .empty;
+ try c2.appendSlice(allocator, "oops");
+
+ var content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = id1, .content = c1 } });
+ try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = id2, .content = c2 } });
+ try conv.messages.append(allocator, .{ .role = .user, .content = content });
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-4o");
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const msgs = parsed.value.object.get("messages").?.array.items;
+ try testing.expectEqual(@as(usize, 2), msgs.len);
+
+ try testing.expectEqualStrings("tool", msgs[0].object.get("role").?.string);
+ try testing.expectEqualStrings("call_a", msgs[0].object.get("tool_call_id").?.string);
+ try testing.expectEqualStrings("42", msgs[0].object.get("content").?.string);
+
+ try testing.expectEqualStrings("tool", msgs[1].object.get("role").?.string);
+ try testing.expectEqualStrings("call_b", msgs[1].object.get("tool_call_id").?.string);
+ try testing.expectEqualStrings("oops", msgs[1].object.get("content").?.string);
+}
+
+test "parseStreamEvent - tool_calls delta with id and partial arguments" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xyz","type":"function","function":{"name":"echo","arguments":"{\"x\":"}}]}}]}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqual(@as(usize, 1), pd.delta.tool_calls.len);
+ const tc = pd.delta.tool_calls[0];
+ try testing.expectEqual(@as(usize, 0), tc.index);
+ try testing.expectEqualStrings("call_xyz", tc.id.?);
+ try testing.expectEqualStrings("echo", tc.name.?);
+ try testing.expectEqualStrings("{\"x\":", tc.arguments.?);
+}
+
+test "parseStreamEvent - tool_calls delta with only arguments fragment" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1}"}}]}}]}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqual(@as(usize, 1), pd.delta.tool_calls.len);
+ const tc = pd.delta.tool_calls[0];
+ try testing.expectEqual(@as(usize, 0), tc.index);
+ try testing.expect(tc.id == null);
+ try testing.expect(tc.name == null);
+ try testing.expectEqualStrings("1}", tc.arguments.?);
+}
+
+test "parseStreamEvent - finish_reason tool_calls" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqualStrings("tool_calls", pd.delta.finish_reason.?);
+}
+
+test "parseStreamEvent - top-level error event with type and message" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"error":{"message":"Rate limit exceeded","type":"rate_limit_error","code":429}}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqualStrings("Rate limit exceeded", pd.delta.error_message.?);
+ try testing.expectEqualStrings("rate_limit_error", pd.delta.error_type.?);
+}
+
+test "parseStreamEvent - bare-string error" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"error":"something went wrong"}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqualStrings("something went wrong", pd.delta.error_message.?);
+ try testing.expect(pd.delta.error_type == null);
+}