diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 4445789b4e..07d0e52015 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1313,7 +1313,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::compact_session", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn compact_session( coordinator: State<'_, Arc>, request: CompactSessionRequest, ) -> Result", + "signature": "fn compact_session( coordinator: State<'_, Arc>, request: CompactSessionRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index d2860adb8d..3537c42af2 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -393,6 +393,9 @@ pub struct StartDialogTurnResponse { #[serde(rename_all = "camelCase")] pub struct CompactSessionRequest { pub session_id: String, + /// Stable caller id makes a retry after a lost response safe to correlate. + #[serde(default)] + pub turn_id: Option, /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. #[serde(default)] pub workspace_id: Option, @@ -403,6 +406,14 @@ pub struct CompactSessionRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompactSessionResponse { + pub success: bool, + pub message: String, + pub turn_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ActivateSessionGoalRequest { @@ -2695,7 +2706,7 @@ fn desktop_image_attachment(image: ImageContextData) -> AgentInputAttachment { pub async fn compact_session( coordinator: State<'_, Arc>, request: CompactSessionRequest, -) -> Result { +) -> Result { let session_id = request.session_id.trim(); if session_id.is_empty() { return Err("session_id is required".to_string()); @@ -2720,14 +2731,15 @@ pub async fn compact_session( .map_err(|e| format!("Failed to restore session before compacting: {}", e))?; } - coordinator - .compact_session_manually(session_id.to_string()) + let turn_id = coordinator + .start_manual_compaction_detached(session_id.to_string(), request.turn_id) .await .map_err(|e| format!("Failed to compact session: {}", e))?; - Ok(StartDialogTurnResponse { + Ok(CompactSessionResponse { success: true, - message: "Session compaction started".to_string(), + message: "Session compaction accepted".to_string(), + turn_id, }) } diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 12eb4f289b..9ced0403e3 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -6501,6 +6501,23 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .map(|_task| ()) } + /// Start a manual compaction and return once the maintenance turn has been + /// admitted. The compaction model request and context commit continue in + /// the detached task; callers observe completion through the normal + /// DialogTurn/ContextCompression events. + pub async fn start_manual_compaction_detached( + &self, + session_id: String, + requested_turn_id: Option, + ) -> OpenBitFunResult { + let task = self + .start_manual_compaction_task(session_id, requested_turn_id) + .await?; + let turn_id = task.turn_id.clone(); + drop(task.completion); + Ok(turn_id) + } + pub(crate) async fn prepare_input_images( &self, session_id: &str, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 4b12facab5..6572ddbeba 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -87,7 +87,7 @@ import { sessionComposerStore, type PendingLargePasteMap, } from '../store/sessionComposerStore'; -import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { getActiveSurfaceScope, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; import { useAssistantBootstrap } from '@/app/hooks/useAssistantBootstrap'; import { clearComposerForSubmission, @@ -116,6 +116,11 @@ import { ThreadGoalDialogs } from './thread-goal/ThreadGoalDialogs'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; +import { + isManualCompactionPending, + markManualCompactionPending, + settleManualCompaction, +} from '../services/manualCompactionState'; import { interruptedTurnRecoveryGate } from '@/flow_chat/services/interruptedTurnRecoveryGate'; import { getDeepReviewLaunchErrorMessage, @@ -241,7 +246,11 @@ import { } from '../utils/tokenUsageDisplay'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import type { SessionPermissionMode } from '@/infrastructure/api/service-api/AgentAPI'; -import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandError'; +import { + isOutcomeUnknownError, + isSessionInUseError, + isTauriCommandError, +} from '@/infrastructure/api/errors/TauriCommandError'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import { isBtwParentBackedSession, isBtwSessionDraft } from '../utils/modelSelectionTarget'; @@ -312,6 +321,18 @@ import { const log = createLogger('ChatInput'); +function manualCompactionTurnId(): string { + return globalThis.crypto?.randomUUID?.() + ?? `manual-compact-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function isUncertainCompactionOutcome(error: unknown): boolean { + if (isSurfaceChangedError(error) || isOutcomeUnknownError(error)) return true; + if (isTauriCommandError(error) && error.isNetworkError()) return true; + const message = error instanceof Error ? error.message : String(error); + return /timeout|network|connection|fetch|transport|aborted|surface changed/i.test(message); +} + export interface ChatInputProps { /** Conversation hosts use an in-flow composer without the workbench context bar. */ presentation?: 'standard' | 'conversation'; @@ -3830,6 +3851,11 @@ export const ChatInput: React.FC = ({ return; } + if (isManualCompactionPending(effectiveTargetSessionId)) { + notificationService.info(t('chatInput.compactPending')); + return; + } + if (derivedState?.isProcessing) { notificationService.warning( t('chatInput.compactBusy') @@ -3845,17 +3871,30 @@ export const ChatInput: React.FC = ({ return; } + const requestedTurnId = manualCompactionTurnId(); + markManualCompactionPending(effectiveTargetSessionId, requestedTurnId); dispatchInput({ type: 'CLEAR_VALUE' }); setQueuedInput(null); setSlashCommandState({ isActive: false, kind: 'all', query: '', selectedIndex: 0 }); try { - await FlowChatManager.getInstance().compactSession(effectiveTargetSessionId); + const result = await FlowChatManager.getInstance().compactSession( + effectiveTargetSessionId, + requestedTurnId, + ); + if (!result.accepted) { + throw new Error(t('chatInput.compactFailed')); + } } catch (error) { log.error('Failed to trigger /compact', { error, sessionId: effectiveTargetSessionId, }); + if (isUncertainCompactionOutcome(error)) { + notificationService.info(t('chatInput.compactPending')); + return; + } + settleManualCompaction(effectiveTargetSessionId, requestedTurnId); dispatchInput({ type: 'SET_VALUE', payload: message }); notificationService.error( error instanceof Error ? error.message : t('error.unknown'), diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 85a94c208b..c4f836e2ae 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -78,7 +78,7 @@ import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefres import { installDispatchJobObserver } from '../session-drivers/dispatch/install'; import { driverForSession } from '../session-drivers/registry'; import { registerDriverSessionLookup } from '../session-drivers/resolve'; -import type { SendMessageOptions } from '../session-drivers/types'; +import type { CompactSessionResult, SendMessageOptions } from '../session-drivers/types'; const log = createLogger('FlowChatManager'); @@ -873,11 +873,11 @@ export class FlowChatManager { } /** Manually compact a session's context through its driver. */ - async compactSession(sessionId: string): Promise { + async compactSession(sessionId: string, turnId?: string): Promise { const draft = this.context.flowChatStore.getState().sessions.get(sessionId); if (draft?.draft) await prepareSessionDraftForCommand(draft); const session = this.context.flowChatStore.getState().sessions.get(sessionId); - return driverForSession(sessionId, session).compactSession(this.context, sessionId); + return driverForSession(sessionId, session).compactSession(this.context, sessionId, turnId); } /** Produce the session usage report and show it, through its driver. */ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 02d159db2a..58f169dff0 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -30,9 +30,14 @@ import { promptSuggestionKey, usePromptSuggestionStore } from '../../store/promp import { clearAgentCanvasForPeerSwitch, switchAgentCanvasScope, useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; import { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; +import { + isManualCompactionPending, + resetManualCompactionStateForTest, +} from '../manualCompactionState'; const { buildBuiltInBrowserTabOptions, + handleCompressionStarted, handleCompressionCompleted, handleTokenUsageUpdate, } = __test_only__; @@ -2137,6 +2142,7 @@ describe('handleCompressionCompleted', () => { afterEach(() => { resetFlowChatStore(); stateMachineManager.clear(); + resetManualCompactionStateForTest(); }); it('writes the compacted token count into currentTokenUsage when applied', () => { @@ -2167,6 +2173,28 @@ describe('handleCompressionCompleted', () => { }); }); + it('settles a manual compaction whose turn was replayed after the surface switch', () => { + const context = createFlowChatContext(); + handleCompressionStarted(context, { + sessionId: 'session-1', + turnId: 'turn-1', + compressionId: 'compression-1', + trigger: 'manual', + }); + expect(isManualCompactionPending('session-1')).toBe(true); + + handleCompressionCompleted(context, { + sessionId: 'session-1', + turnId: 'turn-1', + compressionId: 'compression-1', + applied: true, + tokensBefore: 90_000, + tokensAfter: 15_000, + }); + + expect(isManualCompactionPending('session-1')).toBe(false); + }); + it('does not touch currentTokenUsage when the compression was not applied', () => { putFinishingSessionInStore(); const context = createFlowChatContext(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index cfb1e7b5d2..2b237edecf 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -100,6 +100,11 @@ import { scheduleModelResponseStatus, } from './RuntimeStatusModule'; import { requestRuntimeProjectionRepair } from './PeerSessionRefreshModule'; +import { + isManualCompactionPending, + markManualCompactionPending, + settleManualCompaction, +} from '../manualCompactionState'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { optimisticTurnAdoptionKey, @@ -112,6 +117,16 @@ import { resolveLegacySessionWorkspace } from '@/infrastructure/api/service-api/ const pendingImageAnalysisTurns = new Map(); +function settleManualCompactionEvent(sessionId: string, turnId?: string): void { + // Older hosts may ignore the caller-provided id. Once a terminal event is + // observed for the pending session, adopt its authoritative turn id before + // settling so replay remains compatible across upgrades. + if (isManualCompactionPending(sessionId) && typeof turnId === 'string') { + markManualCompactionPending(sessionId, turnId); + } + settleManualCompaction(sessionId, turnId); +} + /** Apply host timing separately from model arguments and tolerate event batching. */ export function handleUserQuestionWaiting(context: FlowChatContext, event: { session_id: string; @@ -219,6 +234,7 @@ export const __test_only__ = { handleModelRoundStart, handleModelRoundComplete, handleTokenUsageUpdate, + handleCompressionStarted, handleCompressionCompleted, handleDialogTurnInterrupted, handleDialogTurnRecovered, @@ -2597,6 +2613,10 @@ function handleCompressionStarted(_context: FlowChatContext, event: any): void { log.info('Context compression started', { sessionId, turnId, compressionId, trigger, tokensBefore, contextWindow }); + + if (trigger === 'manual' && typeof sessionId === 'string') { + markManualCompactionPending(sessionId, typeof turnId === 'string' ? turnId : undefined); + } const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); @@ -2678,8 +2698,12 @@ function handleCompressionCompleted(context: FlowChatContext, event: any): void || !session?.dialogTurns.some(turn => turn.id === turnId) ) { log.debug('Dropped compression completion for non-visible turn', { sessionId, turnId }); + settleManualCompactionEvent(sessionId, turnId); + requestRuntimeProjectionRepair(sessionId); return; } + + settleManualCompactionEvent(sessionId, turnId); store.updateModelRoundItem(sessionId, turnId, compressionId, { toolResult: { @@ -2727,6 +2751,14 @@ function handleCompressionFailed(context: FlowChatContext, event: any): void { log.error('Context compression failed', { sessionId, turnId, compressionId, error }); const store = FlowChatStore.getInstance(); + + settleManualCompactionEvent(sessionId, turnId); + + const session = store.getState().sessions.get(sessionId); + if (!session || !session.dialogTurns.some(turn => turn.id === turnId)) { + requestRuntimeProjectionRepair(sessionId); + return; + } store.updateModelRoundItem(sessionId, turnId, compressionId, { toolResult: { @@ -2837,6 +2869,7 @@ export function handleDialogTurnComplete( log.warn('DialogTurnCompleted missing sessionId or turnId', { event }); return; } + settleManualCompactionEvent(sessionId, turnId); const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); const existingTurn = session?.dialogTurns.find(turn => turn.id === turnId); @@ -2957,6 +2990,7 @@ function handleDialogTurnFailed(context: FlowChatContext, event: any): void { } log.error('Dialog turn failed', { sessionId, turnId, error, errorDetail }); + settleManualCompactionEvent(sessionId, turnId); clearPendingTurnCompletion(context, sessionId, turnId); clearRuntimeStatus(context, sessionId, turnId); @@ -3063,6 +3097,7 @@ function handleDialogTurnCancelled( } log.info('Dialog turn cancelled', { sessionId, turnId }); + settleManualCompactionEvent(sessionId, turnId); clearPendingTurnCompletion(context, sessionId, turnId); clearRuntimeStatus(context, sessionId, turnId); @@ -3136,6 +3171,7 @@ function handleDialogTurnInterrupted(context: FlowChatContext, event: any): void log.warn('DialogTurnInterrupted missing identity or generation', { event }); return; } + settleManualCompactionEvent(sessionId, turnId); interruptedTurnRecoveryGate.clearInterrupted({ sessionId, turnId, executionGeneration }); if (context.handledTerminalTurnEvents.has(`${sessionId}:${turnId}`)) { log.debug('Ignoring DialogTurnInterrupted after authoritative terminal settlement', { diff --git a/src/web-ui/src/flow_chat/services/manualCompactionState.test.ts b/src/web-ui/src/flow_chat/services/manualCompactionState.test.ts new file mode 100644 index 0000000000..63be1f428d --- /dev/null +++ b/src/web-ui/src/flow_chat/services/manualCompactionState.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + isManualCompactionPending, + markManualCompactionPending, + resetManualCompactionStateForTest, + settleManualCompaction, +} from './manualCompactionState'; + +describe('manualCompactionState', () => { + afterEach(() => resetManualCompactionStateForTest()); + + it('keeps a pending admission across turn-id assignment', () => { + markManualCompactionPending('session-1'); + expect(isManualCompactionPending('session-1')).toBe(true); + + markManualCompactionPending('session-1', 'turn-1'); + settleManualCompaction('session-1', 'other-turn'); + expect(isManualCompactionPending('session-1')).toBe(true); + + settleManualCompaction('session-1', 'turn-1'); + expect(isManualCompactionPending('session-1')).toBe(false); + }); + + it('keeps independent sessions isolated', () => { + markManualCompactionPending('session-1', 'turn-1'); + markManualCompactionPending('session-2', 'turn-2'); + + settleManualCompaction('session-1', 'turn-1'); + expect(isManualCompactionPending('session-1')).toBe(false); + expect(isManualCompactionPending('session-2')).toBe(true); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/manualCompactionState.ts b/src/web-ui/src/flow_chat/services/manualCompactionState.ts new file mode 100644 index 0000000000..548341f302 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/manualCompactionState.ts @@ -0,0 +1,40 @@ +/** + * Process-local admission state for a user-triggered context compaction. + * + * The Desktop command has a bounded transport timeout while the maintenance + * turn can keep running. Keep this state outside React/session projections so + * a surface switch does not make a still-running compaction submit-able again. + */ + +const pendingManualCompactions = new Map(); + +export function markManualCompactionPending(sessionId: string, turnId?: string): void { + const normalizedSessionId = sessionId.trim(); + if (!normalizedSessionId) return; + + if (turnId || !pendingManualCompactions.has(normalizedSessionId)) { + pendingManualCompactions.set(normalizedSessionId, turnId); + } +} + +export function isManualCompactionPending(sessionId: string): boolean { + return pendingManualCompactions.has(sessionId.trim()); +} + +/** Settle only the matching turn when the backend supplies an identity. */ +export function settleManualCompaction(sessionId: string, turnId?: string): void { + const normalizedSessionId = sessionId.trim(); + const pendingTurnId = pendingManualCompactions.get(normalizedSessionId); + if (!pendingManualCompactions.has(normalizedSessionId)) return; + if (turnId && pendingTurnId && turnId !== pendingTurnId) return; + pendingManualCompactions.delete(normalizedSessionId); +} + +export function clearManualCompaction(sessionId: string): void { + pendingManualCompactions.delete(sessionId.trim()); +} + +/** Test-only reset; no product code should clear another session's state. */ +export function resetManualCompactionStateForTest(): void { + pendingManualCompactions.clear(); +} diff --git a/src/web-ui/src/flow_chat/session-drivers/dispatch/DispatchSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/dispatch/DispatchSessionDriver.ts index c84df544f6..df357b8d5d 100644 --- a/src/web-ui/src/flow_chat/session-drivers/dispatch/DispatchSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/dispatch/DispatchSessionDriver.ts @@ -24,6 +24,7 @@ import type { SessionCascadeRemoval, SessionCreationSeed, SessionDriver, + CompactSessionResult, SessionDriverNavigationStatusSource, SessionDriverOwnershipSource, StartTurnInput, @@ -500,7 +501,7 @@ export const dispatchSessionDriver: SessionDriver = { return response.cancelled; }, - async compactSession(context: FlowChatContext, sessionId: string): Promise { + async compactSession(context: FlowChatContext, sessionId: string, turnId?: string): Promise { const session = context.flowChatStore.getState().sessions.get(sessionId); const jobId = session?.config.dispatchJobId; if (!jobId) { @@ -522,7 +523,8 @@ export const dispatchSessionDriver: SessionDriver = { 'compact', undefined, () => - globalThis.crypto?.randomUUID?.() + turnId + ?? globalThis.crypto?.randomUUID?.() ?? `dispatch-compact-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); try { @@ -541,6 +543,7 @@ export const dispatchSessionDriver: SessionDriver = { releaseSubmissionRetry(COMPACT_RETRY_SCOPE, sessionId, retry.id); } requestDispatchJobRefresh(jobId); + return { accepted: true, turnId: retry.id }; }, async runUsageReport( diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 996acd8a9a..43640f3843 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -29,6 +29,7 @@ import type { SessionCascadeRemoval, SessionCreationSeed, SessionDriver, + CompactSessionResult, StartTurnInput, StartTurnResult, SubmissionPlan, @@ -348,18 +349,20 @@ export const localSessionDriver: SessionDriver = { return { kind: 'queue' }; }, - async compactSession(context: FlowChatContext, sessionId: string): Promise { + async compactSession(context: FlowChatContext, sessionId: string, turnId?: string): Promise { const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session) { throw new Error(`Session does not exist: ${sessionId}`); } - await agentAPI.compactSession({ + const response = await agentAPI.compactSession({ sessionId, + turnId, workspaceId: sessionOwningWorkspaceId(session), workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, }); + return { accepted: response.success, turnId: response.turnId }; }, async runUsageReport( diff --git a/src/web-ui/src/flow_chat/session-drivers/types.ts b/src/web-ui/src/flow_chat/session-drivers/types.ts index 6e3abddc66..a03b08cbc7 100644 --- a/src/web-ui/src/flow_chat/session-drivers/types.ts +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -303,7 +303,7 @@ export interface SessionDriver { * Manually compact the session's context. Local sessions call the runtime * directly; dispatch sessions queue a compact turn on the target. */ - compactSession(context: FlowChatContext, sessionId: string): Promise; + compactSession(context: FlowChatContext, sessionId: string, turnId?: string): Promise; /** * Produce the session usage report and show it. `uiParams` carries the @@ -339,3 +339,8 @@ export interface SessionDriver { feedback?: string, ): Promise; } + +export interface CompactSessionResult { + accepted: boolean; + turnId?: string; +} diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 1fc6e9969f..f0bba2373b 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -244,6 +244,7 @@ export type PermissionRequestEvent = export interface CompactSessionRequest { sessionId: string; + turnId?: string; /** Workspace identity; authoritative when present. Paths below are IO projections. */ workspaceId?: string; workspacePath?: string; @@ -251,6 +252,12 @@ export interface CompactSessionRequest { remoteSshHost?: string; } +export interface CompactSessionResponse { + success: boolean; + message: string; + turnId?: string; +} + export interface SessionInfo { sessionId: string; @@ -861,9 +868,9 @@ export class AgentAPI { } } - async compactSession(request: CompactSessionRequest): Promise<{ success: boolean; message: string }> { + async compactSession(request: CompactSessionRequest): Promise { try { - return await api.invoke<{ success: boolean; message: string }>('compact_session', { request }); + return await api.invoke('compact_session', { request }); } catch (error) { throw createTauriCommandError('compact_session', error, request); } diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 43311b2eb8..13cca86523 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1101,6 +1101,7 @@ "compactAction": "Compact session", "compactNoSession": "No active session for /compact", "compactBusy": "Wait until the session is idle before using /compact.", + "compactPending": "Compaction is still running. Wait for it to finish before trying /compact again.", "compactUsage": "Use /compact without extra arguments.", "compactFailed": "Session compaction failed", "usageAction": "Session usage", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 361fca49b7..3199ebcfdd 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1101,6 +1101,7 @@ "compactAction": "压缩会话", "compactNoSession": "当前没有可用于 /compact 的会话", "compactBusy": "请等待当前会话空闲后再使用 /compact。", + "compactPending": "会话压缩仍在进行中,请等待完成后再使用 /compact。", "compactUsage": "请直接使用 /compact,不要附加额外参数。", "compactFailed": "会话压缩失败", "goalAction": "会话目标", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index d4b9319926..baef9ad91a 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1101,6 +1101,7 @@ "compactAction": "壓縮會話", "compactNoSession": "目前沒有可用於 /compact 的會話", "compactBusy": "請等待目前會話空閒後再使用 /compact。", + "compactPending": "會話壓縮仍在進行中,請等待完成後再使用 /compact。", "compactUsage": "請直接使用 /compact,不要附加額外參數。", "compactFailed": "會話壓縮失敗", "goalAction": "會話目標",