From beb702485853c1afba657d2d5ea9209c3ca81401 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 2 Aug 2026 19:44:11 +0100 Subject: [PATCH] fix: scope delayed clear feedback --- ui/src/pages/chat/chat-history.ts | 53 ++++++++++++++++++++++----- ui/src/pages/chat/chat-send.test.ts | 57 +++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index 30d710c2dbd8..68c9476809fc 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -1109,6 +1109,13 @@ type ClearChatHistoryState = ChatState & type ClearChatHistoryResult = "completed" | "failed" | "uncertain"; +type ClearChatViewOwner = { + client: ClearChatHistoryState["client"]; + connectionEpoch: number; + sessionKey: string; + agentId?: string; +}; + type RewindChatHistoryState = ChatState & Parameters[0] & { handleChatDraftChange: (next: string) => void; @@ -1142,6 +1149,14 @@ function clearCachedChatMessagesForSession( clearChatMessagesFromCache(state.chatMessagesBySession, state, { sessionKey, agentId }); } +function ownsClearChatView(state: ClearChatHistoryState, owner: ClearChatViewOwner): boolean { + return ( + state.client === owner.client && + state.connectionEpoch === owner.connectionEpoch && + visibleSessionMatches(state, owner.sessionKey, owner.agentId) + ); +} + function clearPostResetBranchPrecondition( state: ClearChatHistoryState, target: { @@ -1178,6 +1193,12 @@ export async function clearChatHistory( const connectionEpoch = state.connectionEpoch; const sessionKey = state.sessionKey; const agentParams = scopedAgentParamsForSession(state, sessionKey); + const originalViewOwner: ClearChatViewOwner = { + client, + connectionEpoch, + sessionKey, + agentId: agentParams.agentId, + }; const runId = state.chatRunId; const hadActiveRun = hasAbortableChatSessionRun(state); try { @@ -1197,6 +1218,12 @@ export async function clearChatHistory( state.connectionEpoch !== connectionEpoch || !state.connected ) { + const feedbackOwner: ClearChatViewOwner = { + client: state.client, + connectionEpoch: state.connectionEpoch, + sessionKey, + agentId: agentParams.agentId, + }; let historyRefreshed = false; if ( state.client && @@ -1215,20 +1242,24 @@ export async function clearChatHistory( history, ); } - setChatError( - state, - historyRefreshed - ? "The clear request may have completed. Current history was refreshed; review it before resuming queued messages." - : "The clear request may have completed. Cached history was cleared, but current history could not be refreshed; reconnect and review it before resuming queued messages.", - ); - scheduleChatScroll(state); + if (ownsClearChatView(state, feedbackOwner)) { + setChatError( + state, + historyRefreshed + ? "The clear request may have completed. Current history was refreshed; review it before resuming queued messages." + : "The clear request may have completed. Cached history was cleared, but current history could not be refreshed; reconnect and review it before resuming queued messages.", + ); + scheduleChatScroll(state); + } // sessions.reset is not idempotent. Treat an uncertain completion as // consumed so a durable /clear row cannot erase newer history on retry. return "uncertain"; } } catch (err) { - setChatError(state, String(err)); - scheduleChatScroll(state); + if (ownsClearChatView(state, originalViewOwner)) { + setChatError(state, String(err)); + scheduleChatScroll(state); + } return "failed"; } if (!visibleSessionMatches(state, sessionKey, agentParams.agentId)) { @@ -1253,7 +1284,9 @@ export async function clearChatHistory( { client, connectionEpoch, sessionKey, agentId: agentParams.agentId }, history, ); - scheduleChatScroll(state); + if (ownsClearChatView(state, originalViewOwner)) { + scheduleChatScroll(state); + } return "completed"; } diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 7a5deb5b7f15..5b3e8860c4fa 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -7318,7 +7318,7 @@ describe("handleSendChat", () => { expect(listStoredChatOutboxes(host)).toStrictEqual([]); }); - it("invalidates the captured session cache when a rejected clear switches routes", async () => { + it("invalidates the captured session cache without replacing the visible route error", async () => { const reset = createDeferred(); const sourceSessionKey = "agent:main:source"; @@ -7348,6 +7348,8 @@ describe("handleSendChat", () => { host.sessionKey = visibleSessionKey; syncVisibleChatQueueProjection(host); host.chatMessages = [{ role: "user", content: "visible history" }]; + host.lastError = "Visible session error"; + host.chatError = "Visible session error"; reset.reject(new Error("post-commit lifecycle failed")); await clearing; @@ -7358,8 +7360,8 @@ describe("handleSendChat", () => { }), ).toEqual([{ role: "user", content: "cached visible history" }]); expect(host.chatMessages).toEqual([{ role: "user", content: "visible history" }]); - expect(host.lastError).toContain("clear request may have completed"); - expect(host.lastError).toContain("could not be refreshed"); + expect(host.lastError).toBe("Visible session error"); + expect(host.chatError).toBe("Visible session error"); expect(listStoredChatOutboxes(host)).toStrictEqual([]); }); @@ -7412,6 +7414,55 @@ describe("handleSendChat", () => { ).toHaveLength(0); }); + it.each(["route", "connection"] as const)( + "does not apply uncertain clear feedback after a %s change during history refresh", + async (change) => { + const reset = createDeferred(); + const history = createDeferred(); + const sourceSessionKey = "agent:main:source"; + const replacementRequest = makeRequestMock({ + "chat.history": () => history.promise, + }); + const host = makeHost({ + requestHandlers: { + "sessions.reset": () => reset.promise, + }, + connectionEpoch: 1, + chatMessage: "/clear", + chatMessages: [{ role: "user", content: "source history" }], + sessionKey: sourceSessionKey, + }); + + const clearing = handleSendChat(host); + await waitForFast(() => + expect(host.request).toHaveBeenCalledWith("sessions.reset", { key: sourceSessionKey }), + ); + host.client = clientWithRequest(replacementRequest); + host.connectionEpoch = 2; + reset.resolve({ ok: true }); + await waitForFast(() => + expect(replacementRequest).toHaveBeenCalledWith("chat.history", { + sessionKey: sourceSessionKey, + limit: 100, + }), + ); + + if (change === "route") { + host.sessionKey = "agent:main:replacement"; + } else { + host.client = clientWithRequest(makeRequestMock()); + host.connectionEpoch = 3; + } + host.lastError = "Replacement session error"; + host.chatError = "Replacement session error"; + history.resolve({ messages: [], thinkingLevel: null }); + await clearing; + + expect(host.lastError).toBe("Replacement session error"); + expect(host.chatError).toBe("Replacement session error"); + }, + ); + it("clears a canonically equivalent alias that becomes visible while reset is pending", async () => { const reset = createDeferred();