diff --git a/src/peer_wake_loop.zig b/src/peer_wake_loop.zig index b5bcb9a4..2a33840b 100644 --- a/src/peer_wake_loop.zig +++ b/src/peer_wake_loop.zig @@ -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; } @@ -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])); +} diff --git a/src/session_peer.zig b/src/session_peer.zig index 707546d2..72a578e3 100644 --- a/src/session_peer.zig +++ b/src/session_peer.zig @@ -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) {