summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-05 11:43:29 -0600
committert <t@tjp.lol>2026-06-05 11:43:43 -0600
commitccdaef8e682960ecadf73d3b2df70559a0baaf56 (patch)
tree2a6e455ca3359b68bf5cb5b83015054bd6e2eb6f /src
parent03ac69658517a0054d2a573d1e1b60c1bfaaf977 (diff)
Move session persistence into the Agent; collapse CLI plumbing
The Agent now owns its Conversation and a SessionStore, and persists everything it generates as turns progress. Embedders get persistence for free; the CLI no longer drives the session log. libpanto: - Agent.init takes a SessionStore + optional Conversation to adopt (resume path). Agent owns/tears down the conversation; turn-driving methods drop the *Conversation param and operate on self.conversation. - New Agent methods: submitUserMessage (adds+persists the user prompt immediately), addSystemMessage(text, mode), runStep persists the turn on every exit path (incl. partial turns on error), compactAndPersist for the explicit /compact path. Auto-compaction persists its window via runStep's tail. - turn_persist.zig: DiskMessage mapping moved out of the CLI; reads usage off Message.usage (no per-message-usage list). System mode rides on DiskMessage.mode, derived from the System block. - NullStore is now an allocator-bearing struct (frees consumed messages per the store-consumes-messages contract). - Tests: in-memory CapturingStore round-trip + NullStore turn test. panto CLI: - Delete src/session_persist.zig. REPL is submitUserMessage + runStep. - Reorder construction: store -> registry -> agent -> bootstrap -> seed/ reconcile system prompt through the agent -> load extensions. - command.Context drops conv/session_mgr; commands reach ctx.agent. - system_prompt seed/reconcile call agent.addSystemMessage. - CLIReceiver is display-only (per_message_usage removed). Phases 3-5 of docs/pluggable-session-store.md. Behavior preserved; full build + test suite green.
Diffstat (limited to 'src')
-rw-r--r--src/command.zig8
-rw-r--r--src/compaction.zig10
-rw-r--r--src/main.zig147
-rw-r--r--src/session_persist.zig153
-rw-r--r--src/system_prompt.zig114
5 files changed, 119 insertions, 313 deletions
diff --git a/src/command.zig b/src/command.zig
index 26acc7a..49ad5b8 100644
--- a/src/command.zig
+++ b/src/command.zig
@@ -32,12 +32,10 @@ const panto = @import("panto");
pub const Context = struct {
allocator: std.mem.Allocator,
- /// In-memory conversation the agent is driving.
- conv: *panto.conversation.Conversation,
- /// The active agent (model/provider/tooling snapshot).
+ /// The active agent. Owns the conversation (`agent.conversation`) and
+ /// the session store (`agent.session_store`); commands reach both
+ /// through it rather than holding separate pointers.
agent: *panto.agent.Agent,
- /// Session log to persist any changes a command makes.
- session_mgr: *panto.session_manager.SessionManager,
/// REPL output writer and its backing file writer (for flushing).
stdout: *std.Io.Writer,
diff --git a/src/compaction.zig b/src/compaction.zig
index a1f6e89..4ec5732 100644
--- a/src/compaction.zig
+++ b/src/compaction.zig
@@ -8,7 +8,6 @@
const std = @import("std");
const panto = @import("panto");
const command = @import("command.zig");
-const session_persist = @import("session_persist.zig");
/// Install the `/compact` command into `registry`.
pub fn register(registry: *command.Registry) !void {
@@ -23,20 +22,13 @@ pub fn register(registry: *command.Registry) !void {
fn run(args: []const u8, ctx: *command.Context) anyerror!void {
const extra: ?[]const u8 = if (args.len > 0) args else null;
- const res = ctx.agent.compact(ctx.conv, ctx.compaction_prompt, extra) catch |err| {
+ const res = ctx.agent.compactAndPersist(ctx.compaction_prompt, extra) catch |err| {
try ctx.stdout.print("\n[compaction failed: {s}]\n> ", .{@errorName(err)});
try ctx.stdout_file.flush();
return;
};
if (res.compacted) {
- try session_persist.persistCompaction(
- ctx.allocator,
- ctx.session_mgr,
- ctx.conv,
- ctx.provider_name,
- ctx.model_name,
- );
try ctx.stdout.print(
"\n[compacted: summarized {d} message(s), kept {d} recent turn(s)]\n> ",
.{ res.summarized_messages, res.kept_turns },
diff --git a/src/main.zig b/src/main.zig
index 61e78d5..9059a6f 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -14,7 +14,6 @@ const glob = @import("glob.zig");
const system_prompt = @import("system_prompt.zig");
const command = @import("command.zig");
const command_compaction = @import("compaction.zig");
-const session_persist = @import("session_persist.zig");
// Shorthand alias for the Lua C API. The bridge module owns the actual
// `@cImport`; we re-use it here so the smoke check uses identical types.
@@ -44,7 +43,6 @@ test {
_ = system_prompt;
_ = command;
_ = command_compaction;
- _ = session_persist;
}
const Receiver = panto.provider.Receiver;
@@ -55,34 +53,24 @@ const MessageRole = panto.conversation.MessageRole;
/// Receiver that prints streaming deltas to stdout. Thinking blocks are
/// dimmed with ANSI escape codes; text blocks render plain.
///
-/// Also captures one `?Usage` per assistant response during a turn. In
-/// a tool-using turn the agent loop drives multiple streamStep calls;
-/// each one ends with `onMessageComplete(msg, usage)`. We append the
-/// usage — including `null` when the wire didn't deliver any — to
-/// `per_message_usage` in order so `persistTurn` can pair each captured
-/// usage with its corresponding assistant message.
+/// Persistence is owned by the agent now; the receiver is display-only.
const CLIReceiver = struct {
stdout: *std.Io.Writer,
file: *std.Io.File.Writer,
allocator: std.mem.Allocator,
- /// One slot per assistant message completed during the current
- /// turn, in completion order. `null` means the provider did not
- /// report usage on the wire for that message (typical of
- /// OpenAI-compatible proxies that ignore `stream_options.include_usage`).
- per_message_usage: std.ArrayList(?panto.session_manager.Usage) = .empty,
-
pub fn receiver(self: *CLIReceiver) Receiver {
return .{ .ptr = self, .vtable = &vtable };
}
- /// Reset usage state at the start of each turn.
+ /// Per-turn reset hook. Currently a no-op (no per-turn receiver state),
+ /// retained as the REPL's turn-boundary signal.
pub fn beginTurn(self: *CLIReceiver) void {
- self.per_message_usage.clearRetainingCapacity();
+ _ = self;
}
pub fn deinit(self: *CLIReceiver) void {
- self.per_message_usage.deinit(self.allocator);
+ _ = self;
}
const vtable: ReceiverVTable = .{
@@ -184,13 +172,8 @@ const CLIReceiver = struct {
usage: ?panto.session_manager.Usage,
) anyerror!void {
const self: *CLIReceiver = @ptrCast(@alignCast(ptr));
- // Only assistant messages come through streaming. The receiver
- // contract says onMessageComplete fires exactly once per
- // streamStep, with role=.assistant; record the usage slot in
- // turn order regardless of whether the wire actually had usage.
- if (message.role == .assistant) {
- try self.per_message_usage.append(self.allocator, usage);
- }
+ _ = message;
+ _ = usage;
try self.stdout.writeAll("\n");
try self.file.flush();
}
@@ -403,17 +386,19 @@ pub fn main(init: std.process.Init) !void {
};
defer session_mgr.deinit();
- var conv = panto.conversation.Conversation.init(alloc);
- defer conv.deinit();
-
const is_resume = session_mgr.getEntries().len > 0;
+
+ // On resume, reconstruct the conversation from the store. The dangling
+ // user prompt (a trailing user entry with no assistant reply) is split
+ // out and currently ignored (a future TUI will prefill it for editing).
+ // On a fresh session, the agent starts with an empty conversation.
+ var adopted_conversation: ?panto.conversation.Conversation = null;
if (is_resume) {
- // Resumed an existing session — rebuild the conversation from the
- // log. The system prompt is part of the log. (Reconciliation
- // against the on-disk SYSTEM.md happens after the agent tree is
- // staged, below.)
- conv.deinit();
- conv = try session_mgr.rebuildConversation();
+ var loaded = try session_mgr.loadConversation(alloc);
+ // We don't use the dangling prompt yet; free it.
+ if (loaded.dangling_user) |d| alloc.free(d);
+ loaded.dangling_user = null;
+ adopted_conversation = loaded.conversation;
try stdout.print(
"resumed session {s} ({d} entries)\n",
.{ session_mgr.getSessionId()[0..@min(8, session_mgr.getSessionId().len)], session_mgr.getEntries().len },
@@ -421,11 +406,33 @@ pub fn main(init: std.process.Init) !void {
}
// The tool registry is owned here and referenced by the active config
- // snapshot. Extensions register their tool source into it *before* the
- // snapshot is built, so the agent's first turn sees them.
+ // snapshot. Extensions register their tool source into it *after* the
+ // agent is built; the agent holds a `*const Config` pointing at this
+ // registry, so in-place registration before the first turn is visible.
var registry = panto.ToolRegistry.init(alloc);
defer registry.deinit();
+ // Assemble the active configuration snapshot and build the agent now,
+ // before luarocks bootstrap and extension loading. The agent adopts the
+ // store (for free persistence) and the resumed conversation (or starts
+ // fresh). System-prompt seeding/reconciliation below runs *through* the
+ // agent so those entries persist.
+ const active_config: panto.config.Config = .{
+ .provider = provider_config,
+ .registry = &registry,
+ .compaction = compaction_cfg,
+ };
+ var agent = panto.agent.Agent.init(
+ alloc,
+ io,
+ &active_config,
+ session_mgr.store(),
+ adopted_conversation,
+ );
+ defer agent.deinit();
+ agent.persist_provider = banner_provider_initial;
+ agent.persist_model = banner_model_initial;
+
// Spin up the long-lived Lua runtime. All Lua extensions load into
// one `lua_State`; module-global state survives across calls. The
// runtime registers with the agent as a single `ToolSource` named
@@ -461,8 +468,7 @@ pub fn main(init: std.process.Init) !void {
io,
init.environ_map,
luarocks_rt.layout.agent_dir,
- &conv,
- &session_mgr,
+ &agent,
);
} else {
// Fresh session — source and install the system prompt.
@@ -471,8 +477,7 @@ pub fn main(init: std.process.Init) !void {
io,
init.environ_map,
luarocks_rt.layout.agent_dir,
- &conv,
- &session_mgr,
+ &agent,
);
}
@@ -506,17 +511,6 @@ pub fn main(init: std.process.Init) !void {
try registry.registerSource(rt.toolSource());
}
- // Assemble the active configuration snapshot (provider + tool registry)
- // and create the agent holding a pointer to it. Swapping this pointer
- // later (model/provider/tool changes) takes effect at the next turn.
- const active_config: panto.config.Config = .{
- .provider = provider_config,
- .registry = &registry,
- .compaction = compaction_cfg,
- };
- var agent = panto.agent.Agent.init(alloc, io, &active_config);
- defer agent.deinit();
-
// Resolve the compaction system prompt (COMPACTION.md across layers,
// last wins; built-in default otherwise) and arm automatic compaction.
// Owned by `sp_arena`, which lives for the whole REPL.
@@ -572,9 +566,7 @@ pub fn main(init: std.process.Init) !void {
var cmd_ctx: command.Context = .{
.allocator = alloc,
- .conv = &conv,
.agent = &agent,
- .session_mgr = &session_mgr,
.stdout = stdout,
.stdout_file = &stdout_file,
.compaction_prompt = compaction_prompt,
@@ -617,54 +609,18 @@ pub fn main(init: std.process.Init) !void {
continue;
}
- try conv.addUserMessage(line);
- try session_persist.appendUserPromptToSession(
- alloc,
- &session_mgr,
- line,
- banner_provider_initial,
- banner_model_initial,
- );
-
- const entries_before_step = conv.messages.items.len;
+ // Submit + persist the user prompt, then drive the turn. The agent
+ // owns the conversation and persists everything it generates
+ // (assistant/tool-result messages, and the compaction window on
+ // auto-compaction) through its session store — the CLI no longer
+ // touches persistence.
+ try agent.submitUserMessage(line);
cli_recv.beginTurn();
- agent.runStep(&conv, &recv) catch |err| {
+ agent.runStep(&recv) catch |err| {
try stdout.print("\n[error: {s}]\n", .{@errorName(err)});
};
- if (agent.auto_compacted) {
- // Automatic compaction rewrote the in-memory conversation, so
- // message indices no longer align with `entries_before_step`.
- // Persist the whole post-compaction window (summary + duplicated
- // kept suffix + the retried turn) as fresh entries; the
- // superseded prefix stays on disk and is ignored on replay.
- try session_persist.persistCompaction(
- alloc,
- &session_mgr,
- &conv,
- banner_provider_initial,
- banner_model_initial,
- );
- } else {
- // Persist whatever new entries the agent produced this turn. The
- // agent loop may have appended:
- // - assistant message(s) (one per provider response)
- // - user messages containing ToolResult blocks (one per tool round)
- // Each assistant message gets paired with the Usage that the
- // receiver captured at its onMessageComplete time (or null if
- // the provider didn't emit usage that round).
- try session_persist.persistTurn(
- alloc,
- &session_mgr,
- &conv,
- entries_before_step,
- banner_provider_initial,
- banner_model_initial,
- cli_recv.per_message_usage.items,
- );
- }
-
try stdout.writeAll("\n> ");
try stdout_file.flush();
}
@@ -784,4 +740,3 @@ fn openSession(
},
}
}
-
diff --git a/src/session_persist.zig b/src/session_persist.zig
deleted file mode 100644
index d5349bc..0000000
--- a/src/session_persist.zig
+++ /dev/null
@@ -1,153 +0,0 @@
-//! Session-log persistence helpers shared between the REPL loop
-//! (`main.zig`) and slash-command handlers (e.g. `compaction.zig`).
-//!
-//! These were previously private to `main.zig`. They were lifted into
-//! their own module so that command handlers living outside the root
-//! source file can reuse them without depending on `main.zig` (which,
-//! being the executable root, can't be imported as a normal module).
-
-const std = @import("std");
-const panto = @import("panto");
-
-/// Append a single user-prompt message to the session log.
-pub fn appendUserPromptToSession(
- alloc: std.mem.Allocator,
- mgr: *panto.session_manager.SessionManager,
- text: []const u8,
- provider: []const u8,
- model: []const u8,
-) !void {
- const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1);
- blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } };
- _ = try mgr.appendMessage(
- .{ .role = .user, .content = blocks },
- provider,
- model,
- );
-}
-
-/// After the agent loop has driven a turn to completion, persist every
-/// new in-memory message at index `>= start_index` to the session log.
-///
-/// Each in-memory message is mapped to disk:
-/// - assistant → assistant entry with provider/model/stop_reason metadata
-/// and Usage (from `per_message_usage`, one slot per assistant message in
-/// completion order).
-/// - user (with ToolResult blocks) → user entry stamped with provider/model.
-///
-/// `stop_reason` is recorded as `"stop"` for now; surfacing the real wire
-/// value requires plumbing it through the Receiver vtable (future work,
-/// separate from token plumbing).
-pub fn persistTurn(
- alloc: std.mem.Allocator,
- mgr: *panto.session_manager.SessionManager,
- conv: *const panto.conversation.Conversation,
- start_index: usize,
- provider: []const u8,
- model: []const u8,
- per_message_usage: []const ?panto.session_manager.Usage,
-) !void {
- var batch_messages: std.ArrayList(panto.session_manager.DiskMessage) = .empty;
- defer batch_messages.deinit(alloc);
- var batch_providers: std.ArrayList(?[]const u8) = .empty;
- defer batch_providers.deinit(alloc);
- var batch_models: std.ArrayList(?[]const u8) = .empty;
- defer batch_models.deinit(alloc);
-
- var i = start_index;
- var assistant_seen: usize = 0;
- while (i < conv.messages.items.len) : (i += 1) {
- const msg = conv.messages.items[i];
- const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, msg.content.items.len);
- var allocated: usize = 0;
- errdefer {
- for (blocks[0..allocated]) |b| b.deinit(alloc);
- alloc.free(blocks);
- }
- for (msg.content.items) |block| {
- blocks[allocated] = try panto.session.contentBlockToDisk(alloc, block);
- allocated += 1;
- }
- if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) {
- for (blocks[0..allocated]) |b| b.deinit(alloc);
- alloc.free(blocks);
- continue;
- }
- switch (msg.role) {
- .system => {
- // Mid-turn system messages aren't a thing in pantograph today.
- // Treat as harmless and persist verbatim, sans stamps.
- try batch_messages.append(alloc, .{ .role = .system, .content = blocks });
- try batch_providers.append(alloc, null);
- try batch_models.append(alloc, null);
- },
- .user => {
- try batch_messages.append(alloc, .{ .role = .user, .content = blocks });
- try batch_providers.append(alloc, provider);
- try batch_models.append(alloc, model);
- },
- .assistant => {
- // Pair this assistant message with its usage, if the
- // receiver captured one for this position. (A turn
- // ending in a stream error can have fewer usage entries
- // than assistant messages; treat as null and move on.)
- const usage: ?panto.session_manager.Usage = if (assistant_seen < per_message_usage.len)
- per_message_usage[assistant_seen]
- else
- null;
- assistant_seen += 1;
- try batch_messages.append(alloc, .{
- .role = .assistant,
- .content = blocks,
- .provider = try alloc.dupe(u8, provider),
- .model = try alloc.dupe(u8, model),
- .stop_reason = try alloc.dupe(u8, "stop"),
- .usage = usage,
- });
- try batch_providers.append(alloc, null);
- try batch_models.append(alloc, null);
- },
- }
- }
- try mgr.appendMessagesAtomic(batch_messages.items, batch_providers.items, batch_models.items);
-}
-
-/// Persist a compaction to the session log. The agent rewrote the
-/// in-memory conversation to `[system..., summary, kept-suffix...]`; the
-/// superseded prefix stays on disk untouched. We append everything from
-/// the compaction summary onward as fresh entries: the summary message
-/// (carrying the `compactionSummary` block) followed by the duplicated
-/// kept-verbatim suffix. On replay, the latest summary resets effective
-/// context, so the duplicated suffix is what survives.
-///
-/// Usage is not threaded through here: the duplicated entries are replays
-/// of already-sized turns, and the summary itself isn't an assistant turn.
-pub fn persistCompaction(
- alloc: std.mem.Allocator,
- mgr: *panto.session_manager.SessionManager,
- conv: *const panto.conversation.Conversation,
- provider: []const u8,
- model: []const u8,
-) !void {
- const start = panto.conversation.latestCompactionIndex(conv.messages.items) orelse return;
- try persistTurn(alloc, mgr, conv, start, provider, model, &.{});
-}
-
-pub fn hasToolUseWithoutFollowingResults(conv: *const panto.conversation.Conversation, index: usize) bool {
- const msg = conv.messages.items[index];
- for (msg.content.items) |block| {
- if (block != .ToolUse) continue;
- if (index + 1 >= conv.messages.items.len) return true;
- const next = conv.messages.items[index + 1];
- if (next.role != .user) return true;
- var found = false;
- for (next.content.items) |next_block| {
- if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) {
- found = true;
- break;
- }
- }
- if (!found) return true;
- }
- return false;
-}
diff --git a/src/system_prompt.zig b/src/system_prompt.zig
index 0b449dc..b5423f8 100644
--- a/src/system_prompt.zig
+++ b/src/system_prompt.zig
@@ -137,12 +137,11 @@ pub fn seedFresh(
io: Io,
environ_map: *const std.process.Environ.Map,
base_dir: []const u8,
- conv: *panto.conversation.Conversation,
- mgr: *panto.session_manager.SessionManager,
+ agent: *panto.agent.Agent,
) !void {
const user_dir = try userLayerDir(arena, environ_map);
const project_dir = try projectLayerDir(arena, io);
- return seedFreshLayers(arena, io, base_dir, user_dir, project_dir, conv, mgr);
+ return seedFreshLayers(arena, io, base_dir, user_dir, project_dir, agent);
}
/// Layer-explicit variant of `seedFresh` (see `resolveLayers`).
@@ -152,14 +151,13 @@ pub fn seedFreshLayers(
base_dir: ?[]const u8,
user_dir: ?[]const u8,
project_dir: ?[]const u8,
- conv: *panto.conversation.Conversation,
- mgr: *panto.session_manager.SessionManager,
+ agent: *panto.agent.Agent,
) !void {
const resolved = try resolveLayers(arena, io, base_dir, user_dir, project_dir);
const blocks = try resolved.blocks(arena);
for (blocks) |text| {
- try conv.addSystemMessage(text);
- try appendSystemEntry(mgr.allocator, mgr, text, .append);
+ // The agent adds to its conversation and persists the entry.
+ try agent.addSystemMessage(text, .append);
}
}
@@ -176,12 +174,11 @@ pub fn reconcileResume(
io: Io,
environ_map: *const std.process.Environ.Map,
base_dir: []const u8,
- conv: *panto.conversation.Conversation,
- mgr: *panto.session_manager.SessionManager,
+ agent: *panto.agent.Agent,
) !void {
const user_dir = try userLayerDir(arena, environ_map);
const project_dir = try projectLayerDir(arena, io);
- return reconcileResumeLayers(arena, io, base_dir, user_dir, project_dir, conv, mgr);
+ return reconcileResumeLayers(arena, io, base_dir, user_dir, project_dir, agent);
}
/// Layer-explicit variant of `reconcileResume` (see `resolveLayers`).
@@ -191,22 +188,21 @@ pub fn reconcileResumeLayers(
base_dir: ?[]const u8,
user_dir: ?[]const u8,
project_dir: ?[]const u8,
- conv: *panto.conversation.Conversation,
- mgr: *panto.session_manager.SessionManager,
+ agent: *panto.agent.Agent,
) !void {
const resolved = try resolveLayers(arena, io, base_dir, user_dir, project_dir);
const config_blocks = try resolved.blocks(arena);
- const window = try effectiveConfigWindow(arena, conv.messages.items);
+ const window = try effectiveConfigWindow(arena, agent.conversation.messages.items);
if (blocksEqual(config_blocks, window)) return;
// Re-seed: one `replace` for the seed, then one `append` per append.
- try conv.replaceSystemMessage(config_blocks[0]);
- try appendSystemEntry(mgr.allocator, mgr, config_blocks[0], .replace);
+ // The agent adds to its conversation and persists each entry (with the
+ // correct system mode).
+ try agent.addSystemMessage(config_blocks[0], .replace);
for (config_blocks[1..]) |text| {
- try conv.addSystemMessage(text);
- try appendSystemEntry(mgr.allocator, mgr, text, .append);
+ try agent.addSystemMessage(text, .append);
}
}
@@ -292,22 +288,6 @@ fn blocksEqual(a: []const []const u8, b: []const []const u8) bool {
return true;
}
-/// Append a system-role entry to the session log carrying `mode`.
-fn appendSystemEntry(
- alloc: Allocator,
- mgr: *panto.session_manager.SessionManager,
- text: []const u8,
- mode: panto.session_manager.DiskSystemMode,
-) !void {
- const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1);
- blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } };
- _ = try mgr.appendMessage(
- .{ .role = .system, .mode = mode, .content = blocks },
- null,
- null,
- );
-}
-
/// Read `dir/name`, returning its contents (owned by `arena`) or null if the
/// file does not exist. Other I/O errors propagate.
fn readLayerFile(
@@ -487,6 +467,34 @@ fn openTmpSession(arena: Allocator, root: []const u8) !panto.session_manager.Ses
return panto.session_manager.SessionManager.init(testing.allocator, testing.io, sessions, "/cwd");
}
+/// Minimal agent harness for system-prompt tests: an empty registry and a
+/// throwaway provider config, wrapping a session store and (optionally) an
+/// adopted conversation. Holds the registry/config so the agent's borrowed
+/// pointers stay valid for the agent's lifetime.
+const SPAgentHarness = struct {
+ registry: panto.ToolRegistry,
+ config: panto.config.Config,
+ agent: panto.agent.Agent,
+
+ fn init(
+ self: *SPAgentHarness,
+ store: panto.session_store.SessionStore,
+ adopted: ?panto.conversation.Conversation,
+ ) void {
+ self.registry = panto.ToolRegistry.init(testing.allocator);
+ self.config = .{
+ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } },
+ .registry = &self.registry,
+ };
+ self.agent = panto.agent.Agent.init(testing.allocator, testing.io, &self.config, store, adopted);
+ }
+
+ fn deinit(self: *SPAgentHarness) void {
+ self.agent.deinit();
+ self.registry.deinit();
+ }
+};
+
/// Force the session to flush to disk by appending an assistant entry.
/// (System/user entries buffer in memory until the first assistant entry;
/// a realistic resume only sees what a completed turn persisted.)
@@ -512,12 +520,13 @@ test "seedFresh then no-config-change resume is a no-op" {
try layers.writeProject("APPEND_SYSTEM.md", "project append");
const project_dir = try layers.projectDir(arena);
- // Fresh session: seed + persist.
+ // Fresh session: seed + persist (through the agent).
var mgr = try openTmpSession(arena, layers.root);
{
- var conv = panto.conversation.Conversation.init(alloc);
- defer conv.deinit();
- try seedFreshLayers(arena, testing.io, null, null, project_dir, &conv, &mgr);
+ var h: SPAgentHarness = undefined;
+ h.init(mgr.store(), null);
+ defer h.deinit();
+ try seedFreshLayers(arena, testing.io, null, null, project_dir, &h.agent);
}
try forceFlush(&mgr); // persist seed + append (+ assistant) to disk
const entries_on_disk = mgr.getEntries().len;
@@ -525,13 +534,15 @@ test "seedFresh then no-config-change resume is a no-op" {
const session_file = try arena.dupe(u8, mgr.getSessionFile());
mgr.deinit();
- // Resume: reopen the file, rebuild + reconcile against unchanged config
- // → no new entries.
+ // Resume: reopen the file, adopt the rebuilt conversation + reconcile
+ // against unchanged config → no new entries.
var mgr2 = try panto.session_manager.SessionManager.open(alloc, testing.io, session_file);
defer mgr2.deinit();
- var conv2 = try mgr2.rebuildConversation();
- defer conv2.deinit();
- try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &conv2, &mgr2);
+ const conv2 = try mgr2.rebuildConversation();
+ var h2: SPAgentHarness = undefined;
+ h2.init(mgr2.store(), conv2);
+ defer h2.deinit();
+ try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &h2.agent);
try testing.expectEqual(entries_on_disk, mgr2.getEntries().len);
}
@@ -548,9 +559,10 @@ test "resume after config change appends replace + append sequence" {
var mgr = try openTmpSession(arena, layers.root);
{
- var conv = panto.conversation.Conversation.init(alloc);
- defer conv.deinit();
- try seedFreshLayers(arena, testing.io, null, null, project_dir, &conv, &mgr);
+ var h: SPAgentHarness = undefined;
+ h.init(mgr.store(), null);
+ defer h.deinit();
+ try seedFreshLayers(arena, testing.io, null, null, project_dir, &h.agent);
}
try forceFlush(&mgr); // persist old seed (+ assistant) to disk
try testing.expectEqual(@as(usize, 2), mgr.getEntries().len); // seed + assistant
@@ -563,15 +575,17 @@ test "resume after config change appends replace + append sequence" {
var mgr2 = try panto.session_manager.SessionManager.open(alloc, testing.io, session_file);
defer mgr2.deinit();
- var conv2 = try mgr2.rebuildConversation();
- defer conv2.deinit();
- try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &conv2, &mgr2);
+ const conv2 = try mgr2.rebuildConversation();
+ var h2: SPAgentHarness = undefined;
+ h2.init(mgr2.store(), conv2);
+ defer h2.deinit();
+ try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &h2.agent);
// old seed + assistant + replace(new seed) + append(new append) = 4.
try testing.expectEqual(@as(usize, 4), mgr2.getEntries().len);
// The effective prompt now reflects only the new config blocks.
- const eff = try panto.conversation.effectiveSystemBlocks(arena, conv2.messages.items);
+ const eff = try panto.conversation.effectiveSystemBlocks(arena, h2.agent.conversation.messages.items);
try testing.expectEqual(@as(usize, 2), eff.items.len);
try testing.expectEqualStrings("new seed", eff.items[0]);
try testing.expectEqualStrings("new append", eff.items[1]);
@@ -579,7 +593,7 @@ test "resume after config change appends replace + append sequence" {
// A second no-op resume must not append again (anchors to the new
// `replace` window, not the stale original seed).
const after_first = mgr2.getEntries().len;
- try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &conv2, &mgr2);
+ try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &h2.agent);
try testing.expectEqual(after_first, mgr2.getEntries().len);
}