From 20f0834c51da0b52b7a574d26c5bb5a8081b7062 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 23 Feb 2026 11:52:10 -0600 Subject: [PATCH 1/6] fix: suppress browser notifications for idle compaction completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle compaction is background maintenance, not user-initiated work. Previously, the frontend tracked idle compaction via a Set populated from the idle-compaction-started chat event, but onChat only subscribes for the active workspace — so the event never arrived for truly background workspaces (the main idle compaction target). Fix: thread isIdleCompaction through the activity snapshot, which is the only data channel reaching the frontend for ALL workspaces. The backend tags the snapshot during updateStreamingStatus when an idle compaction is in progress. The frontend reads the flag on the streaming true→false transition and suppresses the notification. Changes: - Add isIdleCompaction field to WorkspaceActivitySnapshotSchema - Backend: track idle compaction Set, enrich snapshot, clear on stop - Frontend: remove broken idleCompactingWorkspaces Set, read from previous snapshot's isIdleCompaction flag instead - App.tsx: early-return before notification when compaction?.isIdle --- src/browser/App.tsx | 5 ++++- src/browser/stores/WorkspaceStore.test.ts | 6 +++--- src/browser/stores/WorkspaceStore.ts | 18 ++++++++++++++--- .../messages/StreamingMessageAggregator.ts | 2 +- src/common/orpc/schemas/workspace.ts | 3 +++ src/node/services/workspaceService.ts | 20 ++++++++++++++++++- 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 312606dd51..0ac1a16968 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -867,7 +867,7 @@ function AppInner() { _messageId: string, isFinal: boolean, finalText: string, - compaction?: { hasContinueMessage: boolean }, + compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, completedAt?: number | null ) => { // Only notify on final message (when assistant is done with all work) @@ -882,6 +882,9 @@ function AppInner() { updatePersistedState(getWorkspaceLastReadKey(workspaceId), completedAt); } + // Skip notification for idle compaction (background maintenance, not user-initiated). + if (compaction?.isIdle) return; + // Skip notification if compaction completed with a continue message. // We use the compaction metadata instead of queued state since the queue // can be drained before compaction finishes. diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index 14b047ee44..78705f20bb 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -1169,7 +1169,7 @@ describe("WorkspaceStore", () => { _messageId: string, _isFinal: boolean, _finalText: string, - _compaction?: { hasContinueMessage: boolean }, + _compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, _completedAt?: number | null ) => undefined ); @@ -1297,7 +1297,7 @@ describe("WorkspaceStore", () => { _messageId: string, _isFinal: boolean, _finalText: string, - _compaction?: { hasContinueMessage: boolean }, + _compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, _completedAt?: number | null ) => undefined ); @@ -1397,7 +1397,7 @@ describe("WorkspaceStore", () => { _messageId: string, _isFinal: boolean, _finalText: string, - _compaction?: { hasContinueMessage: boolean }, + _compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, _completedAt?: number | null ) => undefined ); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 0d572ba047..629d3187cc 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -585,7 +585,7 @@ export class WorkspaceStore { messageId: string, isFinal: boolean, finalText: string, - compaction?: { hasContinueMessage: boolean }, + compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, completedAt?: number | null ) => void) | null = null; @@ -1290,7 +1290,7 @@ export class WorkspaceStore { messageId: string, isFinal: boolean, finalText: string, - compaction?: { hasContinueMessage: boolean }, + compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, completedAt?: number | null ) => void ): void { @@ -2369,6 +2369,12 @@ export class WorkspaceStore { const backgroundCompaction = isBackgroundStreamingStop ? this.getBackgroundCompletionCompaction(workspaceId) : undefined; + // The backend tags both the streaming=true and streaming=false snapshots with + // isIdleCompaction. Check both: previous covers the normal case; snapshot (the stop + // event) covers UI reconnects where activity.list() restored a previous without the + // transient flag. + const wasIdleCompaction = + previous?.isIdleCompaction === true || snapshot?.isIdleCompaction === true; // Trigger response completion notifications for background workspaces only when // activity indicates a true completion (streaming true -> false WITH recency advance). @@ -2384,7 +2390,13 @@ export class WorkspaceStore { "", true, "", - backgroundCompaction, + backgroundCompaction + ? wasIdleCompaction + ? { ...backgroundCompaction, isIdle: true } + : backgroundCompaction + : wasIdleCompaction + ? { hasContinueMessage: false, isIdle: true } + : undefined, stoppedStreamingSnapshot.recency ); } diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index f55d8cb37a..292cb3c316 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -409,7 +409,7 @@ export class StreamingMessageAggregator { messageId: string, isFinal: boolean, finalText: string, - compaction?: { hasContinueMessage: boolean }, + compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, completedAt?: number | null ) => void; diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index d3a9e4096c..4e91bd62b3 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -121,6 +121,9 @@ export const WorkspaceActivitySnapshotSchema = z.object({ description: "Most recent status_set value for this workspace (used to surface background progress in sidebar).", }), + isIdleCompaction: z.boolean().optional().meta({ + description: "Whether the current streaming activity is an idle (background) compaction", + }), }); export const PostCompactionStateSchema = z.object({ diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 20e0c09452..ccb7df4ca6 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -943,6 +943,10 @@ export class WorkspaceService extends EventEmitter { // from waking a dedicated workspace during archive(). private readonly archivingWorkspaces = new Set(); + // Tracks workspaces undergoing idle (background) compaction so the activity snapshot + // can tag the stream, letting the frontend suppress notifications for maintenance work. + private readonly idleCompactingWorkspaces = new Set(); + // AbortControllers for in-progress workspace initialization (postCreateSetup + initWorkspace). // // Why this lives here: archive/remove are the user-facing lifecycle operations that should @@ -1192,7 +1196,14 @@ export class WorkspaceService extends EventEmitter { model, thinkingLevel ); - this.emitWorkspaceActivity(workspaceId, snapshot); + const isIdleCompaction = this.idleCompactingWorkspaces.has(workspaceId); + this.emitWorkspaceActivity( + workspaceId, + isIdleCompaction ? { ...snapshot, isIdleCompaction: true } : snapshot + ); + if (!streaming) { + this.idleCompactingWorkspaces.delete(workspaceId); + } } catch (error) { log.error("Failed to update workspace streaming status", { workspaceId, error }); } @@ -4623,6 +4634,13 @@ export class WorkspaceService extends EventEmitter { throw new Error(`Failed to execute idle compaction: ${formattedError}`); } + // Mark idle compaction AFTER send succeeds. At this point the idle stream is running + // and the session is busy, so no concurrent user stream can race with this marker. + // The streaming=true snapshot was already emitted without the flag, but the + // streaming=false snapshot (emitted by updateStreamingStatus on stream end) will + // pick it up — the frontend checks both snapshots. + this.idleCompactingWorkspaces.add(workspaceId); + // Notify listeners only after dispatch succeeds so UI state reflects real work. this.emitIdleCompactionStarted(workspaceId); } From 5cad28e1b622a1516f131406ec6b1341b7429e3c Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 23 Feb 2026 12:49:28 -0600 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20simplify=20idle?= =?UTF-8?q?=20compaction=20notification=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarify WorkspaceStore comment about when isIdleCompaction is present - Simplify background compaction payload construction on stream stop - Remove unused idle-compaction-started event plumbing across frontend/backend - Update idle compaction dispatch tests to match removed started-event emission --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$10.37`_ --- src/browser/stores/WorkspaceStore.ts | 55 ++++--------------- .../applyWorkspaceChatEventToAggregator.ts | 3 +- src/common/orpc/schemas/stream.ts | 8 --- src/node/services/workspaceService.test.ts | 27 +-------- src/node/services/workspaceService.ts | 14 ----- 5 files changed, 13 insertions(+), 94 deletions(-) diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 629d3187cc..1f5c1528d6 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -569,9 +569,6 @@ export class WorkspaceStore { private sessionUsage = new Map>(); private sessionUsageRequestVersion = new Map(); - // Idle compaction notification callbacks (called when backend signals idle compaction started) - private idleCompactionCallbacks = new Set<(workspaceId: string) => void>(); - // Global callback for navigating to a workspace (set by App, used for notification clicks) private navigateToWorkspaceCallback: ((workspaceId: string) => void) | null = null; @@ -2369,10 +2366,10 @@ export class WorkspaceStore { const backgroundCompaction = isBackgroundStreamingStop ? this.getBackgroundCompletionCompaction(workspaceId) : undefined; - // The backend tags both the streaming=true and streaming=false snapshots with - // isIdleCompaction. Check both: previous covers the normal case; snapshot (the stop - // event) covers UI reconnects where activity.list() restored a previous without the - // transient flag. + // The backend tags the streaming=false (stop) snapshot with isIdleCompaction. + // The idle marker is added after sendMessage returns (to avoid races with + // concurrent user streams), so only the stop snapshot carries the flag. + // Check both previous and current as defense-in-depth. const wasIdleCompaction = previous?.isIdleCompaction === true || snapshot?.isIdleCompaction === true; @@ -2390,13 +2387,12 @@ export class WorkspaceStore { "", true, "", - backgroundCompaction - ? wasIdleCompaction - ? { ...backgroundCompaction, isIdle: true } - : backgroundCompaction - : wasIdleCompaction - ? { hasContinueMessage: false, isIdle: true } - : undefined, + wasIdleCompaction + ? { + hasContinueMessage: backgroundCompaction?.hasContinueMessage ?? false, + isIdle: true, + } + : backgroundCompaction, stoppedStreamingSnapshot.recency ); } @@ -3130,29 +3126,6 @@ export class WorkspaceStore { this.workspaceCreatedAt.clear(); } - /** - * Subscribe to idle compaction events. - * Callback is called when backend signals a workspace started idle compaction. - * Returns unsubscribe function. - */ - onIdleCompactionStarted(callback: (workspaceId: string) => void): () => void { - this.idleCompactionCallbacks.add(callback); - return () => this.idleCompactionCallbacks.delete(callback); - } - - /** - * Notify all listeners that a workspace started idle compaction. - */ - private notifyIdleCompactionStarted(workspaceId: string): void { - for (const callback of this.idleCompactionCallbacks) { - try { - callback(workspaceId); - } catch (error) { - console.error("Error in idle compaction callback:", error); - } - } - } - /** * Subscribe to file-modifying tool completions. * @param listener Called with workspaceId when a file-modifying tool completes @@ -3374,12 +3347,6 @@ export class WorkspaceStore { return; } - // Handle idle-compaction-started event from backend execution. - if ("type" in data && data.type === "idle-compaction-started") { - this.notifyIdleCompactionStarted(workspaceId); - return; - } - // Heartbeat events are no-ops for UI state - they exist only for connection liveness detection if ("type" in data && data.type === "heartbeat") { return; @@ -3547,8 +3514,6 @@ function getStoreInstance(): WorkspaceStore { * Use this for non-hook subscriptions (e.g., in useEffect callbacks). */ export const workspaceStore = { - onIdleCompactionStarted: (callback: (workspaceId: string) => void) => - getStoreInstance().onIdleCompactionStarted(callback), subscribeFileModifyingTool: (listener: (workspaceId: string) => void, workspaceId?: string) => getStoreInstance().subscribeFileModifyingTool(listener, workspaceId), getFileModifyingToolMs: (workspaceId: string) => diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts index 5166d40d3a..6421470cf1 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts @@ -193,8 +193,7 @@ export function applyWorkspaceChatEventToAggregator( isBashOutputEvent(event) || ("type" in event && event.type === "session-usage-delta") || ("type" in event && event.type === "auto-compaction-triggered") || - ("type" in event && event.type === "auto-compaction-completed") || - ("type" in event && event.type === "idle-compaction-started") + ("type" in event && event.type === "auto-compaction-completed") ) { return "ignored"; } diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index e7e6966845..438013412a 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -74,8 +74,6 @@ export const CaughtUpMessageSchema = z.object({ cursor: OnChatCursorSchema.optional(), }); -/** Sent when backend starts idle compaction for a workspace */ - /** * Progress event for runtime readiness checks. * Used by Coder workspaces to show "Starting Coder workspace..." while ensureReady() blocks. @@ -89,10 +87,6 @@ export const RuntimeStatusEventSchema = z.object({ detail: z.string().optional(), // Human-readable status like "Starting Coder workspace..." }); -export const IdleCompactionStartedEventSchema = z.object({ - type: z.literal("idle-compaction-started"), -}); - export const AutoCompactionTriggeredEventSchema = z.object({ type: z.literal("auto-compaction-triggered"), reason: z.enum(["on-send", "mid-stream", "idle"]), @@ -533,8 +527,6 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [ // Auto-compaction status events AutoCompactionTriggeredEventSchema, AutoCompactionCompletedEventSchema, - // Idle compaction notification - IdleCompactionStartedEventSchema, // Auto-retry status events AutoRetryScheduledEventSchema, AutoRetryStartingEventSchema, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 17ac93477d..7f03b9009a 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -672,35 +672,25 @@ describe("WorkspaceService idle compaction dispatch", () => { await cleanupHistory(); }); - test("marks idle compaction send as synthetic and emits started after dispatch", async () => { + test("marks idle compaction send as synthetic", async () => { const workspaceId = "idle-ws"; const sendMessage = mock(() => Promise.resolve(Ok(undefined))); const buildIdleCompactionSendOptions = mock(() => Promise.resolve({ model: "openai:gpt-4o", agentId: "compact" }) ); - const emitIdleCompactionStarted = mock((_id: string) => undefined); ( workspaceService as unknown as { sendMessage: typeof sendMessage; buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; - emitIdleCompactionStarted: typeof emitIdleCompactionStarted; } ).sendMessage = sendMessage; ( workspaceService as unknown as { sendMessage: typeof sendMessage; buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; - emitIdleCompactionStarted: typeof emitIdleCompactionStarted; } ).buildIdleCompactionSendOptions = buildIdleCompactionSendOptions; - ( - workspaceService as unknown as { - sendMessage: typeof sendMessage; - buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; - emitIdleCompactionStarted: typeof emitIdleCompactionStarted; - } - ).emitIdleCompactionStarted = emitIdleCompactionStarted; await workspaceService.executeIdleCompaction(workspaceId); @@ -715,10 +705,9 @@ describe("WorkspaceService idle compaction dispatch", () => { requireIdle: true, }) ); - expect(emitIdleCompactionStarted).toHaveBeenCalledTimes(1); }); - test("does not emit idle-compaction-started when busy-skip result is returned", async () => { + test("propagates busy-skip errors", async () => { const workspaceId = "idle-busy-ws"; const sendMessage = mock(() => Promise.resolve( @@ -731,29 +720,19 @@ describe("WorkspaceService idle compaction dispatch", () => { const buildIdleCompactionSendOptions = mock(() => Promise.resolve({ model: "openai:gpt-4o", agentId: "compact" }) ); - const emitIdleCompactionStarted = mock((_id: string) => undefined); ( workspaceService as unknown as { sendMessage: typeof sendMessage; buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; - emitIdleCompactionStarted: typeof emitIdleCompactionStarted; } ).sendMessage = sendMessage; ( workspaceService as unknown as { sendMessage: typeof sendMessage; buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; - emitIdleCompactionStarted: typeof emitIdleCompactionStarted; } ).buildIdleCompactionSendOptions = buildIdleCompactionSendOptions; - ( - workspaceService as unknown as { - sendMessage: typeof sendMessage; - buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; - emitIdleCompactionStarted: typeof emitIdleCompactionStarted; - } - ).emitIdleCompactionStarted = emitIdleCompactionStarted; let executionError: unknown; try { @@ -767,8 +746,6 @@ describe("WorkspaceService idle compaction dispatch", () => { throw new Error("Expected idle compaction to throw when workspace is busy"); } expect(executionError.message).toContain("idle-only send was skipped"); - - expect(emitIdleCompactionStarted).toHaveBeenCalledTimes(0); }); }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index ccb7df4ca6..5dce2818be 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -4640,9 +4640,6 @@ export class WorkspaceService extends EventEmitter { // streaming=false snapshot (emitted by updateStreamingStatus on stream end) will // pick it up — the frontend checks both snapshots. this.idleCompactingWorkspaces.add(workspaceId); - - // Notify listeners only after dispatch succeeds so UI state reflects real work. - this.emitIdleCompactionStarted(workspaceId); } private async buildIdleCompactionSendOptions(workspaceId: string): Promise { @@ -4711,15 +4708,4 @@ export class WorkspaceService extends EventEmitter { skipAiSettingsPersistence: true, }; } - - /** - * Emit an idle-compaction-started event to a workspace's stream. - * This is a transient UI hint while backend-initiated compaction is running. - */ - emitIdleCompactionStarted(workspaceId: string): void { - const session = this.sessions.get(workspaceId); - if (session) { - session.emitChatEvent({ type: "idle-compaction-started" }); - } - } } From e36d9cbc88ec4da32a91b459de53f42ba8de5051 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 23 Feb 2026 13:01:26 -0600 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20stale=20idle?= =?UTF-8?q?=20compaction=20marker=20after=20startup=20aborts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard idle-compaction marker assignment behind a post-dispatch session.isBusy() check. If sendMessage succeeds without an active stream (startup-abort or ultra-fast completion), clear any stale marker so later user streams are never misclassified as idle compaction. Also add regression tests for both active-stream and no-stream success paths. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$10.37`_ --- src/node/services/workspaceService.test.ts | 65 +++++++++++++++++++++- src/node/services/workspaceService.ts | 20 +++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 7f03b9009a..46cd3d1a2e 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -672,25 +672,42 @@ describe("WorkspaceService idle compaction dispatch", () => { await cleanupHistory(); }); - test("marks idle compaction send as synthetic", async () => { + test("marks idle compaction send as synthetic when stream stays active", async () => { const workspaceId = "idle-ws"; const sendMessage = mock(() => Promise.resolve(Ok(undefined))); const buildIdleCompactionSendOptions = mock(() => Promise.resolve({ model: "openai:gpt-4o", agentId: "compact" }) ); + let busyChecks = 0; + const session = { + isBusy: mock(() => { + busyChecks += 1; + return busyChecks >= 2; + }), + } as unknown as AgentSession; + ( workspaceService as unknown as { sendMessage: typeof sendMessage; buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; + getOrCreateSession: (workspaceId: string) => AgentSession; } ).sendMessage = sendMessage; ( workspaceService as unknown as { sendMessage: typeof sendMessage; buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; + getOrCreateSession: (workspaceId: string) => AgentSession; } ).buildIdleCompactionSendOptions = buildIdleCompactionSendOptions; + ( + workspaceService as unknown as { + sendMessage: typeof sendMessage; + buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; + getOrCreateSession: (workspaceId: string) => AgentSession; + } + ).getOrCreateSession = (_workspaceId: string) => session; await workspaceService.executeIdleCompaction(workspaceId); @@ -705,6 +722,52 @@ describe("WorkspaceService idle compaction dispatch", () => { requireIdle: true, }) ); + + const idleCompactingWorkspaces = ( + workspaceService as unknown as { idleCompactingWorkspaces: Set } + ).idleCompactingWorkspaces; + expect(idleCompactingWorkspaces.has(workspaceId)).toBe(true); + }); + + test("does not mark idle compaction when send succeeds without active stream", async () => { + const workspaceId = "idle-no-stream-ws"; + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); + const buildIdleCompactionSendOptions = mock(() => + Promise.resolve({ model: "openai:gpt-4o", agentId: "compact" }) + ); + + const session = { + isBusy: mock(() => false), + } as unknown as AgentSession; + + ( + workspaceService as unknown as { + sendMessage: typeof sendMessage; + buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; + getOrCreateSession: (workspaceId: string) => AgentSession; + } + ).sendMessage = sendMessage; + ( + workspaceService as unknown as { + sendMessage: typeof sendMessage; + buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; + getOrCreateSession: (workspaceId: string) => AgentSession; + } + ).buildIdleCompactionSendOptions = buildIdleCompactionSendOptions; + ( + workspaceService as unknown as { + sendMessage: typeof sendMessage; + buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; + getOrCreateSession: (workspaceId: string) => AgentSession; + } + ).getOrCreateSession = (_workspaceId: string) => session; + + await workspaceService.executeIdleCompaction(workspaceId); + + const idleCompactingWorkspaces = ( + workspaceService as unknown as { idleCompactingWorkspaces: Set } + ).idleCompactingWorkspaces; + expect(idleCompactingWorkspaces.has(workspaceId)).toBe(false); }); test("propagates busy-skip errors", async () => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5dce2818be..997e32b350 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -4634,12 +4634,20 @@ export class WorkspaceService extends EventEmitter { throw new Error(`Failed to execute idle compaction: ${formattedError}`); } - // Mark idle compaction AFTER send succeeds. At this point the idle stream is running - // and the session is busy, so no concurrent user stream can race with this marker. - // The streaming=true snapshot was already emitted without the flag, but the - // streaming=false snapshot (emitted by updateStreamingStatus on stream end) will - // pick it up — the frontend checks both snapshots. - this.idleCompactingWorkspaces.add(workspaceId); + // Mark idle compaction only while a stream is actually active. + // sendMessage can succeed on startup-abort paths where no stream is running, + // and leaking this marker into the next user stream would suppress real notifications. + if (session.isBusy()) { + // Marker is added after dispatch to avoid races with concurrent user sends. + // The streaming=true snapshot was already emitted without the flag, but the + // streaming=false snapshot (on stream end) picks up the marker. + this.idleCompactingWorkspaces.add(workspaceId); + return; + } + + // Defensive cleanup for startup-abort paths or extremely fast completions that + // finish before executeIdleCompaction regains control. + this.idleCompactingWorkspaces.delete(workspaceId); } private async buildIdleCompactionSendOptions(workspaceId: string): Promise { From 4288b70f313e1506812cef938bdcc97b7789e939 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 23 Feb 2026 13:13:33 -0600 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20suppress=20compaction?= =?UTF-8?q?-complete=20notify=20when=20queued=20follow-up=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a compaction stream ends with a queued follow-up message, mark the completion as hasContinueMessage=true before forwarding onResponseComplete. This prevents an intermediate "Compaction complete" notification and keeps notification delivery focused on the follow-up response. Also adds a WorkspaceStore regression test covering active compaction + queued follow-up. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$10.37`_ --- src/browser/stores/WorkspaceStore.test.ts | 109 ++++++++++++++++++++++ src/browser/stores/WorkspaceStore.ts | 108 +++++++++++++++++---- 2 files changed, 197 insertions(+), 20 deletions(-) diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index 78705f20bb..646b86a174 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -1344,6 +1344,115 @@ describe("WorkspaceStore", () => { ); }); + it("marks compaction completions with queued follow-up as continue for active callbacks", async () => { + const workspaceId = "active-workspace-queued-follow-up"; + + mockOnChat.mockImplementation(async function* ( + input?: { workspaceId: string; mode?: unknown }, + options?: { signal?: AbortSignal } + ): AsyncGenerator { + if (input?.workspaceId !== workspaceId) { + await waitForAbortSignal(options?.signal); + return; + } + + const timestamp = Date.now(); + + yield { type: "caught-up", hasOlderHistory: false }; + + yield { + type: "message", + id: "compaction-request-msg", + role: "user", + parts: [{ type: "text", text: "/compact" }], + metadata: { + historySequence: 1, + timestamp, + muxMetadata: { + type: "compaction-request", + rawCommand: "/compact", + parsed: { + model: "claude-sonnet-4", + }, + }, + }, + }; + + yield { + type: "stream-start", + workspaceId, + messageId: "compaction-stream", + historySequence: 2, + model: "claude-sonnet-4", + startTime: timestamp + 1, + mode: "compact", + }; + + // A queued message will be auto-sent by the backend when compaction stream ends. + yield { + type: "queued-message-changed", + workspaceId, + queuedMessages: ["follow-up after compaction"], + displayText: "follow-up after compaction", + }; + + yield { + type: "stream-end", + workspaceId, + messageId: "compaction-stream", + metadata: { + model: "claude-sonnet-4", + }, + parts: [], + }; + + await waitForAbortSignal(options?.signal); + }); + + const onResponseComplete = mock( + ( + _workspaceId: string, + _messageId: string, + _isFinal: boolean, + _finalText: string, + _compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, + _completedAt?: number | null + ) => undefined + ); + + store.dispose(); + store = new WorkspaceStore(mockOnModelUsed); + store.setOnResponseComplete(onResponseComplete); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + store.setClient(mockClient as any); + + createAndAddWorkspace(store, workspaceId); + + const waitUntil = async (condition: () => boolean, timeoutMs = 2000): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (condition()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return false; + }; + + const sawResponseComplete = await waitUntil(() => onResponseComplete.mock.calls.length > 0); + expect(sawResponseComplete).toBe(true); + + expect(onResponseComplete).toHaveBeenCalledTimes(1); + expect(onResponseComplete).toHaveBeenCalledWith( + workspaceId, + "compaction-stream", + true, + "", + { hasContinueMessage: true }, + expect.any(Number) + ); + }); + it("does not fire response-complete callback when background streaming stops without recency advance", async () => { const activeWorkspaceId = "active-workspace-no-replay"; const backgroundWorkspaceId = "background-workspace-no-replay"; diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 1f5c1528d6..96b958d35d 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -1294,10 +1294,79 @@ export class WorkspaceStore { this.responseCompleteCallback = callback; // Update existing aggregators with the callback for (const aggregator of this.aggregators.values()) { - aggregator.onResponseComplete = callback; + this.bindAggregatorResponseCompleteCallback(aggregator); } } + private maybeMarkCompactionContinueFromQueuedFollowUp( + workspaceId: string, + compaction: { hasContinueMessage: boolean; isIdle?: boolean } | undefined, + includeQueuedFollowUpSignal: boolean + ): { hasContinueMessage: boolean; isIdle?: boolean } | undefined { + if (!compaction || compaction.hasContinueMessage || !includeQueuedFollowUpSignal) { + return compaction; + } + + const queuedMessage = this.chatTransientState.get(workspaceId)?.queuedMessage; + if (!queuedMessage) { + return compaction; + } + + // A queued message will be auto-sent after stream-end. Suppress the intermediate + // "Compaction complete" notification and only notify for the follow-up response. + return { + ...compaction, + hasContinueMessage: true, + }; + } + + private emitResponseComplete( + workspaceId: string, + messageId: string, + isFinal: boolean, + finalText: string, + compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, + completedAt?: number | null, + includeQueuedFollowUpSignal = true + ): void { + if (!this.responseCompleteCallback) { + return; + } + + this.responseCompleteCallback( + workspaceId, + messageId, + isFinal, + finalText, + this.maybeMarkCompactionContinueFromQueuedFollowUp( + workspaceId, + compaction, + includeQueuedFollowUpSignal + ), + completedAt + ); + } + + private bindAggregatorResponseCompleteCallback(aggregator: StreamingMessageAggregator): void { + aggregator.onResponseComplete = ( + workspaceId: string, + messageId: string, + isFinal: boolean, + finalText: string, + compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, + completedAt?: number | null + ) => { + this.emitResponseComplete( + workspaceId, + messageId, + isFinal, + finalText, + compaction, + completedAt + ); + }; + } + /** * Schedule a state bump during browser idle time. * Instead of updating UI on every delta, wait until the browser has spare capacity. @@ -2378,24 +2447,23 @@ export class WorkspaceStore { // stream-abort/error transitions also flip streaming to false, but recency stays // unchanged there, so suppress completion notifications in those cases. if (stoppedStreamingSnapshot && recencyAdvancedSinceStreamStart && isBackgroundStreamingStop) { - if (this.responseCompleteCallback) { - // Activity snapshots don't include message/content metadata. Reuse any - // still-active stream context captured before this workspace was backgrounded - // so compaction continue turns remain suppressible in App notifications. - this.responseCompleteCallback( - workspaceId, - "", - true, - "", - wasIdleCompaction - ? { - hasContinueMessage: backgroundCompaction?.hasContinueMessage ?? false, - isIdle: true, - } - : backgroundCompaction, - stoppedStreamingSnapshot.recency - ); - } + // Activity snapshots don't include message/content metadata. Reuse any + // still-active stream context captured before this workspace was backgrounded + // so compaction continue turns remain suppressible in App notifications. + this.emitResponseComplete( + workspaceId, + "", + true, + "", + wasIdleCompaction + ? { + hasContinueMessage: backgroundCompaction?.hasContinueMessage ?? false, + isIdle: true, + } + : backgroundCompaction, + stoppedStreamingSnapshot.recency, + false + ); } if (isBackgroundStreamingStop) { @@ -3196,7 +3264,7 @@ export class WorkspaceStore { } // Wire up response complete callback for "notify on response" feature if (this.responseCompleteCallback) { - aggregator.onResponseComplete = this.responseCompleteCallback; + this.bindAggregatorResponseCompleteCallback(aggregator); } this.aggregators.set(workspaceId, aggregator); this.workspaceCreatedAt.set(workspaceId, createdAt); From 1ac13632621d1003b75cfe80a427e6e5e25a2001 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 23 Feb 2026 13:17:54 -0600 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20clear=20idle=20compac?= =?UTF-8?q?tion=20marker=20even=20when=20metadata=20writes=20fail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move idle-compaction marker cleanup for streaming=false transitions into updateStreamingStatus finally block so extension metadata failures cannot leak isIdleCompaction state into later user streams. Adds a regression test that forces setStreaming failure and verifies the marker is still cleared. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$10.37`_ --- src/node/services/workspaceService.test.ts | 31 ++++++++++++++++++++++ src/node/services/workspaceService.ts | 7 +++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 46cd3d1a2e..b5f29676d3 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -810,6 +810,37 @@ describe("WorkspaceService idle compaction dispatch", () => { } expect(executionError.message).toContain("idle-only send was skipped"); }); + test("clears idle marker when streaming=false metadata update fails", async () => { + const workspaceId = "idle-streaming-false-failure"; + + const setStreaming = mock(() => Promise.reject(new Error("setStreaming failed"))); + const extensionMetadata = { + setStreaming, + } as unknown as ExtensionMetadataService; + + ( + workspaceService as unknown as { + extensionMetadata: ExtensionMetadataService; + } + ).extensionMetadata = extensionMetadata; + + const internals = workspaceService as unknown as { + idleCompactingWorkspaces: Set; + updateStreamingStatus: ( + workspaceId: string, + streaming: boolean, + model?: string, + agentId?: string + ) => Promise; + }; + + internals.idleCompactingWorkspaces.add(workspaceId); + + await internals.updateStreamingStatus(workspaceId, false); + + expect(internals.idleCompactingWorkspaces.has(workspaceId)).toBe(false); + expect(setStreaming).toHaveBeenCalledWith(workspaceId, false, undefined, undefined); + }); }); describe("WorkspaceService executeBash archive guards", () => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 997e32b350..1510de8e36 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1201,11 +1201,14 @@ export class WorkspaceService extends EventEmitter { workspaceId, isIdleCompaction ? { ...snapshot, isIdleCompaction: true } : snapshot ); + } catch (error) { + log.error("Failed to update workspace streaming status", { workspaceId, error }); + } finally { + // Idle compaction marker is turn-scoped. Always clear on streaming=false transitions, + // even when metadata writes fail, so stale state cannot leak into future user streams. if (!streaming) { this.idleCompactingWorkspaces.delete(workspaceId); } - } catch (error) { - log.error("Failed to update workspace streaming status", { workspaceId, error }); } } From db5268de3ab8904b2bc837c3a508edd237d43bec Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 23 Feb 2026 13:27:51 -0600 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20tag=20idle=20compacti?= =?UTF-8?q?on=20only=20on=20stream-stop=20snapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict updateStreamingStatus idle tagging to streaming=false snapshots so follow-up non-idle turns cannot inherit stale isIdleCompaction metadata from prior maintenance turns. Also adds a regression test for streaming=true behavior while the idle marker is set. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$10.37`_ --- src/node/services/workspaceService.test.ts | 49 ++++++++++++++++++++++ src/node/services/workspaceService.ts | 6 ++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index b5f29676d3..e5624ecf7b 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -810,6 +810,55 @@ describe("WorkspaceService idle compaction dispatch", () => { } expect(executionError.message).toContain("idle-only send was skipped"); }); + test("does not tag streaming=true snapshots as idle compaction", async () => { + const workspaceId = "idle-streaming-true-no-tag"; + const snapshot = { + recency: Date.now(), + streaming: true, + lastModel: "claude-sonnet-4", + lastThinkingLevel: null, + }; + + const setStreaming = mock(() => Promise.resolve(snapshot)); + const emitWorkspaceActivity = mock( + (_workspaceId: string, _snapshot: typeof snapshot) => undefined + ); + + ( + workspaceService as unknown as { + extensionMetadata: ExtensionMetadataService; + emitWorkspaceActivity: typeof emitWorkspaceActivity; + } + ).extensionMetadata = { + setStreaming, + } as unknown as ExtensionMetadataService; + ( + workspaceService as unknown as { + extensionMetadata: ExtensionMetadataService; + emitWorkspaceActivity: typeof emitWorkspaceActivity; + } + ).emitWorkspaceActivity = emitWorkspaceActivity; + + const internals = workspaceService as unknown as { + idleCompactingWorkspaces: Set; + updateStreamingStatus: ( + workspaceId: string, + streaming: boolean, + model?: string, + agentId?: string + ) => Promise; + }; + + internals.idleCompactingWorkspaces.add(workspaceId); + + await internals.updateStreamingStatus(workspaceId, true); + + expect(setStreaming).toHaveBeenCalledWith(workspaceId, true, undefined, undefined); + expect(emitWorkspaceActivity).toHaveBeenCalledTimes(1); + expect(emitWorkspaceActivity).toHaveBeenCalledWith(workspaceId, snapshot); + expect(internals.idleCompactingWorkspaces.has(workspaceId)).toBe(true); + }); + test("clears idle marker when streaming=false metadata update fails", async () => { const workspaceId = "idle-streaming-false-failure"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 1510de8e36..2d69f4ef96 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1196,10 +1196,12 @@ export class WorkspaceService extends EventEmitter { model, thinkingLevel ); - const isIdleCompaction = this.idleCompactingWorkspaces.has(workspaceId); + // Idle compaction tagging is stop-snapshot only. Never tag streaming=true updates, + // otherwise fast follow-up turns can inherit stale idle metadata before cleanup runs. + const shouldTagIdleCompaction = !streaming && this.idleCompactingWorkspaces.has(workspaceId); this.emitWorkspaceActivity( workspaceId, - isIdleCompaction ? { ...snapshot, isIdleCompaction: true } : snapshot + shouldTagIdleCompaction ? { ...snapshot, isIdleCompaction: true } : snapshot ); } catch (error) { log.error("Failed to update workspace streaming status", { workspaceId, error });