From 5f647cb31c31c4b38227d35a295a681b6dcf62d0 Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:03:49 +0800 Subject: [PATCH] peer mail: reading the inbox no longer rewrites sent history At each step boundary with an empty inbox, placeWaitingBeforeHuman removed every peer wake line from the whole history, including the one the current turn started from, which the model had already been sent. That rewrote history the provider had cached: on Claude Agent it started a fresh claude process ("restart: earlier history changed") on every woken turn, and on other providers it broke the cached prefix. It now removes only wakes after the last non-user message, the ones the model was never sent. A leftover wake placed before a human prompt is still dropped once the inbox is clear. --- src/peer_wake_loop.zig | 29 +++++++++++++++++++++++++++-- src/session_peer.zig | 22 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/peer_wake_loop.zig b/src/peer_wake_loop.zig index b5bcb9a4c..2a33840bf 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 707546d2e..72a578e32 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) {