diff options
Diffstat (limited to 'libpanto/src/file_system_jsonl_store.zig')
| -rw-r--r-- | libpanto/src/file_system_jsonl_store.zig | 81 |
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); +} |
