diff options
Diffstat (limited to 'libpanto/src')
| -rw-r--r-- | libpanto/src/file_system_jsonl_store.zig | 98 | ||||
| -rw-r--r-- | libpanto/src/provider_openai_chat.zig | 52 |
2 files changed, 93 insertions, 57 deletions
diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig index 5b7f450..9bc9436 100644 --- a/libpanto/src/file_system_jsonl_store.zig +++ b/libpanto/src/file_system_jsonl_store.zig @@ -554,7 +554,11 @@ pub const SessionFile = struct { if (self.flushed) { try self.persistEntries(entries); } else { - try self.flushBufferedMany(entries); + // File is created on the first assistant message (see module docs). + for (messages) |m| if (m.role == .assistant) { + try self.flushBufferedMany(entries); + break; + }; } for (entries, 0..) |entry, i| { @@ -976,39 +980,6 @@ fn buildFileInfo( // Recent / resume helpers // ============================================================================= -/// Find the most recent session file in `session_dir`. Returns null if -/// none exist. Caller owns the returned path. -pub fn findMostRecentSession(allocator: Allocator, io: Io, session_dir: []const u8) !?[]u8 { - var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - defer dir.close(io); - - var best_name: ?[]u8 = null; - errdefer if (best_name) |b| allocator.free(b); - - var it = dir.iterate(); - while (try it.next(io)) |entry| { - if (entry.kind != .file) continue; - if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue; - if (best_name) |b| { - // Lexicographic compare. UUIDv7 filenames sort chronologically. - if (std.mem.order(u8, entry.name, b) == .gt) { - allocator.free(b); - best_name = try allocator.dupe(u8, entry.name); - } - } else { - best_name = try allocator.dupe(u8, entry.name); - } - } - - const name = best_name orelse return null; - defer allocator.free(name); - best_name = null; - return try std.fs.path.join(allocator, &.{ session_dir, name }); -} - /// Resolve a (possibly abbreviated) session id to a session file path /// within `session_dir`. Errors if no match or ambiguous prefix. pub fn resolveSessionId( @@ -1209,9 +1180,16 @@ pub const FileSystemJSONLStore = struct { fn latestVT(ctx: *anyopaque) anyerror!?session_store_mod.Session { const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx)); - const path = (try findMostRecentSession(self.allocator, self.io, self.dir)) orelse return null; - defer self.allocator.free(path); - return try self.sessionFromPath(path); + // Most-recently-*modified* wins, matching the `list()` sort order — + // not newest-created (lexicographic UUIDv7 filename). + const fis = try listSessions(self.allocator, self.io, self.dir, null); + defer { + for (fis) |fi| fi.deinit(self.allocator); + self.allocator.free(fis); + } + if (fis.len == 0) return null; + const info = try self.infoFromFileInfo(fis[0]); + return .{ .info = info, .store = self.store() }; } fn sessionFromPath(self: *FileSystemJSONLStore, path: []const u8) !?session_store_mod.Session { @@ -1436,25 +1414,25 @@ test "SessionFile: full flow — buffer, flush on assistant, append, resume" { ); defer mgr.deinit(); - // First append flushes the header + entry to disk immediately. + // System message buffers in memory — nothing on disk yet. const sys_blocks = try testing.allocator.alloc(StoredContentBlock, 1); sys_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "You are helpful.") } }; _ = try mgr.appendMessage( .{ .role = .system, .content = sys_blocks }, null, ); - try testing.expect(mgr.isFlushed()); + try testing.expect(!mgr.isFlushed()); - // User message — appended to the already-flushed file. + // User message — still buffered. const usr_blocks = try testing.allocator.alloc(StoredContentBlock, 1); usr_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi there") } }; _ = try mgr.appendMessage( .{ .role = .user, .content = usr_blocks }, oaStamp(), ); - try testing.expect(mgr.isFlushed()); + try testing.expect(!mgr.isFlushed()); - // Assistant message. + // Assistant message — first flush: header + all buffered entries. const a_blocks = try testing.allocator.alloc(StoredContentBlock, 1); a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; _ = try mgr.appendMessage( @@ -1698,7 +1676,7 @@ test "listSessions: returns most recent first, with counts" { try testing.expect(std.mem.order(u8, infos[0].modified, infos[1].modified) != .lt); } -test "findMostRecentSession: picks lexicographically greatest" { +test "latest: picks most-recently-modified, not newest-created" { const io = testing.io; var td = try TmpSessionDir.init(testing.allocator); @@ -1706,12 +1684,17 @@ test "findMostRecentSession: picks lexicographically greatest" { const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); defer testing.allocator.free(sessions); - // Pre-resolution before any sessions exist → null. - try testing.expect((try findMostRecentSession(testing.allocator, io, sessions)) == null); + var catalog = try FileSystemJSONLStore.init(testing.allocator, io, sessions); + defer catalog.deinit(); + const st = catalog.store(); - // Create two. - var second_file: ?[]u8 = null; - defer if (second_file) |s| testing.allocator.free(s); + // Before any sessions exist → null. + try testing.expect((try st.latest()) == null); + + // Create session A, then session B (newer id), then append to A again + // so A is the most recently *modified*. + var first_id: ?[]u8 = null; + defer if (first_id) |s| testing.allocator.free(s); for (0..2) |i| { var mgr = try SessionFile.init(testing.allocator, io, sessions, null); @@ -1721,14 +1704,23 @@ test "findMostRecentSession: picks lexicographically greatest" { _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp()); const a = try testing.allocator.alloc(StoredContentBlock, 1); a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; - _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null); - if (i == 1) second_file = try testing.allocator.dupe(u8, mgr.getSessionFile()); + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, oaStamp()); + if (i == 0) first_id = try testing.allocator.dupe(u8, mgr.header.id); io.sleep(.fromMilliseconds(2), .real) catch {}; } + { + const path = try resolveSessionId(testing.allocator, io, sessions, first_id.?); + defer testing.allocator.free(path); + var mgr = try SessionFile.open(testing.allocator, io, path); + defer mgr.deinit(); + const a = try testing.allocator.alloc(StoredContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, oaStamp()); + } - const found = (try findMostRecentSession(testing.allocator, io, sessions)).?; - defer testing.allocator.free(found); - try testing.expectEqualStrings(second_file.?, found); + var latest = (try st.latest()).?; + defer latest.info.deinit(testing.allocator); + try testing.expectEqualStrings(first_id.?, latest.info.id); } test "SessionFile: tool-use round-trip — assistant w/ ToolUse, user w/ ToolResult, assistant" { diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig index f653ff6..a05c7ba 100644 --- a/libpanto/src/provider_openai_chat.zig +++ b/libpanto/src/provider_openai_chat.zig @@ -674,23 +674,23 @@ fn handleEvent( try out.push(.{ .message_start = .assistant }); } - if (d.reasoning_content) |rc| { + if (d.reasoning_content) |rc| if (rc.len > 0) { if (!state.started) { state.started = true; try out.push(.{ .message_start = .assistant }); } try state.openBlock(.thinking, out); try state.appendDelta(out, rc); - } + }; - if (d.content) |c| { + if (d.content) |c| if (c.len > 0) { if (!state.started) { state.started = true; try out.push(.{ .message_start = .assistant }); } try state.openBlock(.text, out); try state.appendDelta(out, c); - } + }; if (d.tool_calls.len > 0) { if (!state.started) { @@ -909,6 +909,50 @@ test "openai_chat: omitted stream usage yields null on message_complete" { try testing.expectEqualStrings("msg_complete[usage:null]", found.?); } +test "openai_chat: empty content alongside reasoning does not split thinking block" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try addUserText(&conv, "think"); + + var rec = EventRecorder{ .allocator = allocator }; + defer rec.deinit(); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"reasoning_content":"rea","content":""}}]} + , + \\{"choices":[{"delta":{"reasoning_content":"son","content":""}}]} + , + \\{"choices":[{"delta":{"reasoning_content":"ing","content":""}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"stop"}]} + , + "[DONE]", + }; + try runStreamedTurn(allocator, &conv, &rec, &events); + + const expected = [_][]const u8{ + "msg_start", + "block_start[0]:Thinking", + "delta[0]:rea", + "delta[0]:son", + "delta[0]:ing", + "block_complete[0]", + "msg_complete[usage:null]", + }; + try testing.expectEqual(expected.len, rec.events.items.len); + for (expected, rec.events.items) |want, got| { + try testing.expectEqualStrings(want, got); + } + + const asst = conv.messages.items[1]; + try testing.expectEqual(@as(usize, 1), asst.content.items.len); + try testing.expectEqualStrings("reasoning", asst.content.items[0].Thinking.text.items); +} + test "fragmented tool_call id and name are reassembled" { // Lenient OpenAI-compatible providers occasionally split `id` and // `function.name` across multiple deltas instead of sending them whole |
