mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
Spill committed edit snapshots into the session result store
Large before/after file snapshots no longer inline into the conversation event log or the recovery checkpoint. Edits past the inline budget persist one content-addressed artifact under tool-results and the durable records carry its handle. Resume reloads the snapshots for the full diff view and falls back to the compact preview when an artifact is missing or corrupt. Older session logs remain readable.
This commit is contained in:
10 files changed
+922
-14
No files matched your search
@@ -180,6 +180,11 @@ fn dupeCommittedFilePresentation(
|
||||
.call_id = try durableIdentifier(alloc, id.call_id),
|
||||
} else null;
|
||||
errdefer if (lifecycle_id) |id| alloc.free(@constCast(id.call_id));
|
||||
const content_handle = if (presentation.content_handle) |handle|
|
||||
try alloc.dupe(u8, handle)
|
||||
else
|
||||
null;
|
||||
errdefer if (content_handle) |handle| alloc.free(handle);
|
||||
return .{
|
||||
.path = path,
|
||||
.kind = presentation.kind,
|
||||
@@ -190,6 +195,7 @@ fn dupeCommittedFilePresentation(
|
||||
.previous_content = previous_content,
|
||||
.after_content = after_content,
|
||||
.lifecycle_id = lifecycle_id,
|
||||
.content_handle = content_handle,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4554,6 +4554,93 @@ pub fn Runtime(comptime App: type) type {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Loads spilled previous/after snapshots for a replayed presentation.
|
||||
/// A missing or corrupt artifact degrades to the inline preview, never
|
||||
/// to a resume failure; the full-diff expansion is simply absent.
|
||||
fn loadResumeDiffContent(
|
||||
app: *App,
|
||||
call_id: []const u8,
|
||||
handle: []const u8,
|
||||
) ?result_store.DiffContentPack {
|
||||
if (comptime !@hasField(App, "session_persistence")) return null;
|
||||
const loaded = if (app.session_persistence.writable) |*value|
|
||||
value
|
||||
else
|
||||
return null;
|
||||
if (loaded.childCapability()) |capability| {
|
||||
const pack = result_store.loadDiffContentManaged(app.alloc, capability, handle) catch |err| {
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"resume diff content load failed call_id={s} err={s}; rendering preview only",
|
||||
.{ call_id, @errorName(err) },
|
||||
);
|
||||
return null;
|
||||
};
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"event=diff_content_loaded call_id={s} previous_bytes={d} after_bytes={d} route=managed",
|
||||
.{
|
||||
call_id,
|
||||
if (pack.previous_content) |content| content.len else 0,
|
||||
if (pack.after_content) |content| content.len else 0,
|
||||
},
|
||||
);
|
||||
return pack;
|
||||
} else |_| {}
|
||||
// Sessions without an attached child capability read through the
|
||||
// same tool-results route the persistence writer spilled into.
|
||||
const base = io_mod.dirRealpathAlloc(app.alloc, loaded.log.dir.dir, ".") catch |err| {
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"resume diff content route unavailable call_id={s} err={s}",
|
||||
.{ call_id, @errorName(err) },
|
||||
);
|
||||
return null;
|
||||
};
|
||||
defer app.alloc.free(base);
|
||||
const result_dir = std.fs.path.join(app.alloc, &.{ base, "tool-results" }) catch |err| {
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"resume diff content route unavailable call_id={s} err={s}",
|
||||
.{ call_id, @errorName(err) },
|
||||
);
|
||||
return null;
|
||||
};
|
||||
defer app.alloc.free(result_dir);
|
||||
var capability = session_child_store.SessionChildCapability.initLegacyRoute(
|
||||
app.alloc,
|
||||
result_dir,
|
||||
.tool_results,
|
||||
.read_only,
|
||||
) catch |err| {
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"resume diff content route unavailable call_id={s} err={s}",
|
||||
.{ call_id, @errorName(err) },
|
||||
);
|
||||
return null;
|
||||
};
|
||||
defer capability.deinit();
|
||||
const pack = result_store.loadDiffContentManaged(app.alloc, &capability, handle) catch |err| {
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"resume diff content load failed call_id={s} err={s}; rendering preview only",
|
||||
.{ call_id, @errorName(err) },
|
||||
);
|
||||
return null;
|
||||
};
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"event=diff_content_loaded call_id={s} previous_bytes={d} after_bytes={d} route=legacy",
|
||||
.{
|
||||
call_id,
|
||||
if (pack.previous_content) |content| content.len else 0,
|
||||
if (pack.after_content) |content| content.len else 0,
|
||||
},
|
||||
);
|
||||
return pack;
|
||||
}
|
||||
|
||||
fn writeCommittedFilePresentation(
|
||||
app: *App,
|
||||
sink: anytype,
|
||||
@@ -4567,7 +4654,20 @@ pub fn Runtime(comptime App: type) type {
|
||||
!@hasDecl(App, "preparePersistedFileDiff") or
|
||||
!@hasDecl(App, "registerAndEmitDiffBlock")) return false;
|
||||
|
||||
const payload = app.preparePersistedFileDiff(presentation) catch |err| {
|
||||
var resolved = presentation;
|
||||
var content_pack: result_store.DiffContentPack = .{};
|
||||
defer content_pack.deinit(app.alloc);
|
||||
if (presentation.content_handle) |handle| {
|
||||
if (presentation.previous_content == null or presentation.after_content == null) {
|
||||
if (loadResumeDiffContent(app, result.tool_call_id, handle)) |pack| {
|
||||
content_pack = pack;
|
||||
resolved.previous_content = content_pack.previous_content;
|
||||
resolved.after_content = content_pack.after_content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = app.preparePersistedFileDiff(resolved) catch |err| {
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"resume committed file presentation unavailable call_id={s} err={s}",
|
||||
|
||||
@@ -38,7 +38,10 @@ const private_file_permissions = std.Io.File.Permissions.fromMode(0o600);
|
||||
pub const file_name = "history-cache.bin";
|
||||
|
||||
const magic = "fx-history-cache\x1a\n"; // 18 bytes; all offsets use magic.len
|
||||
const format_version: u16 = 1;
|
||||
// Version 2: ConversationToolResult carries CommittedFilePresentation
|
||||
// .content_handle (spilled diff snapshots). The schema binding on
|
||||
// conversation_schema_version already discards version-1 caches.
|
||||
const format_version: u16 = 2;
|
||||
/// Frames carry 32-bit lengths; a single log line can be large (embedded tool
|
||||
/// output), so the cap stays generous. Anything larger is a corrupt cache.
|
||||
const max_frame_bytes: u32 = 1024 * 1024 * 1024;
|
||||
|
||||
@@ -313,6 +313,126 @@ pub fn storeLargeResultManaged(
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// Bound on one serialized diff content pack (previous + after snapshots of
|
||||
/// a committed file edit). Oversized packs stay inline and the enclosing
|
||||
/// record's own size guard covers them.
|
||||
pub const diff_content_max_bytes: usize = 2 * stored_text_max_bytes;
|
||||
|
||||
/// Restored previous/after contents of a committed file presentation.
|
||||
/// The caller owns both slices; release with deinit.
|
||||
pub const DiffContentPack = struct {
|
||||
previous_content: ?[]u8 = null,
|
||||
after_content: ?[]u8 = null,
|
||||
|
||||
pub fn deinit(self: *DiffContentPack, alloc: Allocator) void {
|
||||
if (self.previous_content) |content| alloc.free(content);
|
||||
if (self.after_content) |content| alloc.free(content);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn isDiffContentHandle(handle: []const u8) bool {
|
||||
return std.mem.startsWith(u8, handle, "diff-") and
|
||||
std.mem.endsWith(u8, handle, ".json");
|
||||
}
|
||||
|
||||
/// Persists one edit's previous/after snapshots as a single content-addressed
|
||||
/// artifact in the session result store and returns its handle. Keeping the
|
||||
/// snapshots out of the event log and recovery checkpoint keeps those records
|
||||
/// small; readers resolve the handle on demand.
|
||||
pub fn storeDiffContent(
|
||||
alloc: Allocator,
|
||||
result_dir: []const u8,
|
||||
tool_call_id: []const u8,
|
||||
previous_content: ?[]const u8,
|
||||
after_content: ?[]const u8,
|
||||
) ![]u8 {
|
||||
const pack = try encodeDiffContentPack(alloc, previous_content, after_content);
|
||||
defer alloc.free(pack);
|
||||
const handle = try makeDiffContentHandle(alloc, tool_call_id, pack);
|
||||
errdefer alloc.free(handle);
|
||||
try storeLargeResultAtHandle(alloc, result_dir, handle, pack);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// Loads and digest-verifies a pack written by storeDiffContent.
|
||||
pub fn loadDiffContentManaged(
|
||||
alloc: Allocator,
|
||||
capability: *session_child_store.SessionChildCapability,
|
||||
handle: []const u8,
|
||||
) !DiffContentPack {
|
||||
if (!isDiffContentHandle(handle)) return error.InvalidResultHandle;
|
||||
var reader = try openReaderManaged(alloc, capability, handle);
|
||||
defer reader.deinit();
|
||||
if (reader.size > diff_content_max_bytes) return error.ResultTooLarge;
|
||||
const bytes = try reader.readPage(alloc, 0, diff_content_max_bytes);
|
||||
defer alloc.free(bytes);
|
||||
var digest: [32]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
|
||||
if (!artifact_digest.handleMatchesContentDigest(handle, ".json", digest)) {
|
||||
return error.DiffContentArtifactChanged;
|
||||
}
|
||||
const Wire = struct {
|
||||
previous_content: ?[]const u8 = null,
|
||||
after_content: ?[]const u8 = null,
|
||||
};
|
||||
var parsed = std.json.parseFromSlice(Wire, alloc, bytes, .{
|
||||
.allocate = .alloc_always,
|
||||
.max_value_len = diff_content_max_bytes,
|
||||
}) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return error.InvalidDiffContentArtifact,
|
||||
};
|
||||
defer parsed.deinit();
|
||||
var pack: DiffContentPack = .{};
|
||||
errdefer pack.deinit(alloc);
|
||||
if (parsed.value.previous_content) |content| {
|
||||
pack.previous_content = try alloc.dupe(u8, content);
|
||||
}
|
||||
if (parsed.value.after_content) |content| {
|
||||
pack.after_content = try alloc.dupe(u8, content);
|
||||
}
|
||||
return pack;
|
||||
}
|
||||
|
||||
fn encodeDiffContentPack(
|
||||
alloc: Allocator,
|
||||
previous_content: ?[]const u8,
|
||||
after_content: ?[]const u8,
|
||||
) ![]u8 {
|
||||
var out: std.Io.Writer.Allocating = .init(alloc);
|
||||
errdefer out.deinit();
|
||||
try out.writer.writeAll("{\"previous_content\":");
|
||||
try writeOptionalPackString(&out.writer, previous_content);
|
||||
try out.writer.writeAll(",\"after_content\":");
|
||||
try writeOptionalPackString(&out.writer, after_content);
|
||||
try out.writer.writeByte('}');
|
||||
if (out.written().len > diff_content_max_bytes) return error.DiffContentTooLarge;
|
||||
return try out.toOwnedSlice();
|
||||
}
|
||||
|
||||
fn writeOptionalPackString(writer: *std.Io.Writer, value: ?[]const u8) !void {
|
||||
if (value) |text| {
|
||||
try std.json.Stringify.value(text, .{}, writer);
|
||||
} else {
|
||||
try writer.writeAll("null");
|
||||
}
|
||||
}
|
||||
|
||||
fn makeDiffContentHandle(alloc: Allocator, tool_call_id: []const u8, pack: []const u8) ![]u8 {
|
||||
var content_digest: [32]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(pack, &content_digest, .{});
|
||||
const content_hex = std.fmt.bytesToHex(content_digest[0..8].*, .lower);
|
||||
var call_digest: [32]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(tool_call_id, &call_digest, .{});
|
||||
const call_hex = std.fmt.bytesToHex(call_digest[0..8].*, .lower);
|
||||
return std.fmt.allocPrint(
|
||||
alloc,
|
||||
"diff-{s}-{s}.json",
|
||||
.{ &call_hex, &content_hex },
|
||||
);
|
||||
}
|
||||
|
||||
fn storeLargeResultAtHandleManaged(
|
||||
alloc: Allocator,
|
||||
capability: *session_child_store.SessionChildCapability,
|
||||
@@ -590,6 +710,53 @@ test "large result storage creates stable handle and bounded preview" {
|
||||
try std.testing.expectEqualStrings(prepared.memory.output_handle.?, again.memory.output_handle.?);
|
||||
}
|
||||
|
||||
test "diff content packs round trip, bound, and reject tampering" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir = try io_mod.dirRealpathAlloc(alloc, tmp.dir, ".");
|
||||
defer alloc.free(dir);
|
||||
|
||||
const handle = try storeDiffContent(alloc, dir, "call-edit-1", "line one\nline two\n", "line one\nline 2\n");
|
||||
defer alloc.free(handle);
|
||||
try std.testing.expect(isDiffContentHandle(handle));
|
||||
try std.testing.expect(!isDiffContentHandle("result-shell.txt"));
|
||||
|
||||
var capability = try session_child_store.SessionChildCapability.initLegacyRoute(
|
||||
alloc,
|
||||
dir,
|
||||
.tool_results,
|
||||
.writable,
|
||||
);
|
||||
defer capability.deinit();
|
||||
var pack = try loadDiffContentManaged(alloc, &capability, handle);
|
||||
defer pack.deinit(alloc);
|
||||
try std.testing.expectEqualStrings("line one\nline two\n", pack.previous_content.?);
|
||||
try std.testing.expectEqualStrings("line one\nline 2\n", pack.after_content.?);
|
||||
|
||||
// Null snapshots survive the round trip.
|
||||
const partial = try storeDiffContent(alloc, dir, "call-edit-2", null, "created\n");
|
||||
defer alloc.free(partial);
|
||||
var partial_pack = try loadDiffContentManaged(alloc, &capability, partial);
|
||||
defer partial_pack.deinit(alloc);
|
||||
try std.testing.expect(partial_pack.previous_content == null);
|
||||
try std.testing.expectEqualStrings("created\n", partial_pack.after_content.?);
|
||||
|
||||
// A tampered artifact fails the content digest check.
|
||||
var rewritten = try capability.atomicReplace(alloc, .tool_results, handle, "{\"previous_content\":\"forged\",\"after_content\":null}");
|
||||
rewritten.deinit(alloc);
|
||||
try std.testing.expectError(
|
||||
error.DiffContentArtifactChanged,
|
||||
loadDiffContentManaged(alloc, &capability, handle),
|
||||
);
|
||||
|
||||
// Handles from other artifact families are rejected before any read.
|
||||
try std.testing.expectError(
|
||||
error.InvalidResultHandle,
|
||||
loadDiffContentManaged(alloc, &capability, "result-shell.txt"),
|
||||
);
|
||||
}
|
||||
|
||||
test "saved preparation externalizes small results" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
|
||||
@@ -1740,6 +1740,8 @@ fn writeCommittedFilePresentation(
|
||||
} else {
|
||||
try writer.writeAll("null");
|
||||
}
|
||||
try writer.writeAll(",\"content_handle\":");
|
||||
try writeOptionalDurableBytes(writer, presentation.content_handle);
|
||||
try writer.writeByte('}');
|
||||
}
|
||||
|
||||
@@ -2459,6 +2461,7 @@ fn parseCommittedFilePresentation(
|
||||
"previous_content",
|
||||
"after_content",
|
||||
"lifecycle_id",
|
||||
"content_handle",
|
||||
});
|
||||
const path = try parseRequiredDurableBytes(alloc, object, "path");
|
||||
errdefer mem_utils.free(alloc, path);
|
||||
@@ -2482,6 +2485,12 @@ fn parseCommittedFilePresentation(
|
||||
object.get("lifecycle_id") orelse return error.InvalidSessionFormat,
|
||||
);
|
||||
errdefer if (lifecycle_id) |id| mem_utils.free(alloc, id.call_id);
|
||||
// Older records predate externalized diff snapshots and carry no key.
|
||||
const content_handle = if (object.get("content_handle")) |handle_value|
|
||||
try parseOptionalDurableBytes(alloc, handle_value)
|
||||
else
|
||||
null;
|
||||
errdefer if (content_handle) |handle| mem_utils.free(alloc, handle);
|
||||
return .{
|
||||
.path = path,
|
||||
.kind = std.meta.stringToEnum(
|
||||
@@ -2495,6 +2504,7 @@ fn parseCommittedFilePresentation(
|
||||
.previous_content = previous_content,
|
||||
.after_content = after_content,
|
||||
.lifecycle_id = lifecycle_id,
|
||||
.content_handle = content_handle,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,17 @@ pub const raw_state_chunk_bytes: usize = 4 * 1024 * 1024;
|
||||
pub const Identifier = [16]u8;
|
||||
pub const Digest = [Sha256.digest_length]u8;
|
||||
|
||||
pub const conversation_schema_version: u8 = 2;
|
||||
// Version 3 adds CommittedFilePresentation.content_handle on tool_result
|
||||
// frames: previous/after snapshots may live in a result-store artifact with
|
||||
// only the handle inline. Version 1 and 2 frames never carry the field and
|
||||
// remain readable.
|
||||
pub const conversation_schema_version: u8 = 3;
|
||||
|
||||
/// Every historical frame version a reader must still accept. Writers always
|
||||
/// emit conversation_schema_version.
|
||||
pub fn supportedConversationSchema(version: u8) bool {
|
||||
return version >= 1 and version <= conversation_schema_version;
|
||||
}
|
||||
pub const max_conversation_text_bytes: usize = event_frame_max_bytes;
|
||||
pub const max_conversation_identity_bytes: usize = types.ConversationIdentity.max_bytes;
|
||||
pub const max_conversation_arguments_bytes: usize = event_frame_max_bytes;
|
||||
@@ -208,7 +218,7 @@ pub fn validateConversationTransition(
|
||||
state: ConversationStateView,
|
||||
envelope: ConversationEnvelope,
|
||||
) ConversationTransitionError!void {
|
||||
if (envelope.schema_version != 1 and envelope.schema_version != conversation_schema_version) {
|
||||
if (!supportedConversationSchema(envelope.schema_version)) {
|
||||
return error.UnsupportedConversationSchema;
|
||||
}
|
||||
const expected_seq = std.math.add(u64, state.last_seq, 1) catch
|
||||
@@ -345,6 +355,9 @@ fn validateConversationEventShape(event: ConversationEvent, schema_version: u8)
|
||||
if (presentation.lifecycle_id) |lifecycle_id| {
|
||||
try validateConversationIdentity(lifecycle_id.call_id);
|
||||
}
|
||||
if (presentation.content_handle) |handle| {
|
||||
try validateConversationIdentity(handle);
|
||||
}
|
||||
}
|
||||
if ((result.command_replay_ref == null) !=
|
||||
(result.command_replay_bytes == null))
|
||||
@@ -454,7 +467,7 @@ pub fn decodeConversationFrame(
|
||||
else => return error.InvalidConversationFrame,
|
||||
};
|
||||
errdefer parsed.deinit();
|
||||
if ((parsed.value.schema_version != 1 and parsed.value.schema_version != conversation_schema_version) or
|
||||
if (!supportedConversationSchema(parsed.value.schema_version) or
|
||||
parsed.value.seq == 0 or
|
||||
parsed.value.timestamp_ms < 0)
|
||||
{
|
||||
@@ -3338,10 +3351,10 @@ test "conversation cancellation provenance preserves ordinary frame bytes" {
|
||||
});
|
||||
defer alloc.free(encoded);
|
||||
try std.testing.expectEqualStrings(
|
||||
"{\"schema_version\":2,\"seq\":1,\"timestamp_ms\":1,\"event\":{\"interrupted\":{\"reason\":\"cancelled\",\"partial_text\":null,\"command_replay_ref\":null,\"command_replay_bytes\":null,\"command_artifact_ref\":null,\"files\":[],\"turn_summary\":null}}}\n",
|
||||
"{\"schema_version\":3,\"seq\":1,\"timestamp_ms\":1,\"event\":{\"interrupted\":{\"reason\":\"cancelled\",\"partial_text\":null,\"command_replay_ref\":null,\"command_replay_bytes\":null,\"command_artifact_ref\":null,\"files\":[],\"turn_summary\":null}}}\n",
|
||||
encoded,
|
||||
);
|
||||
for ([_]u8{ 1, 2 }) |version| {
|
||||
for ([_]u8{ 1, 2, 3 }) |version| {
|
||||
for (std.enums.values(session.InterruptedTerminalReason)) |reason| {
|
||||
const old = try std.fmt.allocPrint(alloc, "{{\"schema_version\":{d},\"seq\":1,\"timestamp_ms\":1,\"event\":{{\"interrupted\":{{\"reason\":\"{s}\"}}}}}}\n", .{ version, @tagName(reason) });
|
||||
defer alloc.free(old);
|
||||
@@ -3529,6 +3542,48 @@ test "review feedback conversation metadata defaults old records and omits false
|
||||
}
|
||||
}
|
||||
|
||||
test "conversation frame carries a spilled diff content handle" {
|
||||
const alloc = std.testing.allocator;
|
||||
const encoded = try encodeConversationFrame(alloc, .{
|
||||
.seq = 3,
|
||||
.timestamp_ms = 7,
|
||||
.event = .{ .tool_result = .{
|
||||
.call_id = "call-edit",
|
||||
.tool_name = "edit_file",
|
||||
.status = .success,
|
||||
.artifact_ref = "result-edit_file-aaaa.txt",
|
||||
.stored_bytes = 6,
|
||||
.completeness = .complete,
|
||||
.committed_file_presentation = .{
|
||||
.path = "src/a.zig",
|
||||
.kind = .edited,
|
||||
.lines = &.{.{ .kind = .addition, .new_line = 1, .text = "new line" }},
|
||||
.additions = 1,
|
||||
.deletions = 0,
|
||||
.truncated = false,
|
||||
.lifecycle_id = .{ .turn_id = 1, .call_id = "call-edit" },
|
||||
.content_handle = "diff-0123456789abcdef-0123456789abcdef.json",
|
||||
},
|
||||
} },
|
||||
});
|
||||
defer alloc.free(encoded);
|
||||
try std.testing.expect(std.mem.find(u8, encoded, "\"content_handle\":\"diff-") != null);
|
||||
|
||||
var decoded = try decodeConversationFrame(alloc, encoded);
|
||||
defer decoded.deinit();
|
||||
const presentation = decoded.value.event.tool_result.committed_file_presentation.?;
|
||||
try std.testing.expectEqualStrings("diff-0123456789abcdef-0123456789abcdef.json", presentation.content_handle.?);
|
||||
try std.testing.expect(presentation.previous_content == null);
|
||||
}
|
||||
|
||||
test "conversation frame rejects an oversized diff content handle" {
|
||||
const alloc = std.testing.allocator;
|
||||
const oversized = "diff-0123456789abcdef-0123456789abcdef.json" ++ ("x" ** 300);
|
||||
const frame = try std.fmt.allocPrint(alloc, "{{\"schema_version\":3,\"seq\":1,\"timestamp_ms\":1,\"event\":{{\"tool_result\":{{\"call_id\":\"call-edit\",\"tool_name\":\"edit_file\",\"status\":\"success\",\"artifact_ref\":\"result.txt\",\"stored_bytes\":0,\"completeness\":\"complete\",\"committed_file_presentation\":{{\"path\":\"src/a.zig\",\"kind\":\"edited\",\"lines\":[],\"additions\":1,\"deletions\":1,\"truncated\":false,\"previous_content\":null,\"after_content\":null,\"lifecycle_id\":null,\"content_handle\":\"{s}\"}}}}}}}}}}\n", .{oversized});
|
||||
defer alloc.free(frame);
|
||||
try std.testing.expectError(error.InvalidConversationFrame, decodeConversationFrame(alloc, frame));
|
||||
}
|
||||
|
||||
test "conversation frame round trips an external tool result reference" {
|
||||
const alloc = std.testing.allocator;
|
||||
const encoded = try encodeConversationFrame(alloc, .{
|
||||
@@ -3572,7 +3627,7 @@ test "conversation frame reads old records and preserves new reasoning-only assi
|
||||
defer alloc.free(encoded);
|
||||
var current = try decodeConversationFrame(alloc, encoded);
|
||||
defer current.deinit();
|
||||
try std.testing.expectEqual(@as(u8, 2), current.value.schema_version);
|
||||
try std.testing.expectEqual(conversation_schema_version, current.value.schema_version);
|
||||
try std.testing.expectEqualStrings(replay.parts_json, current.value.event.assistant.provider_replay.?.parts_json);
|
||||
try validateConversationTransition(.{ .last_seq = 1 }, current.value);
|
||||
}
|
||||
|
||||
@@ -260,6 +260,8 @@ fn writeCommittedFilePresentationJson(
|
||||
} else {
|
||||
try writer.writeAll("null");
|
||||
}
|
||||
try writer.writeAll(",\"content_handle\":");
|
||||
try writeOptionalStringJson(writer, presentation.content_handle);
|
||||
try writer.writeByte('}');
|
||||
}
|
||||
|
||||
@@ -1005,6 +1007,8 @@ fn parseCommittedFilePresentation(
|
||||
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));
|
||||
const content_handle = try optionalStringDup(alloc, object.get("content_handle"));
|
||||
errdefer if (content_handle) |handle| mem_utils.free(alloc, handle);
|
||||
return .{
|
||||
.path = path,
|
||||
.kind = try parseCommittedFilePresentationKind(try requireString(object, "kind")),
|
||||
@@ -1015,6 +1019,7 @@ fn parseCommittedFilePresentation(
|
||||
.previous_content = previous_content,
|
||||
.after_content = after_content,
|
||||
.lifecycle_id = lifecycle_id,
|
||||
.content_handle = content_handle,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -769,6 +769,137 @@ fn writeConversationRecoveryState(
|
||||
/// carries their content-addressed handle instead.
|
||||
const recovery_checkpoint_inline_output_max_bytes: usize = result_store.preview_bytes;
|
||||
|
||||
/// Inline budget for committed-edit previous/after snapshots inside durable
|
||||
/// records (conversation log, recovery checkpoint). Larger contents move to a
|
||||
/// result-store artifact and the record carries its content-addressed handle.
|
||||
const file_presentation_inline_max_bytes: usize = result_store.preview_bytes;
|
||||
|
||||
fn presentationNeedsSpill(presentation: types.CommittedFilePresentation) bool {
|
||||
if (presentation.content_handle != null) return false;
|
||||
const previous_bytes = if (presentation.previous_content) |content| content.len else 0;
|
||||
const after_bytes = if (presentation.after_content) |content| content.len else 0;
|
||||
return previous_bytes +| after_bytes > file_presentation_inline_max_bytes;
|
||||
}
|
||||
|
||||
/// Spills one result's oversized diff snapshots into the session result
|
||||
/// store, returning a copy whose presentation carries only the handle. Store
|
||||
/// or path failures keep the presentation inline so a hiccup cannot block the
|
||||
/// enclosing commit. `result_dir` is resolved lazily and reused across calls.
|
||||
fn spillResultFilePresentation(
|
||||
alloc: Allocator,
|
||||
dir: *io_mod.VerifiedDir,
|
||||
result_dir: *?[]const u8,
|
||||
result: types.PersistedToolResult,
|
||||
) !types.PersistedToolResult {
|
||||
const presentation = result.committed_file_presentation orelse return result;
|
||||
if (!presentationNeedsSpill(presentation)) return result;
|
||||
if (result_dir.* == null) {
|
||||
const base = io_mod.dirRealpathAlloc(alloc, dir.dir, ".") catch |err| {
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"event=diff_content_spill_unavailable err={s}; keeping presentation inline",
|
||||
.{@errorName(err)},
|
||||
);
|
||||
return result;
|
||||
};
|
||||
result_dir.* = std.fs.path.join(alloc, &.{ base, "tool-results" }) catch |err| {
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"event=diff_content_spill_unavailable err={s}; keeping presentation inline",
|
||||
.{@errorName(err)},
|
||||
);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
const handle = result_store.storeDiffContent(
|
||||
alloc,
|
||||
result_dir.*.?,
|
||||
result.tool_call_id,
|
||||
presentation.previous_content,
|
||||
presentation.after_content,
|
||||
) catch |err| {
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"event=diff_content_spill_failed call_id={s} err={s}; keeping presentation inline",
|
||||
.{ result.tool_call_id, @errorName(err) },
|
||||
);
|
||||
return result;
|
||||
};
|
||||
debug_trace.logf(
|
||||
"session",
|
||||
"event=diff_content_spilled call_id={s} previous_bytes={d} after_bytes={d}",
|
||||
.{
|
||||
result.tool_call_id,
|
||||
if (presentation.previous_content) |content| content.len else 0,
|
||||
if (presentation.after_content) |content| content.len else 0,
|
||||
},
|
||||
);
|
||||
var projected = result;
|
||||
var projected_presentation = presentation;
|
||||
projected_presentation.previous_content = null;
|
||||
projected_presentation.after_content = null;
|
||||
projected_presentation.content_handle = handle;
|
||||
projected.committed_file_presentation = projected_presentation;
|
||||
return projected;
|
||||
}
|
||||
|
||||
/// Returns a copy of `turn` whose oversized committed-edit snapshots are
|
||||
/// spilled to the session result store. Untouched payloads keep borrowing the
|
||||
/// source turn; spilled handles and projected slices are allocated from
|
||||
/// `alloc`, which the caller releases in bulk after the append. Only OOM
|
||||
/// propagates; store failures keep the offending presentation inline.
|
||||
fn spillHistoryTurnFilePresentations(
|
||||
alloc: Allocator,
|
||||
dir: *io_mod.VerifiedDir,
|
||||
turn: types.HistoryTurn,
|
||||
) !types.HistoryTurn {
|
||||
const execution = switch (turn) {
|
||||
.assistant => |entry| entry.execution,
|
||||
.interrupted => |entry| entry.execution,
|
||||
.compacted_summary => return turn,
|
||||
};
|
||||
var needs_spill = false;
|
||||
scan: for (execution.tool_steps) |step| {
|
||||
for (step.tool_results) |result| {
|
||||
if (result.committed_file_presentation) |presentation| {
|
||||
if (presentationNeedsSpill(presentation)) {
|
||||
needs_spill = true;
|
||||
break :scan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!needs_spill) return turn;
|
||||
|
||||
var result_dir: ?[]const u8 = null;
|
||||
const steps = try alloc.alloc(types.ToolExecutionStep, execution.tool_steps.len);
|
||||
for (execution.tool_steps, 0..) |step, index| {
|
||||
steps[index] = step;
|
||||
var step_needs_spill = false;
|
||||
for (step.tool_results) |result| {
|
||||
if (result.committed_file_presentation) |presentation| {
|
||||
if (presentationNeedsSpill(presentation)) {
|
||||
step_needs_spill = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!step_needs_spill) continue;
|
||||
const results = try alloc.alloc(types.PersistedToolResult, step.tool_results.len);
|
||||
for (step.tool_results, 0..) |result, result_index| {
|
||||
results[result_index] = try spillResultFilePresentation(alloc, dir, &result_dir, result);
|
||||
}
|
||||
steps[index].tool_results = results;
|
||||
}
|
||||
var projected = turn;
|
||||
switch (projected) {
|
||||
.assistant => |*entry| entry.execution.tool_steps = steps,
|
||||
.interrupted => |*entry| entry.execution.tool_steps = steps,
|
||||
.compacted_summary => {},
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
/// Returns a copy of the checkpoint whose oversized tool-result outputs are
|
||||
/// spilled to the session result store and replaced by their handle. The
|
||||
/// source checkpoint is borrowed; the projection owns only its own
|
||||
@@ -781,13 +912,19 @@ fn spillRecoveryCheckpointOutputs(
|
||||
checkpoint: session_codec.RecoveryCheckpoint,
|
||||
) !session_codec.RecoveryCheckpoint {
|
||||
var spills = false;
|
||||
for (checkpoint.execution.tool_steps) |step| {
|
||||
scan: for (checkpoint.execution.tool_steps) |step| {
|
||||
for (step.tool_results) |result| {
|
||||
if (result.output_handle != null or
|
||||
result.output.len > recovery_checkpoint_inline_output_max_bytes)
|
||||
{
|
||||
spills = true;
|
||||
break;
|
||||
break :scan;
|
||||
}
|
||||
if (result.committed_file_presentation) |presentation| {
|
||||
if (presentationNeedsSpill(presentation)) {
|
||||
spills = true;
|
||||
break :scan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -805,6 +942,12 @@ fn spillRecoveryCheckpointOutputs(
|
||||
results_changed = true;
|
||||
break;
|
||||
}
|
||||
if (result.committed_file_presentation) |presentation| {
|
||||
if (presentationNeedsSpill(presentation)) {
|
||||
results_changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!results_changed) continue;
|
||||
const results = try alloc.alloc(types.PersistedToolResult, step.tool_results.len);
|
||||
@@ -859,7 +1002,7 @@ fn projectRecoveryResult(
|
||||
}
|
||||
projected.output = "";
|
||||
}
|
||||
return projected;
|
||||
return spillResultFilePresentation(alloc, dir, result_dir, projected);
|
||||
}
|
||||
|
||||
fn loadConversationPermissionState(
|
||||
@@ -1523,7 +1666,14 @@ fn openConversationWritableSession(
|
||||
// prompt can replace the recovery slot. Sequence binding makes a
|
||||
// crash after this append safe even if sidecar cleanup did not run.
|
||||
const timestamp = io_mod.milliTimestamp();
|
||||
try conversation_writer.appendHistoryTurn(alloc, timestamp, checkpoint.interruptedTurn());
|
||||
var spill_arena = std.heap.ArenaAllocator.init(alloc);
|
||||
defer mem_utils.deinit_arena(spill_arena);
|
||||
const restored_turn = try spillHistoryTurnFilePresentations(
|
||||
spill_arena.allocator(),
|
||||
&writable.dir,
|
||||
checkpoint.interruptedTurn(),
|
||||
);
|
||||
try conversation_writer.appendHistoryTurn(alloc, timestamp, restored_turn);
|
||||
writeConversationRecoveryState(alloc, &writable.dir, null, conversation_writer.last_seq) catch |err| {
|
||||
debug_trace.logf("session", "compaction source committed but recovery cleanup failed err={s}", .{@errorName(err)});
|
||||
};
|
||||
@@ -3122,11 +3272,21 @@ pub const LoadedWritableSession = struct {
|
||||
null;
|
||||
defer if (prepared) |turn| session.freeHistoryTurn(alloc, turn);
|
||||
if (prepared) |*turn| try self.prepareHistoryTurnForCommit(alloc, turn);
|
||||
var spill_arena = std.heap.ArenaAllocator.init(alloc);
|
||||
defer mem_utils.deinit_arena(spill_arena);
|
||||
const append_prefix: ?types.AssistantHistoryTurn = if (prepared) |turn| blk: {
|
||||
const projected = try spillHistoryTurnFilePresentations(
|
||||
spill_arena.allocator(),
|
||||
&self.log.dir,
|
||||
turn,
|
||||
);
|
||||
break :blk projected.assistant;
|
||||
} else null;
|
||||
try self.conversation_writer.appendContextCompaction(
|
||||
alloc,
|
||||
timestamp_ms,
|
||||
summary,
|
||||
if (prepared) |turn| turn.assistant else null,
|
||||
append_prefix,
|
||||
retained_from,
|
||||
);
|
||||
if (prepared) |turn| self.writeFirstConversationTitle(alloc, turn);
|
||||
@@ -3149,10 +3309,17 @@ pub const LoadedWritableSession = struct {
|
||||
else
|
||||
null;
|
||||
errdefer if (work_id) |value| mem_utils.free(alloc, value);
|
||||
var spill_arena = std.heap.ArenaAllocator.init(alloc);
|
||||
defer mem_utils.deinit_arena(spill_arena);
|
||||
const turn = try spillHistoryTurnFilePresentations(
|
||||
spill_arena.allocator(),
|
||||
&self.log.dir,
|
||||
payload.turn,
|
||||
);
|
||||
try self.conversation_writer.appendHistoryTurn(
|
||||
alloc,
|
||||
timestamp_ms,
|
||||
payload.turn,
|
||||
turn,
|
||||
);
|
||||
self.writeFirstConversationTitle(alloc, payload.turn);
|
||||
if (self.conversation_writer.failure) |err| return err;
|
||||
@@ -6073,6 +6240,261 @@ test "tool-result spill keeps an over-cap checkpoint persistable and resumable"
|
||||
}
|
||||
}
|
||||
|
||||
test "committed edit diff snapshots spill out of the conversation log" {
|
||||
const alloc = std.testing.allocator;
|
||||
var temp = try TempRoot.init(alloc);
|
||||
defer temp.deinit(alloc);
|
||||
var initial = try testState(alloc, "conversation-diff-spill", 10);
|
||||
defer initial.deinit(alloc);
|
||||
|
||||
const previous = try alloc.alloc(u8, 3000);
|
||||
defer alloc.free(previous);
|
||||
@memset(previous, 'o');
|
||||
@memcpy(previous[previous.len - 9 ..], "OLDMARKER");
|
||||
const after = try alloc.alloc(u8, 3000);
|
||||
defer alloc.free(after);
|
||||
@memset(after, 'n');
|
||||
@memcpy(after[after.len - 9 ..], "NEWMARKER");
|
||||
|
||||
var diff_lines = [_]types.CommittedFilePresentationLine{
|
||||
.{ .kind = .deletion, .old_line = 1, .text = @constCast("old line") },
|
||||
.{ .kind = .addition, .new_line = 1, .text = @constCast("new line") },
|
||||
};
|
||||
var calls = [_]types.ToolCall{.{ .id = "call-edit", .name = "edit_file", .arguments_json = "{\"path\":\"src/a.zig\"}" }};
|
||||
var results = [_]types.PersistedToolResult{.{
|
||||
.tool_call_id = @constCast("call-edit"),
|
||||
.tool_name = @constCast("edit_file"),
|
||||
.status = .success,
|
||||
.output = @constCast("edited"),
|
||||
.output_handle = @constCast("result-edit_file-aaaa1111.txt"),
|
||||
.output_bytes = 6,
|
||||
.stored_output_bytes = 6,
|
||||
.committed_file_presentation = .{
|
||||
.path = @constCast("src/a.zig"),
|
||||
.kind = .edited,
|
||||
.lines = &diff_lines,
|
||||
.additions = 1,
|
||||
.deletions = 1,
|
||||
.truncated = false,
|
||||
.previous_content = previous,
|
||||
.after_content = after,
|
||||
.lifecycle_id = .{ .turn_id = 1, .call_id = @constCast("call-edit") },
|
||||
},
|
||||
}};
|
||||
var steps = [_]types.ToolExecutionStep{.{ .tool_calls = &calls, .tool_results = &results }};
|
||||
const turn = types.HistoryTurn{ .assistant = .{
|
||||
.user = .{ .text = @constCast("change the file") },
|
||||
.assistant = @constCast("done"),
|
||||
.execution = .{ .tool_steps = &steps },
|
||||
} };
|
||||
|
||||
{
|
||||
var loaded = try temp.root.startConversationSession(alloc, initial, .{});
|
||||
defer loaded.deinit(alloc);
|
||||
_ = try loaded.appendEvent(alloc, .{ .history_turn_committed = .{
|
||||
.conversation_language = .literal("en"),
|
||||
.total_input_tokens = 1,
|
||||
.total_output_tokens = 1,
|
||||
.turn = turn,
|
||||
} }, 20);
|
||||
|
||||
// The log frame references the artifact; the snapshots stay out of it.
|
||||
const log_bytes = try readManagedFileAlloc(alloc, &loaded.log.dir, events_file, 1024 * 1024);
|
||||
defer alloc.free(log_bytes);
|
||||
try std.testing.expect(std.mem.find(u8, log_bytes, "\"content_handle\":\"diff-") != null);
|
||||
try std.testing.expect(std.mem.find(u8, log_bytes, "OLDMARKER") == null);
|
||||
try std.testing.expect(std.mem.find(u8, log_bytes, "NEWMARKER") == null);
|
||||
}
|
||||
|
||||
// Resume restores the handle, not the inline contents.
|
||||
var resumed = try temp.root.resumeForWrite(alloc, initial.id, .{});
|
||||
defer resumed.deinit(alloc);
|
||||
const restored = resumed.state.history[0].assistant.execution.tool_steps[0].tool_results[0];
|
||||
const presentation = restored.committed_file_presentation orelse
|
||||
return error.TestExpectedPresentation;
|
||||
const handle = presentation.content_handle orelse return error.TestExpectedHandle;
|
||||
try std.testing.expect(result_store.isDiffContentHandle(handle));
|
||||
try std.testing.expect(presentation.previous_content == null);
|
||||
try std.testing.expect(presentation.after_content == null);
|
||||
try std.testing.expectEqual(@as(usize, 2), presentation.lines.len);
|
||||
|
||||
// The artifact resolves back to the exact snapshots.
|
||||
const session_base = try io_mod.dirRealpathAlloc(alloc, resumed.log.dir.dir, ".");
|
||||
defer alloc.free(session_base);
|
||||
const result_dir = try std.fs.path.join(alloc, &.{ session_base, "tool-results" });
|
||||
defer alloc.free(result_dir);
|
||||
var capability = try session_child_store.SessionChildCapability.initLegacyRoute(
|
||||
alloc,
|
||||
result_dir,
|
||||
.tool_results,
|
||||
.read_only,
|
||||
);
|
||||
defer capability.deinit();
|
||||
var pack = try result_store.loadDiffContentManaged(alloc, &capability, handle);
|
||||
defer pack.deinit(alloc);
|
||||
try std.testing.expectEqualStrings(previous, pack.previous_content.?);
|
||||
try std.testing.expectEqualStrings(after, pack.after_content.?);
|
||||
}
|
||||
|
||||
test "small committed edit snapshots stay inline in the conversation log" {
|
||||
const alloc = std.testing.allocator;
|
||||
var temp = try TempRoot.init(alloc);
|
||||
defer temp.deinit(alloc);
|
||||
var initial = try testState(alloc, "conversation-diff-inline", 10);
|
||||
defer initial.deinit(alloc);
|
||||
|
||||
var diff_lines = [_]types.CommittedFilePresentationLine{
|
||||
.{ .kind = .addition, .new_line = 1, .text = @constCast("new line") },
|
||||
};
|
||||
var calls = [_]types.ToolCall{.{ .id = "call-edit", .name = "edit_file", .arguments_json = "{\"path\":\"src/a.zig\"}" }};
|
||||
var results = [_]types.PersistedToolResult{.{
|
||||
.tool_call_id = @constCast("call-edit"),
|
||||
.tool_name = @constCast("edit_file"),
|
||||
.status = .success,
|
||||
.output = @constCast("edited"),
|
||||
.output_handle = @constCast("result-edit_file-bbbb2222.txt"),
|
||||
.output_bytes = 6,
|
||||
.stored_output_bytes = 6,
|
||||
.committed_file_presentation = .{
|
||||
.path = @constCast("src/a.zig"),
|
||||
.kind = .edited,
|
||||
.lines = &diff_lines,
|
||||
.additions = 1,
|
||||
.deletions = 1,
|
||||
.truncated = false,
|
||||
.previous_content = @constCast("small old"),
|
||||
.after_content = @constCast("small new"),
|
||||
.lifecycle_id = .{ .turn_id = 1, .call_id = @constCast("call-edit") },
|
||||
},
|
||||
}};
|
||||
var steps = [_]types.ToolExecutionStep{.{ .tool_calls = &calls, .tool_results = &results }};
|
||||
const turn = types.HistoryTurn{ .assistant = .{
|
||||
.user = .{ .text = @constCast("change the file") },
|
||||
.assistant = @constCast("done"),
|
||||
.execution = .{ .tool_steps = &steps },
|
||||
} };
|
||||
|
||||
{
|
||||
var loaded = try temp.root.startConversationSession(alloc, initial, .{});
|
||||
defer loaded.deinit(alloc);
|
||||
_ = try loaded.appendEvent(alloc, .{ .history_turn_committed = .{
|
||||
.conversation_language = .literal("en"),
|
||||
.total_input_tokens = 1,
|
||||
.total_output_tokens = 1,
|
||||
.turn = turn,
|
||||
} }, 20);
|
||||
|
||||
const log_bytes = try readManagedFileAlloc(alloc, &loaded.log.dir, events_file, 1024 * 1024);
|
||||
defer alloc.free(log_bytes);
|
||||
try std.testing.expect(std.mem.find(u8, log_bytes, "small old") != null);
|
||||
try std.testing.expect(std.mem.find(u8, log_bytes, "\"content_handle\":null") != null);
|
||||
}
|
||||
|
||||
var resumed = try temp.root.resumeForWrite(alloc, initial.id, .{});
|
||||
defer resumed.deinit(alloc);
|
||||
const presentation = resumed.state.history[0].assistant.execution.tool_steps[0].tool_results[0].committed_file_presentation orelse
|
||||
return error.TestExpectedPresentation;
|
||||
try std.testing.expect(presentation.content_handle == null);
|
||||
try std.testing.expectEqualStrings("small old", presentation.previous_content.?);
|
||||
try std.testing.expectEqualStrings("small new", presentation.after_content.?);
|
||||
}
|
||||
|
||||
test "recovery checkpoint spills diff snapshots into the result store" {
|
||||
const alloc = std.testing.allocator;
|
||||
var temp = try TempRoot.init(alloc);
|
||||
defer temp.deinit(alloc);
|
||||
var initial = try testState(alloc, "conversation-recovery-diff-spill", 10);
|
||||
defer initial.deinit(alloc);
|
||||
|
||||
const previous = try alloc.alloc(u8, 3000);
|
||||
defer alloc.free(previous);
|
||||
@memset(previous, 'o');
|
||||
@memcpy(previous[previous.len - 9 ..], "OLDMARKER");
|
||||
const after = try alloc.alloc(u8, 3000);
|
||||
defer alloc.free(after);
|
||||
@memset(after, 'n');
|
||||
@memcpy(after[after.len - 9 ..], "NEWMARKER");
|
||||
|
||||
var diff_lines = [_]types.CommittedFilePresentationLine{
|
||||
.{ .kind = .addition, .new_line = 1, .text = @constCast("new line") },
|
||||
};
|
||||
var calls = [_]types.ToolCall{.{ .id = "call-edit", .name = "edit_file", .arguments_json = "{}" }};
|
||||
var results = [_]types.PersistedToolResult{.{
|
||||
.tool_call_id = @constCast("call-edit"),
|
||||
.tool_name = @constCast("edit_file"),
|
||||
.status = .success,
|
||||
.output = @constCast("edited"),
|
||||
.output_bytes = 6,
|
||||
.stored_output_bytes = 6,
|
||||
.committed_file_presentation = .{
|
||||
.path = @constCast("src/a.zig"),
|
||||
.kind = .edited,
|
||||
.lines = &diff_lines,
|
||||
.additions = 1,
|
||||
.deletions = 1,
|
||||
.truncated = false,
|
||||
.previous_content = previous,
|
||||
.after_content = after,
|
||||
.lifecycle_id = .{ .turn_id = 7, .call_id = @constCast("call-edit") },
|
||||
},
|
||||
}};
|
||||
var steps = [_]types.ToolExecutionStep{.{ .tool_calls = &calls, .tool_results = &results }};
|
||||
const checkpoint = session_codec.RecoveryCheckpoint{
|
||||
.turn_id = 7,
|
||||
.user = .{ .text = @constCast("continue the request") },
|
||||
.assistant_source = @constCast("partial"),
|
||||
.execution = .{ .tool_steps = &steps },
|
||||
.cause = .network_interrupted,
|
||||
.action = .retrying_request,
|
||||
.authority = .{ .provider = .gateway, .model = @constCast("test/model") },
|
||||
.requested_fast_mode = false,
|
||||
.fast_mode = false,
|
||||
.max_provider_attempts = 3,
|
||||
.consumed_provider_attempts = 1,
|
||||
};
|
||||
{
|
||||
var loaded = try temp.root.startConversationSession(alloc, initial, .{});
|
||||
defer loaded.deinit(alloc);
|
||||
_ = try loaded.appendEvent(alloc, .{
|
||||
.recovery_checkpoint_set = .{ .checkpoint = checkpoint },
|
||||
}, 20);
|
||||
const bytes = try readManagedFileAlloc(
|
||||
alloc,
|
||||
&loaded.log.dir,
|
||||
recovery_checkpoint_file,
|
||||
session_codec.max_recovery_checkpoint_bytes + 128,
|
||||
);
|
||||
defer alloc.free(bytes);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, "\"content_handle\":\"diff-") != null);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, "OLDMARKER") == null);
|
||||
try std.testing.expect(std.mem.find(u8, bytes, "NEWMARKER") == null);
|
||||
}
|
||||
{
|
||||
var resumed = try temp.root.resumeForWrite(alloc, initial.id, .{});
|
||||
defer resumed.deinit(alloc);
|
||||
const restored = resumed.state.recovery_checkpoint.?.execution.tool_steps[0].tool_results[0];
|
||||
const presentation = restored.committed_file_presentation orelse
|
||||
return error.TestExpectedPresentation;
|
||||
const handle = presentation.content_handle orelse return error.TestExpectedHandle;
|
||||
try std.testing.expect(presentation.previous_content == null);
|
||||
const session_base = try io_mod.dirRealpathAlloc(alloc, resumed.log.dir.dir, ".");
|
||||
defer alloc.free(session_base);
|
||||
const result_dir = try std.fs.path.join(alloc, &.{ session_base, "tool-results" });
|
||||
defer alloc.free(result_dir);
|
||||
var capability = try session_child_store.SessionChildCapability.initLegacyRoute(
|
||||
alloc,
|
||||
result_dir,
|
||||
.tool_results,
|
||||
.read_only,
|
||||
);
|
||||
defer capability.deinit();
|
||||
var pack = try result_store.loadDiffContentManaged(alloc, &capability, handle);
|
||||
defer pack.deinit(alloc);
|
||||
try std.testing.expectEqualStrings(previous, pack.previous_content.?);
|
||||
try std.testing.expectEqualStrings(after, pack.after_content.?);
|
||||
}
|
||||
}
|
||||
|
||||
test "recovery checkpoint spill failure keeps the result inline" {
|
||||
const alloc = std.testing.allocator;
|
||||
var temp = try TempRoot.init(alloc);
|
||||
|
||||
@@ -1011,6 +1011,12 @@ pub const CommittedFilePresentation = struct {
|
||||
previous_content: ?[]const u8 = null,
|
||||
after_content: ?[]const u8 = null,
|
||||
lifecycle_id: ?ToolLifecycleId = null,
|
||||
/// Content-addressed artifact handle holding the previous/after content
|
||||
/// pack in the session result store. When present, the durable record
|
||||
/// omits the inline contents and readers load them through the handle.
|
||||
/// In-memory live-turn presentations keep contents inline and leave this
|
||||
/// null; only the persistence projection sets it.
|
||||
content_handle: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const PersistedToolResult = struct {
|
||||
@@ -2857,6 +2863,11 @@ pub fn dupeCommittedFilePresentation(
|
||||
.call_id = try alloc.dupe(u8, id.call_id),
|
||||
} else null;
|
||||
errdefer if (lifecycle_id) |id| alloc.free(@constCast(id.call_id));
|
||||
const content_handle = if (presentation.content_handle) |handle|
|
||||
try alloc.dupe(u8, handle)
|
||||
else
|
||||
null;
|
||||
errdefer if (content_handle) |handle| alloc.free(handle);
|
||||
return .{
|
||||
.path = path,
|
||||
.kind = presentation.kind,
|
||||
@@ -2867,6 +2878,7 @@ pub fn dupeCommittedFilePresentation(
|
||||
.previous_content = previous_content,
|
||||
.after_content = after_content,
|
||||
.lifecycle_id = lifecycle_id,
|
||||
.content_handle = content_handle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2880,6 +2892,7 @@ pub fn freeCommittedFilePresentation(
|
||||
if (presentation.previous_content) |content| alloc.free(@constCast(content));
|
||||
if (presentation.after_content) |content| alloc.free(@constCast(content));
|
||||
if (presentation.lifecycle_id) |id| alloc.free(@constCast(id.call_id));
|
||||
if (presentation.content_handle) |handle| alloc.free(@constCast(handle));
|
||||
}
|
||||
|
||||
fn dupePersistedToolResult(alloc: std.mem.Allocator, result: PersistedToolResult) !PersistedToolResult {
|
||||
|
||||
@@ -6016,6 +6016,133 @@ test.skipIf(!tmuxAvailable())(
|
||||
90_000,
|
||||
);
|
||||
|
||||
test.skipIf(!tmuxAvailable())(
|
||||
"spilled diff snapshots stay out of events.jsonl and reload on resume",
|
||||
async () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-resume-diff-spill-")));
|
||||
const home = join(root, "home");
|
||||
const workspace = join(root, "workspace");
|
||||
const stderrPath = join(root, "stderr.log");
|
||||
mkdirSync(join(home, ".fx"), { recursive: true });
|
||||
mkdirSync(workspace);
|
||||
writeFileSync(
|
||||
join(home, ".fx", "settings.json"),
|
||||
JSON.stringify({ sandbox: "none", permission_mode: "ask", permission: {} }),
|
||||
);
|
||||
writeFileSync(stderrPath, "");
|
||||
|
||||
// 400 lines x ~32 chars is about 12.8 KB, past the 4 KB inline budget.
|
||||
const spilledLines = Array.from(
|
||||
{ length: 400 },
|
||||
(_, index) => `SPILLED_DIFF_LINE_${String(index + 1).padStart(3, "0")}_0123456789`,
|
||||
);
|
||||
const completion = "SPILLED_DIFF_COMPLETE";
|
||||
const gateway = startFakeGateway([
|
||||
fakeGatewayToolCall("write-spilled-diff", "write_file", {
|
||||
path: "spilled-diff.md",
|
||||
content: `${spilledLines.join("\n")}\n`,
|
||||
}),
|
||||
fakeGatewayFinalText(completion),
|
||||
]);
|
||||
let active: TmuxSession | null = null;
|
||||
try {
|
||||
active = await TmuxSession.create({
|
||||
cmd: FX_BIN,
|
||||
cwd: realpathSync(workspace),
|
||||
env: { ...gatewayEnv(home, gateway), FX_RECORD: join(root, "initial.fxtape") },
|
||||
stderrPath,
|
||||
width: 120,
|
||||
height: 32,
|
||||
});
|
||||
await active.waitForComposer(TIMEOUT);
|
||||
await active.sendText("Create the spilled diff fixture file.");
|
||||
await active.waitForText("Apply this change?", TIMEOUT);
|
||||
await active.sendKeys("1");
|
||||
await active.sendKeys("Enter");
|
||||
const live = await waitForScrollback(active, completion);
|
||||
expect(live).toContain("Wrote spilled-diff.md +400");
|
||||
expect(readFileSync(join(workspace, "spilled-diff.md"), "utf8")).toBe(
|
||||
`${spilledLines.join("\n")}\n`,
|
||||
);
|
||||
await active.sendText("/quit");
|
||||
expect(await active.waitForSessionEnd()).toBe(true);
|
||||
await active.kill();
|
||||
active = null;
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
|
||||
// The log frame carries only the artifact reference; the snapshots live
|
||||
// in the session result store.
|
||||
const sessionId = sessionIdFromHome(home);
|
||||
const sessionDir = join(home, ".fx", "sessions", sessionId);
|
||||
const eventsJsonl = readFileSync(join(sessionDir, "events.jsonl"), "utf8");
|
||||
const toolResultFrame = eventsJsonl
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line))
|
||||
.find((frame) => frame.event?.tool_result);
|
||||
const presentation = toolResultFrame?.event?.tool_result?.committed_file_presentation;
|
||||
expect(presentation).toBeDefined();
|
||||
expect(presentation.previous_content).toBeNull();
|
||||
expect(presentation.after_content).toBeNull();
|
||||
expect(presentation.content_handle).toMatch(/^diff-[0-9a-f]{16}-[0-9a-f]{16}\.json$/);
|
||||
// The elided middle of the file is nowhere in the result frame; only
|
||||
// the bounded preview lines stay inline. (The write arguments in the
|
||||
// tool_call frame legitimately carry the full content.)
|
||||
expect(JSON.stringify(toolResultFrame)).not.toContain("SPILLED_DIFF_LINE_200_");
|
||||
const storedArtifact = readFileSync(
|
||||
join(sessionDir, "tool-results", presentation.content_handle),
|
||||
"utf8",
|
||||
);
|
||||
expect(storedArtifact).toContain("SPILLED_DIFF_LINE_200_");
|
||||
expect(storedArtifact).toContain("SPILLED_DIFF_LINE_400");
|
||||
|
||||
// Resume renders the compact row from the inline preview lines and the
|
||||
// full diff reloads the spilled snapshots through the handle.
|
||||
const resumedGateway = startFakeGateway([]);
|
||||
try {
|
||||
active = await TmuxSession.create({
|
||||
cmd: `${FX_BIN} --resume-${sessionId}`,
|
||||
cwd: realpathSync(workspace),
|
||||
env: { ...gatewayEnv(home, resumedGateway), FX_RECORD: join(root, "resumed.fxtape") },
|
||||
stderrPath,
|
||||
width: 120,
|
||||
height: 32,
|
||||
});
|
||||
await active.waitForComposer(TIMEOUT);
|
||||
const resumed = await waitForScrollback(active, completion);
|
||||
expect(resumed).toContain("Wrote spilled-diff.md +400");
|
||||
expect(resumed).not.toContain("SPILLED_DIFF_LINE_001_");
|
||||
|
||||
await active.sendKeys("C-o");
|
||||
await active.waitForText("┃ full detail · ctrl+o close", TIMEOUT);
|
||||
await active.waitForText("SPILLED_DIFF_LINE_400", TIMEOUT);
|
||||
const full = await active.capturePane();
|
||||
expect(full).toContain("SPILLED_DIFF_LINE_400");
|
||||
await active.sendKeys("C-o");
|
||||
await active.waitForComposer(TIMEOUT);
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
await active.sendText("/quit");
|
||||
expect(await active.waitForSessionEnd()).toBe(true);
|
||||
await active.kill();
|
||||
active = null;
|
||||
} finally {
|
||||
if (active) {
|
||||
try {
|
||||
await active.sendText("/quit");
|
||||
} catch {}
|
||||
await active.kill();
|
||||
}
|
||||
resumedGateway.stop();
|
||||
}
|
||||
} finally {
|
||||
if (active) await active.kill();
|
||||
gateway.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
90_000,
|
||||
);
|
||||
|
||||
test.skipIf(!tmuxAvailable())(
|
||||
"command output folding survives flag and picker resume",
|
||||
async () => {
|
||||
|
||||
Reference in new issue
Block a user