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 };