summaryrefslogtreecommitdiff
path: root/libpanto/src/session_manager.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/session_manager.zig')
-rw-r--r--libpanto/src/session_manager.zig202
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());
+}