fix(ui): prevent premature compaction status update on retry (#55132)

Merged via squash.

Prepared head SHA: e7e562f982
Co-authored-by: mpz4life <32388289+mpz4life@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Kris Wu 2026-04-02 04:38:51 +08:00 committed by GitHub
parent 7cb323d84f
commit 7027dda8cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 408 additions and 128 deletions

View File

@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Gateway/session reset: emit the typed `before_reset` hook for gateway `/new` and `/reset`, preserving reset-hook behavior even when the previous transcript has already been archived. (#53872) thanks @VACInc
- Plugins/commands: pass the active host `sessionKey` into plugin command contexts, and include `sessionId` when it is already available from the active session entry, so bundled and third-party commands can resolve the current conversation reliably. (#59044) Thanks @jalehman.
- Agents/auth: honor `models.providers.*.authHeader` for pi embedded runner model requests by injecting `Authorization: Bearer <apiKey>` when requested. (#54390) Thanks @lndyzwdxhs.
- UI/compaction: keep the compaction indicator in a retry-pending state until the run actually finishes, so the UI does not show `Context compacted` before compaction actually finishes. (#55132) Thanks @mpz4life.
## 2026.4.2

View File

@ -139,4 +139,174 @@ describe("app-tool-stream fallback lifecycle handling", () => {
expect(host.fallbackStatus?.previous).toBe("deepinfra/moonshotai/Kimi-K2.5");
vi.useRealTimers();
});
it("keeps compaction in retry-pending state until the matching lifecycle end", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "compaction",
ts: Date.now(),
sessionKey: "main",
data: { phase: "start" },
});
expect(host.compactionStatus).toEqual({
phase: "active",
runId: "run-1",
startedAt: expect.any(Number),
completedAt: null,
});
handleAgentEvent(host, {
runId: "run-1",
seq: 2,
stream: "compaction",
ts: Date.now(),
sessionKey: "main",
data: { phase: "end", willRetry: true, completed: true },
});
expect(host.compactionStatus).toEqual({
phase: "retrying",
runId: "run-1",
startedAt: expect.any(Number),
completedAt: null,
});
expect(host.compactionClearTimer).toBeNull();
handleAgentEvent(host, {
runId: "run-2",
seq: 3,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: { phase: "end" },
});
expect(host.compactionStatus).toEqual({
phase: "retrying",
runId: "run-1",
startedAt: expect.any(Number),
completedAt: null,
});
handleAgentEvent(host, {
runId: "run-1",
seq: 4,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: { phase: "end" },
});
expect(host.compactionStatus).toEqual({
phase: "complete",
runId: "run-1",
startedAt: expect.any(Number),
completedAt: expect.any(Number),
});
expect(host.compactionClearTimer).not.toBeNull();
vi.advanceTimersByTime(5_000);
expect(host.compactionStatus).toBeNull();
expect(host.compactionClearTimer).toBeNull();
vi.useRealTimers();
});
it("treats lifecycle error as terminal for retry-pending compaction", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "compaction",
ts: Date.now(),
sessionKey: "main",
data: { phase: "start" },
});
handleAgentEvent(host, {
runId: "run-1",
seq: 2,
stream: "compaction",
ts: Date.now(),
sessionKey: "main",
data: { phase: "end", willRetry: true, completed: true },
});
expect(host.compactionStatus).toEqual({
phase: "retrying",
runId: "run-1",
startedAt: expect.any(Number),
completedAt: null,
});
handleAgentEvent(host, {
runId: "run-1",
seq: 3,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: { phase: "error", error: "boom" },
});
expect(host.compactionStatus).toEqual({
phase: "complete",
runId: "run-1",
startedAt: expect.any(Number),
completedAt: expect.any(Number),
});
expect(host.compactionClearTimer).not.toBeNull();
vi.advanceTimersByTime(5_000);
expect(host.compactionStatus).toBeNull();
expect(host.compactionClearTimer).toBeNull();
vi.useRealTimers();
});
it("does not surface retrying or complete when retry compaction failed", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "compaction",
ts: Date.now(),
sessionKey: "main",
data: { phase: "start" },
});
handleAgentEvent(host, {
runId: "run-1",
seq: 2,
stream: "compaction",
ts: Date.now(),
sessionKey: "main",
data: { phase: "end", willRetry: true, completed: false },
});
expect(host.compactionStatus).toBeNull();
expect(host.compactionClearTimer).toBeNull();
handleAgentEvent(host, {
runId: "run-1",
seq: 3,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: { phase: "error", error: "boom" },
});
expect(host.compactionStatus).toBeNull();
expect(host.compactionClearTimer).toBeNull();
vi.useRealTimers();
});
});

View File

