fix: scope delayed clear feedback

This commit is contained in:
Shakker
2026-08-02 19:44:11 +01:00
parent d44b720b34
commit beb7024858
2 changed files with 97 additions and 13 deletions
+43 -10
View File
@@ -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<typeof scheduleChatScroll>[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";
}
+54 -3
View File
@@ -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<unknown>();
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<unknown>();
const history = createDeferred<unknown>();
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<unknown>();