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..646b86a174 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 ); @@ -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"; @@ -1397,7 +1506,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..96b958d35d 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; @@ -585,7 +582,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,15 +1287,84 @@ export class WorkspaceStore { messageId: string, isFinal: boolean, finalText: string, - compaction?: { hasContinueMessage: boolean }, + compaction?: { hasContinueMessage: boolean; isIdle?: boolean }, completedAt?: number | null ) => void ): void { 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 + ); + }; } /** @@ -2369,25 +2435,35 @@ export class WorkspaceStore { const backgroundCompaction = isBackgroundStreamingStop ? this.getBackgroundCompletionCompaction(workspaceId) : undefined; + // 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; // Trigger response completion notifications for background workspaces only when // activity indicates a true completion (streaming true -> false WITH recency advance). // 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, - "", - 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) { @@ -3118,29 +3194,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 @@ -3211,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); @@ -3362,12 +3415,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; @@ -3535,8 +3582,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/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/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/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.test.ts b/src/node/services/workspaceService.test.ts index 17ac93477d..e5624ecf7b 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -672,35 +672,42 @@ 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 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" }) ); - const emitIdleCompactionStarted = mock((_id: string) => undefined); + + 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; - emitIdleCompactionStarted: typeof emitIdleCompactionStarted; + getOrCreateSession: (workspaceId: string) => AgentSession; } ).sendMessage = sendMessage; ( workspaceService as unknown as { sendMessage: typeof sendMessage; buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; - emitIdleCompactionStarted: typeof emitIdleCompactionStarted; + getOrCreateSession: (workspaceId: string) => AgentSession; } ).buildIdleCompactionSendOptions = buildIdleCompactionSendOptions; ( workspaceService as unknown as { sendMessage: typeof sendMessage; buildIdleCompactionSendOptions: typeof buildIdleCompactionSendOptions; - emitIdleCompactionStarted: typeof emitIdleCompactionStarted; + getOrCreateSession: (workspaceId: string) => AgentSession; } - ).emitIdleCompactionStarted = emitIdleCompactionStarted; + ).getOrCreateSession = (_workspaceId: string) => session; await workspaceService.executeIdleCompaction(workspaceId); @@ -715,10 +722,55 @@ describe("WorkspaceService idle compaction dispatch", () => { requireIdle: true, }) ); - expect(emitIdleCompactionStarted).toHaveBeenCalledTimes(1); + + 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("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 +783,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 +809,86 @@ 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"); + }); + 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"; + + const setStreaming = mock(() => Promise.reject(new Error("setStreaming failed"))); + const extensionMetadata = { + setStreaming, + } as unknown as ExtensionMetadataService; - expect(emitIdleCompactionStarted).toHaveBeenCalledTimes(0); + ( + 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); }); }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 20e0c09452..2d69f4ef96 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,9 +1196,21 @@ export class WorkspaceService extends EventEmitter { model, thinkingLevel ); - this.emitWorkspaceActivity(workspaceId, snapshot); + // 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, + shouldTagIdleCompaction ? { ...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); + } } } @@ -4623,8 +4639,20 @@ export class WorkspaceService extends EventEmitter { throw new Error(`Failed to execute idle compaction: ${formattedError}`); } - // Notify listeners only after dispatch succeeds so UI state reflects real work. - this.emitIdleCompactionStarted(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 { @@ -4693,15 +4721,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" }); - } - } }