From 545840a9b364ff10516faaf45adea7a753619eb3 Mon Sep 17 00:00:00 2001 From: testikun Date: Wed, 2 Sep 2026 11:25:17 +0800 Subject: [PATCH 1/6] feat(desktop): support side conversation context compaction Add exact /compact handling for idle committed Side Conversations, routing compaction to the companion Session and preserving existing feedback and draft ownership. Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 196 ++++++++++++++++++ .../workbar-services-adapter.test.ts | 2 + .../src/renderer/features/workbar/index.ts | 12 ++ .../src/renderer/features/workbar/ports.ts | 6 +- .../src/renderer/features/workbar/testing.ts | 4 + .../quote-companion-context-compaction.ts | 152 ++++++++++++++ .../tools/side-chat/quote-companion-panel.tsx | 124 ++++++++--- .../tools/side-chat/use-quote-companion.ts | 89 ++++++++ .../src/renderer/locales/conversation-copy.ts | 31 +++ .../desktop/create-workbar-services.ts | 1 + .../stories/session-workbar.stories.tsx | 15 ++ .../src/__tests__/session-projector.test.ts | 37 ++++ .../src/adapter/session-projector.ts | 3 + 13 files changed, 647 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-context-compaction.ts diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index d2e4f29224..3eda0df97a 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -33,6 +33,7 @@ import type { } from '@maka/core/session'; import { createFakeWorkbarServices, + dispatchQuoteCompanionInput, useQuoteCompanion, sessionHasExactModelChoice, WorkbarServicesProvider, @@ -362,6 +363,201 @@ test('first send after a completed turn forks through the settled turn', async ( }); test('does not fork on mount or when the source Session object refreshes', async () => { +test('dispatches /compact to the committed companion fork without sending model input', async () => { + const compactCalls: string[] = []; + let sendCalls = 0; + let steerCalls = 0; + const rendered = await renderOwnershipProbe({ + compact: async (sessionId) => { + compactCalls.push(sessionId); + return { + kind: 'finished' as const, + turn: { + sessionId, + turnId: 'compact-turn', + runId: 'compact-run', + status: 'completed' as const, + terminalEventId: 'compact-complete', + contextCompactionOutcome: { kind: 'unchanged' as const, reason: 'already_current' }, + }, + outcome: { kind: 'unchanged' as const, reason: 'already_current' }, + }; + }, + send: async () => { + sendCalls += 1; + return { ok: true as const, turnId: 'unexpected-send' }; + }, + steer: async () => { + steerCalls += 1; + return { kind: 'started' as const, turnId: 'unexpected-steer' }; + }, + }); + + assert.equal(await rendered.send(' /compact '), true); + assert.deepEqual(compactCalls, ['side-conversation']); + assert.equal(sendCalls, 0); + assert.equal(steerCalls, 0); +}); + +test('dispatches the exact /compact Composer command before steering or ordinary send', async () => { + const calls: string[] = []; + assert.equal( + await dispatchQuoteCompanionInput({ + text: ' /compact ', + streaming: true, + compact: async () => { + calls.push('compact'); + return true; + }, + steer: async () => { + calls.push('steer'); + return true; + }, + send: async () => { + calls.push('send'); + return true; + }, + }), + true, + ); + assert.deepEqual(calls, ['compact']); +}); + +test('keeps an async companion compaction exclusive until its terminal event', async () => { + let compactCalls = 0; + let sendCalls = 0; + const rendered = await renderOwnershipProbe({ + compact: async (sessionId) => { + compactCalls += 1; + return { + kind: 'started' as const, + turn: { + sessionId, + turnId: 'compact-turn', + runId: 'compact-run', + status: 'running' as const, + }, + }; + }, + send: async () => { + sendCalls += 1; + return { ok: true as const, turnId: 'unexpected-send' }; + }, + }); + + assert.equal(await rendered.send('/compact'), true); + assert.equal(await rendered.send('ordinary question'), false); + assert.equal(compactCalls, 1); + assert.equal(sendCalls, 0); +}); + +test('clears a failed companion compaction request so it can be retried', async () => { + let compactCalls = 0; + const rendered = await renderOwnershipProbe({ + compact: async (sessionId) => { + compactCalls += 1; + if (compactCalls === 1) throw new Error('temporary compact failure'); + return { + kind: 'finished' as const, + turn: { + sessionId, + turnId: 'compact-retry-turn', + runId: 'compact-retry-run', + status: 'completed' as const, + terminalEventId: 'compact-retry-complete', + contextCompactionOutcome: { kind: 'unchanged' as const, reason: 'already_current' }, + }, + outcome: { kind: 'unchanged' as const, reason: 'already_current' }, + }; + }, + }); + + assert.equal(await rendered.send('/compact'), false); + assert.equal(await rendered.send('/compact'), true); + assert.equal(compactCalls, 2); +}); + +test('rejects /compact while the companion is running without consuming staged quotes', async () => { + const pendingSend = deferred<{ ok: true; turnId: string }>(); + let compactCalls = 0; + const consumed: CompanionQuoteSnapshot[] = []; + const rendered = await renderOwnershipProbe( + { + compact: async () => { + compactCalls += 1; + throw new Error('compact should not run while busy'); + }, + send: () => pendingSend.promise, + }, + { + pendingQuotes: [{ id: 'quote-1', value: { text: 'quoted context' } }], + onQuotesConsumed: (snapshot) => consumed.push(snapshot), + }, + ); + + let sendResult!: Promise; + await act(async () => { + sendResult = rendered.send('ordinary question'); + await Promise.resolve(); + }); + assert.equal(await rendered.send('/compact'), false); + assert.equal(compactCalls, 0); + assert.deepEqual(consumed, []); + + await act(async () => { + pendingSend.resolve({ ok: true, turnId: 'running-turn' }); + assert.equal(await sendResult, true); + }); +}); + +test('rejects /compact while the companion fork is preparing', async () => { + const pendingFork = deferred(); + let send!: (text: string) => Promise; + let compactCalls = 0; + let branchStarted = false; + const rendered = await renderProbe( + { + branchFromTurn: async () => { + branchStarted = true; + return { ok: true as const, session: await pendingFork.promise }; + }, + compact: async () => { + compactCalls += 1; + throw new Error('compact should not run before fork commit'); + }, + }, + { + ownership: true, + onSend: (value) => (send = value), + ready: () => branchStarted, + }, + ); + + assert.equal(await send('/compact'), false); + assert.equal(compactCalls, 0); + await act(async () => { + pendingFork.resolve(session('side-conversation')); + await Promise.resolve(); + }); +}); + +test('rejects /compact for an archived companion fork without invoking Runtime Host', async () => { + let compactCalls = 0; + const rendered = await renderOwnershipProbe({ + branchFromTurn: async () => ({ + ok: true as const, + session: session('side-conversation', { isArchived: true }), + }), + compact: async () => { + compactCalls += 1; + throw new Error('compact should not run for an archived fork'); + }, + }); + + assert.equal(await rendered.send('/compact'), false); + assert.equal(compactCalls, 0); +}); + let branchCount = 0; const { container, root, services } = await renderProbe( { diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 43d806fa21..75fa733055 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -166,6 +166,7 @@ describe('createDesktopWorkbarServices', () => { }); await services.sideChat.cleanupSessionCopy('fork'); await services.sideChat.abandonSessionCopy('s', 'copy'); + await services.sideChat.compact('fork'); await services.sideChat.send('fork', { type: 'send', turnId: 'turn-2', @@ -227,6 +228,7 @@ describe('createDesktopWorkbarServices', () => { 'sessions.branchFromTurn', 'sessions.cleanupSessionCopy', 'sessions.abandonSessionCopy', + 'sessions.compact', 'sessions.send', 'sessions.stop', 'sessions.submitMessage', diff --git a/apps/desktop/src/renderer/features/workbar/index.ts b/apps/desktop/src/renderer/features/workbar/index.ts index 2b2decd585..49ff31f37b 100644 --- a/apps/desktop/src/renderer/features/workbar/index.ts +++ b/apps/desktop/src/renderer/features/workbar/index.ts @@ -28,3 +28,15 @@ export { WorkbarServicesProvider } from './services-context'; export { useWorkbarController } from './controller/use-workbar-controller'; export type { SessionWorkbarTabKind } from './model/workbar-tabs'; export type { WorkbarServices } from './ports'; +export { + createQuoteCompanionCompactionPresentation, + dispatchQuoteCompanionInput, + isExactCompactCommand, + presentQuoteCompanionCompactionResult, + quoteCompanionCompactionNotice, +} from './tools/side-chat/quote-companion-context-compaction'; +export type { + QuoteCompanionCompactionCopy, + QuoteCompanionCompactionNotice, + QuoteCompanionCompactionPresentation, +} from './tools/side-chat/quote-companion-context-compaction'; diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 8963776e0e..ca077e1a56 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -45,7 +45,10 @@ import type { SessionTrace } from '@maka/core/session-trace'; import type { SessionTodoItem } from '@maka/core/session-todo'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { Result } from '@maka/core/result'; -import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; +import type { + ContextCompactResult, + ContextDiagnosticsResult, +} from '@maka/runtime-host/protocol'; import type { MergedUsageSummary } from '@maka/core/usage-ledger-merge'; import type { ShellRunPtyDataEvent, @@ -233,6 +236,7 @@ export interface SideChatSessionPort { >; cleanupSessionCopy(sessionId: string): Promise; abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; + compact(sessionId: string): Promise; send( sessionId: string, command: { diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index d00ff698ce..a6c30b119e 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -36,6 +36,7 @@ export { compactNumberFormatter, InspectorCompositionSection } from './tools/ins export * from './tools/inspector/session-inspector-overview-model.js'; export * from './tools/side-chat/quote-companion-panel-state.js'; export * from './tools/side-chat/quote-companion-core.js'; +export * from './tools/side-chat/quote-companion-context-compaction.js'; export * from './tools/side-chat/quote-companion-visibility.js'; export { useQuoteCompanion, @@ -128,6 +129,9 @@ export function createFakeWorkbarServices( }, cleanupSessionCopy: async () => undefined, abandonSessionCopy: async () => undefined, + compact: async () => { + throw new Error('Fake sideChat.compact is not configured'); + }, send: async () => ({ ok: false, reason: 'not configured' }), stop: async () => undefined, steer: async () => { diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-context-compaction.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-context-compaction.ts new file mode 100644 index 0000000000..527e258200 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-context-compaction.ts @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ContextCompactionOutcome } from '@maka/core/events'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { ContextCompactResult } from '@maka/runtime-host/protocol'; + +const SETTLED_PRESENTATION_LIMIT = 128; + +export interface QuoteCompanionCompactionNotice { + level: 'success' | 'info' | 'error'; + title: string; + description: string; +} + +export interface QuoteCompanionCompactionCopy { + compactSuccessTitle: string; + compactSuccessDescription: string; + compactStartedTitle: string; + compactStartedDescription: string; + compactUnchangedTitle: string; + compactUnchangedDescription: string; + compactErrorTitle: string; + compactErrorFallback: string; +} + +export function isExactCompactCommand(input: string): boolean { + return input.trim() === '/compact'; +} + +export function dispatchQuoteCompanionInput(input: { + text: string; + streaming: boolean; + compact(): Promise; + steer(text: string): Promise; + send(): Promise; +}): Promise { + if (isExactCompactCommand(input.text)) return input.compact(); + if (input.streaming) return input.steer(input.text); + return input.send(); +} + +export function quoteCompanionCompactionNotice( + outcome: ContextCompactionOutcome, + copy: QuoteCompanionCompactionCopy, +): QuoteCompanionCompactionNotice { + if (outcome.kind === 'compacted') { + return { + level: 'success', + title: copy.compactSuccessTitle, + description: copy.compactSuccessDescription, + }; + } + if (outcome.kind === 'unchanged') { + return { + level: 'info', + title: copy.compactUnchangedTitle, + description: copy.compactUnchangedDescription, + }; + } + return { + level: 'error', + title: copy.compactErrorTitle, + description: copy.compactErrorFallback, + }; +} + +export function createQuoteCompanionCompactionPresentation(options: { + toastApi: { + toast(input: { + title: string; + description?: string; + variant?: 'info'; + duration?: number; + }): string; + dismiss(id: string): void; + }; + copyForLocale(uiLocale: UiLocale): QuoteCompanionCompactionCopy; + presentTerminal(sessionId: string, notice: QuoteCompanionCompactionNotice): void; +}) { + const runningToastByTurn = new Map(); + const settledTurns = new Set(); + const settledTurnOrder: string[] = []; + + return { + started(sessionId: string, turnId: string, uiLocale: UiLocale): void { + const key = `${sessionId}\u0000${turnId}`; + if (runningToastByTurn.has(key) || settledTurns.has(key)) return; + const copy = options.copyForLocale(uiLocale); + runningToastByTurn.set( + key, + options.toastApi.toast({ + title: copy.compactStartedTitle, + description: copy.compactStartedDescription, + variant: 'info', + duration: 0, + }), + ); + }, + + finished(sessionId: string, turnId: string, outcome: ContextCompactionOutcome, uiLocale: UiLocale): void { + const key = `${sessionId}\u0000${turnId}`; + if (settledTurns.has(key)) return; + settledTurns.add(key); + settledTurnOrder.push(key); + if (settledTurnOrder.length > SETTLED_PRESENTATION_LIMIT) { + settledTurns.delete(settledTurnOrder.shift()!); + } + const runningToastId = runningToastByTurn.get(key); + if (runningToastId) options.toastApi.dismiss(runningToastId); + runningToastByTurn.delete(key); + options.presentTerminal( + sessionId, + quoteCompanionCompactionNotice(outcome, options.copyForLocale(uiLocale)), + ); + }, + }; +} + +export type QuoteCompanionCompactionPresentation = ReturnType< + typeof createQuoteCompanionCompactionPresentation +>; + +export function presentQuoteCompanionCompactionResult( + presentation: QuoteCompanionCompactionPresentation, + sessionId: string, + result: ContextCompactResult, + uiLocale: UiLocale, +): boolean { + if (result.kind === 'started') { + presentation.started(sessionId, result.turn.turnId, uiLocale); + return true; + } + presentation.finished(sessionId, result.turn.turnId, result.outcome, uiLocale); + return result.outcome.kind !== 'failed'; +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 5f50ee231b..feeb77b8cf 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -32,6 +32,10 @@ import { type ComposerHandle, } from '@maka/ui'; import type { SessionSummary } from '@maka/core/session'; +import { + generalizedErrorMessage, + generalizedErrorMessageChinese, +} from '@maka/core/redaction'; import { useQuoteCompanion } from './use-quote-companion'; import { useComposerAttachments } from '../../../../use-composer-attachments'; import { useComposerMentionsContext } from '../../../../composer-mentions.js'; @@ -39,6 +43,11 @@ import { preflightAttachmentItems } from '../../../../attachment-preflight'; import { toComposerIngestItems } from '../../../../composer-attachments'; import { getDesktopConversationCopy } from '../../../../locales/conversation-copy.js'; import { deriveTurnFooterActions } from '../../../../turn-footer-actions'; +import { + createQuoteCompanionCompactionPresentation, + dispatchQuoteCompanionInput, + presentQuoteCompanionCompactionResult, +} from './quote-companion-context-compaction.js'; import type { CompanionQuoteTarget, CompanionQuoteSnapshot, @@ -83,6 +92,22 @@ export function QuoteCompanionPanel(props: { const copy = getDesktopConversationCopy(locale).quoteCompanion; const composerRef = useRef(null); const initialPromptStartedRef = useRef(false); + const contextCompactionPresentationRef = useRef< + ReturnType + >(undefined); + if (!contextCompactionPresentationRef.current) { + contextCompactionPresentationRef.current = createQuoteCompanionCompactionPresentation({ + toastApi: toast, + copyForLocale: (nextLocale) => getDesktopConversationCopy(nextLocale).quoteCompanion, + presentTerminal(sessionId, notice) { + if (notice.level === 'error') { + toast.error(notice.title, notice.description, undefined, { sessionId }); + } else { + toast[notice.level](notice.title, notice.description); + } + }, + }); + } const draftKey = `quote-companion:${props.panelId}`; const { pendingAttachments, @@ -104,6 +129,42 @@ export function QuoteCompanionPanel(props: { onQuotesConsumed: props.onQuotesConsumed, confirmBypass: props.confirmBypass, onForkVisibilityChange: props.onForkVisibilityChange, + onContextCompactionResult: (sessionId, result) => { + presentQuoteCompanionCompactionResult( + contextCompactionPresentationRef.current!, + sessionId, + result, + locale, + ); + }, + onContextCompactionOutcome: (sessionId, turnId, outcome) => { + contextCompactionPresentationRef.current!.finished( + sessionId, + turnId, + outcome, + locale, + ); + }, + onContextCompactionError: (sessionId, error) => { + if (isWorkspaceUnavailableError(error)) { + toast.error( + getDesktopConversationCopy(locale).quoteCompanion.workspaceUnavailableTitle, + getDesktopConversationCopy(locale).quoteCompanion.workspaceUnavailableDescription, + undefined, + { sessionId }, + ); + return; + } + const compactCopy = getDesktopConversationCopy(locale).quoteCompanion; + toast.error( + compactCopy.compactErrorTitle, + locale === 'zh' + ? generalizedErrorMessageChinese(error, compactCopy.compactErrorFallback) + : generalizedErrorMessage(error, compactCopy.compactErrorFallback), + undefined, + { sessionId }, + ); + }, }); useEffect(() => { props.onContentStateChange?.(props.panelId, companion.hasContent); @@ -238,31 +299,36 @@ export function QuoteCompanionPanel(props: { )} { - // Mid-turn the same submit is steering — the side chat has no - // slash commands, so the split is just the turn's state. - if (companion.streaming) return companion.steer(text); - try { - preflightAttachmentItems(pendingAttachments, locale); - } catch (error) { - toast.error( - copy.errors.sendRejected, - error instanceof Error ? error.message : String(error), - ); - return false; - } - const accepted = await companion.send( + onSend={(text) => + dispatchQuoteCompanionInput({ text, - pendingAttachments.length > 0 - ? toComposerIngestItems(pendingAttachments) - : undefined, - ); - if (accepted) { - props.onPromptAccepted?.(props.panelId, text); - } - if (accepted) clearSubmittedAttachments(pendingAttachments); - return accepted; - }} + streaming: companion.streaming, + compact: companion.compact, + steer: companion.steer, + send: async () => { + try { + preflightAttachmentItems(pendingAttachments, locale); + } catch (error) { + toast.error( + copy.errors.sendRejected, + error instanceof Error ? error.message : String(error), + ); + return false; + } + const accepted = await companion.send( + text, + pendingAttachments.length > 0 + ? toComposerIngestItems(pendingAttachments) + : undefined, + ); + if (accepted) { + props.onPromptAccepted?.(props.panelId, text); + } + if (accepted) clearSubmittedAttachments(pendingAttachments); + return accepted; + }, + }) + } onStop={() => void companion.stop()} hidden={Boolean(activeInteraction)} streaming={companion.streaming} @@ -323,3 +389,13 @@ export function QuoteCompanionPanel(props: { ); } + +function isWorkspaceUnavailableError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const value = error as { code?: unknown; message?: unknown }; + return ( + value.code === 'SESSION_WORKSPACE_UNAVAILABLE' || + (typeof value.message === 'string' && + value.message.includes('SESSION_WORKSPACE_UNAVAILABLE:')) + ); +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index f9dc10287b..5afbfc66fd 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -31,6 +31,7 @@ import { import type { SandboxBoundaryRequestEvent, ClientCapabilityRequestEvent, + ContextCompactionOutcome, QuoteRef, SessionEvent, UserQuestionRequestEvent, @@ -42,6 +43,7 @@ import type { SessionSummary, StoredMessage } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { ContextCompactResult } from '@maka/runtime-host/protocol'; import { useWorkbarServices } from '../../services-context.js'; import type { WorkbarIngestInput } from '../../ports.js'; import { @@ -57,6 +59,7 @@ import { type CompanionErrorCode, type EnsureCompanionForkResult, } from './quote-companion-core.js'; +import { isExactCompactCommand } from './quote-companion-context-compaction.js'; import { mergeSettledMessages } from '../../../../settled-message-merge.js'; import { getDesktopConversationCopy } from '../../../../locales/conversation-copy.js'; import { @@ -119,6 +122,15 @@ export interface UseQuoteCompanionInput { /** Reports creation and authoritative cleanup so the host can keep every * ephemeral fork hidden for its complete lifetime. */ onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void; + /** Presents the immediate result of an explicit companion compaction. */ + onContextCompactionResult?: (sessionId: string, result: ContextCompactResult) => void; + /** Presents the terminal event for an asynchronous companion compaction. */ + onContextCompactionOutcome?: ( + sessionId: string, + turnId: string, + outcome: ContextCompactionOutcome, + ) => void; + onContextCompactionError?: (sessionId: string, error: unknown) => void; } export async function requestPermissionModeWithConfirmation( @@ -153,6 +165,8 @@ export interface UseQuoteCompanionResult { activeSandboxBoundary: SandboxBoundaryRequestEvent | undefined; activeClientCapability: ClientCapabilityRequestEvent | undefined; activeQuestion: UserQuestionRequestEvent | undefined; + /** Runs `/compact` against the committed companion fork when it is idle. */ + compact: () => Promise; /** Returns whether the send was accepted; false leaves the draft + staged * quotes in place so the user can retry. */ send: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; @@ -197,6 +211,9 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan pendingQuotes, onQuotesConsumed, onForkVisibilityChange, + onContextCompactionResult, + onContextCompactionOutcome, + onContextCompactionError, } = input; const copy = getDesktopConversationCopy(locale).quoteCompanion; const [companion, setCompanion] = useState(undefined); @@ -225,11 +242,20 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const settlingTurnIdsRef = useRef>(new Set()); const onForkVisibilityChangeRef = useRef(onForkVisibilityChange); onForkVisibilityChangeRef.current = onForkVisibilityChange; + const onContextCompactionResultRef = useRef(onContextCompactionResult); + onContextCompactionResultRef.current = onContextCompactionResult; + const onContextCompactionOutcomeRef = useRef(onContextCompactionOutcome); + onContextCompactionOutcomeRef.current = onContextCompactionOutcome; + const onContextCompactionErrorRef = useRef(onContextCompactionError); + onContextCompactionErrorRef.current = onContextCompactionError; const localeRef = useRef(locale); localeRef.current = locale; const copyRef = useRef(copy); copyRef.current = copy; const ownTurnIdsRef = useRef>(new Set()); + const compactionRequestInFlightRef = useRef(false); + const compactionTurnIdRef = useRef(null); + const completedCompactionTurnIdRef = useRef(null); const [allMessages, setAllMessages] = useState([]); const [liveTurn, setLiveTurn] = useState(undefined); const liveTurnRef = useRef(liveTurn); @@ -312,6 +338,22 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const applyOwnedEvent = useCallback( (forkId: string, event: SessionEvent) => { + if (event.type === 'complete' && event.contextCompactionOutcome) { + const ownsCompaction = + compactionRequestInFlightRef.current || compactionTurnIdRef.current === event.turnId; + completedCompactionTurnIdRef.current = event.turnId; + if (compactionTurnIdRef.current === event.turnId) { + compactionTurnIdRef.current = null; + } + if (ownsCompaction) { + compactionRequestInFlightRef.current = false; + onContextCompactionOutcomeRef.current?.( + forkId, + event.turnId, + event.contextCompactionOutcome, + ); + } + } const effect = companionRunEventEffect( event, activeTurnIdRef.current, @@ -702,16 +744,61 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }; }, [panelId, sideChat]); + const compact = useCallback(async (): Promise => { + const fork = companionRef.current; + if ( + !mountedRef.current || + !fork || + fork.isArchived || + !sessionHasExactModelChoice(fork, modelChoicesRef.current) || + compactionRequestInFlightRef.current || + submitLockRef.current || + pendingAdmissionRef.current || + activeTurnIdRef.current || + (fork.runningTurnIds?.length ?? 0) > 0 + ) { + return false; + } + compactionRequestInFlightRef.current = true; + completedCompactionTurnIdRef.current = null; + let awaitingTerminal = false; + try { + const result = await sideChat.compact(fork.id); + if (!mountedRef.current) return false; + awaitingTerminal = result.kind === 'started'; + compactionTurnIdRef.current = result.turn.turnId; + onContextCompactionResultRef.current?.(fork.id, result); + if ( + result.kind === 'finished' || + completedCompactionTurnIdRef.current === result.turn.turnId + ) { + compactionRequestInFlightRef.current = false; + compactionTurnIdRef.current = null; + } + return result.kind === 'started' || result.outcome.kind !== 'failed'; + } catch (error) { + onContextCompactionErrorRef.current?.(fork.id, error); + return false; + } finally { + if (!mountedRef.current || !awaitingTerminal) { + compactionRequestInFlightRef.current = false; + if (!awaitingTerminal) compactionTurnIdRef.current = null; + } + } + }, [mountedRef, sideChat]); + const send = useCallback( async ( text: string, attachmentItems?: WorkbarIngestInput[], ): Promise => { const trimmed = text.trim(); + if (isExactCompactCommand(trimmed)) return compact(); if ( !mountedRef.current || !trimmed || submitLockRef.current || + compactionRequestInFlightRef.current || activeTurnIdRef.current || pendingAdmissionRef.current || !sourceSession @@ -872,6 +959,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan mountedRef, sideChat, bindAdmittedTurn, + compact, releaseAdmission, resolveAdmission, setPendingAdmission, @@ -1126,6 +1214,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan activeSandboxBoundary, activeClientCapability, activeQuestion, + compact, send, steer, setPermissionMode, diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 9de85db23b..05fba0ff9c 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -298,6 +298,16 @@ export interface DesktopConversationCopy { namePrefix: string; permissionStreaming: string; scrollToBottom: string; + compactSuccessTitle: string; + compactSuccessDescription: string; + compactStartedTitle: string; + compactStartedDescription: string; + compactUnchangedTitle: string; + compactUnchangedDescription: string; + compactErrorTitle: string; + compactErrorFallback: string; + workspaceUnavailableTitle: string; + workspaceUnavailableDescription: string; closeConfirmation: { title(count: number): string; description(count: number): string; @@ -612,6 +622,16 @@ const COPY = { namePrefix: '侧聊:', permissionStreaming: '侧边对话运行中暂时不能更改权限', scrollToBottom: '滚动侧边对话到底部', + compactSuccessTitle: '上下文已压缩', + compactSuccessDescription: '较早的上下文已替换为检查点摘要。', + compactStartedTitle: '正在压缩上下文', + compactStartedDescription: '正在将较早的上下文整理为检查点摘要。', + compactUnchangedTitle: '无需压缩', + compactUnchangedDescription: '任务已使用最新的检查点。', + compactErrorTitle: '压缩失败', + compactErrorFallback: '任务暂时无法压缩,请稍后重试。', + workspaceUnavailableTitle: '工作目录不可用', + workspaceUnavailableDescription: '工作目录不存在或无法访问。请选择有效目录创建新任务。', closeConfirmation: { title: (count) => count > 1 ? `关闭 ${count} 个侧边对话?` : '关闭侧边对话?', description: (count) => @@ -845,6 +865,17 @@ const COPY = { namePrefix: 'Side: ', permissionStreaming: 'Permissions cannot change while the side chat is running', scrollToBottom: 'Scroll side conversation to bottom', + compactSuccessTitle: 'Context compacted', + compactSuccessDescription: 'Older context was replaced with a checkpoint summary.', + compactStartedTitle: 'Compacting context', + compactStartedDescription: 'Summarizing older context into a checkpoint.', + compactUnchangedTitle: 'Nothing to compact', + compactUnchangedDescription: 'The task already uses the latest checkpoint.', + compactErrorTitle: 'Compaction failed', + compactErrorFallback: 'The task could not be compacted. Try again later.', + workspaceUnavailableTitle: 'Working directory unavailable', + workspaceUnavailableDescription: + 'The working directory does not exist or cannot be accessed. Select a valid folder for a new task.', closeConfirmation: { title: (count) => count > 1 ? `Close ${count} side chats?` : 'Close side chat?', description: (count) => diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 31fdc82832..5b53b29c91 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -121,6 +121,7 @@ export function createDesktopWorkbarServices( bridge.sessions.cleanupSessionCopy(sessionId), abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), + compact: (sessionId) => bridge.sessions.compact(sessionId), send: (sessionId, command) => bridge.sessions.send(sessionId, command), stop: async (sessionId, target) => { const result = await bridge.sessions.stop( diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 2d9eb2e3bf..213f83d284 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -901,6 +901,21 @@ function bridge(options: { branchFromTurn: async () => ({ ok: true, session: SIDE_CHAT_SESSION }), cleanupSessionCopy: async () => undefined, abandonSessionCopy: async () => undefined, + compact: async () => ({ + kind: 'finished' as const, + turn: { + sessionId: SIDE_CHAT_SESSION.id, + turnId: 'story-side-chat-compact-turn', + runId: 'story-side-chat-compact-run', + status: 'completed' as const, + terminalEventId: 'story-side-chat-compact-complete', + contextCompactionOutcome: { + kind: 'unchanged' as const, + reason: 'already_current', + }, + }, + outcome: { kind: 'unchanged' as const, reason: 'already_current' }, + }), send: async () => ({ ok: true, turnId: 'story-side-chat-turn' }), stop: async () => undefined, steer: async () => ({ kind: 'started', turnId: 'story-side-chat-turn' }), diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index b6e34f1d90..01fba0aa9f 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -117,6 +117,43 @@ test('applies authoritative replacement once and does not complete it again at T ); }); +test('forwards a terminal context-compaction outcome with the synthesized complete event', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const events = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'completed', + terminalEventId: 'compact-terminal-1', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_current' }, + }, + }), + }).events; + + assert.deepEqual(events, [ + { + type: 'complete', + id: 'compact-terminal-1', + turnId: 'turn-1', + ts: 10, + stopReason: 'end_turn', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_current' }, + }, + ]); +}); + test('keeps a revocable in-flight lease pending', () => { const previous = snapshot({ queue: { diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 7924fa38ad..5e85869232 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -473,6 +473,9 @@ export class RuntimeHostSessionProjector { turnId: root.turnId, ts: this.#now(), stopReason: 'end_turn', + ...(root.contextCompactionOutcome + ? { contextCompactionOutcome: root.contextCompactionOutcome } + : {}), }); } else if (root.status === 'failed') { events.push({ From 3a43ac9872684a7faf4b38bb33d142a4fd803467 Mon Sep 17 00:00:00 2001 From: testikun Date: Wed, 2 Sep 2026 13:25:59 +0800 Subject: [PATCH 2/6] fix(desktop): keep compaction helpers feature-private Generated-by: Codex --- apps/desktop/src/renderer/features/workbar/index.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/desktop/src/renderer/features/workbar/index.ts b/apps/desktop/src/renderer/features/workbar/index.ts index 49ff31f37b..2b2decd585 100644 --- a/apps/desktop/src/renderer/features/workbar/index.ts +++ b/apps/desktop/src/renderer/features/workbar/index.ts @@ -28,15 +28,3 @@ export { WorkbarServicesProvider } from './services-context'; export { useWorkbarController } from './controller/use-workbar-controller'; export type { SessionWorkbarTabKind } from './model/workbar-tabs'; export type { WorkbarServices } from './ports'; -export { - createQuoteCompanionCompactionPresentation, - dispatchQuoteCompanionInput, - isExactCompactCommand, - presentQuoteCompanionCompactionResult, - quoteCompanionCompactionNotice, -} from './tools/side-chat/quote-companion-context-compaction'; -export type { - QuoteCompanionCompactionCopy, - QuoteCompanionCompactionNotice, - QuoteCompanionCompactionPresentation, -} from './tools/side-chat/quote-companion-context-compaction'; From ecdcaaabfc96c7398c8752faf905ca856512e4e0 Mon Sep 17 00:00:00 2001 From: testikun Date: Thu, 3 Sep 2026 11:40:28 +0800 Subject: [PATCH 3/6] fix(desktop): release interrupted side compaction Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 50 +++++++++++++++++++ .../tools/side-chat/use-quote-companion.ts | 8 +++ 2 files changed, 58 insertions(+) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 3eda0df97a..495197b0fe 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -137,6 +137,7 @@ async function renderProbe( onStop?: (stop: () => Promise) => void; onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; confirmBypass?: () => Promise; + onContextCompactionError?: (sessionId: string, error: unknown) => void; pendingQuotes?: readonly StagedCompanionQuote[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; } = {}, @@ -160,6 +161,7 @@ async function renderProbe( onSteer: options.onSteer, onStop: options.onStop, onSetPermissionMode: options.onSetPermissionMode, + onContextCompactionError: options.onContextCompactionError, pendingQuotes: options.pendingQuotes, onQuotesConsumed: options.onQuotesConsumed, sourceSession: options.sourceSession, @@ -192,6 +194,7 @@ async function renderOwnershipProbe( onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; + onContextCompactionError?: (sessionId: string, error: unknown) => void; } = {}, ) { let send!: (text: string) => Promise; @@ -451,6 +454,51 @@ test('keeps an async companion compaction exclusive until its terminal event', a assert.equal(sendCalls, 0); }); +test('releases an async companion compaction after a Host interruption', async () => { + let compactCalls = 0; + const compactionErrors: Array<{ sessionId: string; error: unknown }> = []; + const rendered = await renderOwnershipProbe( + { + compact: async (sessionId) => { + compactCalls += 1; + return { + kind: 'started' as const, + turn: { + sessionId, + turnId: `compact-turn-${compactCalls}`, + runId: `compact-run-${compactCalls}`, + status: 'running' as const, + }, + }; + }, + }, + { + onContextCompactionError: (sessionId, error) => { + compactionErrors.push({ sessionId, error }); + }, + }, + ); + + assert.equal(await rendered.send('/compact'), true); + assert.equal(await rendered.send('/compact'), false); + await act(async () => { + rendered.emit({ + type: 'abort', + id: 'compact-aborted', + turnId: 'compact-turn-1', + ts: 1, + reason: 'crash', + }); + await Promise.resolve(); + }); + + assert.equal(await rendered.send('/compact'), true); + assert.equal(compactCalls, 2); + assert.equal(compactionErrors.length, 1); + assert.equal(compactionErrors[0]?.sessionId, 'side-conversation'); + assert.equal((compactionErrors[0]?.error as SessionEvent | undefined)?.type, 'abort'); +}); + test('clears a failed companion compaction request so it can be retried', async () => { let compactCalls = 0; const rendered = await renderOwnershipProbe({ @@ -1806,6 +1854,7 @@ function QuoteCompanionOwnershipProbe(props: { onSteer?: (steer: (text: string) => Promise) => void; onStop?: (stop: () => Promise) => void; onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; + onContextCompactionError?: (sessionId: string, error: unknown) => void; pendingQuotes?: readonly StagedCompanionQuote[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; sourceSession?: SessionSummary; @@ -1820,6 +1869,7 @@ function QuoteCompanionOwnershipProbe(props: { locale: 'en', onQuotesConsumed: props.onQuotesConsumed ?? (() => undefined), confirmBypass: async () => true, + onContextCompactionError: props.onContextCompactionError, }); props.onSend(companion.send); props.onSteer?.(companion.steer); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 5afbfc66fd..b0703d5955 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -338,6 +338,14 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const applyOwnedEvent = useCallback( (forkId: string, event: SessionEvent) => { + if ( + compactionTurnIdRef.current === event.turnId && + (event.type === 'abort' || (event.type === 'error' && !event.recoverable)) + ) { + compactionTurnIdRef.current = null; + compactionRequestInFlightRef.current = false; + onContextCompactionErrorRef.current?.(forkId, event); + } if (event.type === 'complete' && event.contextCompactionOutcome) { const ownsCompaction = compactionRequestInFlightRef.current || compactionTurnIdRef.current === event.turnId; From 81d1b522e7e4b595d29de5714e3cf99ed0ed1aef Mon Sep 17 00:00:00 2001 From: testikun Date: Thu, 3 Sep 2026 11:58:01 +0800 Subject: [PATCH 4/6] fix(desktop): fence pending side compaction terminal events Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 68 ++++++++++++++++++ .../tools/side-chat/use-quote-companion.ts | 72 ++++++++++++------- 2 files changed, 114 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 495197b0fe..6edf671e3a 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -31,6 +31,7 @@ import type { SessionSummary, TurnRecord, } from '@maka/core/session'; +import type { ContextCompactResult } from '@maka/runtime-host/protocol'; import { createFakeWorkbarServices, dispatchQuoteCompanionInput, @@ -499,6 +500,73 @@ test('releases an async companion compaction after a Host interruption', async ( assert.equal((compactionErrors[0]?.error as SessionEvent | undefined)?.type, 'abort'); }); +test('does not settle a pending companion compaction from another turn outcome', async () => { + const pendingCompact = deferred(); + let compactCalls = 0; + const rendered = await renderOwnershipProbe({ + compact: async (sessionId) => { + compactCalls += 1; + if (compactCalls === 1) return pendingCompact.promise; + return { + kind: 'finished' as const, + turn: { + sessionId, + turnId: 'compact-turn-after-guard', + runId: 'compact-run-after-guard', + status: 'completed' as const, + terminalEventId: 'compact-complete-after-guard', + contextCompactionOutcome: { kind: 'unchanged' as const, reason: 'already_current' }, + }, + outcome: { kind: 'unchanged' as const, reason: 'already_current' }, + }; + }, + }); + + let compactResult!: Promise; + await act(async () => { + compactResult = rendered.send('/compact'); + await Promise.resolve(); + }); + await act(async () => { + rendered.emit({ + type: 'complete', + id: 'unrelated-complete', + turnId: 'unrelated-turn', + ts: 1, + stopReason: 'end_turn', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_current' }, + }); + await Promise.resolve(); + }); + + assert.equal(await rendered.send('/compact'), false); + pendingCompact.resolve({ + kind: 'started', + turn: { + sessionId: 'side-conversation', + turnId: 'compact-turn-unrelated-guard', + runId: 'compact-run-unrelated-guard', + status: 'running', + }, + }); + assert.equal(await compactResult, true); + assert.equal(compactCalls, 1); + + await act(async () => { + rendered.emit({ + type: 'complete', + id: 'compact-complete', + turnId: 'compact-turn-unrelated-guard', + ts: 2, + stopReason: 'end_turn', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_current' }, + }); + await Promise.resolve(); + }); + assert.equal(await rendered.send('/compact'), true); + assert.equal(compactCalls, 2); +}); + test('clears a failed companion compaction request so it can be retried', async () => { let compactCalls = 0; const rendered = await renderOwnershipProbe({ diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index b0703d5955..a944d1bfd7 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -78,6 +78,14 @@ type PendingAdmission = { stopPromise?: Promise<'confirmed' | 'unknown'>; }; +type PendingCompactionTerminal = + | { kind: 'outcome'; turnId: string; outcome: ContextCompactionOutcome } + | { kind: 'error'; turnId: string; error: unknown }; + +function readMutableRef(ref: { current: T }): T { + return ref.current; +} + type AdmissionOutcome = | { kind: 'admitted'; turnId: string } | { kind: 'retracted' }; @@ -255,7 +263,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const ownTurnIdsRef = useRef>(new Set()); const compactionRequestInFlightRef = useRef(false); const compactionTurnIdRef = useRef(null); - const completedCompactionTurnIdRef = useRef(null); + const pendingCompactionTerminalRef = useRef(null); const [allMessages, setAllMessages] = useState([]); const [liveTurn, setLiveTurn] = useState(undefined); const liveTurnRef = useRef(liveTurn); @@ -338,28 +346,26 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const applyOwnedEvent = useCallback( (forkId: string, event: SessionEvent) => { - if ( - compactionTurnIdRef.current === event.turnId && - (event.type === 'abort' || (event.type === 'error' && !event.recoverable)) - ) { - compactionTurnIdRef.current = null; - compactionRequestInFlightRef.current = false; - onContextCompactionErrorRef.current?.(forkId, event); - } - if (event.type === 'complete' && event.contextCompactionOutcome) { - const ownsCompaction = - compactionRequestInFlightRef.current || compactionTurnIdRef.current === event.turnId; - completedCompactionTurnIdRef.current = event.turnId; - if (compactionTurnIdRef.current === event.turnId) { + const terminal: PendingCompactionTerminal | undefined = + event.type === 'complete' && event.contextCompactionOutcome + ? { kind: 'outcome', turnId: event.turnId, outcome: event.contextCompactionOutcome } + : event.type === 'abort' || (event.type === 'error' && !event.recoverable) + ? { kind: 'error', turnId: event.turnId, error: event } + : undefined; + if (terminal) { + if (compactionTurnIdRef.current === terminal.turnId) { compactionTurnIdRef.current = null; - } - if (ownsCompaction) { compactionRequestInFlightRef.current = false; - onContextCompactionOutcomeRef.current?.( - forkId, - event.turnId, - event.contextCompactionOutcome, - ); + if (terminal.kind === 'outcome') { + onContextCompactionOutcomeRef.current?.(forkId, terminal.turnId, terminal.outcome); + } else { + onContextCompactionErrorRef.current?.(forkId, terminal.error); + } + } else if (compactionRequestInFlightRef.current) { + // The Host can publish the terminal event before the compact RPC + // returns its turn identity. Keep it fenced until the response + // proves that this event belongs to the pending compaction. + pendingCompactionTerminalRef.current = terminal; } } const effect = companionRunEventEffect( @@ -768,7 +774,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan return false; } compactionRequestInFlightRef.current = true; - completedCompactionTurnIdRef.current = null; + pendingCompactionTerminalRef.current = null; let awaitingTerminal = false; try { const result = await sideChat.compact(fork.id); @@ -776,10 +782,24 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan awaitingTerminal = result.kind === 'started'; compactionTurnIdRef.current = result.turn.turnId; onContextCompactionResultRef.current?.(fork.id, result); - if ( - result.kind === 'finished' || - completedCompactionTurnIdRef.current === result.turn.turnId - ) { + const pendingTerminal = readMutableRef(pendingCompactionTerminalRef); + if (pendingTerminal?.turnId === result.turn.turnId) { + pendingCompactionTerminalRef.current = null; + compactionRequestInFlightRef.current = false; + compactionTurnIdRef.current = null; + if (result.kind === 'started') { + if (pendingTerminal.kind === 'outcome') { + onContextCompactionOutcomeRef.current?.( + fork.id, + pendingTerminal.turnId, + pendingTerminal.outcome, + ); + } else { + onContextCompactionErrorRef.current?.(fork.id, pendingTerminal.error); + } + } + } else if (result.kind === 'finished') { + pendingCompactionTerminalRef.current = null; compactionRequestInFlightRef.current = false; compactionTurnIdRef.current = null; } From a41dc374edf0423635624e133223d34046dd3bef Mon Sep 17 00:00:00 2001 From: testikun Date: Thu, 3 Sep 2026 13:38:50 +0800 Subject: [PATCH 5/6] test(desktop): keep side compaction cases top-level Generated-by: Codex --- apps/desktop/src/main/__tests__/quote-companion-retry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 6edf671e3a..ede7021d9e 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -366,7 +366,6 @@ test('first send after a completed turn forks through the settled turn', async ( assert.equal(probe.getAttribute('data-error'), ''); }); -test('does not fork on mount or when the source Session object refreshes', async () => { test('dispatches /compact to the committed companion fork without sending model input', async () => { const compactCalls: string[] = []; let sendCalls = 0; @@ -674,6 +673,7 @@ test('rejects /compact for an archived companion fork without invoking Runtime H assert.equal(compactCalls, 0); }); +test('does not fork on mount or when the source Session object refreshes', async () => { let branchCount = 0; const { container, root, services } = await renderProbe( { From 967dac9112820e0669784e86949a5c1016edaadb Mon Sep 17 00:00:00 2001 From: testikun Date: Thu, 3 Sep 2026 13:44:48 +0800 Subject: [PATCH 6/6] test(desktop): adapt compaction coverage to lazy forks Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index ede7021d9e..cb7dbce1e9 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -238,6 +238,16 @@ async function renderOwnershipProbe( }; } +async function commitIdleCompanion( + rendered: Awaited>, +): Promise { + await act(async () => { + assert.equal(await rendered.send('prepare side conversation'), false); + await Promise.resolve(); + }); + await awaitCompanion(rendered.container); +} + const REBOUND_MODEL: Partial = { llmConnectionId: 'connection-2', llmConnectionSlug: 'openai-2', @@ -388,7 +398,7 @@ test('dispatches /compact to the committed companion fork without sending model }, send: async () => { sendCalls += 1; - return { ok: true as const, turnId: 'unexpected-send' }; + return { ok: false as const, reason: 'seed only' }; }, steer: async () => { steerCalls += 1; @@ -396,6 +406,8 @@ test('dispatches /compact to the committed companion fork without sending model }, }); + await commitIdleCompanion(rendered); + sendCalls = 0; assert.equal(await rendered.send(' /compact '), true); assert.deepEqual(compactCalls, ['side-conversation']); assert.equal(sendCalls, 0); @@ -444,10 +456,12 @@ test('keeps an async companion compaction exclusive until its terminal event', a }, send: async () => { sendCalls += 1; - return { ok: true as const, turnId: 'unexpected-send' }; + return { ok: false as const, reason: 'seed only' }; }, }); + await commitIdleCompanion(rendered); + sendCalls = 0; assert.equal(await rendered.send('/compact'), true); assert.equal(await rendered.send('ordinary question'), false); assert.equal(compactCalls, 1); @@ -479,6 +493,7 @@ test('releases an async companion compaction after a Host interruption', async ( }, ); + await commitIdleCompanion(rendered); assert.equal(await rendered.send('/compact'), true); assert.equal(await rendered.send('/compact'), false); await act(async () => { @@ -521,6 +536,7 @@ test('does not settle a pending companion compaction from another turn outcome', }, }); + await commitIdleCompanion(rendered); let compactResult!: Promise; await act(async () => { compactResult = rendered.send('/compact'); @@ -587,6 +603,7 @@ test('clears a failed companion compaction request so it can be retried', async }, }); + await commitIdleCompanion(rendered); assert.equal(await rendered.send('/compact'), false); assert.equal(await rendered.send('/compact'), true); assert.equal(compactCalls, 2); @@ -627,32 +644,30 @@ test('rejects /compact while the companion is running without consuming staged q test('rejects /compact while the companion fork is preparing', async () => { const pendingFork = deferred(); - let send!: (text: string) => Promise; let compactCalls = 0; let branchStarted = false; - const rendered = await renderProbe( - { - branchFromTurn: async () => { - branchStarted = true; - return { ok: true as const, session: await pendingFork.promise }; - }, - compact: async () => { - compactCalls += 1; - throw new Error('compact should not run before fork commit'); - }, + const rendered = await renderOwnershipProbe({ + branchFromTurn: async () => { + branchStarted = true; + return { ok: true as const, session: await pendingFork.promise }; }, - { - ownership: true, - onSend: (value) => (send = value), - ready: () => branchStarted, + compact: async () => { + compactCalls += 1; + throw new Error('compact should not run before fork commit'); }, - ); + }); - assert.equal(await send('/compact'), false); + let sendResult!: Promise; + await act(async () => { + sendResult = rendered.send('prepare pending fork'); + await Promise.resolve(); + }); + await waitUntil(() => branchStarted); + assert.equal(await rendered.send('/compact'), false); assert.equal(compactCalls, 0); await act(async () => { pendingFork.resolve(session('side-conversation')); - await Promise.resolve(); + assert.equal(await sendResult, false); }); }); @@ -669,6 +684,7 @@ test('rejects /compact for an archived companion fork without invoking Runtime H }, }); + await commitIdleCompanion(rendered); assert.equal(await rendered.send('/compact'), false); assert.equal(compactCalls, 0); });