const std = @import("std"); const debug_trace = @import("../shared/debug_trace.zig"); const mem_utils = @import("../shared/mem_utils.zig"); const tool_result_errors = @import("../tooling/tool_result_errors.zig"); const types = @import("../shared/types.zig"); const session = @import("session.zig"); const Allocator = std.mem.Allocator; const legacy_schema_v1: i64 = 1; const legacy_schema_v2: i64 = 2; pub const LegacySchemaVersion = enum(u8) { v1 = 1, v2 = 2, }; const TestStoredSession = struct { id: []u8, workspace_root: ?[]u8 = null, created_at_ms: i64, updated_at_ms: i64, conversation_language: session.ConversationLanguage, history: []session.HistoryTurn, total_input_tokens: u64 = 0, total_output_tokens: u64 = 0, total_web_search_requests: u64 = 0, fn deinit(self: *TestStoredSession, alloc: Allocator) void { if (self.id.len > 0) alloc.free(self.id); if (self.workspace_root) |workspace_root| alloc.free(workspace_root); session.freeHistoryTurnSlice(alloc, self.history); self.* = undefined; } }; /// Renders one session record as JSON; caller owns the returned slice and frees with allocator.free. pub const SessionTokenUsage = struct { input: u64 = 0, output: u64 = 0, web_search_requests: u64 = 0, }; pub noinline fn renderSessionJson( alloc: Allocator, active_id: []const u8, created_at_ms: i64, updated_at_ms: i64, language: session.ConversationLanguage, workspace_root: []const u8, history: []const session.HistoryTurn, token_usage: SessionTokenUsage, ) ![]u8 { var out: std.Io.Writer.Allocating = .init(alloc); defer out.deinit(); try out.writer.writeAll("{\"schema_version\":1,\"id\":"); try std.json.Stringify.value(active_id, .{}, &out.writer); try out.writer.print(",\"created_at_ms\":{d},\"updated_at_ms\":{d}", .{ created_at_ms, updated_at_ms }); try out.writer.writeAll(",\"workspace_root\":"); try std.json.Stringify.value(workspace_root, .{}, &out.writer); try out.writer.writeAll(",\"conversation_language\":"); try std.json.Stringify.value(language.view(), .{}, &out.writer); if (token_usage.input > 0 or token_usage.output > 0) { try out.writer.print(",\"total_input_tokens\":{d},\"total_output_tokens\":{d}", .{ token_usage.input, token_usage.output }); } if (token_usage.web_search_requests > 0) { try out.writer.print(",\"total_web_search_requests\":{d}", .{token_usage.web_search_requests}); } try out.writer.print(",\"history_len\":{d},\"history\":[", .{history.len}); for (history, 0..) |turn, i| { if (i > 0) try out.writer.writeByte(','); try writeHistoryTurnJson(&out.writer, turn); } try out.writer.writeAll("]}"); return try out.toOwnedSlice(); } fn writeHistoryTurnJson(writer: *std.Io.Writer, turn: session.HistoryTurn) !void { switch (turn) { .compacted_summary => |entry| { try writer.writeAll("{\"kind\":\"compacted_summary\",\"summary\":"); try std.json.Stringify.value(entry.summary, .{}, writer); try writer.print(",\"removed_turn_count\":{d},\"compaction_count\":{d}", .{ entry.removed_turn_count, entry.compaction_count }); try writer.writeByte('}'); }, .assistant => |entry| { try writer.writeAll("{\"kind\":\"assistant\",\"user\":"); try writeUserTurnJson(writer, entry.user); try writer.writeAll(",\"assistant\":"); try std.json.Stringify.value(entry.assistant, .{}, writer); if (!entry.execution.isEmpty()) { try writer.writeAll(",\"execution\":"); try writeExecutionMemoryJson(writer, entry.execution); } try writer.writeByte('}'); }, .interrupted => |entry| { try writer.writeAll("{\"kind\":\"interrupted\",\"user\":"); try writeUserTurnJson(writer, entry.user); try writer.writeAll(",\"assistant\":"); if (entry.assistant) |assistant| { try std.json.Stringify.value(assistant, .{}, writer); } else { try writer.writeAll("null"); } try writer.writeAll(",\"tool_call\":"); if (entry.tool_call) |tool_call| { try writeToolCallJson(writer, tool_call); } else { try writer.writeAll("null"); } try writer.writeAll(",\"completed_tool_names\":"); try writeStringArrayJson(writer, entry.completed_tool_names); try writer.writeAll(",\"terminal_reason\":"); try std.json.Stringify.value(@tagName(entry.terminal_reason), .{}, writer); if (entry.cancellation_origin == .compaction) { try writer.writeAll(",\"cancellation_origin\":\"compaction\""); } if (!entry.execution.isEmpty()) { try writer.writeAll(",\"execution\":"); try writeExecutionMemoryJson(writer, entry.execution); } try writer.writeByte('}'); }, } } fn writeToolCallJson(writer: *std.Io.Writer, tool_call: session.ToolCall) !void { try writer.writeAll("{\"id\":"); try std.json.Stringify.value(tool_call.id, .{}, writer); try writer.writeAll(",\"name\":"); try std.json.Stringify.value(tool_call.name, .{}, writer); try writer.writeAll(",\"arguments_json\":"); try std.json.Stringify.value(tool_call.arguments_json, .{}, writer); try writer.writeAll(",\"provider_result\":"); if (tool_call.provider_result) |provider_result| { try std.json.Stringify.value(provider_result, .{}, writer); } else { try writer.writeAll("null"); } try writer.writeByte('}'); } pub fn writeExecutionMemoryJson(writer: *std.Io.Writer, execution: session.ExecutionMemory) !void { try writer.writeAll("{\"schema_version\":3,\"tool_steps\":["); for (execution.tool_steps, 0..) |step, i| { if (i > 0) try writer.writeByte(','); try writer.writeAll("{\"assistant\":"); if (step.assistant) |assistant| { try std.json.Stringify.value(assistant, .{}, writer); } else { try writer.writeAll("null"); } try writer.writeAll(",\"tool_calls\":["); for (step.tool_calls, 0..) |tool_call, call_index| { if (call_index > 0) try writer.writeByte(','); try writeToolCallJson(writer, tool_call); } try writer.writeAll("],\"tool_results\":["); for (step.tool_results, 0..) |result, result_index| { if (result_index > 0) try writer.writeByte(','); try writePersistedToolResultJson(writer, result); } try writer.writeAll("]}"); } try writer.writeAll("],\"files\":["); for (execution.files, 0..) |file, i| { if (i > 0) try writer.writeByte(','); try writeFileEvidenceJson(writer, file); } try writer.writeAll("],\"steering\":["); for (execution.steering, 0..) |steering, i| { if (i > 0) try writer.writeByte(','); try writer.writeAll("{\"text\":"); try std.json.Stringify.value(steering.text, .{}, writer); try writer.writeAll(",\"assistant_prefix\":"); if (steering.assistant_prefix) |prefix| { try std.json.Stringify.value(prefix, .{}, writer); } else { try writer.writeAll("null"); } try writer.print(",\"after_tool_step_count\":{d}}}", .{steering.after_tool_step_count}); } try writer.writeAll("]}"); } fn writePersistedToolResultJson(writer: *std.Io.Writer, result: session.PersistedToolResult) !void { try writer.writeAll("{\"tool_call_id\":"); try std.json.Stringify.value(result.tool_call_id, .{}, writer); try writer.writeAll(",\"tool_name\":"); try std.json.Stringify.value(result.tool_name, .{}, writer); try writer.writeAll(",\"status\":"); try std.json.Stringify.value(@tagName(result.status), .{}, writer); try writer.writeAll(",\"output\":"); try std.json.Stringify.value(result.output, .{}, writer); if (result.output_handle) |handle| { try writer.writeAll(",\"output_handle\":"); try std.json.Stringify.value(handle, .{}, writer); } if (result.preview) |preview| { try writer.writeAll(",\"preview\":"); try std.json.Stringify.value(preview, .{}, writer); } try writer.print(",\"output_bytes\":{d},\"stored_output_bytes\":{d},\"truncated\":{s},\"provider_native\":{s},\"created_at_ms\":{d}", .{ result.output_bytes, result.stored_output_bytes, if (result.truncated) "true" else "false", if (result.provider_native) "true" else "false", result.created_at_ms, }); try writer.writeAll(",\"permission_feedback\":["); for (result.permission_feedback, 0..) |feedback, i| { if (i > 0) try writer.writeByte(','); try std.json.Stringify.value(feedback, .{}, writer); } try writer.writeByte(']'); if (result.committed_file_presentation) |presentation| { try writer.writeAll(",\"committed_file_presentation\":"); try writeCommittedFilePresentationJson(writer, presentation); } try writer.writeByte('}'); } fn writeCommittedFilePresentationJson( writer: *std.Io.Writer, presentation: types.CommittedFilePresentation, ) !void { try writer.writeAll("{\"path\":"); try std.json.Stringify.value(presentation.path, .{}, writer); try writer.writeAll(",\"kind\":"); try std.json.Stringify.value(@tagName(presentation.kind), .{}, writer); try writer.writeAll(",\"lines\":["); for (presentation.lines, 0..) |line, index| { if (index > 0) try writer.writeByte(','); try writer.writeAll("{\"kind\":"); try std.json.Stringify.value(@tagName(line.kind), .{}, writer); try writer.writeAll(",\"old_line\":"); try writeOptionalU32Json(writer, line.old_line); try writer.writeAll(",\"new_line\":"); try writeOptionalU32Json(writer, line.new_line); try writer.writeAll(",\"text\":"); try std.json.Stringify.value(line.text, .{}, writer); try writer.writeByte('}'); } try writer.print("],\"additions\":{d},\"deletions\":{d},\"truncated\":{s},\"previous_content\":", .{ presentation.additions, presentation.deletions, if (presentation.truncated) "true" else "false", }); try writeOptionalStringJson(writer, presentation.previous_content); try writer.writeAll(",\"after_content\":"); try writeOptionalStringJson(writer, presentation.after_content); try writer.writeAll(",\"lifecycle_id\":"); if (presentation.lifecycle_id) |id| { try writer.print("{{\"turn_id\":{d},\"call_id\":", .{id.turn_id}); try std.json.Stringify.value(id.call_id, .{}, writer); try writer.writeByte('}'); } else { try writer.writeAll("null"); } try writer.writeByte('}'); } fn writeOptionalU32Json(writer: *std.Io.Writer, value: ?u32) !void { if (value) |number| { try writer.print("{d}", .{number}); } else { try writer.writeAll("null"); } } fn writeOptionalStringJson(writer: *std.Io.Writer, value: ?[]const u8) !void { if (value) |text| { try std.json.Stringify.value(text, .{}, writer); } else { try writer.writeAll("null"); } } fn writeFileEvidenceJson(writer: *std.Io.Writer, file: session.FileEvidence) !void { try writer.writeAll("{\"path\":"); try std.json.Stringify.value(file.path, .{}, writer); try writer.writeAll(",\"new_path\":"); if (file.new_path) |new_path| { try std.json.Stringify.value(new_path, .{}, writer); } else { try writer.writeAll("null"); } try writer.writeAll(",\"tool_call_id\":"); try std.json.Stringify.value(file.tool_call_id, .{}, writer); try writer.writeAll(",\"tool_name\":"); try std.json.Stringify.value(file.tool_name, .{}, writer); try writer.writeAll(",\"action\":"); try std.json.Stringify.value(@tagName(file.action), .{}, writer); try writer.writeAll(",\"status\":"); try std.json.Stringify.value(@tagName(file.status), .{}, writer); try writer.print(",\"model_view_covers_full_file\":{s},\"stale\":{s}", .{ if (file.model_view_covers_full_file) "true" else "false", if (file.stale) "true" else "false", }); try writer.writeByte('}'); } fn writeUserTurnJson(writer: *std.Io.Writer, user: session.UserTurn) !void { try writer.writeAll("{\"text\":"); try std.json.Stringify.value(user.text, .{}, writer); try writer.writeAll(",\"images\":["); for (user.images, 0..) |image, i| { if (i > 0) try writer.writeByte(','); try writer.writeAll("{\"path\":"); try std.json.Stringify.value(image.path, .{}, writer); try writer.writeAll(",\"media_type\":"); try std.json.Stringify.value(image.media_type, .{}, writer); try writer.writeAll(",\"snapshot_path\":"); try writeImageSnapshotLocatorJson(writer, image.snapshot_path); try writer.writeAll(",\"snapshot_sha256\":"); try std.json.Stringify.value(image.snapshot_sha256, .{}, writer); try writer.writeByte('}'); } try writer.writeAll("]}"); } fn writeImageSnapshotLocatorJson(writer: *std.Io.Writer, value: ?[]const u8) !void { const path = value orelse { try writer.writeAll("null"); return; }; var locator_buffer: [std.Io.Dir.max_path_bytes]u8 = undefined; const locator = try session.projectSnapshotLocator(&locator_buffer, path); try std.json.Stringify.value(locator, .{}, writer); } test "ordinary session JSON presentation omits per-turn work provenance" { const alloc = std.testing.allocator; const history = [_]session.HistoryTurn{.{ .assistant = .{ .user = .{ .text = @constCast("canonical prompt"), .work_id = @constCast("work-private"), }, .assistant = @constCast("canonical reply"), } }}; const json = try renderSessionJson( alloc, "presentation", 1, 2, session.ConversationLanguage.literal("en"), "/tmp/workspace", &history, .{}, ); defer alloc.free(json); try std.testing.expect(std.mem.find(u8, json, "work-private") == null); try std.testing.expect(std.mem.find(u8, json, "work_id") == null); try std.testing.expect(std.mem.find(u8, json, "canonical prompt") != null); } fn writeStringArrayJson(writer: *std.Io.Writer, items: anytype) !void { try writer.writeByte('['); for (items, 0..) |item, i| { if (i > 0) try writer.writeByte(','); try std.json.Stringify.value(item, .{}, writer); } try writer.writeByte(']'); } /// Parses a session summary; caller owns returned fields and frees with SessionSummary.deinit. pub fn parseSessionSummary(comptime SessionSummary: type, alloc: Allocator, json_text: []const u8) !SessionSummary { var source = std.Io.Reader.fixed(json_text); return parseLegacySummaryStreaming(SessionSummary, alloc, &source) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.UnsupportedSessionSchema => return error.UnsupportedSessionSchema, else => return error.InvalidSessionFormat, }; } pub fn parseLegacySummaryStreaming( comptime LegacySummary: type, alloc: Allocator, source: *std.Io.Reader, ) !LegacySummary { return parseLegacySummaryStreamingImpl(LegacySummary, alloc, source) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.ReadFailed => return error.ReadFailed, error.UnsupportedSessionSchema => return error.UnsupportedSessionSchema, else => return error.InvalidSessionFormat, }; } fn parseLegacySummaryStreamingImpl( comptime LegacySummary: type, alloc: Allocator, source: *std.Io.Reader, ) !LegacySummary { var reader = std.json.Reader.init(alloc, source); defer reader.deinit(); try expectToken(try reader.next(), .object_begin); var schema_version: ?i64 = null; var id: ?[]u8 = null; errdefer if (id) |owned| mem_utils.free(alloc, owned); var workspace_root: ?[]u8 = null; errdefer if (workspace_root) |owned| mem_utils.free(alloc, owned); var workspace_root_seen = false; var created_at_ms: ?i64 = null; var updated_at_ms: ?i64 = null; var conversation_language: ?session.ConversationLanguage = null; var history_len: ?usize = null; while (true) { const token = try reader.nextAllocMax(alloc, .alloc_if_needed, 64); defer freeToken(alloc, token); if (token == .object_end) break; const key = switch (token) { .string => |value| value, .allocated_string => |value| value, else => return error.InvalidSessionFormat, }; if (std.mem.eql(u8, key, "history")) { if (summaryComplete( schema_version, id, created_at_ms, updated_at_ms, conversation_language, history_len, )) { try requireLegacySchema(schema_version.?); var result: LegacySummary = .{ .id = id.?, .workspace_root = workspace_root, .created_at_ms = created_at_ms.?, .updated_at_ms = updated_at_ms.?, .conversation_language = conversation_language.?, .history_len = history_len.?, }; if (@hasField(LegacySummary, "schema_version")) { @field(result, "schema_version") = try legacySchemaVersion(schema_version.?); } return result; } try reader.skipValue(); continue; } if (std.mem.eql(u8, key, "schema_version")) { if (schema_version != null) return error.InvalidSessionFormat; schema_version = try readI64Token(&reader, alloc); } else if (std.mem.eql(u8, key, "id")) { if (id != null) return error.InvalidSessionFormat; id = try readStringOwned(&reader, alloc, 255); } else if (std.mem.eql(u8, key, "workspace_root")) { if (workspace_root_seen) return error.InvalidSessionFormat; workspace_root_seen = true; workspace_root = try readOptionalStringOwned( &reader, alloc, std.Io.Dir.max_path_bytes, ); } else if (std.mem.eql(u8, key, "created_at_ms")) { if (created_at_ms != null) return error.InvalidSessionFormat; created_at_ms = try readI64Token(&reader, alloc); } else if (std.mem.eql(u8, key, "updated_at_ms")) { if (updated_at_ms != null) return error.InvalidSessionFormat; updated_at_ms = try readI64Token(&reader, alloc); } else if (std.mem.eql(u8, key, "conversation_language")) { if (conversation_language != null) return error.InvalidSessionFormat; const raw = try readStringOwned( &reader, alloc, session.ConversationLanguage.max_len, ); defer alloc.free(raw); conversation_language = try parseConversationLanguage(raw); } else if (std.mem.eql(u8, key, "history_len")) { if (history_len != null) return error.InvalidSessionFormat; const value = try readU64Token(&reader, alloc); if (value > std.math.maxInt(usize)) return error.InvalidSessionFormat; history_len = @intCast(value); } else { try reader.skipValue(); } } if (!summaryComplete( schema_version, id, created_at_ms, updated_at_ms, conversation_language, history_len, )) return error.InvalidSessionFormat; try requireLegacySchema(schema_version.?); try expectToken(try reader.next(), .end_of_document); var result: LegacySummary = .{ .id = id.?, .workspace_root = workspace_root, .created_at_ms = created_at_ms.?, .updated_at_ms = updated_at_ms.?, .conversation_language = conversation_language.?, .history_len = history_len.?, }; if (@hasField(LegacySummary, "schema_version")) { @field(result, "schema_version") = try legacySchemaVersion(schema_version.?); } return result; } /// Parses a full stored session; caller owns returned fields and frees with StoredSession.deinit. pub noinline fn parseStoredSession(comptime StoredSession: type, alloc: Allocator, json_text: []const u8) !StoredSession { return parseLegacyExact(StoredSession, alloc, json_text); } pub fn parseLegacyExact( comptime LegacySession: type, alloc: Allocator, json_text: []const u8, ) !LegacySession { var parsed = std.json.parseFromSlice(std.json.Value, alloc, json_text, .{}) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.InvalidSessionFormat, }; defer parsed.deinit(); const root = try requireObject(parsed.value); try requireSchemaVersion(root); const history_value = root.get("history") orelse return error.InvalidSessionFormat; if (history_value != .array) return error.InvalidSessionFormat; const history: []session.HistoryTurn = if (history_value.array.items.len > 0) try alloc.alloc(session.HistoryTurn, history_value.array.items.len) else &.{}; errdefer if (history.len > 0) mem_utils.free(alloc, history); var parsed_count: usize = 0; errdefer { var i: usize = 0; while (i < parsed_count) : (i += 1) session.freeHistoryTurn(alloc, history[i]); } for (history_value.array.items, 0..) |entry, i| { history[i] = try parseLegacyHistoryTurn(alloc, entry); parsed_count += 1; } if (try requireUsize(root, "history_len") != history.len) return error.InvalidSessionFormat; const id = try alloc.dupe(u8, try requireString(root, "id")); errdefer mem_utils.free(alloc, id); const workspace_root = try optionalStringDup(alloc, root.get("workspace_root")); errdefer if (workspace_root) |wr| mem_utils.free(alloc, wr); const language = try parseConversationLanguage(try requireString(root, "conversation_language")); const total_input_tokens: u64 = if (root.get("total_input_tokens")) |v| blk: { if (v == .integer and v.integer >= 0) break :blk @intCast(v.integer); break :blk 0; } else 0; const total_output_tokens: u64 = if (root.get("total_output_tokens")) |v| blk: { if (v == .integer and v.integer >= 0) break :blk @intCast(v.integer); break :blk 0; } else 0; const total_web_search_requests: u64 = if (root.get("total_web_search_requests")) |v| blk: { if (v == .integer and v.integer >= 0) break :blk @intCast(v.integer); break :blk 0; } else 0; return .{ .id = id, .workspace_root = workspace_root, .created_at_ms = try requireI64(root, "created_at_ms"), .updated_at_ms = try requireI64(root, "updated_at_ms"), .conversation_language = language, .history = history, .total_input_tokens = total_input_tokens, .total_output_tokens = total_output_tokens, .total_web_search_requests = total_web_search_requests, }; } pub fn parseLegacySchemaVersion( alloc: Allocator, json_text: []const u8, ) !LegacySchemaVersion { var parsed = std.json.parseFromSlice( std.json.Value, alloc, json_text, .{}, ) catch return error.InvalidSessionFormat; defer parsed.deinit(); const root = try requireObject(parsed.value); return legacySchemaVersion(try requireI64(root, "schema_version")); } fn formatLegacyBackgroundAssistant( alloc: Allocator, assistant: ?[]const u8, log_path: []const u8, url: ?[]const u8, ) ![]u8 { var out: std.Io.Writer.Allocating = .init(alloc); errdefer out.deinit(); if (assistant) |text| { if (text.len != 0) { try out.writer.writeAll(text); if (!std.mem.endsWith(u8, text, "\n")) try out.writer.writeByte('\n'); } } try out.writer.writeAll( "[Historical command record: fx no longer owns or controls this process", ); if (log_path.len != 0) try out.writer.print("; former log={s}", .{log_path}); if (url) |value| try out.writer.print("; recorded url={s}", .{value}); try out.writer.writeByte(']'); return out.toOwnedSlice(); } fn parseLegacyHistoryTurn(alloc: Allocator, value: std.json.Value) !session.HistoryTurn { const object = try requireObject(value); const kind = try requireString(object, "kind"); if (std.mem.eql(u8, kind, "compacted_summary")) { const root_user_messages: [][]u8 = if (object.get("root_user_messages")) |messages_value| blk: { if (messages_value != .array) return error.InvalidSessionFormat; const messages = try alloc.alloc([]u8, messages_value.array.items.len); errdefer mem_utils.free(alloc, messages); var copied: usize = 0; errdefer for (messages[0..copied]) |message| mem_utils.free(alloc, message); for (messages_value.array.items, 0..) |item, index| { if (item != .string) return error.InvalidSessionFormat; messages[index] = try alloc.dupe(u8, item.string); copied += 1; } break :blk messages; } else &.{}; errdefer session.freeCompletedToolNames(alloc, root_user_messages); const permission_feedback = try parseOptionalStringArray( alloc, object.get("permission_feedback"), ); errdefer types.freePermissionFeedback(alloc, permission_feedback); const removed_turn_count = try requireUsize(object, "removed_turn_count"); return .{ .compacted_summary = .{ .summary = try alloc.dupe(u8, try requireString(object, "summary")), .removed_turn_count = removed_turn_count, .compaction_count = try requireUsize(object, "compaction_count"), .root_user_messages = root_user_messages, .root_user_messages_complete = if (object.get("root_user_messages_complete") != null) try requireBool(object, "root_user_messages_complete") else object.get("root_user_messages") != null or removed_turn_count == 0, .permission_feedback = permission_feedback, .permission_feedback_complete = if (object.get("permission_feedback_complete") != null) try requireBool(object, "permission_feedback_complete") else removed_turn_count == 0, } }; } if (std.mem.eql(u8, kind, "assistant")) { const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat); errdefer session.freeUserTurn(alloc, user); const assistant = try alloc.dupe(u8, try requireString(object, "assistant")); errdefer mem_utils.free(alloc, assistant); const execution = try parseOptionalExecutionMemory(alloc, object.get("execution")); errdefer session.freeExecutionMemory(alloc, execution); return .{ .assistant = .{ .user = user, .assistant = assistant, .execution = execution, } }; } if (std.mem.eql(u8, kind, "background_command")) { const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat); errdefer session.freeUserTurn(alloc, user); const legacy_assistant = try optionalStringDup(alloc, object.get("assistant")); defer if (legacy_assistant) |text| alloc.free(text); const execution = try parseOptionalExecutionMemory(alloc, object.get("execution")); errdefer session.freeExecutionMemory(alloc, execution); const log_path = try alloc.dupe(u8, try requireString(object, "log_path")); defer alloc.free(log_path); const url = try optionalStringDup(alloc, object.get("url")); defer if (url) |value_copy| alloc.free(value_copy); _ = try requireBool(object, "expect_url"); _ = try parseOptionalBackgroundRecordId( object.get("background_record_id"), ); const assistant = try formatLegacyBackgroundAssistant( alloc, legacy_assistant, log_path, url, ); return .{ .assistant = .{ .user = user, .assistant = assistant, .execution = execution, } }; } if (std.mem.eql(u8, kind, "interrupted")) { const user = try parseUserTurn(alloc, object.get("user") orelse return error.InvalidSessionFormat); errdefer session.freeUserTurn(alloc, user); const assistant = try optionalStringDup(alloc, object.get("assistant")); errdefer if (assistant) |text| mem_utils.free(alloc, text); const tool_call = try parseOptionalToolCall(alloc, object.get("tool_call")); errdefer if (tool_call) |call| session.freeToolCall(alloc, call); const completed_tool_names = try parseOptionalStringArray(alloc, object.get("completed_tool_names")); errdefer session.freeCompletedToolNames(alloc, completed_tool_names); const terminal_reason = try parseLegacyInterruptedTerminalReason(object.get("terminal_reason")); const cancellation_origin: types.CancellationOrigin = if (object.get("cancellation_origin")) |origin| blk: { if (origin != .string) return error.InvalidSessionFormat; break :blk std.meta.stringToEnum(types.CancellationOrigin, origin.string) orelse return error.InvalidSessionFormat; } else .turn; const execution = try parseOptionalExecutionMemory(alloc, object.get("execution")); return .{ .interrupted = .{ .user = user, .assistant = assistant, .tool_call = tool_call, .completed_tool_names = completed_tool_names, .execution = execution, .terminal_reason = terminal_reason, .cancellation_origin = cancellation_origin, } }; } return error.InvalidSessionFormat; } fn parseLegacyInterruptedTerminalReason( value: ?std.json.Value, ) !session.InterruptedTerminalReason { const raw = value orelse return .cancelled; if (raw != .string) return error.InvalidSessionFormat; if (std.mem.eql(u8, raw.string, "cancelled")) return .cancelled; if (std.mem.eql(u8, raw.string, "failed")) return .failed; return error.InvalidSessionFormat; } fn parseOptionalToolCall(alloc: Allocator, maybe_value: ?std.json.Value) !?session.ToolCall { const value = maybe_value orelse return null; var call: ?session.ToolCall = switch (value) { .null => null, .object => try parseToolCall(alloc, value), else => return error.InvalidSessionFormat, }; errdefer if (call) |owned| session.freeToolCall(alloc, owned); if (call) |*parsed| { try session.repairPersistedInterruptedToolArguments(alloc, parsed, .legacy); } return call; } fn parseToolCall(alloc: Allocator, value: std.json.Value) !session.ToolCall { const object = try requireObject(value); const id = try alloc.dupe(u8, try requireString(object, "id")); errdefer mem_utils.free(alloc, id); const name = try alloc.dupe(u8, try requireString(object, "name")); errdefer mem_utils.free(alloc, name); const raw_arguments_json = try requireString(object, "arguments_json"); const argument_integrity = try types.ToolArgumentIntegrity.classifyFunctionInput(alloc, raw_arguments_json); const arguments_json = try alloc.dupe(u8, if (argument_integrity == .malformed_json) "{}" else raw_arguments_json); errdefer mem_utils.free(alloc, arguments_json); const provider_result = try optionalStringDup(alloc, object.get("provider_result")); errdefer if (provider_result) |result| mem_utils.free(alloc, result); return .{ .id = id, .name = name, .arguments_json = arguments_json, .argument_integrity = argument_integrity, .provider_result = provider_result, }; } fn parseOptionalExecutionMemory(alloc: Allocator, maybe_value: ?std.json.Value) !session.ExecutionMemory { const value = maybe_value orelse return .{}; if (value == .null) return .{}; const object = try requireObject(value); const schema_version: i64 = if (object.get("schema_version")) |version| blk: { if (version != .integer or version.integer < 1 or version.integer > 3) { return error.InvalidSessionFormat; } break :blk version.integer; } else 1; const tool_steps = try parseToolExecutionSteps( alloc, object.get("tool_steps"), schema_version, ); errdefer session.freeExecutionMemory(alloc, .{ .tool_steps = tool_steps }); const steering = try parseOptionalSteering( alloc, object.get("steering"), schema_version, tool_steps.len, ); errdefer types.freePersistedSteering(alloc, steering); const files = try parseFileEvidenceSlice(alloc, object.get("files")); return .{ .tool_steps = tool_steps, .files = files, .steering = steering }; } fn parseOptionalSteering( alloc: Allocator, maybe_value: ?std.json.Value, schema_version: i64, tool_step_count: usize, ) ![]types.PersistedSteering { const value = maybe_value orelse return &.{}; if (value != .array) return error.InvalidSessionFormat; if (value.array.items.len == 0) return &.{}; const steering = try alloc.alloc(types.PersistedSteering, value.array.items.len); errdefer mem_utils.free(alloc, steering); var parsed_count: usize = 0; errdefer for (steering[0..parsed_count]) |item| { alloc.free(item.text); if (item.assistant_prefix) |prefix| alloc.free(prefix); }; var prior_tool_step_count: usize = 0; for (value.array.items, 0..) |item, index| { const parsed = if (schema_version <= 2) types.PersistedSteering{ .text = try alloc.dupe(u8, if (item == .string) item.string else return error.InvalidSessionFormat), .after_tool_step_count = tool_step_count, } else blk: { const object = try requireObject(item); const after_tool_step_count = try requireUsize(object, "after_tool_step_count"); if (after_tool_step_count > tool_step_count or (index > 0 and after_tool_step_count < prior_tool_step_count)) { return error.InvalidSessionFormat; } const text = try alloc.dupe(u8, try requireString(object, "text")); const assistant_prefix = optionalStringDup( alloc, object.get("assistant_prefix"), ) catch |err| { alloc.free(text); return err; }; break :blk types.PersistedSteering{ .text = text, .assistant_prefix = assistant_prefix, .after_tool_step_count = after_tool_step_count, }; }; steering[index] = parsed; parsed_count += 1; prior_tool_step_count = parsed.after_tool_step_count; } return steering; } fn parseToolExecutionSteps( alloc: Allocator, maybe_value: ?std.json.Value, schema_version: i64, ) ![]session.ToolExecutionStep { const value = maybe_value orelse return &.{}; if (value != .array) return error.InvalidSessionFormat; if (value.array.items.len == 0) return &.{}; const steps = try alloc.alloc(session.ToolExecutionStep, value.array.items.len); errdefer mem_utils.free(alloc, steps); var parsed_count: usize = 0; errdefer { var i: usize = 0; while (i < parsed_count) : (i += 1) freeParsedToolExecutionStep(alloc, steps[i]); } for (value.array.items, 0..) |item, i| { const object = try requireObject(item); const assistant = try optionalStringDup(alloc, object.get("assistant")); errdefer if (assistant) |text| mem_utils.free(alloc, text); const tool_calls = try parseToolCallArray(alloc, object.get("tool_calls")); errdefer session.freeToolCallSlice(alloc, tool_calls); const tool_results = try parsePersistedToolResultArray( alloc, object.get("tool_results"), schema_version, ); errdefer session.freePersistedToolResults(alloc, tool_results); try session.repairPersistedToolArguments(alloc, tool_calls, tool_results, .legacy); steps[i] = .{ .assistant = assistant, .tool_calls = tool_calls, .tool_results = tool_results, }; parsed_count += 1; } return steps; } fn parseToolCallArray(alloc: Allocator, maybe_value: ?std.json.Value) ![]session.ToolCall { const value = maybe_value orelse return &.{}; if (value != .array) return error.InvalidSessionFormat; if (value.array.items.len == 0) return &.{}; const calls = try alloc.alloc(session.ToolCall, value.array.items.len); errdefer mem_utils.free(alloc, calls); var parsed_count: usize = 0; errdefer { var i: usize = 0; while (i < parsed_count) : (i += 1) session.freeToolCall(alloc, calls[i]); } for (value.array.items, 0..) |item, i| { calls[i] = try parseToolCall(alloc, item); parsed_count += 1; } return calls; } fn parsePersistedToolResultArray( alloc: Allocator, maybe_value: ?std.json.Value, schema_version: i64, ) ![]session.PersistedToolResult { const value = maybe_value orelse return &.{}; if (value != .array) return error.InvalidSessionFormat; if (value.array.items.len == 0) return &.{}; const results = try alloc.alloc(session.PersistedToolResult, value.array.items.len); errdefer mem_utils.free(alloc, results); var parsed_count: usize = 0; errdefer { var i: usize = 0; while (i < parsed_count) : (i += 1) freeParsedPersistedToolResult(alloc, results[i]); } for (value.array.items, 0..) |item, i| { results[i] = try parsePersistedToolResult(alloc, item, schema_version); parsed_count += 1; } return results; } fn parsePersistedToolResult( alloc: Allocator, value: std.json.Value, schema_version: i64, ) !session.PersistedToolResult { const object = try requireObject(value); const tool_call_id = try alloc.dupe(u8, try requireString(object, "tool_call_id")); errdefer mem_utils.free(alloc, tool_call_id); const tool_name = try alloc.dupe(u8, try requireString(object, "tool_name")); errdefer mem_utils.free(alloc, tool_name); const output = try alloc.dupe(u8, try requireString(object, "output")); errdefer mem_utils.free(alloc, output); const output_handle = try optionalStringDup(alloc, object.get("output_handle")); errdefer if (output_handle) |handle| mem_utils.free(alloc, handle); const preview = try optionalStringDup(alloc, object.get("preview")); errdefer if (preview) |text| mem_utils.free(alloc, text); const permission_feedback: [][]u8 = if (schema_version == 1) &.{} else blk: { const feedback_value = object.get("permission_feedback") orelse return error.InvalidSessionFormat; if (feedback_value != .array) return error.InvalidSessionFormat; break :blk try parseOptionalStringArray(alloc, feedback_value); }; errdefer types.freePermissionFeedback(alloc, permission_feedback); const committed_file_presentation = if (object.get("committed_file_presentation")) |presentation_value| try parseCommittedFilePresentation(alloc, presentation_value) else null; errdefer if (committed_file_presentation) |presentation| { types.freeCommittedFilePresentation(alloc, presentation); }; return .{ .tool_call_id = tool_call_id, .tool_name = tool_name, .status = try parsePersistedToolStatus(try requireString(object, "status")), .output = output, .output_handle = output_handle, .preview = preview, .output_bytes = try requireUsize(object, "output_bytes"), .stored_output_bytes = try requireUsize(object, "stored_output_bytes"), .truncated = try requireBool(object, "truncated"), .provider_native = try requireBool(object, "provider_native"), .created_at_ms = try requireI64(object, "created_at_ms"), .permission_feedback = permission_feedback, .committed_file_presentation = committed_file_presentation, }; } fn parseCommittedFilePresentation( alloc: Allocator, value: std.json.Value, ) !types.CommittedFilePresentation { const object = try requireObject(value); const path = try alloc.dupe(u8, try requireString(object, "path")); errdefer mem_utils.free(alloc, path); const lines = try parseCommittedFilePresentationLines(alloc, object.get("lines")); errdefer { for (lines) |line| alloc.free(@constCast(line.text)); if (lines.len > 0) alloc.free(@constCast(lines)); } const previous_content = try optionalStringDup(alloc, object.get("previous_content")); errdefer if (previous_content) |content| mem_utils.free(alloc, content); const after_content = try optionalStringDup(alloc, object.get("after_content")); errdefer if (after_content) |content| mem_utils.free(alloc, content); const lifecycle_id = try parseOptionalLifecycleId(alloc, object.get("lifecycle_id")); errdefer if (lifecycle_id) |id| mem_utils.free(alloc, @constCast(id.call_id)); return .{ .path = path, .kind = try parseCommittedFilePresentationKind(try requireString(object, "kind")), .lines = lines, .additions = try requireUsize(object, "additions"), .deletions = try requireUsize(object, "deletions"), .truncated = try requireBool(object, "truncated"), .previous_content = previous_content, .after_content = after_content, .lifecycle_id = lifecycle_id, }; } fn parseCommittedFilePresentationLines( alloc: Allocator, maybe_value: ?std.json.Value, ) ![]types.CommittedFilePresentationLine { const value = maybe_value orelse return error.InvalidSessionFormat; if (value != .array) return error.InvalidSessionFormat; if (value.array.items.len == 0) return &.{}; const lines = try alloc.alloc(types.CommittedFilePresentationLine, value.array.items.len); errdefer mem_utils.free(alloc, lines); var parsed_count: usize = 0; errdefer for (lines[0..parsed_count]) |line| mem_utils.free(alloc, @constCast(line.text)); for (value.array.items, 0..) |item, index| { const object = try requireObject(item); lines[index] = .{ .kind = try parseCommittedFilePresentationLineKind(try requireString(object, "kind")), .old_line = try optionalU32(object.get("old_line")), .new_line = try optionalU32(object.get("new_line")), .text = try alloc.dupe(u8, try requireString(object, "text")), }; parsed_count += 1; } return lines; } fn parseOptionalLifecycleId( alloc: Allocator, maybe_value: ?std.json.Value, ) !?types.ToolLifecycleId { const value = maybe_value orelse return null; if (value == .null) return null; const object = try requireObject(value); const call_id = try alloc.dupe(u8, try requireString(object, "call_id")); errdefer mem_utils.free(alloc, call_id); return .{ .turn_id = @intCast(try requireUsize(object, "turn_id")), .call_id = call_id, }; } test "session JSON frees lifecycle call id when turn id is invalid" { const alloc = std.testing.allocator; var parsed = try std.json.parseFromSlice( std.json.Value, alloc, "{\"call_id\":\"call_lifecycle\",\"turn_id\":-1}", .{}, ); defer parsed.deinit(); try std.testing.expectError( error.InvalidSessionFormat, parseOptionalLifecycleId(alloc, parsed.value), ); } fn optionalU32(maybe_value: ?std.json.Value) !?u32 { const value = maybe_value orelse return error.InvalidSessionFormat; if (value == .null) return null; if (value != .integer or value.integer < 0) return error.InvalidSessionFormat; return std.math.cast(u32, value.integer) orelse error.InvalidSessionFormat; } fn parseCommittedFilePresentationKind( value: []const u8, ) !types.CommittedFilePresentationKind { if (std.mem.eql(u8, value, "added")) return .added; if (std.mem.eql(u8, value, "edited")) return .edited; return error.InvalidSessionFormat; } fn parseCommittedFilePresentationLineKind( value: []const u8, ) !types.CommittedFilePresentationLineKind { if (std.mem.eql(u8, value, "context")) return .context; if (std.mem.eql(u8, value, "addition")) return .addition; if (std.mem.eql(u8, value, "deletion")) return .deletion; if (std.mem.eql(u8, value, "elision")) return .elision; if (std.mem.eql(u8, value, "notice")) return .notice; return error.InvalidSessionFormat; } fn parseFileEvidenceSlice(alloc: Allocator, maybe_value: ?std.json.Value) ![]session.FileEvidence { const value = maybe_value orelse return &.{}; if (value != .array) return error.InvalidSessionFormat; if (value.array.items.len == 0) return &.{}; const files = try alloc.alloc(session.FileEvidence, value.array.items.len); errdefer mem_utils.free(alloc, files); var parsed_count: usize = 0; errdefer { var i: usize = 0; while (i < parsed_count) : (i += 1) freeParsedFileEvidence(alloc, files[i]); } for (value.array.items, 0..) |item, i| { files[i] = try parseFileEvidence(alloc, item); parsed_count += 1; } return files; } fn parseFileEvidence(alloc: Allocator, value: std.json.Value) !session.FileEvidence { const object = try requireObject(value); const path = try alloc.dupe(u8, try requireString(object, "path")); errdefer mem_utils.free(alloc, path); const new_path = try optionalStringDup(alloc, object.get("new_path")); errdefer if (new_path) |path_copy| mem_utils.free(alloc, path_copy); const tool_call_id = try alloc.dupe(u8, try requireString(object, "tool_call_id")); errdefer mem_utils.free(alloc, tool_call_id); const tool_name = try alloc.dupe(u8, try requireString(object, "tool_name")); errdefer mem_utils.free(alloc, tool_name); return .{ .path = path, .new_path = new_path, .tool_call_id = tool_call_id, .tool_name = tool_name, .action = try parseFileEvidenceAction(try requireString(object, "action")), .status = try parsePersistedToolStatus(try requireString(object, "status")), .model_view_covers_full_file = try requireBool(object, "model_view_covers_full_file"), .stale = try requireBool(object, "stale"), }; } fn parsePersistedToolStatus(value: []const u8) !session.PersistedToolStatus { if (std.mem.eql(u8, value, "success")) return .success; if (std.mem.eql(u8, value, "failure")) return .failure; return error.InvalidSessionFormat; } fn parseFileEvidenceAction(value: []const u8) !session.FileEvidenceAction { if (std.mem.eql(u8, value, "read")) return .read; if (std.mem.eql(u8, value, "write")) return .write; if (std.mem.eql(u8, value, "edit")) return .edit; if (std.mem.eql(u8, value, "delete")) return .delete; if (std.mem.eql(u8, value, "rename")) return .rename; if (std.mem.eql(u8, value, "copy")) return .copy; if (std.mem.eql(u8, value, "search")) return .search; if (std.mem.eql(u8, value, "list")) return .list; if (std.mem.eql(u8, value, "unknown")) return .unknown; return error.InvalidSessionFormat; } fn freeParsedToolExecutionStep(alloc: Allocator, step: session.ToolExecutionStep) void { if (step.assistant) |assistant| alloc.free(assistant); session.freeToolCallSlice(alloc, step.tool_calls); session.freePersistedToolResults(alloc, step.tool_results); } fn freeParsedPersistedToolResult(alloc: Allocator, result: session.PersistedToolResult) void { alloc.free(result.tool_call_id); alloc.free(result.tool_name); alloc.free(result.output); if (result.output_handle) |handle| alloc.free(handle); if (result.preview) |preview| alloc.free(preview); types.freePermissionFeedback(alloc, result.permission_feedback); if (result.committed_file_presentation) |presentation| { types.freeCommittedFilePresentation(alloc, presentation); } } fn freeParsedFileEvidence(alloc: Allocator, file: session.FileEvidence) void { alloc.free(file.path); if (file.new_path) |new_path| alloc.free(new_path); alloc.free(file.tool_call_id); alloc.free(file.tool_name); } fn parseUserTurn(alloc: Allocator, value: std.json.Value) !session.UserTurn { const object = try requireObject(value); const text = try alloc.dupe(u8, try requireString(object, "text")); errdefer mem_utils.free(alloc, text); const images = try validateImagesArray(alloc, object.get("images")); errdefer session.freeImageAttachmentSlice(alloc, images); return .{ .text = text, .images = images }; } fn parseConversationLanguage(raw: []const u8) !session.ConversationLanguage { return session.ConversationLanguage.fromSlice(raw) catch error.InvalidSessionFormat; } /// Parses only the workspace_root field; caller owns a returned string and frees with allocator.free. pub fn parseWorkspaceRoot(alloc: Allocator, json_text: []const u8) !?[]u8 { var parsed = std.json.parseFromSlice(std.json.Value, alloc, json_text, .{}) catch return error.InvalidSessionFormat; defer parsed.deinit(); const root = try requireObject(parsed.value); return try optionalStringDup(alloc, root.get("workspace_root")); } fn validateImagesArray(alloc: Allocator, maybe_value: ?std.json.Value) ![]session.ImageAttachment { const value = maybe_value orelse return &.{}; if (value != .array) return error.InvalidSessionFormat; if (value.array.items.len == 0) return &.{}; const images = try alloc.alloc(session.ImageAttachment, value.array.items.len); errdefer mem_utils.free(alloc, images); var parsed_count: usize = 0; errdefer { var i: usize = 0; while (i < parsed_count) : (i += 1) { types.freeImageAttachment(alloc, images[i]); } } for (value.array.items, 0..) |entry, i| { const object = try requireObject(entry); const snapshot_path = try optionalStringDup(alloc, object.get("snapshot_path")); var owns_snapshot_path = true; errdefer if (owns_snapshot_path) { if (snapshot_path) |path| alloc.free(path); }; const snapshot_sha256 = try optionalStringDup(alloc, object.get("snapshot_sha256")); var owns_snapshot_sha256 = true; errdefer if (owns_snapshot_sha256) { if (snapshot_sha256) |sha256| alloc.free(sha256); }; if ((snapshot_path == null) != (snapshot_sha256 == null)) return error.InvalidSessionFormat; images[i] = try dupeParsedImageAttachment( alloc, if (object.get("id") != null) try requireUsize(object, "id") else 0, try requireString(object, "path"), try requireString(object, "media_type"), snapshot_path, snapshot_sha256, ); owns_snapshot_path = false; owns_snapshot_sha256 = false; parsed_count += 1; } return images; } fn dupeParsedImageAttachment( alloc: Allocator, id: usize, path_src: []const u8, media_type_src: []const u8, snapshot_path_src: ?[]u8, snapshot_sha256_src: ?[]u8, ) !session.ImageAttachment { const path = try alloc.dupe(u8, path_src); errdefer mem_utils.free(alloc, path); const media_type = try alloc.dupe(u8, media_type_src); errdefer mem_utils.free(alloc, media_type); return .{ .id = id, .path = path, .media_type = media_type, .snapshot_path = snapshot_path_src, .snapshot_sha256 = snapshot_sha256_src, }; } fn requireSchemaVersion(object: std.json.ObjectMap) !void { try requireLegacySchema(try requireI64(object, "schema_version")); } fn requireLegacySchema(version: i64) !void { _ = try legacySchemaVersion(version); } fn legacySchemaVersion(version: i64) !LegacySchemaVersion { return switch (version) { legacy_schema_v1 => .v1, legacy_schema_v2 => .v2, else => error.UnsupportedSessionSchema, }; } fn summaryComplete( schema_version: ?i64, id: ?[]u8, created_at_ms: ?i64, updated_at_ms: ?i64, conversation_language: ?session.ConversationLanguage, history_len: ?usize, ) bool { return schema_version != null and id != null and created_at_ms != null and updated_at_ms != null and conversation_language != null and history_len != null; } fn readStringOwned( reader: *std.json.Reader, alloc: Allocator, max_len: usize, ) ![]u8 { const token = try reader.nextAllocMax(alloc, .alloc_always, max_len); return switch (token) { .allocated_string => |value| value, .string => |value| try alloc.dupe(u8, value), else => { freeToken(alloc, token); return error.InvalidSessionFormat; }, }; } fn readOptionalStringOwned( reader: *std.json.Reader, alloc: Allocator, max_len: usize, ) !?[]u8 { const token = try reader.nextAllocMax(alloc, .alloc_always, max_len); return switch (token) { .null => null, .allocated_string => |value| value, .string => |value| try alloc.dupe(u8, value), else => { freeToken(alloc, token); return error.InvalidSessionFormat; }, }; } fn readI64Token(reader: *std.json.Reader, alloc: Allocator) !i64 { const token = try reader.nextAllocMax(alloc, .alloc_if_needed, 32); defer freeToken(alloc, token); const raw = switch (token) { .number => |value| value, .allocated_number => |value| value, else => return error.InvalidSessionFormat, }; return std.fmt.parseInt(i64, raw, 10) catch error.InvalidSessionFormat; } fn readU64Token(reader: *std.json.Reader, alloc: Allocator) !u64 { const token = try reader.nextAllocMax(alloc, .alloc_if_needed, 32); defer freeToken(alloc, token); const raw = switch (token) { .number => |value| value, .allocated_number => |value| value, else => return error.InvalidSessionFormat, }; return std.fmt.parseUnsigned(u64, raw, 10) catch error.InvalidSessionFormat; } fn expectToken(actual: std.json.Token, comptime expected: std.meta.Tag(std.json.Token)) !void { if (std.meta.activeTag(actual) != expected) return error.InvalidSessionFormat; } fn freeToken(alloc: Allocator, token: std.json.Token) void { switch (token) { .allocated_number => |value| alloc.free(value), .allocated_string => |value| alloc.free(value), else => {}, } } fn parseOptionalBackgroundRecordId( maybe_value: ?std.json.Value, ) !?[16]u8 { const value = maybe_value orelse return null; if (value == .null) return null; if (value != .string or value.string.len != 32) return error.InvalidSessionFormat; var id: [16]u8 = undefined; _ = std.fmt.hexToBytes(&id, value.string) catch return error.InvalidSessionFormat; const canonical = std.fmt.bytesToHex(id, .lower); if (!std.mem.eql(u8, &canonical, value.string)) return error.InvalidSessionFormat; return id; } noinline fn requireObject(value: std.json.Value) !std.json.ObjectMap { if (value != .object) return error.InvalidSessionFormat; return value.object; } noinline fn requireString(object: std.json.ObjectMap, key: []const u8) ![]const u8 { const value = object.get(key) orelse return error.InvalidSessionFormat; if (value != .string) return error.InvalidSessionFormat; return value.string; } fn requireBool(object: std.json.ObjectMap, key: []const u8) !bool { const value = object.get(key) orelse return error.InvalidSessionFormat; if (value != .bool) return error.InvalidSessionFormat; return value.bool; } noinline fn requireI64(object: std.json.ObjectMap, key: []const u8) !i64 { const value = object.get(key) orelse return error.InvalidSessionFormat; if (value != .integer) return error.InvalidSessionFormat; if (value.integer < std.math.minInt(i64) or value.integer > std.math.maxInt(i64)) { return error.InvalidSessionFormat; } return @intCast(value.integer); } noinline fn requireUsize(object: std.json.ObjectMap, key: []const u8) !usize { const value = try requireI64(object, key); if (value < 0) return error.InvalidSessionFormat; return @intCast(value); } noinline fn optionalStringDup(alloc: Allocator, maybe_value: ?std.json.Value) !?[]u8 { const value = maybe_value orelse return null; return switch (value) { .null => null, .string => |text| try alloc.dupe(u8, text), else => error.InvalidSessionFormat, }; } fn parseOptionalStringArray(alloc: Allocator, maybe_value: ?std.json.Value) ![][]u8 { const value = maybe_value orelse return &.{}; if (value == .null) return &.{}; if (value != .array) return error.InvalidSessionFormat; if (value.array.items.len == 0) return &.{}; const items = try alloc.alloc([]u8, value.array.items.len); errdefer mem_utils.free(alloc, items); var parsed_count: usize = 0; errdefer { var i: usize = 0; while (i < parsed_count) : (i += 1) alloc.free(items[i]); } for (value.array.items, 0..) |entry, i| { if (entry != .string) return error.InvalidSessionFormat; items[i] = try alloc.dupe(u8, entry.string); parsed_count += 1; } return items; } test "validateImagesArray frees path when media_type allocation fails" { const alloc = std.testing.allocator; var parsed = try std.json.parseFromSlice(std.json.Value, alloc, "{\"images\":[{\"path\":\"/tmp/a.png\",\"media_type\":\"image/png\"}]}", .{}); defer parsed.deinit(); var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = 2 }); try std.testing.expectError(error.OutOfMemory, validateImagesArray(failing.allocator(), parsed.value.object.get("images"))); } test "parseSessionSummary rejects integer above i64 range" { const session_store = @import("session_store.zig"); const alloc = std.testing.allocator; const json_text = "{\"schema_version\":1,\"id\":\"overflow\",\"created_at_ms\":9223372036854775808,\"updated_at_ms\":1," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":0,\"history\":[]}"; try std.testing.expectError(error.InvalidSessionFormat, parseSessionSummary(session_store.SessionSummary, alloc, json_text)); } test "parseSessionSummary rejects integer below i64 range" { const session_store = @import("session_store.zig"); const alloc = std.testing.allocator; const json_text = "{\"schema_version\":1,\"id\":\"underflow\",\"created_at_ms\":1,\"updated_at_ms\":-9223372036854775809," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":0,\"history\":[]}"; try std.testing.expectError(error.InvalidSessionFormat, parseSessionSummary(session_store.SessionSummary, alloc, json_text)); } test "parseWorkspaceRoot probes workspace without requiring supported schema" { const alloc = std.testing.allocator; const workspace = try parseWorkspaceRoot(alloc, "{\"schema_version\":99,\"workspace_root\":\"/tmp/workspace\"}") orelse return error.TestExpectedEqual; defer alloc.free(workspace); try std.testing.expectEqualStrings("/tmp/workspace", workspace); try std.testing.expect(try parseWorkspaceRoot(alloc, "{\"schema_version\":99}") == null); try std.testing.expect(try parseWorkspaceRoot(alloc, "{\"schema_version\":99,\"workspace_root\":null}") == null); } test "parseWorkspaceRoot rejects non-object and non-string workspace root" { try std.testing.expectError(error.InvalidSessionFormat, parseWorkspaceRoot(std.testing.allocator, "[]")); try std.testing.expectError(error.InvalidSessionFormat, parseWorkspaceRoot(std.testing.allocator, "{\"workspace_root\":123}")); } test "legacy session migration keeps missing compacted authority incomplete" { const alloc = std.testing.allocator; var parsed = try std.json.parseFromSlice( std.json.Value, alloc, "{\"kind\":\"compacted_summary\",\"summary\":\"legacy\",\"removed_turn_count\":2,\"compaction_count\":1}", .{}, ); defer parsed.deinit(); const migrated = try parseLegacyHistoryTurn(alloc, parsed.value); defer session.freeHistoryTurn(alloc, migrated); try std.testing.expect(!migrated.compacted_summary.root_user_messages_complete); try std.testing.expect(!migrated.compacted_summary.permission_feedback_complete); const history = [_]session.HistoryTurn{migrated}; const json = try renderSessionJson( alloc, "legacy-incomplete", 1, 2, session.ConversationLanguage.literal("und-Latn"), "/tmp/workspace", &history, .{}, ); defer alloc.free(json); var reloaded = try parseStoredSession(TestStoredSession, alloc, json); defer reloaded.deinit(alloc); try std.testing.expect(!reloaded.history[0].compacted_summary.root_user_messages_complete); try std.testing.expect(!reloaded.history[0].compacted_summary.permission_feedback_complete); } test "session JSON round-trips assistant execution memory" { const alloc = std.testing.allocator; var feedback = [_][]u8{@constCast("read it after writing")}; var calls = [_]session.ToolCall{.{ .id = "call_read", .name = "read_file", .arguments_json = "{\"path\":\"src/main.zig\"}", }}; var results = [_]session.PersistedToolResult{.{ .tool_call_id = @constCast("call_read"), .tool_name = @constCast("read_file"), .status = .success, .output = @constCast("src/main.zig\nconst std = @import(\"std\");"), .output_bytes = 54, .stored_output_bytes = 54, .truncated = false, .provider_native = false, .created_at_ms = 1234, .permission_feedback = feedback[0..], }}; var steps = [_]session.ToolExecutionStep{.{ .assistant = @constCast("I'll inspect it."), .tool_calls = calls[0..], .tool_results = results[0..], }}; var steering = [_]types.PersistedSteering{.{ .text = @constCast("focus on rendering"), .assistant_prefix = @constCast("partial assistant"), .after_tool_step_count = 1, }}; var files = [_]session.FileEvidence{.{ .path = @constCast("src/main.zig"), .tool_call_id = @constCast("call_read"), .tool_name = @constCast("read_file"), .action = .read, .status = .success, .model_view_covers_full_file = true, .stale = false, }}; const history = [_]session.HistoryTurn{.{ .assistant = .{ .user = .{ .text = @constCast("what is main") }, .assistant = @constCast("main wires the app"), .execution = .{ .tool_steps = steps[0..], .files = files[0..], .steering = steering[0..] }, } }}; const json = try renderSessionJson(alloc, "exec-json", 1, 2, session.ConversationLanguage.literal("en"), "/tmp/workspace", &history, .{}); defer alloc.free(json); try std.testing.expect(std.mem.find(u8, json, "\"execution\"") != null); try std.testing.expect(std.mem.find(u8, json, "\"tool_steps\"") != null); try std.testing.expect(std.mem.find(u8, json, "\"files\"") != null); try std.testing.expect(std.mem.find(u8, json, "\"execution\":{\"schema_version\":3") != null); try std.testing.expect(std.mem.find(u8, json, "\"permission_feedback\"") != null); try std.testing.expect(std.mem.find(u8, json, "\"text\":\"focus on rendering\"") != null); try std.testing.expect(std.mem.find(u8, json, "\"assistant_prefix\":\"partial assistant\"") != null); try std.testing.expect(std.mem.find(u8, json, "\"after_tool_step_count\":1") != null); var loaded = try parseStoredSession(TestStoredSession, alloc, json); defer loaded.deinit(alloc); const execution = loaded.history[0].assistant.execution; try std.testing.expectEqual(@as(usize, 1), execution.tool_steps.len); try std.testing.expectEqualStrings("I'll inspect it.", execution.tool_steps[0].assistant.?); try std.testing.expectEqualStrings("call_read", execution.tool_steps[0].tool_calls[0].id); try std.testing.expectEqualStrings("read_file", execution.tool_steps[0].tool_results[0].tool_name); try std.testing.expectEqual(@as(usize, 54), execution.tool_steps[0].tool_results[0].stored_output_bytes); try std.testing.expectEqual( @as(usize, 1), execution.tool_steps[0].tool_results[0].permission_feedback.len, ); try std.testing.expectEqualStrings( "read it after writing", execution.tool_steps[0].tool_results[0].permission_feedback[0], ); try std.testing.expectEqual(@as(usize, 1), execution.files.len); try std.testing.expectEqual(.read, execution.files[0].action); try std.testing.expect(execution.files[0].model_view_covers_full_file); try std.testing.expectEqual(@as(usize, 1), execution.steering.len); try std.testing.expectEqualStrings("focus on rendering", execution.steering[0].text); try std.testing.expectEqualStrings("partial assistant", execution.steering[0].assistant_prefix.?); try std.testing.expectEqual(@as(usize, 1), execution.steering[0].after_tool_step_count); } fn checkV3SteeringAllocationFailures(alloc: Allocator) !void { const json = "[{\"text\":\"first\",\"assistant_prefix\":\"prefix one\",\"after_tool_step_count\":0}," ++ "{\"text\":\"second\",\"assistant_prefix\":\"prefix two\",\"after_tool_step_count\":1}]"; var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); defer parsed.deinit(); const steering = try parseOptionalSteering(alloc, parsed.value, 3, 1); defer types.freePersistedSteering(alloc, steering); try std.testing.expectEqual(@as(usize, 2), steering.len); } test "session JSON steering cleans up every allocation failure" { try std.testing.checkAllAllocationFailures( std.testing.allocator, checkV3SteeringAllocationFailures, .{}, ); } const legacy_execution_memory_fixture = "{\"schema_version\":1,\"id\":\"execution-ownership\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{\"kind\":\"assistant\",\"user\":{\"text\":\"inspect\",\"images\":[]},\"assistant\":\"done\"," ++ "\"execution\":{\"schema_version\":2,\"tool_steps\":[" ++ "{\"assistant\":\"checking\",\"tool_calls\":[{\"id\":\"call_write\",\"name\":\"write_file\",\"arguments_json\":\"{}\",\"provider_result\":null}],\"tool_results\":[{\"tool_call_id\":\"call_write\",\"tool_name\":\"write_file\",\"status\":\"success\",\"output\":\"wrote\",\"output_handle\":null,\"preview\":null,\"output_bytes\":5,\"stored_output_bytes\":5,\"truncated\":false,\"provider_native\":false,\"created_at_ms\":1,\"permission_feedback\":[\"read it\"]}]}],\"files\":[" ++ "{\"path\":\"src/main.zig\",\"new_path\":null,\"tool_call_id\":\"call_read\",\"tool_name\":\"read_file\"," ++ "\"action\":\"read\",\"status\":\"success\",\"model_view_covers_full_file\":true,\"stale\":false}]," ++ "\"steering\":[\"legacy steer\"]}}]}"; fn checkLegacyExecutionMemoryAllocationFailures(alloc: Allocator) !void { var loaded = try parseStoredSession( TestStoredSession, alloc, legacy_execution_memory_fixture, ); defer loaded.deinit(alloc); try std.testing.expectEqual( @as(usize, 1), loaded.history[0].assistant.execution.tool_steps.len, ); try std.testing.expectEqual( @as(usize, 1), loaded.history[0].assistant.execution.files.len, ); try std.testing.expectEqualStrings( "legacy steer", loaded.history[0].assistant.execution.steering[0].text, ); try std.testing.expectEqual( @as(usize, 1), loaded.history[0].assistant.execution.steering[0].after_tool_step_count, ); try std.testing.expectEqual( @as(usize, 1), loaded.history[0].assistant.execution.tool_steps[0].tool_results[0].permission_feedback.len, ); } test "legacy execution memory frees parsed tool steps when files are malformed" { const json = "{\"schema_version\":1,\"id\":\"execution-malformed\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{\"kind\":\"assistant\",\"user\":{\"text\":\"inspect\",\"images\":[]},\"assistant\":\"done\"," ++ "\"execution\":{\"schema_version\":1,\"tool_steps\":[" ++ "{\"assistant\":\"checking\",\"tool_calls\":[],\"tool_results\":[]}],\"files\":{}}}]}"; try std.testing.expectError( error.InvalidSessionFormat, parseStoredSession(TestStoredSession, std.testing.allocator, json), ); } test "legacy execution memory cleans up every allocation failure" { try std.testing.checkAllAllocationFailures( std.testing.allocator, checkLegacyExecutionMemoryAllocationFailures, .{}, ); } test "session JSON persists malformed argument recovery as a safe failed pair" { const alloc = std.testing.allocator; const failure_output = "{\"error\":{\"type\":\"tool_execution_failed\",\"tool_name\":\"read_file\"," ++ "\"message\":\"Tool arguments were not valid JSON.\",\"suggestion\":" ++ "\"Reissue the tool call with complete valid JSON arguments matching the tool schema.\"}}"; var calls = [_]session.ToolCall{.{ .id = "call_bad", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json, }}; var results = [_]session.PersistedToolResult{.{ .tool_call_id = @constCast("call_bad"), .tool_name = @constCast("read_file"), .status = .failure, .output = @constCast(failure_output), .output_bytes = failure_output.len, .stored_output_bytes = failure_output.len, .truncated = false, .provider_native = false, .created_at_ms = 1234, }}; var steps = [_]session.ToolExecutionStep{.{ .assistant = @constCast("I'll inspect it."), .tool_calls = calls[0..], .tool_results = results[0..], }}; const history = [_]session.HistoryTurn{.{ .assistant = .{ .user = .{ .text = @constCast("inspect it") }, .assistant = @constCast("Recovered."), .execution = .{ .tool_steps = steps[0..] }, } }}; const json = try renderSessionJson( alloc, "malformed-recovery", 1, 2, session.ConversationLanguage.literal("en"), "/tmp/workspace", &history, .{}, ); defer alloc.free(json); try std.testing.expect(std.mem.find(u8, json, "\"arguments_json\":\"{}\"") != null); try std.testing.expect(std.mem.find(u8, json, "malformed_json") == null); var loaded = try parseStoredSession(TestStoredSession, alloc, json); defer loaded.deinit(alloc); const execution = loaded.history[0].assistant.execution; try std.testing.expectEqual(@as(usize, 1), execution.tool_steps.len); try std.testing.expectEqualStrings("{}", execution.tool_steps[0].tool_calls[0].arguments_json); try std.testing.expectEqual(.valid, execution.tool_steps[0].tool_calls[0].argument_integrity); try std.testing.expectEqual(session.PersistedToolStatus.failure, execution.tool_steps[0].tool_results[0].status); try std.testing.expectEqualStrings(failure_output, execution.tool_steps[0].tool_results[0].output); } test "non-object legacy function inputs are repaired without changing recorded outcomes" { const alloc = std.testing.allocator; for ([_]session.PersistedToolStatus{ .success, .failure }) |status| { for (0..3) |native_kind| { var calls = [_]session.ToolCall{.{ .id = "call", .name = "read_file", .arguments_json = "[]" }}; calls[0].provider_result = if (native_kind == 1) "provider result" else null; var results = [_]session.PersistedToolResult{.{ .tool_call_id = @constCast("call"), .tool_name = @constCast("read_file"), .status = status, .output = @constCast("recorded"), .output_bytes = 8, .stored_output_bytes = 8, .truncated = false, .provider_native = native_kind == 2, .created_at_ms = 1, }}; var steps = [_]session.ToolExecutionStep{.{ .tool_calls = &calls, .tool_results = &results }}; var encoded: std.Io.Writer.Allocating = .init(alloc); defer encoded.deinit(); try writeExecutionMemoryJson(&encoded.writer, .{ .tool_steps = &steps }); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, encoded.written(), .{}); defer parsed.deinit(); const decoded = try parseOptionalExecutionMemory(alloc, parsed.value); defer session.freeExecutionMemory(alloc, decoded); const step = decoded.tool_steps[0]; try std.testing.expectEqualStrings(if (native_kind == 0) "{}" else "[]", step.tool_calls[0].arguments_json); try std.testing.expectEqual(if (native_kind == 0) types.ToolExecutionProvenance.fx_local else .provider_executed, step.tool_calls[0].provenance); try std.testing.expectEqual(types.ToolArgumentIntegrity.valid, step.tool_calls[0].argument_integrity); try std.testing.expectEqual(status, step.tool_results[0].status); try std.testing.expectEqualStrings("recorded", step.tool_results[0].output); try std.testing.expectEqual(@as(usize, 8), step.tool_results[0].stored_output_bytes); } } } fn checkNonObjectLegacyInterruptedAllocationFailures(alloc: Allocator, value: std.json.Value) !void { const call = (try parseOptionalToolCall(alloc, value)).?; defer session.freeToolCall(alloc, call); try std.testing.expectEqualStrings("{}", call.arguments_json); } test "non-object legacy interrupted repair cleans every allocation failure" { const alloc = std.testing.allocator; var parsed = try std.json.parseFromSlice(std.json.Value, alloc, \\{"id":"pending","name":"read_file","arguments_json":"[]","provider_result":null} , .{}); defer parsed.deinit(); try std.testing.checkAllAllocationFailures(alloc, checkNonObjectLegacyInterruptedAllocationFailures, .{parsed.value}); } test "legacy cancellation provenance reads old sessions and roundtrips optional origin" { const Case = struct { fn run(alloc: Allocator, origin_field: []const u8, expected: types.CancellationOrigin) !void { const json = try std.fmt.allocPrint( alloc, "{{\"schema_version\":1,\"id\":\"old\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{{\"kind\":\"interrupted\",\"user\":{{\"text\":\"request\",\"images\":[]}},\"assistant\":\"partial\"{s}}}]}}", .{origin_field}, ); defer alloc.free(json); var loaded = try parseStoredSession(TestStoredSession, alloc, json); defer loaded.deinit(alloc); try std.testing.expectEqual(expected, loaded.history[0].interrupted.cancellation_origin); try std.testing.expectEqual(types.InterruptedTerminalReason.cancelled, loaded.history[0].interrupted.terminal_reason); // This in-memory writer reports injected allocation failure as WriteFailed. const encoded = renderSessionJson(alloc, loaded.id, loaded.created_at_ms, loaded.updated_at_ms, loaded.conversation_language, loaded.workspace_root.?, loaded.history, .{}) catch |err| return if (err == error.WriteFailed) error.OutOfMemory else err; defer alloc.free(encoded); try std.testing.expectEqual(expected == .compaction, std.mem.find(u8, encoded, "\"cancellation_origin\"") != null); var reloaded = try parseStoredSession(TestStoredSession, alloc, encoded); defer reloaded.deinit(alloc); try std.testing.expectEqual(expected, reloaded.history[0].interrupted.cancellation_origin); try std.testing.expectEqualStrings("partial", reloaded.history[0].interrupted.assistant.?); } }; try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{ "", types.CancellationOrigin.turn }); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{ ",\"cancellation_origin\":\"turn\"", types.CancellationOrigin.turn }); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{ ",\"cancellation_origin\":\"compaction\"", types.CancellationOrigin.compaction }); } test "legacy cancellation provenance rejects invalid values after owned fields" { const alloc = std.testing.allocator; for ([_][]const u8{ "\"unknown\"", "null", "1", "false", "[]", "{}" }) |origin| { const json = try std.fmt.allocPrint( alloc, "{{\"kind\":\"interrupted\",\"user\":{{\"text\":\"request\",\"images\":[]}},\"assistant\":\"partial\",\"completed_tool_names\":[\"read_file\"],\"cancellation_origin\":{s}}}", .{origin}, ); defer alloc.free(json); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, json, .{}); defer parsed.deinit(); try std.testing.expectError(error.InvalidSessionFormat, parseLegacyHistoryTurn(alloc, parsed.value)); } } test "old assistant session JSON without execution parses as empty memory" { const alloc = std.testing.allocator; const json = "{\"schema_version\":1,\"id\":\"old\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{\"kind\":\"assistant\",\"user\":{\"text\":\"hello\",\"images\":[]},\"assistant\":\"world\"}" ++ "]}"; var loaded = try parseStoredSession(TestStoredSession, alloc, json); defer loaded.deinit(alloc); try std.testing.expectEqual(@as(usize, 1), loaded.history.len); try std.testing.expectEqual(@as(usize, 0), loaded.history[0].assistant.execution.tool_steps.len); try std.testing.expectEqual(@as(usize, 0), loaded.history[0].assistant.execution.files.len); } test "legacy session JSON repairs duplicate-key tool arguments before projection" { const io_mod = @import("../shared/io.zig"); const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); defer alloc.free(root); const trace_path = try std.fs.path.join(alloc, &.{ root, "legacy-repair-trace.log" }); defer alloc.free(trace_path); debug_trace.resetForTest(); defer debug_trace.resetForTest(); try debug_trace.configureForTestWithScopes(alloc, trace_path, "session"); const json = "{\"schema_version\":1,\"id\":\"legacy-malformed\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{\"kind\":\"assistant\",\"user\":{\"text\":\"inspect\",\"images\":[]},\"assistant\":\"failed\"," ++ "\"execution\":{\"schema_version\":1,\"tool_steps\":[{\"assistant\":null,\"tool_calls\":[" ++ "{\"id\":\"call_bad\",\"name\":\"read_file\",\"arguments_json\":\"{\\\"depth\\\":1,\\\"depth\\\":2}\",\"provider_result\":null}" ++ "],\"tool_results\":[{\"tool_call_id\":\"call_bad\",\"tool_name\":\"read_file\",\"status\":\"success\"," ++ "\"output\":\"stale success\",\"output_handle\":null,\"preview\":null,\"output_bytes\":13,\"stored_output_bytes\":13," ++ "\"truncated\":false,\"provider_native\":false,\"created_at_ms\":1}]}],\"files\":[]}}]}"; var loaded = try parseStoredSession(TestStoredSession, alloc, json); defer loaded.deinit(alloc); const step = loaded.history[0].assistant.execution.tool_steps[0]; try std.testing.expectEqualStrings("{}", step.tool_calls[0].arguments_json); try std.testing.expectEqual(.valid, step.tool_calls[0].argument_integrity); try std.testing.expectEqual(session.PersistedToolStatus.failure, step.tool_results[0].status); try std.testing.expect(tool_result_errors.isToolExecutionFailedOutput(step.tool_results[0].output)); const rendered = try renderSessionJson( alloc, loaded.id, loaded.created_at_ms, loaded.updated_at_ms, loaded.conversation_language, loaded.workspace_root.?, loaded.history, .{}, ); defer alloc.free(rendered); try std.testing.expect(std.mem.find(u8, rendered, "\"depth\":1") == null); var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var messages: std.ArrayList(types.ChatMessage) = .empty; defer messages.deinit(arena); try session.appendHistoryChatMessages(arena, &messages, loaded.history); try std.testing.expectEqual(@as(usize, 4), messages.items.len); try std.testing.expectEqualStrings("{}", messages.items[1].tool_calls[0].arguments_json); try std.testing.expect(std.mem.find(u8, messages.items[1].tool_calls[0].arguments_json, "\"depth\":1") == null); try std.testing.expect(tool_result_errors.isToolExecutionFailedOutput(messages.items[2].content.?)); try std.testing.expect(std.mem.find(u8, messages.items[2].content.?, "\"depth\":1") == null); debug_trace.shutdown(); var trace_file = try std.Io.Dir.openFileAbsolute(io_mod.getIo(), trace_path, .{}); defer trace_file.close(io_mod.getIo()); const trace = try io_mod.readFileToEnd(alloc, &trace_file, 8192); defer alloc.free(trace); try std.testing.expect(std.mem.find(u8, trace, "event=persisted_tool_arguments_repaired") != null); try std.testing.expect(std.mem.find(u8, trace, "source=legacy") != null); try std.testing.expect(std.mem.find(u8, trace, "call_id=call_bad") != null); try std.testing.expect(std.mem.find(u8, trace, "\"depth\":1") == null); } test "legacy interrupted session sanitizes duplicate-key tool arguments" { const json = "{\"schema_version\":1,\"id\":\"legacy-interrupted\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{\"kind\":\"interrupted\",\"user\":{\"text\":\"inspect\",\"images\":[]},\"assistant\":\"working\"," ++ "\"tool_call\":{\"id\":\"call_bad\",\"name\":\"glob_files\",\"arguments_json\":\"{\\\"depth\\\":1,\\\"depth\\\":2}\",\"provider_result\":null}," ++ "\"completed_tool_names\":[]}]}"; var loaded = try parseStoredSession(TestStoredSession, std.testing.allocator, json); defer loaded.deinit(std.testing.allocator); const call = loaded.history[0].interrupted.tool_call.?; try std.testing.expectEqualStrings("{}", call.arguments_json); try std.testing.expectEqual(.valid, call.argument_integrity); } test "legacy session JSON rejects ambiguous malformed tool result pairings" { const read_result = "{\"tool_call_id\":\"call_bad\",\"tool_name\":\"read_file\",\"status\":\"success\"," ++ "\"output\":\"stale\",\"output_handle\":null,\"preview\":null,\"output_bytes\":5,\"stored_output_bytes\":5," ++ "\"truncated\":false,\"provider_native\":false,\"created_at_ms\":1}"; const list_result = "{\"tool_call_id\":\"call_bad\",\"tool_name\":\"glob_files\",\"status\":\"success\"," ++ "\"output\":\"stale\",\"output_handle\":null,\"preview\":null,\"output_bytes\":5,\"stored_output_bytes\":5," ++ "\"truncated\":false,\"provider_native\":false,\"created_at_ms\":1}"; const cases = [_]struct { calls: []const u8, results: []const u8, }{ .{ .calls = "{\"id\":\"call_bad\",\"name\":\"read_file\",\"arguments_json\":\"{]\",\"provider_result\":null}," ++ "{\"id\":\"call_bad\",\"name\":\"read_file\",\"arguments_json\":\"{]\",\"provider_result\":null}", .results = read_result, }, .{ .calls = "{\"id\":\"call_bad\",\"name\":\"read_file\",\"arguments_json\":\"{]\",\"provider_result\":null}", .results = "", }, .{ .calls = "{\"id\":\"call_bad\",\"name\":\"read_file\",\"arguments_json\":\"{]\",\"provider_result\":null}", .results = read_result ++ "," ++ read_result, }, .{ .calls = "{\"id\":\"call_bad\",\"name\":\"read_file\",\"arguments_json\":\"{]\",\"provider_result\":null}", .results = list_result, }, }; for (cases) |case| { const json = try legacySessionWithToolStep(std.testing.allocator, case.calls, case.results); defer std.testing.allocator.free(json); try std.testing.expectError( error.InvalidSessionFormat, parseStoredSession(TestStoredSession, std.testing.allocator, json), ); } } test "legacy duplicate-key repair preserves allocation failures" { try std.testing.checkAllAllocationFailures( std.testing.allocator, checkLegacyToolArgumentRepairAllocationFailures, .{}, ); } fn legacySessionWithToolStep( alloc: Allocator, calls: []const u8, results: []const u8, ) ![]u8 { return std.mem.concat(alloc, u8, &.{ "{\"schema_version\":1,\"id\":\"legacy-malformed\",\"created_at_ms\":1,\"updated_at_ms\":2,", "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[", "{\"kind\":\"assistant\",\"user\":{\"text\":\"inspect\",\"images\":[]},\"assistant\":\"failed\",", "\"execution\":{\"schema_version\":1,\"tool_steps\":[{\"assistant\":null,\"tool_calls\":[", calls, "],\"tool_results\":[", results, "]}],\"files\":[]}}]}", }); } fn checkLegacyToolArgumentRepairAllocationFailures(alloc: Allocator) !void { const json = "{\"schema_version\":1,\"id\":\"legacy-oom\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{\"kind\":\"assistant\",\"user\":{\"text\":\"inspect\",\"images\":[]},\"assistant\":\"failed\"," ++ "\"execution\":{\"schema_version\":1,\"tool_steps\":[{\"assistant\":null,\"tool_calls\":[" ++ "{\"id\":\"call_bad\",\"name\":\"read_file\",\"arguments_json\":\"{\\\"depth\\\":1,\\\"depth\\\":2}\",\"provider_result\":null}" ++ "],\"tool_results\":[{\"tool_call_id\":\"call_bad\",\"tool_name\":\"read_file\",\"status\":\"success\"," ++ "\"output\":\"stale\",\"output_handle\":null,\"preview\":null,\"output_bytes\":5,\"stored_output_bytes\":5," ++ "\"truncated\":false,\"provider_native\":false,\"created_at_ms\":1}]}],\"files\":[]}}]}"; var loaded = try parseStoredSession(TestStoredSession, alloc, json); defer loaded.deinit(alloc); const step = loaded.history[0].assistant.execution.tool_steps[0]; try std.testing.expectEqualStrings("{}", step.tool_calls[0].arguments_json); try std.testing.expectEqual(session.PersistedToolStatus.failure, step.tool_results[0].status); } test "session JSON round-trips large result handle metadata and accepts old inline results" { const alloc = std.testing.allocator; var calls = [_]session.ToolCall{.{ .id = "call_large", .name = "read_file", .arguments_json = "{\"path\":\"large.txt\"}", }}; var results = [_]session.PersistedToolResult{.{ .tool_call_id = @constCast("call_large"), .tool_name = @constCast("read_file"), .status = .success, .output = @constCast("\nevidence\n"), .output_bytes = 90_000, .stored_output_bytes = 90_000, .truncated = true, .provider_native = false, .created_at_ms = 1234, .output_handle = @constCast("result-call_large-abc.txt"), .preview = @constCast("evidence"), }}; var steps = [_]session.ToolExecutionStep{.{ .tool_calls = calls[0..], .tool_results = results[0..], }}; var files = [_]session.FileEvidence{.{ .path = @constCast("large.txt"), .tool_call_id = @constCast("call_large"), .tool_name = @constCast("read_file"), .action = .read, .status = .success, .model_view_covers_full_file = false, }}; const history = [_]session.HistoryTurn{.{ .assistant = .{ .user = .{ .text = @constCast("read large file") }, .assistant = @constCast("captured"), .execution = .{ .tool_steps = steps[0..], .files = files[0..], }, } }}; const json = try renderSessionJson(alloc, "large-json", 1, 2, session.ConversationLanguage.literal("en"), "/tmp/workspace", &history, .{}); defer alloc.free(json); try std.testing.expect(std.mem.find(u8, json, "\"output_handle\"") != null); try std.testing.expect(std.mem.find(u8, json, "\"preview\"") != null); var loaded = try parseStoredSession(TestStoredSession, alloc, json); defer loaded.deinit(alloc); const result = loaded.history[0].assistant.execution.tool_steps[0].tool_results[0]; try std.testing.expect(result.truncated); try std.testing.expectEqualStrings("result-call_large-abc.txt", result.output_handle.?); try std.testing.expectEqualStrings("evidence", result.preview.?); try std.testing.expectEqual(@as(usize, 1), loaded.history[0].assistant.execution.files.len); try std.testing.expect(!loaded.history[0].assistant.execution.files[0].model_view_covers_full_file); const old_json = "{\"schema_version\":1,\"id\":\"old-inline\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{\"kind\":\"assistant\",\"user\":{\"text\":\"u\",\"images\":[]},\"assistant\":\"a\",\"execution\":{\"schema_version\":1,\"tool_steps\":[" ++ "{\"assistant\":null,\"tool_calls\":[{\"id\":\"c\",\"name\":\"read_file\",\"arguments_json\":\"{}\",\"provider_result\":null}],\"tool_results\":[" ++ "{\"tool_call_id\":\"c\",\"tool_name\":\"read_file\",\"status\":\"success\",\"output\":\"inline\",\"output_bytes\":6,\"stored_output_bytes\":6,\"truncated\":false,\"provider_native\":false,\"created_at_ms\":1}" ++ "]}],\"files\":[]}}" ++ "]}"; var old_loaded = try parseStoredSession(TestStoredSession, alloc, old_json); defer old_loaded.deinit(alloc); const old_result = old_loaded.history[0].assistant.execution.tool_steps[0].tool_results[0]; try std.testing.expect(old_result.output_handle == null); try std.testing.expect(old_result.preview == null); } test "interactive session round trips total web search requests and loads absent value as zero" { const alloc = std.testing.allocator; const json = try renderSessionJson(alloc, "search-usage", 1, 2, session.ConversationLanguage.literal("en"), "/tmp/workspace", &.{}, .{ .web_search_requests = 7, }); defer alloc.free(json); var loaded = try parseStoredSession(TestStoredSession, alloc, json); defer loaded.deinit(alloc); try std.testing.expectEqual(@as(u64, 7), loaded.total_web_search_requests); var old_loaded = try parseStoredSession( TestStoredSession, alloc, "{\"schema_version\":1,\"id\":\"old\",\"created_at_ms\":1,\"updated_at_ms\":2,\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":0,\"history\":[]}", ); defer old_loaded.deinit(alloc); try std.testing.expectEqual(@as(u64, 0), old_loaded.total_web_search_requests); } test "legacy summary streaming stops before writer-produced history payload" { const session_store = @import("session_store.zig"); const alloc = std.testing.allocator; const large_text = try alloc.alloc(u8, 128 * 1024); defer alloc.free(large_text); @memset(large_text, 'h'); const history = [_]session.HistoryTurn{.{ .assistant = .{ .user = .{ .text = @constCast("prompt") }, .assistant = large_text, } }}; const json = try renderSessionJson( alloc, "stream-summary", 10, 20, session.ConversationLanguage.literal("en"), "/tmp/workspace", &history, .{}, ); defer alloc.free(json); const history_start = std.mem.indexOf(u8, json, "\"history\":[") orelse return error.TestExpectedEqual; const summary_prefix_end = history_start + "\"history\":".len; var source = std.Io.Reader.fixed(json[0..summary_prefix_end]); var summary = try parseLegacySummaryStreaming( session_store.SessionSummary, alloc, &source, ); defer summary.deinit(alloc); try std.testing.expectEqualStrings("stream-summary", summary.id); try std.testing.expectEqual(@as(usize, 1), summary.history_len); } test "legacy summary streaming structurally skips externally reordered history" { const session_store = @import("session_store.zig"); const alloc = std.testing.allocator; const json = "{\"schema_version\":2,\"history\":[{\"kind\":\"compacted_summary\",\"summary\":\"x\"," ++ "\"removed_turn_count\":1,\"compaction_count\":1}],\"id\":\"reordered\"," ++ "\"created_at_ms\":1,\"updated_at_ms\":2,\"workspace_root\":null," ++ "\"conversation_language\":\"en\",\"history_len\":1}"; var source = std.Io.Reader.fixed(json); var summary = try parseLegacySummaryStreaming( session_store.SessionSummary, alloc, &source, ); defer summary.deinit(alloc); try std.testing.expectEqualStrings("reordered", summary.id); try std.testing.expect(summary.workspace_root == null); try std.testing.expectEqual(@as(usize, 1), summary.history_len); } test "schema v2 legacy exact reader migrates background ownership to inert history" { const alloc = std.testing.allocator; const json = "{\"schema_version\":2,\"id\":\"legacy-v2\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":2,\"history\":[" ++ "{\"kind\":\"assistant\",\"user\":{\"text\":\"u\",\"images\":[{\"id\":7,\"path\":\"/tmp/a.png\",\"media_type\":\"image/png\"}]}," ++ "\"assistant\":\"a\",\"execution\":{\"schema_version\":1,\"tool_steps\":[],\"files\":[]}}," ++ "{\"kind\":\"background_command\",\"user\":{\"text\":\"run\",\"images\":[]},\"log_path\":\"/tmp/log\"," ++ "\"expect_url\":false,\"url\":null,\"background_record_id\":\"00112233445566778899aabbccddeeff\"}" ++ "]}"; var loaded = try parseLegacyExact(TestStoredSession, alloc, json); defer loaded.deinit(alloc); try std.testing.expectEqual(@as(usize, 7), loaded.history[0].assistant.user.images[0].id); try std.testing.expect(loaded.history[1] == .assistant); try std.testing.expect(std.mem.find( u8, loaded.history[1].assistant.assistant, "fx no longer owns or controls this process", ) != null); try std.testing.expect(std.mem.find( u8, loaded.history[1].assistant.assistant, "former log=/tmp/log", ) != null); } test "legacy exact reader rejects durable byte objects in string fields" { const alloc = std.testing.allocator; const snapshots = [_][]const u8{ "{\"schema_version\":1,\"id\":\"legacy-v1\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{\"kind\":\"compacted_summary\",\"summary\":{\"encoding\":\"base64\",\"data\":\"/w==\"}," ++ "\"removed_turn_count\":1,\"compaction_count\":1}]}", "{\"schema_version\":2,\"id\":\"legacy-v2\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":1,\"history\":[" ++ "{\"kind\":\"compacted_summary\",\"summary\":{\"encoding\":\"base64\",\"data\":\"/w==\"}," ++ "\"removed_turn_count\":1,\"compaction_count\":1}]}", }; for (snapshots) |snapshot| { try std.testing.expectError( error.InvalidSessionFormat, parseLegacyExact(TestStoredSession, alloc, snapshot), ); } } test "legacy exact and summary readers preserve resource exhaustion" { const session_store = @import("session_store.zig"); const json = "{\"schema_version\":1,\"id\":\"oom\",\"created_at_ms\":1,\"updated_at_ms\":2," ++ "\"workspace_root\":\"/tmp/workspace\",\"conversation_language\":\"en\",\"history_len\":0,\"history\":[]}"; var exact_failing = std.testing.FailingAllocator.init( std.testing.allocator, .{ .fail_index = 0 }, ); try std.testing.expectError( error.OutOfMemory, parseLegacyExact(TestStoredSession, exact_failing.allocator(), json), ); var source = std.Io.Reader.fixed(json); var summary_failing = std.testing.FailingAllocator.init( std.testing.allocator, .{ .fail_index = 0 }, ); try std.testing.expectError( error.OutOfMemory, parseLegacySummaryStreaming( session_store.SessionSummary, summary_failing.allocator(), &source, ), ); } test { _ = @import("session_codec.zig"); _ = @import("session_event.zig"); _ = @import("session_projection.zig"); }