summaryrefslogtreecommitdiff
path: root/libpanto/src/file_system_jsonl_store.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-18 14:15:55 -0600
committert <t@tjp.lol>2026-06-18 14:16:24 -0600
commit270cd00129551647e7c764a13836e03e0b2dc4e2 (patch)
treef939bbf999dd67d996b4025b03dbfe4abd30b9f4 /libpanto/src/file_system_jsonl_store.zig
parent7c2d8825660f73198149c0b2ce26166b16ba3532 (diff)
preserve signature origins across compactions
Diffstat (limited to 'libpanto/src/file_system_jsonl_store.zig')
-rw-r--r--libpanto/src/file_system_jsonl_store.zig81
1 files changed, 80 insertions, 1 deletions
diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig
index e47698c..50587f0 100644
--- a/libpanto/src/file_system_jsonl_store.zig
+++ b/libpanto/src/file_system_jsonl_store.zig
@@ -32,6 +32,7 @@ const Io = std.Io;
const session_mod = @import("session.zig");
const conversation_mod = @import("conversation.zig");
const session_store_mod = @import("session_store.zig");
+const turn_persist = @import("turn_persist.zig");
pub const SessionHeader = session_mod.SessionHeader;
pub const SessionEntry = session_mod.SessionEntry;
@@ -714,7 +715,6 @@ pub const SessionFile = struct {
}
return conv;
}
-
};
/// Best-effort extraction of plain prompt text from a user `StoredMessage`.
@@ -772,12 +772,31 @@ fn appendMessageToConv(
.user => .user,
.assistant => .assistant,
};
+ // Reconstruct the per-message producing identity from the wire stamp, so
+ // a later in-session compaction preserves it (rather than re-stamping the
+ // restated turn with the compaction model). Borrowed `stamp` slices are
+ // duped into the conversation allocator.
+ const identity: ?session_store_mod.WireIdentity = if (stamp) |st|
+ try conversation_mod.dupeWireIdentity(allocator, .{
+ .api_style = st.api_style,
+ .base_url = st.base_url,
+ .model = st.model,
+ .reasoning = st.reasoning,
+ .thinking = st.thinking,
+ .effort = st.effort,
+ .thinking_budget_tokens = st.thinking_budget_tokens,
+ .thinking_interleaved = st.thinking_interleaved,
+ })
+ else
+ null;
+ errdefer if (identity) |id| conversation_mod.freeWireIdentity(allocator, id);
// Carry the recorded usage forward so compaction can size the retention
// window after a session is reopened (it's null for user/system).
try conv.messages.append(allocator, .{
.role = role,
.content = content,
.usage = disk_msg.usage,
+ .identity = identity,
});
}
@@ -2147,3 +2166,63 @@ test "FileSystemJSONLStore load restores thinking signature origin from message
"claude-sonnet-4-20250514",
));
}
+
+test "persistTurn: a message's own identity overrides the uniform persist identity" {
+ // Regression for the compaction re-stamping bug: a kept-verbatim turn
+ // produced by model A must persist with A's wire identity even when the
+ // turn is persisted under model B (the compaction model). persistTurn
+ // stamps the uniform identity only on messages that don't already carry
+ // one; a message with its own identity keeps it.
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ var catalog = try FileSystemJSONLStore.init(testing.allocator, io, sessions);
+ defer catalog.deinit();
+ const store = catalog.store();
+ var sess = store.create();
+ defer sess.info.deinit(testing.allocator);
+
+ var conv = conversation_mod.Conversation.init(testing.allocator);
+ defer conv.deinit();
+ // User turn carries no identity (will be stamped with the persist id).
+ try addUserText(&conv, "ping");
+ // Assistant turn was produced by model A — stamp its identity directly,
+ // as a live turn / a reloaded turn would have.
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{
+ .text = try conversation_mod.textualBlockFromSlice(testing.allocator, "thinking..."),
+ .signature = try testing.allocator.dupe(u8, "sig123"),
+ } },
+ .{ .Text = try conversation_mod.textualBlockFromSlice(testing.allocator, "pong") },
+ }, null);
+ conv.messages.items[1].identity = try conversation_mod.dupeWireIdentity(testing.allocator, .{
+ .api_style = .anthropic_messages,
+ .base_url = "https://api.anthropic.com",
+ .model = "claude-sonnet-4-20250514",
+ });
+
+ // Persist under model B (a different, "compaction" identity).
+ const persist_id: session_store_mod.WireIdentity = .{
+ .api_style = .openai_chat,
+ .base_url = "https://api.openai.com/v1",
+ .model = "gpt-4o",
+ };
+ try turn_persist.persistTurn(testing.allocator, &sess, &conv, 0, persist_id, &.{});
+
+ // Reload: the user turn took model B's identity; the assistant turn kept
+ // model A's — so its thinking signature replays to A, not B.
+ var loaded = (try store.load(sess.info.id)).?;
+ defer loaded.deinit();
+
+ const asst_origin = loaded.messages.items[1].content.items[0].Thinking.signature_origin.?;
+ try testing.expect(asst_origin.matches(.anthropic_messages, "https://api.anthropic.com", "claude-sonnet-4-20250514"));
+ try testing.expect(!asst_origin.matches(.openai_chat, "https://api.openai.com/v1", "gpt-4o"));
+
+ const user_id = loaded.messages.items[0].identity.?;
+ try testing.expectEqual(session_store_mod.APIStyle.openai_chat, user_id.api_style);
+ try testing.expectEqualStrings("gpt-4o", user_id.model);
+}