diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd678bcdd6..b402d104b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index 987ed9a735e..ab6a2fe8c5d 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -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(); + }); }); diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index db84eea6aa0..0db0c356079 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -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; } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 296769ad428..b6bb9c2f43f 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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, }, diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index a221e857658..ba93d04bf66 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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`
`; } - if (status.completedAt) { + if (status.phase === "retrying") { + return html` +
+ ${icons.loader} Retrying after compaction... +
+ `; + } + 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`
- ${avatar - ? html`${name}` - : html`` + }

${name}

Ready to chat @@ -741,8 +742,9 @@ function renderPinnedSection( >${icons.chevronDown} - ${vs.pinnedExpanded - ? html` + ${ + vs.pinnedExpanded + ? html`
${entries.map( ({ index, text, role }) => html` @@ -768,7 +770,8 @@ function renderPinnedSection( )}
` - : nothing} + : nothing + }
`; } @@ -801,9 +804,11 @@ function renderSlashMenu( requestUpdate(); }} > - ${vs.slashMenuCommand?.icon - ? html`${icons[vs.slashMenuCommand.icon]}` - : nothing} + ${ + vs.slashMenuCommand?.icon + ? html`${icons[vs.slashMenuCommand.icon]}` + : nothing + } ${arg} /${vs.slashMenuCommand?.name} ${arg}
@@ -845,9 +850,9 @@ function renderSlashMenu( ${entries.map( ({ cmd, globalIdx }) => html`
selectSlashCommand(cmd, props, requestUpdate)} @@ -860,11 +865,15 @@ function renderSlashMenu( /${cmd.name} ${cmd.args ? html`${cmd.args}` : nothing} ${cmd.description} - ${cmd.argOptions?.length - ? html`${cmd.argOptions.length} options` - : cmd.executeLocal && !cmd.args - ? html` instant ` - : nothing} + ${ + cmd.argOptions?.length + ? html`${cmd.argOptions.length} options` + : cmd.executeLocal && !cmd.args + ? html` + instant + ` + : nothing + }
`, )} @@ -944,49 +953,46 @@ export function renderChat(props: ChatProps) { @click=${handleCodeBlockCopy} >
- ${props.loading - ? html` -
-
-
-
-
-
-
+ ${ + props.loading + ? html` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ` - : nothing} + ` + : nothing + } ${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing} - ${isEmpty && vs.searchOpen - ? html`
No matching messages
` - : nothing} + ${ + isEmpty && vs.searchOpen + ? html` +
No matching messages
+ ` + : nothing + } ${repeat( chatItems, (item) => item.key, @@ -1164,8 +1170,9 @@ export function renderChat(props: ChatProps) { > ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} ${props.error ? html`
${props.error}
` : nothing} - ${props.focusMode - ? html` + ${ + props.focusMode + ? html`
` - : nothing} + : nothing + }
- ${props.queue.length - ? html` + ${ + props.queue.length + ? html`
Queued (${props.queue.length})
@@ -1219,8 +1230,10 @@ export function renderChat(props: ChatProps) { (item) => html`
- ${item.text || - (item.attachments?.length ? `Image (${item.attachments.length})` : "")} + ${ + item.text || + (item.attachments?.length ? `Image (${item.attachments.length})` : "") + }
` - : nothing} + : nothing + } ${renderFallbackIndicator(props.fallbackStatus)} ${renderCompactionIndicator(props.compactionStatus)} ${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null)} - ${props.showNewMessages - ? html` + ${ + props.showNewMessages + ? html` ` - : nothing} + : nothing + }
@@ -1260,9 +1276,11 @@ export function renderChat(props: ChatProps) { @change=${(e: Event) => handleFileSelect(e, props)} /> - ${vs.sttRecording && vs.sttInterimText - ? html`
${vs.sttInterimText}
` - : nothing} + ${ + vs.sttRecording && vs.sttInterimText + ? html`
${vs.sttInterimText}
` + : nothing + }