summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--agent/SYSTEM.md3
-rw-r--r--docs/archive/system-prompt.md (renamed from docs/system-prompt.md)30
-rw-r--r--libpanto/src/anthropic_messages_json.zig81
-rw-r--r--libpanto/src/conversation.zig162
-rw-r--r--libpanto/src/openai_chat_json.zig79
-rw-r--r--libpanto/src/session.zig72
-rw-r--r--libpanto/src/session_manager.zig16
-rw-r--r--src/main.zig58
-rw-r--r--src/system_prompt.zig541
9 files changed, 997 insertions, 45 deletions
diff --git a/agent/SYSTEM.md b/agent/SYSTEM.md
new file mode 100644
index 0000000..b643ddf
--- /dev/null
+++ b/agent/SYSTEM.md
@@ -0,0 +1,3 @@
+You are an expert coding assistant inside panto, a coding agent. You help
+with software engineering tasks, working directly in the user's project
+through the tools available to you. Keep your responses concise.
diff --git a/docs/system-prompt.md b/docs/archive/system-prompt.md
index df296f6..e44f953 100644
--- a/docs/system-prompt.md
+++ b/docs/archive/system-prompt.md
@@ -1,6 +1,8 @@
# System Prompt
-Status: design / not yet implemented.
+Status: implemented (libpanto data model + serializers + session store +
+CLI sourcing & resume reconciliation). See `src/system_prompt.zig` and the
+seeding/reconciliation wiring in `src/main.zig`.
This document specifies first-class support for the system prompt across
libpanto (the conversation + session-store data model and provider
@@ -275,15 +277,23 @@ wanted right now — consistent ordering is the contract.
## Implementation order
-1. **libpanto data model:** `SystemMode`, `SystemBlock`, `ContentBlock`
+1. ✅ **libpanto data model:** `SystemMode`, `SystemBlock`, `ContentBlock`
arm + `deinit`; `addSystemMessage` (mode-aware) + `replaceSystemMessage`;
- shared append/replace derivation helper.
-2. **Serializers:** OpenAI leading-system hoist (separate messages);
+ shared `effectiveSystemBlocks` derivation helper.
+2. ✅ **Serializers:** OpenAI leading-system hoist (separate messages);
Anthropic strip-trailing-newlines + `\n\n---\n\n` join. Both via the
- shared derivation. Add/extend tests.
-3. **Session store:** optional `mode` field on system entries (read +
+ shared derivation. Tests added.
+3. ✅ **Session store:** optional `mode` field on system entries (read +
write, default `append`); rebuild reconstructs `.System` blocks. Tests.
-4. **CLI sourcing:** discover `SYSTEM.md` / `APPEND_SYSTEM.md` across the
- three layers; resolve + order per §2.2–§2.3; seed fresh sessions.
-5. **CLI resume reconciliation:** positional first-`K` comparison; append
- `replace + N×append` on any difference. Tests.
+4. ✅ **CLI sourcing** (`src/system_prompt.zig`): discover `SYSTEM.md` /
+ `APPEND_SYSTEM.md` across the three layers (base = `$PANTO_HOME/agent`,
+ user = `${XDG_CONFIG_HOME:-$HOME/.config}/panto`, project = `./.panto`);
+ resolve + order per §2.2–§2.3; seed fresh sessions.
+5. ✅ **CLI resume reconciliation:** positional comparison against the
+ current effective config window (anchored to the latest `replace`);
+ append `replace + N×append` on any difference. Tests.
+
+> Note: the base layer directory is `$PANTO_HOME/agent` (i.e.
+> `$XDG_DATA_HOME/panto/agent`), matching the convention used for extension
+> and tool discovery, rather than the bare `$XDG_DATA_HOME/panto/` sketched
+> in §2.1.
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig
index 5b65c2b..f8bfc2e 100644
--- a/libpanto/src/anthropic_messages_json.zig
+++ b/libpanto/src/anthropic_messages_json.zig
@@ -111,20 +111,26 @@ fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void {
try s.write(parsed.value);
}
+/// Build the top-level Anthropic `system` string. Applies the shared
+/// append/replace derivation (see `conversation.effectiveSystemBlocks`),
+/// strips trailing newlines from each surviving block, and joins them with
+/// a horizontal rule (`\n\n---\n\n`). Anthropic's wire format requires a
+/// single string, so this rule is its concession to that constraint.
fn collectSystemPrompt(
conv: *const conversation.Conversation,
out: *std.ArrayList(u8),
allocator: Allocator,
) !void {
+ var blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items);
+ defer blocks.deinit(allocator);
+
+ const sep = "\n\n---\n\n";
var first = true;
- for (conv.messages.items) |msg| {
- if (msg.role != .system) continue;
- for (msg.content.items) |block| {
- if (block != .Text) continue;
- if (!first) try out.append(allocator, '\n');
- try out.appendSlice(allocator, block.Text.items);
- first = false;
- }
+ for (blocks.items) |text| {
+ const trimmed = std.mem.trimEnd(u8, text, "\n");
+ if (!first) try out.appendSlice(allocator, sep);
+ try out.appendSlice(allocator, trimmed);
+ first = false;
}
}
@@ -208,6 +214,11 @@ fn writeBlock(s: *std.json.Stringify, block: conversation.ContentBlock) !void {
try s.write(tr.content.items);
try s.endObject();
},
+ // System blocks never reach here: `serializeRequest` filters out
+ // `.system` messages before emitting the `messages` array, and the
+ // system text is hoisted into the top-level `system` string by
+ // `collectSystemPrompt`. Present only to keep the switch exhaustive.
+ .System => {},
}
}
@@ -526,7 +537,7 @@ test "serializeRequest - system extracted into top-level field" {
try testing.expectEqualStrings("Hello!", content[0].object.get("text").?.string);
}
-test "serializeRequest - multiple system messages concatenated with newlines" {
+test "serializeRequest - multiple system messages joined with horizontal rule" {
const allocator = testing.allocator;
var conv = conversation.Conversation.init(allocator);
@@ -545,7 +556,57 @@ test "serializeRequest - multiple system messages concatenated with newlines" {
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
defer parsed.deinit();
try testing.expectEqualStrings(
- "Be terse.\nBe accurate.",
+ "Be terse.\n\n---\n\nBe accurate.",
+ parsed.value.object.get("system").?.string,
+ );
+}
+
+test "serializeRequest - replace-mode system block wipes prior system text" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("original seed");
+ try conv.addSystemMessage("original append");
+ try conv.replaceSystemMessage("fresh seed");
+ try conv.addSystemMessage("fresh append");
+ try conv.addUserMessage("Hi");
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ try testing.expectEqualStrings(
+ "fresh seed\n\n---\n\nfresh append",
+ parsed.value.object.get("system").?.string,
+ );
+}
+
+test "serializeRequest - trailing newlines stripped before the rule join" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("line one\n\n");
+ try conv.addSystemMessage("line two\n");
+ try conv.addUserMessage("Hi");
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ try testing.expectEqualStrings(
+ "line one\n\n---\n\nline two",
parsed.value.object.get("system").?.string,
);
}
diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig
index 6776412..f145d7f 100644
--- a/libpanto/src/conversation.zig
+++ b/libpanto/src/conversation.zig
@@ -51,11 +51,29 @@ pub const ToolResultBlock = struct {
}
};
+/// How a `.system` content block combines with the system text collected
+/// before it. `append` adds to the running effective prompt; `replace`
+/// discards everything collected so far and starts fresh from this block.
+pub const SystemMode = enum { append, replace };
+
+/// A system-prompt content block. System prompts remain `.system`-role
+/// messages; this block records the mode that governs how its text folds
+/// into the effective system prompt (see `effectiveSystemBlocks`).
+pub const SystemBlock = struct {
+ text: TextualBlock = .empty,
+ mode: SystemMode = .append,
+
+ pub fn deinit(self: *SystemBlock, alloc: Allocator) void {
+ self.text.deinit(alloc);
+ }
+};
+
pub const ContentBlock = union(enum) {
Text: TextualBlock,
Thinking: ThinkingBlock,
ToolUse: ToolUseBlock,
ToolResult: ToolResultBlock,
+ System: SystemBlock,
pub fn deinit(self: *ContentBlock, alloc: Allocator) void {
switch (self.*) {
@@ -63,6 +81,7 @@ pub const ContentBlock = union(enum) {
.Thinking => |*b| b.deinit(alloc),
.ToolUse => |*b| b.deinit(alloc),
.ToolResult => |*b| b.deinit(alloc),
+ .System => |*b| b.deinit(alloc),
}
}
};
@@ -95,10 +114,30 @@ pub const Conversation = struct {
};
}
+ /// Append a system message in `append` mode. Adds to the effective
+ /// system prompt. (Back-compatible: same external behavior as before
+ /// the `.System` block existed.)
pub fn addSystemMessage(self: *Conversation, text: []const u8) !void {
+ return self.appendSystemBlock(text, .append);
+ }
+
+ /// Append a system message in `replace` mode. When the effective
+ /// prompt is rebuilt (see `effectiveSystemBlocks`), this discards all
+ /// prior system text and starts fresh.
+ pub fn replaceSystemMessage(self: *Conversation, text: []const u8) !void {
+ return self.appendSystemBlock(text, .replace);
+ }
+
+ /// Append a `.system`-role message whose single content block is a
+ /// `.System` block carrying `mode`.
+ fn appendSystemBlock(self: *Conversation, text: []const u8, mode: SystemMode) !void {
const tb = try textualBlockFromSlice(self.allocator, text);
var content: std.ArrayList(ContentBlock) = .empty;
- try content.append(self.allocator, .{ .Text = tb });
+ errdefer {
+ for (content.items) |*b| b.deinit(self.allocator);
+ content.deinit(self.allocator);
+ }
+ try content.append(self.allocator, .{ .System = .{ .text = tb, .mode = mode } });
try self.messages.append(self.allocator, .{
.role = .system,
.content = content,
@@ -137,6 +176,50 @@ pub const Conversation = struct {
}
};
+/// Derive the effective ordered list of system-text blocks from a slice of
+/// messages. This is the single shared rule that governs both provider
+/// serialization and session rebuild.
+///
+/// Walk the messages in order; for each `.system` message's `.System`
+/// block:
+/// - `append`: add the block's text to the running list.
+/// - `replace`: clear the running list, then add this block's text.
+///
+/// The returned slices are **borrowed** from `messages` — valid only as
+/// long as the underlying conversation is unmodified. The caller owns the
+/// returned `ArrayList` itself and must `deinit` it (this frees the slice
+/// storage, not the borrowed text).
+///
+/// Running this walk over a *prefix* of the messages reconstructs the
+/// effective prompt as of that point — the `/tree` faithfulness property.
+pub fn effectiveSystemBlocks(
+ alloc: Allocator,
+ messages: []const Message,
+) !std.ArrayList([]const u8) {
+ var out: std.ArrayList([]const u8) = .empty;
+ errdefer out.deinit(alloc);
+ for (messages) |msg| {
+ if (msg.role != .system) continue;
+ for (msg.content.items) |block| {
+ switch (block) {
+ .System => |sb| {
+ switch (sb.mode) {
+ .append => {},
+ .replace => out.clearRetainingCapacity(),
+ }
+ try out.append(alloc, sb.text.items);
+ },
+ // Be tolerant of plain `.Text` blocks on a system message
+ // (e.g. hand-built test conversations): treat them as
+ // append-mode text.
+ .Text => |tb| try out.append(alloc, tb.items),
+ else => {},
+ }
+ }
+ }
+ return out;
+}
+
test "Conversation - add messages and verify content" {
const allocator = std.testing.allocator;
@@ -154,7 +237,11 @@ test "Conversation - add messages and verify content" {
try std.testing.expectEqual(MessageRole.system, conv.messages.items[0].role);
try std.testing.expectEqualStrings(
"You are a helpful assistant.",
- conv.messages.items[0].content.items[0].Text.items,
+ conv.messages.items[0].content.items[0].System.text.items,
+ );
+ try std.testing.expectEqual(
+ SystemMode.append,
+ conv.messages.items[0].content.items[0].System.mode,
);
try std.testing.expectEqual(MessageRole.user, conv.messages.items[1].role);
@@ -202,3 +289,74 @@ test "ContentBlock - Thinking variant" {
try std.testing.expectEqualStrings("hmm...", conv.messages.items[0].content.items[0].Thinking.text.items);
try std.testing.expectEqualStrings("answer", conv.messages.items[0].content.items[1].Text.items);
}
+
+test "System block - addSystemMessage records append mode, replaceSystemMessage records replace mode" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("base");
+ try conv.replaceSystemMessage("fresh");
+
+ try std.testing.expectEqual(SystemMode.append, conv.messages.items[0].content.items[0].System.mode);
+ try std.testing.expectEqualStrings("base", conv.messages.items[0].content.items[0].System.text.items);
+ try std.testing.expectEqual(SystemMode.replace, conv.messages.items[1].content.items[0].System.mode);
+ try std.testing.expectEqualStrings("fresh", conv.messages.items[1].content.items[0].System.text.items);
+}
+
+test "effectiveSystemBlocks - append accumulates in order" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("a");
+ try conv.addSystemMessage("b");
+ try conv.addUserMessage("hi");
+ try conv.addSystemMessage("c");
+
+ var blocks = try effectiveSystemBlocks(allocator, conv.messages.items);
+ defer blocks.deinit(allocator);
+
+ try std.testing.expectEqual(@as(usize, 3), blocks.items.len);
+ try std.testing.expectEqualStrings("a", blocks.items[0]);
+ try std.testing.expectEqualStrings("b", blocks.items[1]);
+ try std.testing.expectEqualStrings("c", blocks.items[2]);
+}
+
+test "effectiveSystemBlocks - replace wipes everything collected so far" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("a");
+ try conv.addSystemMessage("b");
+ try conv.replaceSystemMessage("fresh");
+ try conv.addSystemMessage("after");
+
+ var blocks = try effectiveSystemBlocks(allocator, conv.messages.items);
+ defer blocks.deinit(allocator);
+
+ try std.testing.expectEqual(@as(usize, 2), blocks.items.len);
+ try std.testing.expectEqualStrings("fresh", blocks.items[0]);
+ try std.testing.expectEqualStrings("after", blocks.items[1]);
+}
+
+test "effectiveSystemBlocks - prefix reconstructs prompt as of that point" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("a");
+ try conv.replaceSystemMessage("fresh");
+ try conv.addSystemMessage("after");
+
+ // Truncate at position 1 (only the first `addSystemMessage`).
+ var blocks = try effectiveSystemBlocks(allocator, conv.messages.items[0..1]);
+ defer blocks.deinit(allocator);
+ try std.testing.expectEqual(@as(usize, 1), blocks.items.len);
+ try std.testing.expectEqualStrings("a", blocks.items[0]);
+}
diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig
index 6991559..3b81c41 100644
--- a/libpanto/src/openai_chat_json.zig
+++ b/libpanto/src/openai_chat_json.zig
@@ -140,7 +140,24 @@ pub fn serializeRequest(
try s.objectField("messages");
try s.beginArray();
+ // Hoist the effective system prompt to the front as separate,
+ // individually-positioned `system` messages (one per surviving block,
+ // in derivation order). Keeping them distinct preserves block-level
+ // addressability for `/tree`-style truncation — we deliberately do NOT
+ // concatenate them the way Anthropic's single-string format forces.
+ var sys_blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items);
+ defer sys_blocks.deinit(allocator);
+ for (sys_blocks.items) |text| {
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write("system");
+ try s.objectField("content");
+ try s.write(text);
+ try s.endObject();
+ }
+ // Then every non-system message, in its original order.
for (conv.messages.items) |msg| {
+ if (msg.role == .system) continue;
try writeMessage(&s, msg, allocator);
}
try s.endArray();
@@ -497,6 +514,68 @@ test "serializeRequest - system + user" {
try testing.expectEqualStrings("Hello!", msgs[1].object.get("content").?.string);
}
+test "serializeRequest - multiple system blocks hoisted as separate leading messages" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("seed");
+ try conv.addUserMessage("Hello!");
+ try conv.addSystemMessage("mid-conversation append");
+ try conv.addUserMessage("again");
+
+ const cfg = testConfig("gpt-4o");
+ var tools = emptyTools();
+ defer tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const msgs = parsed.value.object.get("messages").?.array.items;
+ // Two system messages first (in derivation order), then the two users.
+ try testing.expectEqual(@as(usize, 4), msgs.len);
+ try testing.expectEqualStrings("system", msgs[0].object.get("role").?.string);
+ try testing.expectEqualStrings("seed", msgs[0].object.get("content").?.string);
+ try testing.expectEqualStrings("system", msgs[1].object.get("role").?.string);
+ try testing.expectEqualStrings("mid-conversation append", msgs[1].object.get("content").?.string);
+ try testing.expectEqualStrings("user", msgs[2].object.get("role").?.string);
+ try testing.expectEqualStrings("Hello!", msgs[2].object.get("content").?.string);
+ try testing.expectEqualStrings("user", msgs[3].object.get("role").?.string);
+ try testing.expectEqualStrings("again", msgs[3].object.get("content").?.string);
+}
+
+test "serializeRequest - replace-mode system block wipes prior system messages" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("original");
+ try conv.replaceSystemMessage("fresh seed");
+ try conv.addSystemMessage("fresh append");
+ try conv.addUserMessage("Hi");
+
+ const cfg = testConfig("gpt-4o");
+ var tools = emptyTools();
+ defer tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const msgs = parsed.value.object.get("messages").?.array.items;
+ try testing.expectEqual(@as(usize, 3), msgs.len);
+ try testing.expectEqualStrings("system", msgs[0].object.get("role").?.string);
+ try testing.expectEqualStrings("fresh seed", msgs[0].object.get("content").?.string);
+ try testing.expectEqualStrings("system", msgs[1].object.get("role").?.string);
+ try testing.expectEqualStrings("fresh append", msgs[1].object.get("content").?.string);
+ try testing.expectEqualStrings("user", msgs[2].object.get("role").?.string);
+}
+
test "serializeRequest - assistant Thinking blocks are stripped from outbound history" {
const allocator = testing.allocator;
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig
index e77d121..c1fd1c4 100644
--- a/libpanto/src/session.zig
+++ b/libpanto/src/session.zig
@@ -100,9 +100,18 @@ pub const MessageEntry = struct {
pub const DiskMessageRole = enum { system, user, assistant };
+/// Mode for a system-role message. Mirrors `conversation.SystemMode`.
+/// `append` adds to the effective prompt; `replace` discards all prior
+/// system text. Only meaningful on system messages; absent on disk means
+/// `append` (back-compatible with pre-mode logs).
+pub const DiskSystemMode = enum { append, replace };
+
pub const DiskMessage = struct {
role: DiskMessageRole,
content: []DiskContentBlock, // owned
+ /// System-message mode. Recorded only for system-role messages; an
+ /// absent `mode` on disk parses back as `.append`.
+ mode: DiskSystemMode = .append,
// Assistant-only metadata. Null for system/user messages.
provider: ?[]const u8 = null, // owned
model: ?[]const u8 = null, // owned
@@ -296,6 +305,12 @@ fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void {
try s.beginObject();
try s.objectField("role");
try s.write(@tagName(msg.role));
+ // `mode` is meaningful only for system messages. Emit it there so the
+ // append/replace semantics round-trip; omit it everywhere else.
+ if (msg.role == .system) {
+ try s.objectField("mode");
+ try s.write(@tagName(msg.mode));
+ }
try s.objectField("content");
try s.beginArray();
for (msg.content) |block| {
@@ -479,6 +494,14 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di
if (role_v != .string) return error.MissingField;
const role = std.meta.stringToEnum(DiskMessageRole, role_v.string) orelse return error.UnknownRole;
+ // `mode` is optional; absent defaults to `.append`. Unknown values are
+ // tolerated as `.append` rather than rejecting an otherwise-valid log.
+ const mode: DiskSystemMode = blk: {
+ const mv = obj.get("mode") orelse break :blk .append;
+ if (mv != .string) break :blk .append;
+ break :blk std.meta.stringToEnum(DiskSystemMode, mv.string) orelse .append;
+ };
+
const content_v = obj.get("content") orelse return error.MissingField;
if (content_v != .array) return error.MissingField;
var content_list = try std.ArrayList(DiskContentBlock).initCapacity(allocator, content_v.array.items.len);
@@ -520,6 +543,7 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di
return .{
.role = role,
.content = content,
+ .mode = mode,
.provider = provider,
.model = model,
.stop_reason = stop_reason,
@@ -612,6 +636,13 @@ pub fn contentBlockToDisk(
const content = try allocator.dupe(u8, tr.content.items);
return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } };
},
+ // A `.System` block becomes a disk text block; its mode rides on
+ // the enclosing `DiskMessage.mode` (set by the session manager),
+ // not on the block itself.
+ .System => |sb| {
+ const text = try allocator.dupe(u8, sb.text.items);
+ return .{ .text = .{ .text = text } };
+ },
}
}
@@ -808,6 +839,47 @@ test "serialize/parse tool result message entry" {
try testing.expectEqualStrings("anthropic", got.provider.?);
}
+test "system message mode round-trips; absent mode defaults to append" {
+ const a = testing.allocator;
+
+ // replace-mode system entry round-trips.
+ {
+ var content = try a.alloc(DiskContentBlock, 1);
+ content[0] = .{ .text = .{ .text = try dupe(a, "fresh seed") } };
+ const entry: SessionEntry = .{ .message = .{
+ .base = .{
+ .id = try dupe(a, "aabbccdd"),
+ .parent_id = null,
+ .timestamp = try dupe(a, "2026-04-25T17:40:00Z"),
+ },
+ .message = .{
+ .role = .system,
+ .content = content,
+ .mode = .replace,
+ },
+ } };
+ defer entry.deinit(a);
+
+ const line = try serializeEntry(a, entry);
+ defer a.free(line);
+ try testing.expect(std.mem.indexOf(u8, line, "\"mode\":\"replace\"") != null);
+
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ try testing.expectEqual(DiskSystemMode.replace, fe.entry.message.message.mode);
+ }
+
+ // A legacy system entry with no `mode` parses back as append.
+ {
+ const line =
+ \\{"type":"message","id":"abcdefab","parentId":null,"timestamp":"2026-04-25T17:40:00Z","message":{"role":"system","content":[{"type":"text","text":"hi"}]}}
+ ;
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ try testing.expectEqual(DiskSystemMode.append, fe.entry.message.message.mode);
+ }
+}
+
test "parse: null parentId is handled" {
const a = testing.allocator;
const line =
diff --git a/libpanto/src/session_manager.zig b/libpanto/src/session_manager.zig
index bf9585d..8b503f2 100644
--- a/libpanto/src/session_manager.zig
+++ b/libpanto/src/session_manager.zig
@@ -37,6 +37,7 @@ pub const SessionEntry = session_mod.SessionEntry;
pub const MessageEntry = session_mod.MessageEntry;
pub const DiskMessage = session_mod.DiskMessage;
pub const DiskMessageRole = session_mod.DiskMessageRole;
+pub const DiskSystemMode = session_mod.DiskSystemMode;
pub const DiskContentBlock = session_mod.DiskContentBlock;
pub const Usage = session_mod.Usage;
pub const CURRENT_VERSION = session_mod.CURRENT_VERSION;
@@ -668,8 +669,19 @@ fn appendMessageToConv(
content.deinit(allocator);
}
try content.ensureTotalCapacity(allocator, disk_msg.content.len);
+ const sys_mode: conversation_mod.SystemMode = switch (disk_msg.mode) {
+ .append => .append,
+ .replace => .replace,
+ };
for (disk_msg.content) |db| {
- const block = try session_mod.diskContentBlockToInternal(allocator, db);
+ var block = try session_mod.diskContentBlockToInternal(allocator, db);
+ // System-role text blocks become `.System` blocks carrying the
+ // message's recorded mode, so the append/replace derivation works
+ // on the rebuilt conversation exactly as it did when written.
+ if (disk_msg.role == .system and block == .Text) {
+ const tb = block.Text;
+ block = .{ .System = .{ .text = tb, .mode = sys_mode } };
+ }
content.appendAssumeCapacity(block);
}
const role: conversation_mod.MessageRole = switch (disk_msg.role) {
@@ -1248,7 +1260,7 @@ test "SessionManager: rebuildConversation reconstructs system/user/assistant tur
defer conv.deinit();
try testing.expectEqual(@as(usize, 3), conv.messages.items.len);
try testing.expectEqual(conversation_mod.MessageRole.system, conv.messages.items[0].role);
- try testing.expectEqualStrings("you are helpful", conv.messages.items[0].content.items[0].Text.items);
+ try testing.expectEqualStrings("you are helpful", conv.messages.items[0].content.items[0].System.text.items);
try testing.expectEqual(conversation_mod.MessageRole.user, conv.messages.items[1].role);
try testing.expectEqualStrings("hello", conv.messages.items[1].content.items[0].Text.items);
try testing.expectEqual(conversation_mod.MessageRole.assistant, conv.messages.items[2].role);
diff --git a/src/main.zig b/src/main.zig
index aaf86b5..20686eb 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -11,6 +11,7 @@ const session_paths = @import("session_paths.zig");
const models_toml = @import("models_toml.zig");
const config_file = @import("config_file.zig");
const glob = @import("glob.zig");
+const system_prompt = @import("system_prompt.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.
@@ -37,6 +38,7 @@ test {
_ = models_toml;
_ = config_file;
_ = glob;
+ _ = system_prompt;
}
const Receiver = panto.provider.Receiver;
@@ -363,20 +365,18 @@ pub fn main(init: std.process.Init) !void {
var conv = panto.conversation.Conversation.init(alloc);
defer conv.deinit();
- if (session_mgr.getEntries().len > 0) {
+ const is_resume = session_mgr.getEntries().len > 0;
+ if (is_resume) {
// Resumed an existing session — rebuild the conversation from the
- // log. The system prompt is part of the log.
+ // 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();
try stdout.print(
"resumed session {s} ({d} entries)\n",
.{ session_mgr.getSessionId()[0..@min(8, session_mgr.getSessionId().len)], session_mgr.getEntries().len },
);
- } else {
- // Fresh session — install the default system prompt and record it.
- const system_text = "You are a helpful assistant.";
- try conv.addSystemMessage(system_text);
- try appendSystemToSession(alloc, &session_mgr, system_text);
}
// The tool registry is owned here and referenced by the active config
@@ -405,6 +405,36 @@ pub fn main(init: std.process.Init) !void {
);
defer luarocks_rt.deinit();
+ // Source the system prompt now that the base agent tree has been
+ // staged to `$PANTO_HOME/agent` (the bootstrap above writes the
+ // bundled `SYSTEM.md` there). The prompt is sourced by convention
+ // from SYSTEM.md / APPEND_SYSTEM.md across the base/user/project
+ // layers; the base layer is `luarocks_rt.layout.agent_dir`.
+ var sp_arena = std.heap.ArenaAllocator.init(alloc);
+ defer sp_arena.deinit();
+ if (is_resume) {
+ // SYSTEM.md / APPEND_SYSTEM.md may have changed since this log was
+ // created; reconcile without rewriting history.
+ try system_prompt.reconcileResume(
+ sp_arena.allocator(),
+ io,
+ init.environ_map,
+ luarocks_rt.layout.agent_dir,
+ &conv,
+ &session_mgr,
+ );
+ } else {
+ // Fresh session — source and install the system prompt.
+ try system_prompt.seedFresh(
+ sp_arena.allocator(),
+ io,
+ init.environ_map,
+ luarocks_rt.layout.agent_dir,
+ &conv,
+ &session_mgr,
+ );
+ }
+
// luv is installed (or already present) at this point; wire the
// libuv-driven coroutine scheduler before any extensions get a
// chance to register tools that might want to yield.
@@ -637,20 +667,6 @@ fn openSession(
// Session append helpers — bridge in-memory ContentBlocks to on-disk entries.
// -----------------------------------------------------------------------------
-fn appendSystemToSession(
- alloc: std.mem.Allocator,
- mgr: *panto.session_manager.SessionManager,
- text: []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 = .system, .content = blocks },
- null,
- null,
- );
-}
-
fn appendUserPromptToSession(
alloc: std.mem.Allocator,
mgr: *panto.session_manager.SessionManager,
diff --git a/src/system_prompt.zig b/src/system_prompt.zig
new file mode 100644
index 0000000..b796394
--- /dev/null
+++ b/src/system_prompt.zig
@@ -0,0 +1,541 @@
+//! System-prompt sourcing and resume reconciliation for the `panto` CLI.
+//!
+//! The system prompt is **conversation state**, not config state (see
+//! `docs/system-prompt.md`). It is sourced — by convention, with no TOML
+//! key — from two files discovered across the same three config layers the
+//! CLI already uses, in precedence order base → user → project (project
+//! highest):
+//!
+//! base = $PANTO_HOME/agent/ (= $XDG_DATA_HOME/panto/agent/)
+//! user = ${XDG_CONFIG_HOME:-$HOME/.config}/panto/
+//! project = ./.panto/
+//!
+//! In each layer we look for:
+//! - `SYSTEM.md` — the base/seed prompt (highest layer present wins).
+//! - `APPEND_SYSTEM.md` — an additional appended block (every layer kept).
+//!
+//! Resolution:
+//! 1. Seed = the highest-layer `SYSTEM.md`, else a built-in default.
+//! 2. Appends = each layer's `APPEND_SYSTEM.md`, collected base → user →
+//! project, then emitted reversed: project → user → base. The seed
+//! leads; the most-specific (project) append follows it.
+
+const std = @import("std");
+const panto = @import("panto");
+
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+
+/// Last-resort fallback seed when no `SYSTEM.md` exists at any layer.
+///
+/// In normal operation this is never used: the base layer ships a bundled
+/// `agent/SYSTEM.md` (embedded in the binary and staged to
+/// `$PANTO_HOME/agent/SYSTEM.md` at bootstrap), so a seed is always found.
+/// This constant only matters if that staged file is missing or
+/// unreadable.
+pub const default_seed = "You are a helpful assistant.";
+
+const seed_filename = "SYSTEM.md";
+const append_filename = "APPEND_SYSTEM.md";
+
+/// A resolved set of system blocks for a fresh session: the seed followed
+/// by zero or more appends, already in emission order. All slices
+/// are owned by `arena`.
+pub const Resolved = struct {
+ /// Seed text (always present — a built-in default if no file existed).
+ seed: []const u8,
+ /// Append blocks in emission order (project → user → base).
+ appends: []const []const u8,
+
+ /// The full ordered block list: `[seed, appends...]`. Owned by `arena`.
+ pub fn blocks(self: Resolved, arena: Allocator) ![]const []const u8 {
+ var list = try arena.alloc([]const u8, 1 + self.appends.len);
+ list[0] = self.seed;
+ @memcpy(list[1..], self.appends);
+ return list;
+ }
+};
+
+/// Resolve the system-prompt blocks from the three config layers.
+///
+/// `base_dir` is the base layer directory (`$PANTO_HOME/agent`). The user
+/// layer is derived from `XDG_CONFIG_HOME`/`HOME`; the project layer is
+/// `cwd()/.panto`. Allocations are made in `arena` (caller owns it).
+pub fn resolve(
+ arena: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ base_dir: []const u8,
+) !Resolved {
+ const user_dir = try userLayerDir(arena, environ_map);
+ const project_dir = try projectLayerDir(arena, io);
+ return resolveLayers(arena, io, base_dir, user_dir, project_dir);
+}
+
+/// Core resolution against explicit layer directories (base → user →
+/// project, project highest). Any layer dir may be null (absent). Exposed
+/// for testing; `resolve` derives the user/project dirs from the
+/// environment and cwd.
+pub fn resolveLayers(
+ arena: Allocator,
+ io: Io,
+ base_dir: ?[]const u8,
+ user_dir: ?[]const u8,
+ project_dir: ?[]const u8,
+) !Resolved {
+ // Seed: highest layer present wins (project > user > base).
+ var seed: []const u8 = default_seed;
+ for ([_]?[]const u8{ base_dir, user_dir, project_dir }) |maybe_dir| {
+ const dir = maybe_dir orelse continue;
+ if (try readLayerFile(arena, io, dir, seed_filename)) |text| {
+ seed = text;
+ }
+ }
+
+ // Appends: collect base → user → project, then reverse for emission.
+ var collected: std.ArrayList([]const u8) = .empty;
+ for ([_]?[]const u8{ base_dir, user_dir, project_dir }) |maybe_dir| {
+ const dir = maybe_dir orelse continue;
+ if (try readLayerFile(arena, io, dir, append_filename)) |text| {
+ try collected.append(arena, text);
+ }
+ }
+ std.mem.reverse([]const u8, collected.items);
+
+ return .{ .seed = seed, .appends = try collected.toOwnedSlice(arena) };
+}
+
+/// Seed a fresh conversation with the resolved blocks and record matching
+/// `append`-mode entries in the session log.
+pub fn seedFresh(
+ arena: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ base_dir: []const u8,
+ conv: *panto.conversation.Conversation,
+ mgr: *panto.session_manager.SessionManager,
+) !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);
+}
+
+/// Layer-explicit variant of `seedFresh` (see `resolveLayers`).
+pub fn seedFreshLayers(
+ arena: Allocator,
+ io: Io,
+ base_dir: ?[]const u8,
+ user_dir: ?[]const u8,
+ project_dir: ?[]const u8,
+ conv: *panto.conversation.Conversation,
+ mgr: *panto.session_manager.SessionManager,
+) !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);
+ }
+}
+
+/// Reconcile a resumed conversation against the current on-disk config.
+///
+/// Compares the freshly-resolved config blocks, by position and exact
+/// text, against the conversation's **current effective config window**:
+/// the last `replace`-mode system block plus every system block after it.
+/// If they match, this is a no-op. Otherwise it appends a fresh
+/// `replace + N×append` sequence to both the conversation and the log,
+/// re-seeding the prompt wholesale without rewriting history.
+pub fn reconcileResume(
+ arena: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ base_dir: []const u8,
+ conv: *panto.conversation.Conversation,
+ mgr: *panto.session_manager.SessionManager,
+) !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);
+}
+
+/// Layer-explicit variant of `reconcileResume` (see `resolveLayers`).
+pub fn reconcileResumeLayers(
+ arena: Allocator,
+ io: Io,
+ base_dir: ?[]const u8,
+ user_dir: ?[]const u8,
+ project_dir: ?[]const u8,
+ conv: *panto.conversation.Conversation,
+ mgr: *panto.session_manager.SessionManager,
+) !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);
+
+ 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);
+ for (config_blocks[1..]) |text| {
+ try conv.addSystemMessage(text);
+ try appendSystemEntry(mgr.allocator, mgr, text, .append);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Internals
+// ---------------------------------------------------------------------------
+
+/// The current effective config window: the text of the last `replace`-mode
+/// system block plus every system block after it, in order. If there is no
+/// `replace` block, the window is every leading system block (the original
+/// seed sequence). Returned slices borrow from `messages`.
+fn effectiveConfigWindow(
+ arena: Allocator,
+ messages: []const panto.conversation.Message,
+) ![]const []const u8 {
+ // Find the index of the last message carrying a `replace`-mode block.
+ var anchor: ?usize = null;
+ for (messages, 0..) |msg, i| {
+ if (msg.role != .system) continue;
+ for (msg.content.items) |block| {
+ switch (block) {
+ .System => |sb| if (sb.mode == .replace) {
+ anchor = i;
+ },
+ else => {},
+ }
+ }
+ }
+
+ var out: std.ArrayList([]const u8) = .empty;
+ const start = anchor orelse 0;
+ for (messages[start..]) |msg| {
+ if (msg.role != .system) continue;
+ for (msg.content.items) |block| {
+ switch (block) {
+ .System => |sb| try out.append(arena, sb.text.items),
+ .Text => |tb| try out.append(arena, tb.items),
+ else => {},
+ }
+ }
+ }
+ return out.toOwnedSlice(arena);
+}
+
+fn blocksEqual(a: []const []const u8, b: []const []const u8) bool {
+ if (a.len != b.len) return false;
+ for (a, b) |x, y| {
+ if (!std.mem.eql(u8, x, y)) return false;
+ }
+ 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(
+ arena: Allocator,
+ io: Io,
+ dir: []const u8,
+ name: []const u8,
+) !?[]u8 {
+ const path = try std.fs.path.join(arena, &.{ dir, name });
+ const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
+ error.FileNotFound => return null,
+ else => return err,
+ };
+ defer file.close(io);
+ const len = file.length(io) catch return error.SystemPromptReadFailed;
+ const bytes = try arena.alloc(u8, @intCast(len));
+ errdefer arena.free(bytes);
+ _ = file.readPositionalAll(io, bytes, 0) catch return error.SystemPromptReadFailed;
+ return bytes;
+}
+
+fn userLayerDir(arena: Allocator, environ_map: *const std.process.Environ.Map) !?[]u8 {
+ if (environ_map.get("XDG_CONFIG_HOME")) |xdg| {
+ return try std.fs.path.join(arena, &.{ xdg, "panto" });
+ }
+ if (environ_map.get("HOME")) |home| {
+ return try std.fs.path.join(arena, &.{ home, ".config", "panto" });
+ }
+ return null;
+}
+
+fn projectLayerDir(arena: Allocator, io: Io) ![]u8 {
+ const cwd = try std.process.currentPathAlloc(io, arena);
+ return try std.fs.path.join(arena, &.{ cwd, ".panto" });
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+test "blocksEqual - length and content" {
+ try testing.expect(blocksEqual(&.{ "a", "b" }, &.{ "a", "b" }));
+ try testing.expect(!blocksEqual(&.{ "a", "b" }, &.{ "a", "c" }));
+ try testing.expect(!blocksEqual(&.{"a"}, &.{ "a", "b" }));
+ try testing.expect(blocksEqual(&.{}, &.{}));
+}
+
+test "effectiveConfigWindow - no replace anchors to leading system blocks" {
+ const alloc = testing.allocator;
+ var conv = panto.conversation.Conversation.init(alloc);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("seed");
+ try conv.addSystemMessage("append-1");
+ try conv.addUserMessage("hi");
+
+ var arena_state = std.heap.ArenaAllocator.init(alloc);
+ defer arena_state.deinit();
+ const window = try effectiveConfigWindow(arena_state.allocator(), conv.messages.items);
+
+ try testing.expectEqual(@as(usize, 2), window.len);
+ try testing.expectEqualStrings("seed", window[0]);
+ try testing.expectEqualStrings("append-1", window[1]);
+}
+
+test "effectiveConfigWindow - anchors to last replace block" {
+ const alloc = testing.allocator;
+ var conv = panto.conversation.Conversation.init(alloc);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("old seed");
+ try conv.addUserMessage("hi");
+ try conv.replaceSystemMessage("new seed");
+ try conv.addSystemMessage("new append");
+ try conv.addUserMessage("again");
+
+ var arena_state = std.heap.ArenaAllocator.init(alloc);
+ defer arena_state.deinit();
+ const window = try effectiveConfigWindow(arena_state.allocator(), conv.messages.items);
+
+ try testing.expectEqual(@as(usize, 2), window.len);
+ try testing.expectEqualStrings("new seed", window[0]);
+ try testing.expectEqualStrings("new append", window[1]);
+}
+
+fn writeTmpFile(dir: Io.Dir, sub_path: []const u8, content: []const u8) !void {
+ try dir.writeFile(testing.io, .{ .sub_path = sub_path, .data = content });
+}
+
+test "resolveLayers - highest SYSTEM.md wins, appends reversed project-first" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ // base: SYSTEM.md + APPEND_SYSTEM.md; user: APPEND only; project:
+ // SYSTEM.md + APPEND_SYSTEM.md (matches the design's worked example).
+ try tmp.dir.createDirPath(testing.io, "base");
+ try tmp.dir.createDirPath(testing.io, "user");
+ try tmp.dir.createDirPath(testing.io, "project");
+ try writeTmpFile(tmp.dir, "base/SYSTEM.md", "base seed");
+ try writeTmpFile(tmp.dir, "base/APPEND_SYSTEM.md", "base append");
+ try writeTmpFile(tmp.dir, "user/APPEND_SYSTEM.md", "user append");
+ try writeTmpFile(tmp.dir, "project/SYSTEM.md", "project seed");
+ try writeTmpFile(tmp.dir, "project/APPEND_SYSTEM.md", "project append");
+
+ var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const root_len = try tmp.dir.realPathFile(testing.io, ".", &buf);
+ const root = buf[0..root_len];
+ const base_dir = try std.fs.path.join(arena, &.{ root, "base" });
+ const user_dir = try std.fs.path.join(arena, &.{ root, "user" });
+ const project_dir = try std.fs.path.join(arena, &.{ root, "project" });
+
+ const resolved = try resolveLayers(arena, testing.io, base_dir, user_dir, project_dir);
+ try testing.expectEqualStrings("project seed", resolved.seed);
+
+ const blocks = try resolved.blocks(arena);
+ try testing.expectEqual(@as(usize, 4), blocks.len);
+ try testing.expectEqualStrings("project seed", blocks[0]);
+ try testing.expectEqualStrings("project append", blocks[1]);
+ try testing.expectEqualStrings("user append", blocks[2]);
+ try testing.expectEqualStrings("base append", blocks[3]);
+}
+
+test "resolveLayers - no SYSTEM.md anywhere falls back to built-in default" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const root_len = try tmp.dir.realPathFile(testing.io, ".", &buf);
+ const root = buf[0..root_len];
+
+ const resolved = try resolveLayers(arena, testing.io, root, null, null);
+ try testing.expectEqualStrings(default_seed, resolved.seed);
+ try testing.expectEqual(@as(usize, 0), resolved.appends.len);
+}
+
+const TmpLayers = struct {
+ tmp: std.testing.TmpDir,
+ root: []u8,
+
+ fn init(arena: Allocator) !TmpLayers {
+ var tmp = std.testing.tmpDir(.{ .iterate = true });
+ errdefer tmp.cleanup();
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const n = try tmp.dir.realPathFile(testing.io, ".", &buf);
+ const root = try arena.dupe(u8, buf[0..n]);
+ try tmp.dir.createDirPath(testing.io, "project");
+ return .{ .tmp = tmp, .root = root };
+ }
+
+ fn projectDir(self: TmpLayers, arena: Allocator) ![]u8 {
+ return std.fs.path.join(arena, &.{ self.root, "project" });
+ }
+
+ fn writeProject(self: *TmpLayers, name: []const u8, content: []const u8) !void {
+ const sub = try std.fmt.allocPrint(testing.allocator, "project/{s}", .{name});
+ defer testing.allocator.free(sub);
+ try self.tmp.dir.writeFile(testing.io, .{ .sub_path = sub, .data = content });
+ }
+
+ fn deinit(self: *TmpLayers) void {
+ self.tmp.cleanup();
+ }
+};
+
+fn openTmpSession(arena: Allocator, root: []const u8) !panto.session_manager.SessionManager {
+ const sessions = try std.fs.path.join(arena, &.{ root, "sessions" });
+ return panto.session_manager.SessionManager.init(testing.allocator, testing.io, sessions, "/cwd");
+}
+
+/// 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.)
+fn forceFlush(mgr: *panto.session_manager.SessionManager) !void {
+ const blocks = try mgr.allocator.alloc(panto.session_manager.DiskContentBlock, 1);
+ blocks[0] = .{ .text = .{ .text = try mgr.allocator.dupe(u8, "ok") } };
+ _ = try mgr.appendMessage(
+ .{ .role = .assistant, .content = blocks },
+ "prov",
+ "model",
+ );
+}
+
+test "seedFresh then no-config-change resume is a no-op" {
+ const alloc = testing.allocator;
+ var arena_state = std.heap.ArenaAllocator.init(alloc);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+
+ var layers = try TmpLayers.init(arena);
+ defer layers.deinit();
+ try layers.writeProject("SYSTEM.md", "project seed");
+ try layers.writeProject("APPEND_SYSTEM.md", "project append");
+ const project_dir = try layers.projectDir(arena);
+
+ // Fresh session: seed + persist.
+ 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);
+ }
+ try forceFlush(&mgr); // persist seed + append (+ assistant) to disk
+ const entries_on_disk = mgr.getEntries().len;
+ try testing.expectEqual(@as(usize, 3), entries_on_disk); // seed + append + assistant
+ const session_file = try arena.dupe(u8, mgr.getSessionFile());
+ mgr.deinit();
+
+ // Resume: reopen the file, rebuild + 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);
+ try testing.expectEqual(entries_on_disk, mgr2.getEntries().len);
+}
+
+test "resume after config change appends replace + append sequence" {
+ const alloc = testing.allocator;
+ var arena_state = std.heap.ArenaAllocator.init(alloc);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+
+ var layers = try TmpLayers.init(arena);
+ defer layers.deinit();
+ try layers.writeProject("SYSTEM.md", "old seed");
+ const project_dir = try layers.projectDir(arena);
+
+ 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);
+ }
+ try forceFlush(&mgr); // persist old seed (+ assistant) to disk
+ try testing.expectEqual(@as(usize, 2), mgr.getEntries().len); // seed + assistant
+ const session_file = try arena.dupe(u8, mgr.getSessionFile());
+ mgr.deinit();
+
+ // Change the config: new seed + a new append.
+ try layers.writeProject("SYSTEM.md", "new seed");
+ try layers.writeProject("APPEND_SYSTEM.md", "new append");
+
+ 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);
+
+ // 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);
+ 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]);
+
+ // 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 testing.expectEqual(after_first, mgr2.getEntries().len);
+}
+
+test "Resolved.blocks - seed leads appends" {
+ const alloc = testing.allocator;
+ var arena_state = std.heap.ArenaAllocator.init(alloc);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+
+ const resolved: Resolved = .{ .seed = "S", .appends = &.{ "P", "U", "B" } };
+ const blocks = try resolved.blocks(arena);
+ try testing.expectEqual(@as(usize, 4), blocks.len);
+ try testing.expectEqualStrings("S", blocks[0]);
+ try testing.expectEqualStrings("P", blocks[1]);
+ try testing.expectEqualStrings("U", blocks[2]);
+ try testing.expectEqualStrings("B", blocks[3]);
+}