Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConversationCoordinator>>, request: CompactSessionRequest, ) -> Result<StartDialogTurnResponse, String>",
"signature": "fn compact_session( coordinator: State<'_, Arc<ConversationCoordinator>>, request: CompactSessionRequest, ) -> Result<CompactSessionResponse, String>",
"remoteWorkspacePolicy": "RemoteRouted"
},
{
Expand Down
22 changes: 17 additions & 5 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection.
#[serde(default)]
pub workspace_id: Option<String>,
Expand All @@ -403,6 +406,14 @@ pub struct CompactSessionRequest {
pub remote_ssh_host: Option<String>,
}

#[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 {
Expand Down Expand Up @@ -2695,7 +2706,7 @@ fn desktop_image_attachment(image: ImageContextData) -> AgentInputAttachment {
pub async fn compact_session(
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: CompactSessionRequest,
) -> Result<StartDialogTurnResponse, String> {
) -> Result<CompactSessionResponse, String> {
let session_id = request.session_id.trim();
if session_id.is_empty() {
return Err("session_id is required".to_string());
Expand All @@ -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,
})
}

Expand Down
17 changes: 17 additions & 0 deletions src/crates/assembly/core/src/agentic/coordination/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
) -> OpenBitFunResult<String> {
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,
Expand Down
45 changes: 42 additions & 3 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -3830,6 +3851,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return;
}

if (isManualCompactionPending(effectiveTargetSessionId)) {
notificationService.info(t('chatInput.compactPending'));
return;
}

if (derivedState?.isProcessing) {
notificationService.warning(
t('chatInput.compactBusy')
Expand All @@ -3845,17 +3871,30 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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'),
Expand Down
6 changes: 3 additions & 3 deletions src/web-ui/src/flow_chat/services/FlowChatManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -873,11 +873,11 @@ export class FlowChatManager {
}

/** Manually compact a session's context through its driver. */
async compactSession(sessionId: string): Promise<void> {
async compactSession(sessionId: string, turnId?: string): Promise<CompactSessionResult> {
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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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__;
Expand Down Expand Up @@ -2137,6 +2142,7 @@ describe('handleCompressionCompleted', () => {
afterEach(() => {
resetFlowChatStore();
stateMachineManager.clear();
resetManualCompactionStateForTest();
});

it('writes the compacted token count into currentTokenUsage when applied', () => {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -112,6 +117,16 @@ import { resolveLegacySessionWorkspace } from '@/infrastructure/api/service-api/

const pendingImageAnalysisTurns = new Map<string, string>();

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;
Expand Down Expand Up @@ -219,6 +234,7 @@ export const __test_only__ = {
handleModelRoundStart,
handleModelRoundComplete,
handleTokenUsageUpdate,
handleCompressionStarted,
handleCompressionCompleted,
handleDialogTurnInterrupted,
handleDialogTurnRecovered,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -3063,6 +3097,7 @@ function handleDialogTurnCancelled(
}

log.info('Dialog turn cancelled', { sessionId, turnId });
settleManualCompactionEvent(sessionId, turnId);
clearPendingTurnCompletion(context, sessionId, turnId);
clearRuntimeStatus(context, sessionId, turnId);

Expand Down Expand Up @@ -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', {
Expand Down
32 changes: 32 additions & 0 deletions src/web-ui/src/flow_chat/services/manualCompactionState.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading