Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/peer_wake_loop.zig
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@ fn lastIsHuman(root: *Agent) bool {
}

/// Keep the human prompt newest. If mail is waiting, put one wake *before*
/// that prompt. If the inbox was cleared, drop a leftover wake.
/// that prompt. If the inbox was cleared, drop a leftover wake the model was
/// never sent; wakes already in the sent history stay put.
pub fn placeWaitingBeforeHuman(root: *Agent) void {
if (root.sub) return;
if (lastIsHuman(root)) peer_idle.noteHumanPrompt();
if (!peer_inbox.pending()) {
session_peer.dropAllInjects(&root.messages);
session_peer.dropUnsentInjects(&root.messages);
peer_idle.markPlaced(peer_inbox.generation());
return;
}
Expand Down Expand Up @@ -212,3 +213,27 @@ test "#1137 later human turn still sees waiting peer mail" {
try std.testing.expectEqual(@as(usize, 1), root.messages.items.len);
try std.testing.expect(session_peer.isHumanUserTurn(root.messages.items[0]));
}

test "a wake the model already answered stays in history after the inbox is read" {
peer_inbox.resetForTest();
peer_idle.resetForTest();
defer {
peer_inbox.resetForTest();
peer_idle.resetForTest();
}
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const sent = try std.json.parseFromSliceLeaky(std.json.Value, a,
\\[{"role":"user","content":"fix the parser"},
\\ {"role":"assistant","content":"done"},
\\ {"role":"user","content":"[peer] 1 unread from lead — parked; peer_message action=inbox when relevant"},
\\ {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"peer_message","input":{"action":"inbox"}}]},
\\ {"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"[peer message from lead]: also handle lowercase"}]}]
, .{});
var root = dummyRoot(a, sent.array);
// The inbox is empty again: the next step must not rewrite what was sent.
placeWaitingBeforeHuman(&root);
try std.testing.expectEqual(@as(usize, 5), root.messages.items.len);
try std.testing.expect(peer_context.isPeerInject(root.messages.items[2]));
}
22 changes: 22 additions & 0 deletions src/session_peer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ pub fn messagesForSave(arena: Allocator, items: []const Value) !std.json.Array {
return out;
}

/// Drop wakes the model has not been sent yet: those after the last
/// non-user message. A wake earlier in history was already sent and
/// answered; removing it rewrote history the provider had cached (and made
/// Claude Agent start a fresh process) on every step after an inbox read.
pub fn dropUnsentInjects(msgs: *std.json.Array) void {
var start = msgs.items.len;
while (start > 0) : (start -= 1) {
const m = msgs.items[start - 1];
if (m != .object) break;
const role = m.object.get("role") orelse break;
if (role != .string or !std.mem.eql(u8, role.string, "user")) break;
}
var i = start;
while (i < msgs.items.len) {
if (peer_context.isPeerInject(msgs.items[i])) {
_ = msgs.orderedRemove(i);
continue;
}
i += 1;
}
}

pub fn dropAllInjects(msgs: *std.json.Array) void {
var i: usize = 0;
while (i < msgs.items.len) {
Expand Down
Loading