From 53fa9524cf37e4ecf57cc89ad59f4f6ef71c39f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 11 Nov 2026 08:51:28 +0100 Subject: [PATCH] fix(app): keep rewind invalidation for find results after Next and Previous The rewind marker of the result set was cleared in cancel(), which also stops navigation work. Next and Previous call cancel() while keeping the results, so after one step a remote rewind no longer invalidated them and the selection could stay on a retired message. The marker now shares the result set's lifetime and is cleared only when a new query discards it. --- .../src/agent-stream/chat-find/model.test.ts | 73 +++++++++++++++++++ .../app/src/agent-stream/chat-find/model.ts | 4 +- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/app/src/agent-stream/chat-find/model.test.ts b/packages/app/src/agent-stream/chat-find/model.test.ts index 16b544624f..f0b8f60158 100644 --- a/packages/app/src/agent-stream/chat-find/model.test.ts +++ b/packages/app/src/agent-stream/chat-find/model.test.ts @@ -281,6 +281,79 @@ it("stops a search still receiving pages when a rewind lands between them", asyn } }); +it.each([ + ["Next", "next"], + ["Previous", "previous"], +] as const)("reports a rewind after %s as a changed history", async (_label, step) => { + const model = new ChatFindModel({ + async search() { + return response([ + { seq: 1, role: "assistant" }, + { seq: 2, role: "assistant" }, + ]); + }, + async load() {}, + async reveal() { + return { count: 1, occurrence: 0 }; + }, + clear() {}, + }); + try { + model.updateHistory("epoch", [message(1), message(2)], null); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().phase).toBe("ready"); + model[step](); + await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2"); + await expect.poll(() => model.getSnapshot().phase).toBe("ready"); + model.updateHistory("epoch", [message(1)], 3); + expect(model.getSnapshot()).toMatchObject({ + phase: "error", + failure: "historyChanged", + selectedItemId: null, + }); + } finally { + model.close(); + } +}); + +it("stops a pending Next when a rewind lands before it reveals", async () => { + let reveals = 0; + let revealSecond!: () => void; + const secondReveal = new Promise((resolve) => { + revealSecond = resolve; + }); + const model = new ChatFindModel({ + async search() { + return response([ + { seq: 1, role: "assistant" }, + { seq: 2, role: "assistant" }, + ]); + }, + async load() {}, + async reveal() { + reveals += 1; + if (reveals > 1) await secondReveal; + return { count: 1, occurrence: 0 }; + }, + clear() {}, + }); + try { + model.updateHistory("epoch", [message(1), message(2)], null); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().phase).toBe("ready"); + model.next(); + await expect.poll(() => reveals).toBe(2); + model.updateHistory("epoch", [message(1)], 3); + revealSecond(); + await expect.poll(() => model.getSnapshot().failure).toBe("historyChanged"); + expect(model.getSnapshot()).toMatchObject({ phase: "error", selectedItemId: null }); + } finally { + model.close(); + } +}); + it("can load a search location before the initial chat history has arrived", async () => { const model = new ChatFindModel({ async search() { diff --git a/packages/app/src/agent-stream/chat-find/model.ts b/packages/app/src/agent-stream/chat-find/model.ts index d916a6ba2b..66625e1984 100644 --- a/packages/app/src/agent-stream/chat-find/model.ts +++ b/packages/app/src/agent-stream/chat-find/model.ts @@ -78,7 +78,7 @@ export class ChatFindModel { private resultEpoch: string | null = null; /** The latest rewind applied to the history. A rewind keeps the epoch and retires messages. */ private rewindSeq: number | null = null; - /** The latest rewind when the current search started; undefined while no search runs or holds results. */ + /** The latest rewind when the current result set's search started; undefined until a search starts. */ private searchedRewindSeq: number | null | undefined = undefined; private locations: Location[] = []; private resolved = new Map(); @@ -99,7 +99,6 @@ export class ChatFindModel { this.listeners.forEach((listener) => listener()); } private cancel() { - this.searchedRewindSeq = undefined; this.abort.abort(); this.abort = new AbortController(); clearTimeout(this.timer); @@ -118,6 +117,7 @@ export class ChatFindModel { this.cancel(); this.locations = []; this.resultEpoch = null; + this.searchedRewindSeq = undefined; this.resolved.clear(); this.current = -1; this.inMessage = { occurrence: 0, count: 0 };