@ -245,7 +245,8 @@ export function resetToolStream(host: ToolStreamHost) {
}
export type CompactionStatus = {
active: boolean;
phase: "active" | "retrying" | "complete";
runId: string | null;
startedAt: number | null;
completedAt: number | null;
};
@ -270,34 +271,87 @@ type CompactionHost = ToolStreamHost & {
const COMPACTION_TOAST_DURATION_MS = 5000;
const FALLBACK_TOAST_DURATION_MS = 8000;
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
const phase = typeof data.phase === "string" ? data.phase : "";
// Clear any existing timer
function clearCompactionTimer(host: CompactionHost) {
if (host.compactionClearTimer != null) {
window.clearTimeout(host.compactionClearTimer);
host.compactionClearTimer = null;
}
}
function scheduleCompactionClear(host: CompactionHost) {
host.compactionClearTimer = window.setTimeout(() => {
host.compactionStatus = null;
host.compactionClearTimer = null;
}, COMPACTION_TOAST_DURATION_MS);
}
function setCompactionComplete(host: CompactionHost, runId: string) {
host.compactionStatus = {
phase: "complete",
runId,
startedAt: host.compactionStatus?.startedAt ?? null,
completedAt: Date.now(),
};
scheduleCompactionClear(host);
}
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
const phase = typeof data.phase === "string" ? data.phase : "";
const completed = data.completed === true;
clearCompactionTimer(host);
if (phase === "start") {
host.compactionStatus = {
active: true,
phase: "active",
runId: payload.runId,
startedAt: Date.now(),
completedAt: null,
};
} else if (phase === "end") {
host.compactionStatus = {
active: false,
startedAt: host.compactionStatus?.startedAt ?? null,
completedAt: Date.now(),
};
// Auto-clear the toast after duration
host.compactionClearTimer = window.setTimeout(() => {
host.compactionStatus = null;
host.compactionClearTimer = null;
}, COMPACTION_TOAST_DURATION_MS);
return;
}
if (phase === "end") {
if (data.willRetry === true && completed) {
// Compaction already succeeded, but the run is still retrying.
// Keep that distinct state until the matching lifecycle end arrives.
host.compactionStatus = {
phase: "retrying",
runId: payload.runId,
startedAt: host.compactionStatus?.startedAt ?? Date.now(),
completedAt: null,
};
return;
}
if (completed) {
setCompactionComplete(host, payload.runId);
return;
}
host.compactionStatus = null;
}
}
function handleLifecycleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
const phase = toTrimmedString(data.phase);
if (phase !== "end" && phase !== "error") {
return;
}
// We scope lifecycle cleanup to the visible chat session first, then
// use runId only to match the specific compaction retry we started tracking.
const accepted = resolveAcceptedSession(host, payload, { allowSessionScopedWhenIdle: true });
if (!accepted.accepted) {
return;
}
if (host.compactionStatus?.phase !== "retrying") {
return;
}
if (host.compactionStatus.runId && host.compactionStatus.runId !== payload.runId) {
return;
}
setCompactionComplete(host, payload.runId);
}
function resolveAcceptedSession(
@ -400,7 +454,13 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
return;
}
if (payload.stream === "lifecycle" || payload.stream === "fallback") {
if (payload.stream === "lifecycle") {
handleLifecycleCompactionEvent(host as CompactionHost, payload);
handleLifecycleFallbackEvent(host as CompactionHost, payload);
return;
}
if (payload.stream === "fallback") {
handleLifecycleFallbackEvent(host as CompactionHost, payload);
return;
}

View File

@ -519,7 +519,8 @@ describe("chat view", () => {
renderChat(
createProps({
compactionStatus: {
active: true,
phase: "active",
runId: "run-1",
startedAt: Date.now(),
completedAt: null,
},
@ -533,6 +534,27 @@ describe("chat view", () => {
expect(indicator?.textContent).toContain("Compacting context...");
});
it("renders retry-pending compaction indicator as a badge", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
compactionStatus: {
phase: "retrying",
runId: "run-1",
startedAt: Date.now(),
completedAt: null,
},
}),
),
container,
);
const indicator = container.querySelector(".compaction-indicator--active");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Retrying after compaction...");
});
it("renders completion indicator shortly after compaction", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
@ -540,7 +562,8 @@ describe("chat view", () => {
renderChat(
createProps({
compactionStatus: {
active: false,
phase: "complete",
runId: "run-1",
startedAt: 900,
completedAt: 900,
},
@ -562,7 +585,8 @@ describe("chat view", () => {
renderChat(
createProps({
compactionStatus: {
active: false,
phase: "complete",
runId: "run-1",
startedAt: 0,
completedAt: 0,
},

View File

@ -1,6 +1,10 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import type {
CompactionStatus as CompactionIndicatorStatus,
FallbackStatus as FallbackIndicatorStatus,
} from "../app-tool-stream.ts";
import {
CHAT_ATTACHMENT_ACCEPT,
isSupportedChatAttachmentMimeType,
@ -35,22 +39,6 @@ import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts";
export type CompactionIndicatorStatus = {
active: boolean;
startedAt: number | null;
completedAt: number | null;
};
export type FallbackIndicatorStatus = {
phase?: "active" | "cleared";
selected: string;
active: string;
previous?: string;
reason?: string;
attempts: string[];
occurredAt: number;
};
export type ChatProps = {
sessionKey: string;
onSessionKeyChange: (next: string) => void;
@ -193,7 +181,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (!status) {
return nothing;
}
if (status.active) {
if (status.phase === "active") {
return html`
<div
class="compaction-indicator compaction-indicator--active"
@ -204,7 +192,18 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
</div>
`;
}
if (status.completedAt) {
if (status.phase === "retrying") {
return html`
<div
class="compaction-indicator compaction-indicator--active"
role="status"
aria-live="polite"
>
${icons.loader} Retrying after compaction...
</div>
`;
}
if (status.phase === "complete" && status.completedAt) {
const elapsed = Date.now() - status.completedAt;
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
@ -642,15 +641,17 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
return html`
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
<div class="agent-chat__welcome-glow"></div>
${avatar
? html`<img
${
avatar
? html`<img
src=${avatar}
alt=${name}
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
/>`
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
<img src=${logoUrl} alt="OpenClaw" />
</div>`}
</div>`
}
<h2>${name}</h2>
<div class="agent-chat__badges">
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
@ -741,8 +742,9 @@ function renderPinnedSection(
>${icons.chevronDown}</span
>
</button>
${vs.pinnedExpanded
? html`
${
vs.pinnedExpanded
? html`
<div class="agent-chat__pinned-list">
${entries.map(
({ index, text, role }) => html`
@ -768,7 +770,8 @@ function renderPinnedSection(
)}
</div>
`
: nothing}
: nothing
}
</div>
`;
}
@ -801,9 +804,11 @@ function renderSlashMenu(
requestUpdate();
}}
>
${vs.slashMenuCommand?.icon
? html`<span class="slash-menu-icon">${icons[vs.slashMenuCommand.icon]}</span>`
: nothing}
${
vs.slashMenuCommand?.icon
? html`<span class="slash-menu-icon">${icons[vs.slashMenuCommand.icon]}</span>`
: nothing
}
<span class="slash-menu-name">${arg}</span>
<span class="slash-menu-desc">/${vs.slashMenuCommand?.name} ${arg}</span>
</div>
@ -845,9 +850,9 @@ function renderSlashMenu(
${entries.map(
({ cmd, globalIdx }) => html`
<div
class="slash-menu-item ${globalIdx === vs.slashMenuIndex
? "slash-menu-item--active"
: ""}"
class="slash-menu-item ${
globalIdx === vs.slashMenuIndex ? "slash-menu-item--active" : ""
}"
role="option"
aria-selected=${globalIdx === vs.slashMenuIndex}
@click=${() => selectSlashCommand(cmd, props, requestUpdate)}
@ -860,11 +865,15 @@ function renderSlashMenu(
<span class="slash-menu-name">/${cmd.name}</span>
${cmd.args ? html`<span class="slash-menu-args">${cmd.args}</span>` : nothing}
<span class="slash-menu-desc">${cmd.description}</span>
${cmd.argOptions?.length
? html`<span class="slash-menu-badge">${cmd.argOptions.length} options</span>`
: cmd.executeLocal && !cmd.args
? html` <span class="slash-menu-badge">instant</span> `
: nothing}
${
cmd.argOptions?.length
? html`<span class="slash-menu-badge">${cmd.argOptions.length} options</span>`
: cmd.executeLocal && !cmd.args
? html`
<span class="slash-menu-badge">instant</span>
`
: nothing
}
</div>
`,
)}
@ -944,49 +953,46 @@ export function renderChat(props: ChatProps) {
@click=${handleCodeBlockCopy}
>
<div class="chat-thread-inner">
${props.loading
? html`
<div class="chat-loading-skeleton" aria-label="Loading chat">
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble">
<div
class="skeleton skeleton-line skeleton-line--long"
style="margin-bottom: 8px"
></div>
<div
class="skeleton skeleton-line skeleton-line--medium"
style="margin-bottom: 8px"
></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
${
props.loading
? html`
<div class="chat-loading-skeleton" aria-label="Loading chat">
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--medium" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
<div class="chat-line user" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
</div>
</div>
<div class="chat-line assistant" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
</div>
<div class="chat-line user" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
</div>
</div>
<div class="chat-line assistant" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div
class="skeleton skeleton-line skeleton-line--long"
style="margin-bottom: 8px"
></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
</div>
`
: nothing}
`
: nothing
}
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
${isEmpty && vs.searchOpen
? html` <div class="agent-chat__empty">No matching messages</div> `
: nothing}
${
isEmpty && vs.searchOpen
? html`
<div class="agent-chat__empty">No matching messages</div>
`
: nothing
}
${repeat(
chatItems,
(item) => item.key,
@ -1164,8 +1170,9 @@ export function renderChat(props: ChatProps) {
>
${props.disabledReason ? html`<div class="callout">${props.disabledReason}</div>` : nothing}
${props.error ? html`<div class="callout danger">${props.error}</div>` : nothing}
${props.focusMode
? html`
${
props.focusMode
? html`
<button
class="chat-focus-exit"
type="button"
@ -1176,7 +1183,8 @@ export function renderChat(props: ChatProps) {
${icons.x}
</button>
`
: nothing}
: nothing
}
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
@ -1187,8 +1195,9 @@ export function renderChat(props: ChatProps) {
${thread}
</div>
${sidebarOpen
? html`
${
sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
@ -1207,11 +1216,13 @@ export function renderChat(props: ChatProps) {
})}
</div>
`
: nothing}
: nothing
}
</div>
${props.queue.length
? html`
${
props.queue.length
? html`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__list">
@ -1219,8 +1230,10 @@ export function renderChat(props: ChatProps) {
(item) => html`
<div class="chat-queue__item">
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
${
item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")
}
</div>
<button
class="btn chat-queue__remove"
@ -1236,17 +1249,20 @@ export function renderChat(props: ChatProps) {
</div>
</div>
`
: nothing}
: nothing
}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null)}
${props.showNewMessages
? html`
${
props.showNewMessages
? html`
<button class="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
${icons.arrowDown} New messages
</button>
`
: nothing}
: nothing
}
<!-- Input bar -->
<div class="agent-chat__input">
@ -1260,9 +1276,11 @@ export function renderChat(props: ChatProps) {
@change=${(e: Event) => handleFileSelect(e, props)}
/>
${vs.sttRecording && vs.sttInterimText
? html`<div class="agent-chat__stt-interim">${vs.sttInterimText}</div>`
: nothing}
${
vs.sttRecording && vs.sttInterimText
? html`<div class="agent-chat__stt-interim">${vs.sttInterimText}</div>`
: nothing
}
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
@ -1290,12 +1308,13 @@ export function renderChat(props: ChatProps) {
${icons.paperclip}
</button>
${isSttSupported()
? html`
${
isSttSupported()
? html`
<button
class="agent-chat__input-btn ${vs.sttRecording
? "agent-chat__input-btn--recording"
: ""}"
class="agent-chat__input-btn ${
vs.sttRecording ? "agent-chat__input-btn--recording" : ""
}"
@click=${() => {
if (vs.sttRecording) {
stopStt();
@ -1342,15 +1361,17 @@ export function renderChat(props: ChatProps) {
${vs.sttRecording ? icons.micOff : icons.mic}
</button>
`
: nothing}
: nothing
}
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
</div>
<div class="agent-chat__toolbar-right">
${nothing /* search hidden for now */}
${canAbort
? nothing
: html`
${
canAbort
? nothing
: html`
<button
class="btn btn--ghost"
@click=${props.onNewSession}
@ -1359,7 +1380,8 @@ export function renderChat(props: ChatProps) {
>
${icons.plus}
</button>
`}
`
}
<button
class="btn btn--ghost"
@click=${() => exportMarkdown(props)}
@ -1370,8 +1392,9 @@ export function renderChat(props: ChatProps) {
${icons.download}
</button>
${canAbort && (isBusy || props.sending)
? html`
${
canAbort && (isBusy || props.sending)
? html`
<button
class="chat-send-btn chat-send-btn--stop"
@click=${props.onAbort}
@ -1381,7 +1404,7 @@ export function renderChat(props: ChatProps) {
${icons.stop}
</button>
`
: html`
: html`
<button
class="chat-send-btn"
@click=${() => {
@ -1396,7 +1419,8 @@ export function renderChat(props: ChatProps) {
>
${icons.send}
</button>
`}
`
}
</div>
</div>
</div>

View File

@ -91,6 +91,7 @@ export default defineConfig({
"ui/src/ui/controllers/chat.test.ts",
"ui/src/ui/controllers/sessions.test.ts",
"ui/src/ui/views/sessions.test.ts",
"ui/src/ui/app-tool-stream.node.test.ts",
"ui/src/ui/app-gateway.sessions.node.test.ts",
"ui/src/ui/chat/slash-command-executor.node.test.ts",
],