fix(ui): skip chat history reload during active sends to prevent mess… (#66997)
Merged via squash. Prepared head SHA: cec28cfa90c445c583bddcb371cc54c5d393ca64 Co-authored-by: scotthuang <1670837+scotthuang@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc
This commit is contained in:
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/plugins: stop `--dangerously-force-unsafe-install` plugin installs from falling back to hook-pack installs after security scan failures, while still preserving non-security fallback behavior for real hook packs. (#58909) Thanks @hxy91819.
|
||||
- Claude CLI/sessions: classify `No conversation found with session ID` as `session_expired` so expired CLI-backed conversations clear the stale binding and recover on the next turn. (#65028) thanks @Ivan-Fn.
|
||||
- Context Engine: gracefully fall back to the legacy engine when a third-party context engine plugin fails at resolution time (unregistered id, factory throw, or contract violation), preventing a full gateway outage on every channel. (#66930) Thanks @openperf.
|
||||
- Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc.
|
||||
|
||||
## 2026.4.14
|
||||
|
||||
|
||||
@@ -622,6 +622,38 @@ describe("connectGateway", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["aborted", "error"] as const)(
|
||||
"replays deferred session.message reloads after %s clears the active run",
|
||||
(terminalState) => {
|
||||
const { host, client } = connectHostGateway();
|
||||
host.chatRunId = "main-run-3";
|
||||
loadChatHistoryMock.mockClear();
|
||||
|
||||
client.emitEvent({
|
||||
event: "session.message",
|
||||
payload: {
|
||||
sessionKey: "main",
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadChatHistoryMock).not.toHaveBeenCalled();
|
||||
|
||||
client.emitEvent({
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "main-run-3",
|
||||
sessionKey: "main",
|
||||
state: terminalState,
|
||||
errorMessage: terminalState === "error" ? "chat failed" : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.chatRunId).toBeNull();
|
||||
expect(loadChatHistoryMock).toHaveBeenCalledTimes(1);
|
||||
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
|
||||
},
|
||||
);
|
||||
|
||||
it("clears tracked BTW terminal runs after reconnect hello", () => {
|
||||
const host = createHost();
|
||||
|
||||
|
||||
@@ -142,6 +142,22 @@ describe("handleGatewayEvent session.message", () => {
|
||||
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("skips history reload while a chat run is active", () => {
|
||||
loadChatHistoryMock.mockReset();
|
||||
const host = createHost();
|
||||
host.sessionKey = "agent:qa:main";
|
||||
host.chatRunId = "run-123";
|
||||
|
||||
handleGatewayEvent(host, {
|
||||
type: "event",
|
||||
event: "session.message",
|
||||
payload: { sessionKey: "agent:qa:main" },
|
||||
seq: 1,
|
||||
});
|
||||
|
||||
expect(loadChatHistoryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores transcript updates for other sessions", () => {
|
||||
loadChatHistoryMock.mockReset();
|
||||
const host = createHost();
|
||||
|
||||
@@ -97,6 +97,10 @@ type GatewayHost = {
|
||||
updateAvailable: UpdateAvailable | null;
|
||||
};
|
||||
|
||||
type GatewayHostWithDeferredSessionMessageReload = GatewayHost & {
|
||||
pendingSessionMessageReloadSessionKey?: string | null;
|
||||
};
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
defaultAgentId?: string;
|
||||
mainKey?: string;
|
||||
@@ -409,8 +413,26 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u
|
||||
}
|
||||
const state = handleChatEvent(host as unknown as ChatState, payload);
|
||||
const historyReloaded = handleTerminalChatEvent(host, payload, state);
|
||||
const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload;
|
||||
const deferredSessionKey = deferredReloadHost.pendingSessionMessageReloadSessionKey?.trim();
|
||||
const payloadSessionKey = payload?.sessionKey?.trim();
|
||||
const shouldReplayDeferredSessionMessageReload = Boolean(
|
||||
deferredSessionKey &&
|
||||
payloadSessionKey &&
|
||||
deferredSessionKey === payloadSessionKey &&
|
||||
isTerminalChatState(state) &&
|
||||
payloadSessionKey === host.sessionKey &&
|
||||
!host.chatRunId,
|
||||
);
|
||||
if (deferredSessionKey && payloadSessionKey && deferredSessionKey === payloadSessionKey) {
|
||||
deferredReloadHost.pendingSessionMessageReloadSessionKey = null;
|
||||
}
|
||||
if (state === "final" && !historyReloaded && shouldReloadHistoryForFinalEvent(payload)) {
|
||||
void loadChatHistory(host as unknown as ChatState);
|
||||
return;
|
||||
}
|
||||
if (shouldReplayDeferredSessionMessageReload && !historyReloaded) {
|
||||
void loadChatHistory(host as unknown as ChatState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,10 +440,21 @@ function handleSessionMessageGatewayEvent(
|
||||
host: GatewayHost,
|
||||
payload: { sessionKey?: string } | undefined,
|
||||
) {
|
||||
const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload;
|
||||
const sessionKey = payload?.sessionKey?.trim();
|
||||
if (!sessionKey || sessionKey !== host.sessionKey) {
|
||||
return;
|
||||
}
|
||||
// Skip history reload while a chat run is active. The chat event handler
|
||||
// manages streaming state and appends the final assistant message. Reloading
|
||||
// history mid-run races with the optimistic user-message update and resets
|
||||
// chatStream, which delays the user message card from appearing until the
|
||||
// first LLM delta arrives.
|
||||
if (host.chatRunId) {
|
||||
deferredReloadHost.pendingSessionMessageReloadSessionKey = sessionKey;
|
||||
return;
|
||||
}
|
||||
deferredReloadHost.pendingSessionMessageReloadSessionKey = null;
|
||||
void loadChatHistory(host as unknown as ChatState);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user