diff options
| author | t <t@tjp.lol> | 2026-06-05 11:27:33 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-05 11:27:43 -0600 |
| commit | 03ac69658517a0054d2a573d1e1b60c1bfaaf977 (patch) | |
| tree | c38c4b009c98e96497db7482d04f803bf2ee78f9 /libpanto/src/session_manager.zig | |
| parent | 003908344336cc13e74618291aa9f3af137f030f (diff) | |
Add pluggable SessionStore interface with FSJSONL + Null backends
Introduce a neutral persistence seam (session_store.zig) following the
{ptr, vtable} shape of the other libpanto seams. The interface traffics in
DiskMessage so non-JSONL backends (e.g. Postgres) can implement it.
- session_store.zig: SessionStore vtable (appendMessages, loadConversation,
sessionId, activeModel), LoadedSession{conversation, dangling_user},
re-exported disk types, and an FSJSONLStore alias.
- session_manager.zig: add store() wrapper + loadConversation() with
dangling trailing-user detection (excluded from the rebuilt conversation,
returned as dangling_user).
- null_store.zig: no-op backend, stateless singleton.
- root.zig: export session_store + null_store.
Phase 1+2 of docs/pluggable-session-store.md. Behavior-preserving; CLI not
yet rewired.
Diffstat (limited to 'libpanto/src/session_manager.zig')
| -rw-r--r-- | libpanto/src/session_manager.zig | 202 |
1 files changed, 198 insertions, 4 deletions
diff --git a/libpanto/src/session_manager.zig b/libpanto/src/session_manager.zig index bbc996b..b659506 100644 --- a/libpanto/src/session_manager.zig +++ b/libpanto/src/session_manager.zig @@ -31,6 +31,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"); pub const SessionHeader = session_mod.SessionHeader; pub const SessionEntry = session_mod.SessionEntry; @@ -714,8 +715,110 @@ pub const SessionManager = struct { } return conv; } + + // ============================================================================= + // SessionStore interface + // ============================================================================= + + /// Reconstruct a linear `Conversation` plus an optional dangling + /// trailing user prompt (a user entry with no following assistant — + /// e.g. a crash/quit right after submission). The returned conversation + /// **excludes** that dangling user turn; its text is returned as + /// `dangling_user` (owned by `alloc`) so a resumed agent never + /// auto-sends it. + pub fn loadConversation( + self: *const SessionManager, + alloc: Allocator, + ) !session_store_mod.LoadedSession { + // Detect a dangling trailing user entry: the last message entry is + // a user message and nothing follows it. (Entries are linear; the + // last entry is the leaf.) + const items = self.entries.items; + var dangling: ?[]const u8 = null; + var stop_at: usize = items.len; + if (items.len > 0) { + const last = items[items.len - 1]; + switch (last) { + .message => |me| { + if (me.message.role == .user) { + dangling = try extractUserText(alloc, me.message); + // Only treat it as dangling (and exclude it) when we + // could actually recover prompt text; otherwise keep + // it in the conversation rather than silently drop. + if (dangling != null) stop_at = items.len - 1; + } + }, + } + } + errdefer if (dangling) |d| alloc.free(d); + + var conv = conversation_mod.Conversation.init(alloc); + errdefer conv.deinit(); + for (items[0..stop_at]) |entry| { + switch (entry) { + .message => |me| try appendMessageToConv(&conv, alloc, me.message), + } + } + + return .{ .conversation = conv, .dangling_user = dangling }; + } + + fn appendMessagesVT( + ctx: *anyopaque, + messages: []session_store_mod.DiskMessage, + providers: []const ?[]const u8, + models: []const ?[]const u8, + ) anyerror!void { + const self: *SessionManager = @ptrCast(@alignCast(ctx)); + try self.appendMessagesAtomic(messages, providers, models); + } + + fn loadConversationVT( + ctx: *anyopaque, + alloc: Allocator, + ) anyerror!session_store_mod.LoadedSession { + const self: *const SessionManager = @ptrCast(@alignCast(ctx)); + return self.loadConversation(alloc); + } + + fn sessionIdVT(ctx: *anyopaque) []const u8 { + const self: *const SessionManager = @ptrCast(@alignCast(ctx)); + return self.getSessionId(); + } + + fn activeModelVT(ctx: *anyopaque) ?session_store_mod.ActiveModel { + const self: *const SessionManager = @ptrCast(@alignCast(ctx)); + const am = self.activeModel() orelse return null; + return .{ .provider = am.provider, .model = am.model }; + } + + const store_vtable: session_store_mod.SessionStore.VTable = .{ + .appendMessages = appendMessagesVT, + .loadConversation = loadConversationVT, + .sessionId = sessionIdVT, + .activeModel = activeModelVT, + }; + + /// Wrap this concrete manager as a neutral `SessionStore`. The returned + /// store borrows `self`; `self` must outlive it. + pub fn store(self: *SessionManager) session_store_mod.SessionStore { + return .{ .ptr = self, .vtable = &store_vtable }; + } }; +/// Best-effort extraction of plain prompt text from a user `DiskMessage`. +/// Returns null if the message carries no plain text block (e.g. it is a +/// tool-result-only user message, which is never a "dangling prompt"). +/// Caller owns the returned slice. +fn extractUserText(alloc: Allocator, msg: DiskMessage) !?[]const u8 { + for (msg.content) |block| { + if (block == .text) { + return try alloc.dupe(u8, block.text.text); + } + } + return null; +} + fn appendMessageToConv( conv: *conversation_mod.Conversation, allocator: Allocator, @@ -1507,14 +1610,14 @@ test "SessionManager: tool-use round-trip — assistant w/ ToolUse, user w/ Tool _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); // Assistant emits a ToolUse. - const a1 = try testing.allocator.alloc(DiskContentBlock, 2); - a1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "checking...") } }; - a1[1] = .{ .tool_use = .{ + const am1 = try testing.allocator.alloc(DiskContentBlock, 2); + am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "checking...") } }; + am1[1] = .{ .tool_use = .{ .id = try testing.allocator.dupe(u8, "tool_abc"), .name = try testing.allocator.dupe(u8, "bash"), .input = try testing.allocator.dupe(u8, "{\"command\":\"ls\"}"), } }; - _ = try mgr.appendMessage(.{ .role = .assistant, .content = a1 }, null, null); + _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null); // Tool-result user message. const tr = try testing.allocator.alloc(DiskContentBlock, 1); @@ -1690,3 +1793,94 @@ test "compaction summary round-trips through persist + resume + rebuild" { try testing.expectEqual(@as(usize, 1), sys_blocks.items.len); try testing.expectEqualStrings("you are helpful", sys_blocks.items[0]); } + +test "loadConversation: trailing user prompt is split out as dangling, excluded from conversation" { + 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 mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + // A completed user/assistant round, then a dangling user prompt with no + // following assistant (simulating a crash right after submission). + const um1 = try testing.allocator.alloc(DiskContentBlock, 1); + um1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = um1 }, "openai", "gpt-4o"); + const am1 = try testing.allocator.alloc(DiskContentBlock, 1); + am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null); + const um2 = try testing.allocator.alloc(DiskContentBlock, 1); + um2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "what's 2+2?") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = um2 }, "openai", "gpt-4o"); + + var loaded = try mgr.loadConversation(testing.allocator); + defer loaded.deinit(testing.allocator); + + // Dangling prompt recovered. + try testing.expect(loaded.dangling_user != null); + try testing.expectEqualStrings("what's 2+2?", loaded.dangling_user.?); + // Conversation excludes the dangling user turn: [user hi, assistant hello]. + try testing.expectEqual(@as(usize, 2), loaded.conversation.messages.items.len); + try testing.expectEqual(conversation_mod.MessageRole.user, loaded.conversation.messages.items[0].role); + try testing.expectEqual(conversation_mod.MessageRole.assistant, loaded.conversation.messages.items[1].role); +} + +test "loadConversation: no dangling prompt when log ends with assistant" { + 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 mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + const um1 = try testing.allocator.alloc(DiskContentBlock, 1); + um1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = um1 }, "openai", "gpt-4o"); + const am1 = try testing.allocator.alloc(DiskContentBlock, 1); + am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null); + + var loaded = try mgr.loadConversation(testing.allocator); + defer loaded.deinit(testing.allocator); + try testing.expect(loaded.dangling_user == null); + try testing.expectEqual(@as(usize, 2), loaded.conversation.messages.items.len); +} + +test "store(): SessionStore wrapper delegates to the concrete manager" { + 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 mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + const s = mgr.store(); + + // Append a user+assistant batch through the neutral interface. + var messages = try testing.allocator.alloc(DiskMessage, 2); + defer testing.allocator.free(messages); + const uc = try testing.allocator.alloc(DiskContentBlock, 1); + uc[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } }; + messages[0] = .{ .role = .user, .content = uc }; + const ac = try testing.allocator.alloc(DiskContentBlock, 1); + ac[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } }; + messages[1] = .{ .role = .assistant, .content = ac }; + + try s.appendMessages(messages, &.{ "openai", null }, &.{ "gpt-4o", null }); + try testing.expectEqual(@as(usize, 2), mgr.getEntries().len); + + const am = s.activeModel().?; + try testing.expectEqualStrings("openai", am.provider); + try testing.expectEqualStrings("gpt-4o", am.model); + try testing.expectEqualStrings(mgr.getSessionId(), s.sessionId()); +} |
