From 83e924f7fd5f1b5c02258f963d5f7117ff26adb8 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 21:00:33 -0700 Subject: [PATCH 01/12] refactor(web): unify dispatch-projection detection behind a session driver resolver Three call sites decided "is this session a dispatch observer projection" with three different signal sets: SessionModule checked target+jobId+store, FlowChatStore checked target+store, and PersistenceModule checked only the bound target. During the startup window where DispatchJobObserver has not bound config yet, the weaker predicates could persist a projection to disk or treat it as a local backend session. resolveSessionDriverId is now the single three-signal source of truth (non-local target OR configured job id OR observer-store membership), and all three predicates delegate to it. First step of the SessionDriver seam. --- .../flow-chat-manager/PersistenceModule.ts | 18 ++- .../flow-chat-manager/SessionModule.ts | 11 +- .../flow_chat/session-drivers/resolve.test.ts | 117 ++++++++++++++++++ .../src/flow_chat/session-drivers/resolve.ts | 75 +++++++++++ .../src/flow_chat/store/FlowChatStore.ts | 7 +- 5 files changed, 209 insertions(+), 19 deletions(-) create mode 100644 src/web-ui/src/flow_chat/session-drivers/resolve.test.ts create mode 100644 src/web-ui/src/flow_chat/session-drivers/resolve.ts diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 8375793507..dbf6ed3f83 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -12,6 +12,7 @@ import { effectiveToolInvocation, } from '../../utils/toolInvocationIdentity'; import { requireSessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; +import { resolveSessionDriverId } from '../../session-drivers/resolve'; const log = createLogger('PersistenceModule'); const COALESCED_IMMEDIATE_SAVE_DELAY_MS = 500; @@ -20,10 +21,17 @@ function isTransientSession(session: { isTransient?: boolean } | undefined): boo return session?.isTransient === true; } +/** + * Observer projections are target-owned; the controller must never persist + * them as local sessions. Uses the driver resolver so a projection whose + * config is not bound yet (startup race) is still recognized via the + * observer-store membership signal. + */ function isObserverOnlyDispatchSession( - session: { config?: { dispatchTarget?: { kind?: string } } } | undefined, + sessionId: string, + session: Parameters[1], ): boolean { - return !!session?.config?.dispatchTarget && session.config.dispatchTarget.kind !== 'local'; + return resolveSessionDriverId(sessionId, session) === 'dispatch'; } function requireWorkspacePath(sessionId: string, workspacePath?: string): string { @@ -282,7 +290,7 @@ async function performSaveDialogTurnToDisk( log.debug('Session not found, skipping save', { sessionId, turnId }); return; } - if (isTransientSession(session) || isObserverOnlyDispatchSession(session)) { + if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) { return; } @@ -320,7 +328,7 @@ export async function saveAllInProgressTurns(context: FlowChatContext): Promise< const savePromises: Promise[] = []; for (const [sessionId, session] of state.sessions.entries()) { - if (isTransientSession(session) || isObserverOnlyDispatchSession(session)) { + if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) { continue; } const lastTurn = session.dialogTurns[session.dialogTurns.length - 1]; @@ -525,7 +533,7 @@ export async function updateSessionMetadata( const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session) return; - if (isTransientSession(session) || isObserverOnlyDispatchSession(session)) return; + if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) return; const workspacePath = requireSessionProjectWorkspacePath(session, sessionId); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index ce1836a4cf..b01d3c7618 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -56,6 +56,7 @@ import { } from '@/features/dispatch/types'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; import { forgetDispatchTranscript } from '@/features/dispatch/dispatchTranscriptCache'; +import { resolveSessionDriverId } from '../../session-drivers/resolve'; const log = createLogger('SessionModule'); const pendingSessionCreations = new Map>(); @@ -65,15 +66,7 @@ function isDispatchObserverProjection( sessionId: string, session: Session | undefined, ): boolean { - if ( - isNonLocalDispatchTarget(session?.config.dispatchTarget) - || Boolean(session?.config.dispatchJobId?.trim()) - ) { - return true; - } - - return Object.values(dispatchJobStore.getState().jobs) - .some(job => job.sessionId === sessionId); + return resolveSessionDriverId(sessionId, session) === 'dispatch'; } function dismissDispatchObserverProjection( diff --git a/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts b/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts new file mode 100644 index 0000000000..d8ff71c6c0 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + dispatchJobStoreObservesSession, + resolveSessionDriverId, + resolveSessionDriverIdForCreation, + resolveSessionDriverIdWith, +} from './resolve'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import type { DispatchObserverJob } from '@/features/dispatch/dispatchJobStore'; + +const never = () => false; + +function observerJob(overrides: Partial): DispatchObserverJob { + return { + jobId: 'job-1', + sessionId: 'session-1', + targetRequest: { kind: 'ssh', connectionId: 'ssh-user@host', workspacePath: '/repo' }, + target: { + kind: 'ssh', + connectionId: 'ssh-user@host', + workspacePath: '/repo', + displayName: 'host', + }, + sourceWorkspacePath: '/controller/repo', + title: 'job', + agentType: 'agentic', + approvalPolicy: 'remote', + cursor: 0, + state: 'submitting', + appliedEventIds: [], + pendingPermissions: [], + eventLogComplete: true, + historyTruncated: false, + omittedEventCount: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + } as DispatchObserverJob; +} + +describe('resolveSessionDriverIdWith', () => { + it('resolves local when no dispatch signal is present', () => { + expect(resolveSessionDriverIdWith('s1', { config: {} }, never)).toBe('local'); + expect(resolveSessionDriverIdWith('s1', undefined, never)).toBe('local'); + }); + + it('treats a local dispatch target as local execution', () => { + expect( + resolveSessionDriverIdWith('s1', { config: { dispatchTarget: { kind: 'local' } } }, never), + ).toBe('local'); + }); + + it('resolves dispatch from a bound non-local target', () => { + expect( + resolveSessionDriverIdWith( + 's1', + { + config: { + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-user@host', + workspacePath: '/repo', + displayName: 'host', + }, + }, + }, + never, + ), + ).toBe('dispatch'); + }); + + it('resolves dispatch from a configured job id even without a bound target', () => { + expect( + resolveSessionDriverIdWith('s1', { config: { dispatchJobId: 'dispatch-abc' } }, never), + ).toBe('dispatch'); + expect( + resolveSessionDriverIdWith('s1', { config: { dispatchJobId: ' ' } }, never), + ).toBe('local'); + }); + + it('resolves dispatch from observer-store membership alone', () => { + // The startup race: ensureProjection has not bound config yet, but the + // observer store already knows the session belongs to a target-owned job. + const observes = (sessionId: string) => sessionId === 's1'; + expect(resolveSessionDriverIdWith('s1', { config: {} }, observes)).toBe('dispatch'); + expect(resolveSessionDriverIdWith('s2', { config: {} }, observes)).toBe('local'); + }); +}); + +describe('resolveSessionDriverId (dispatchJobStore integration)', () => { + afterEach(() => { + dispatchJobStore.getState().clear(); + }); + + it('sees sessions registered in the observer store', () => { + expect(dispatchJobStoreObservesSession('session-1')).toBe(false); + dispatchJobStore.getState().registerJob(observerJob({ sessionId: 'session-1' })); + expect(dispatchJobStoreObservesSession('session-1')).toBe(true); + expect(resolveSessionDriverId('session-1', { config: {} })).toBe('dispatch'); + expect(resolveSessionDriverId('other-session', { config: {} })).toBe('local'); + }); +}); + +describe('resolveSessionDriverIdForCreation', () => { + it('keys off the requested dispatch target', () => { + expect(resolveSessionDriverIdForCreation({})).toBe('local'); + expect( + resolveSessionDriverIdForCreation({ dispatchTargetRequest: { kind: 'local' } }), + ).toBe('local'); + expect( + resolveSessionDriverIdForCreation({ + dispatchTargetRequest: { kind: 'device', deviceId: 'd1', workspacePath: '/repo' }, + }), + ).toBe('dispatch'); + }); +}); diff --git a/src/web-ui/src/flow_chat/session-drivers/resolve.ts b/src/web-ui/src/flow_chat/session-drivers/resolve.ts new file mode 100644 index 0000000000..c2dedcf761 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/resolve.ts @@ -0,0 +1,75 @@ +/** + * Session driver resolution. + * + * A session's driver decides which transport family owns its lifecycle, + * submission, permissions, and persistence. The id is always derived — never + * stored on the session — because a projection can exist before its dispatch + * config is bound (startup metadata races `DispatchJobObserver.ensureProjection`) + * and a stored id would go stale in that window. + */ + +import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; +import type { DispatchTarget, DispatchTargetRequest } from '@/features/dispatch/types'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; + +export type SessionDriverId = 'local' | 'dispatch'; + +/** + * Structural view of the session fields the resolver reads. Kept minimal so + * store modules and tests can pass lightweight objects. + */ +export interface DriverResolvableSession { + config?: { + dispatchTarget?: DispatchTarget; + dispatchJobId?: string; + }; +} + +/** + * Three-signal core. All three are required because each covers a window the + * others miss: + * - `dispatchTarget` is unbound while `ensureProjection` races startup + * workspace metadata; + * - `dispatchJobId` survives on config after the observer store was pruned; + * - the job-store membership catches sessions created by the observer before + * any config binding happened. + * + * A weaker predicate here persisted observer projections to disk or created + * controller-side backend sessions for target-owned jobs. + */ +export function resolveSessionDriverIdWith( + sessionId: string, + session: DriverResolvableSession | undefined, + isDispatchObservedSession: (sessionId: string) => boolean, +): SessionDriverId { + if (isNonLocalDispatchTarget(session?.config?.dispatchTarget)) { + return 'dispatch'; + } + if (session?.config?.dispatchJobId?.trim()) { + return 'dispatch'; + } + if (isDispatchObservedSession(sessionId)) { + return 'dispatch'; + } + return 'local'; +} + +export function dispatchJobStoreObservesSession(sessionId: string): boolean { + return Object.values(dispatchJobStore.getState().jobs) + .some(job => job.sessionId === sessionId); +} + +/** The single source of truth for "which driver owns this session". */ +export function resolveSessionDriverId( + sessionId: string, + session: DriverResolvableSession | undefined, +): SessionDriverId { + return resolveSessionDriverIdWith(sessionId, session, dispatchJobStoreObservesSession); +} + +/** Creation has no session yet; the requested target decides the driver. */ +export function resolveSessionDriverIdForCreation(config: { + dispatchTargetRequest?: DispatchTargetRequest; +}): SessionDriverId { + return isNonLocalDispatchTarget(config.dispatchTargetRequest) ? 'dispatch' : 'local'; +} diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 4e426710b3..6316bd6783 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -73,6 +73,7 @@ import { isNonLocalDispatchTarget, } from '@/features/dispatch/types'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import { resolveSessionDriverId } from '../session-drivers/resolve'; const log = createLogger('FlowChatStore'); @@ -80,11 +81,7 @@ function dispatchObserverOwnsSession( sessionId: string, session?: Session, ): boolean { - if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { - return true; - } - return Object.values(dispatchJobStore.getState().jobs) - .some(job => job.sessionId === sessionId); + return resolveSessionDriverId(sessionId, session) === 'dispatch'; } function logPersistedDispatchMetadataOverlap( From 6ead1bb7d32555150352fce69fb6a580da2c9f9c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 21:09:26 -0700 Subject: [PATCH 02/12] refactor(web): move session lifecycle behind SessionDriver implementations Introduces the SessionDriver interface (create/delete/archive/rename/ ensureReady/cancel) with a static registry, and moves the flavor-specific bodies out of SessionModule/MessageModule verbatim: - LocalSessionDriver: agentAPI-backed creation, backend delete/archive/ rename, state-machine cancellation. - DispatchSessionDriver: observer-projection creation (session id + job id minting, observer registration), tombstoned dismissal for delete/archive, local-only rename, dispatchApi cancel. Shared orchestration (workspace/title resolution, creation dedupe, cascade computation, transient handling) stays in SessionModule and now calls the driver at the divergence point. Model-resolution helpers and turn cancellation choreography move to leaf utils so drivers depend downward only; getModelMaxTokens/markCurrentTurnItemsAsCancelled re-export from their old homes. --- .../flow-chat-manager/MessageModule.ts | 70 +--- .../flow-chat-manager/SessionModule.ts | 384 +----------------- .../dispatch/DispatchSessionDriver.ts | 248 +++++++++++ .../local/LocalSessionDriver.ts | 198 +++++++++ .../src/flow_chat/session-drivers/registry.ts | 38 ++ .../src/flow_chat/session-drivers/resolve.ts | 5 +- .../src/flow_chat/session-drivers/types.ts | 76 ++++ .../src/flow_chat/utils/modelResolution.ts | 108 +++++ .../src/flow_chat/utils/turnCancellation.ts | 46 +++ 9 files changed, 738 insertions(+), 435 deletions(-) create mode 100644 src/web-ui/src/flow_chat/session-drivers/dispatch/DispatchSessionDriver.ts create mode 100644 src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts create mode 100644 src/web-ui/src/flow_chat/session-drivers/registry.ts create mode 100644 src/web-ui/src/flow_chat/session-drivers/types.ts create mode 100644 src/web-ui/src/flow_chat/utils/modelResolution.ts create mode 100644 src/web-ui/src/flow_chat/utils/turnCancellation.ts diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index cd43344fc9..14d2708488 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -15,7 +15,6 @@ import { generateTempTitle } from '../../utils/titleUtils'; import { createLogger } from '@/shared/utils/logger'; import type { FlowChatContext, DialogTurn } from './types'; import { ensureBackendSession, getModelMaxTokens, retryCreateBackendSession } from './SessionModule'; -import { cleanupSessionBuffers } from './TextChunkModule'; import type { ImageContextData as ImageInputContextData } from '@/infrastructure/api/service-api/ImageContextTypes'; import { globalEventBus } from '@/infrastructure/event-bus'; import { @@ -32,6 +31,7 @@ import { isDispatchJobTerminal, isNonLocalDispatchTarget } from '@/features/disp import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandError'; import { i18nService } from '@/infrastructure/i18n'; +import { driverForSession } from '../../session-drivers/registry'; import { clearRuntimeStatusState, showRuntimeStatus, @@ -852,39 +852,14 @@ export async function cancelSessionTask(context: FlowChatContext, requestedSessi try { const state = context.flowChatStore.getState(); const sessionId = requestedSessionId || state.activeSessionId; - + if (!sessionId) { log.debug('No active session to cancel'); return false; } const session = state.sessions.get(sessionId); - if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { - const jobId = session?.config.dispatchJobId; - if (!jobId) { - return false; - } - const response = await dispatchApi.cancel(jobId); - if (response.cancelled) { - context.userCancelledSessionIds.add(sessionId); - requestDispatchJobRefresh(jobId); - } - return response.cancelled; - } - - const currentState = stateMachineManager.getCurrentState(sessionId); - const success = currentState === SessionExecutionState.PROCESSING - ? await stateMachineManager.transition(sessionId, SessionExecutionEvent.USER_CANCEL) - : false; - - if (success) { - context.userCancelledSessionIds.add(sessionId); - markCurrentTurnItemsAsCancelled(context, sessionId); - cleanupSessionBuffers(context, sessionId); - } - - return success; - + return await driverForSession(sessionId, session).cancel(context, sessionId); } catch (error) { log.error('Failed to cancel current task', error); return false; @@ -987,41 +962,4 @@ export function installPendingQueueDrainListener(context: FlowChatContext): void }); } -export function markCurrentTurnItemsAsCancelled( - context: FlowChatContext, - sessionId: string -): void { - const state = context.flowChatStore.getState(); - const session = state.sessions.get(sessionId); - if (!session) return; - - const lastDialogTurn = session.dialogTurns[session.dialogTurns.length - 1]; - if (!lastDialogTurn) return; - - if (lastDialogTurn.status === 'completed' || lastDialogTurn.status === 'cancelled') { - return; - } - - lastDialogTurn.modelRounds.forEach(round => { - round.items.forEach(item => { - if (item.status === 'completed' || item.status === 'cancelled' || item.status === 'error') { - return; - } - - context.flowChatStore.updateModelRoundItem(sessionId, lastDialogTurn.id, item.id, { - status: 'cancelled', - ...(item.type === 'text' && { isStreaming: false }), - ...(item.type === 'tool' && { - isParamsStreaming: false, - endTime: Date.now() - }) - } as any); - }); - }); - - context.flowChatStore.updateDialogTurn(sessionId, lastDialogTurn.id, turn => ({ - ...turn, - status: 'cancelled', - endTime: Date.now() - })); -} +export { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index b01d3c7618..d75d263b21 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -16,15 +16,13 @@ import { workspaceManager } from '@/infrastructure/services/business/workspaceMa import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; -import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import type { FlowChatContext, SessionConfig, SessionHistoryHydrationLocation, } from './types'; import type { Session } from '../../types/flow-chat'; -import { touchSessionActivity, cleanupSaveState } from './PersistenceModule'; -import { cleanupSessionBuffers } from './TextChunkModule'; +import { touchSessionActivity } from './PersistenceModule'; import { createTextSessionTitleDescriptor, createDefaultSessionTitleDescriptor, @@ -50,52 +48,11 @@ import { requireSessionProjectWorkspacePath, sessionProjectWorkspacePath, } from '../../utils/sessionWorkspace'; -import { - isNonLocalDispatchTarget, - type DispatchTarget, -} from '@/features/dispatch/types'; -import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; -import { forgetDispatchTranscript } from '@/features/dispatch/dispatchTranscriptCache'; -import { resolveSessionDriverId } from '../../session-drivers/resolve'; +import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; +import { driverForCreation, driverForSession } from '../../session-drivers/registry'; const log = createLogger('SessionModule'); const pendingSessionCreations = new Map>(); -const DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS = 128128; - -function isDispatchObserverProjection( - sessionId: string, - session: Session | undefined, -): boolean { - return resolveSessionDriverId(sessionId, session) === 'dispatch'; -} - -function dismissDispatchObserverProjection( - sessionId: string, - session: Session | undefined, -): void { - // Collect before dismissing: dismissSession removes the store entries that - // are the only remaining link from this session to its cached transcripts. - const jobIds = new Set( - Object.values(dispatchJobStore.getState().jobs) - .filter(job => job.sessionId === sessionId) - .map(job => job.jobId), - ); - const configuredJobId = session?.config.dispatchJobId?.trim(); - if (configuredJobId) { - jobIds.add(configuredJobId); - } - - dispatchJobStore.getState().dismissSession( - sessionId, - session?.config.dispatchJobId, - ); - - // The projection is gone for good, so its cached transcript must not stay - // readable on disk until retention gets around to it. - jobIds.forEach(jobId => { - void forgetDispatchTranscript(jobId); - }); -} const getHydrationLocationKey = ( location: SessionHistoryHydrationLocation | undefined, @@ -578,101 +535,7 @@ function requireSessionWorkspacePath( return workspacePath; } -/** - * Get model's maximum token count - */ -function findEnabledModel(models: AIModelConfig[], modelRef: string | null | undefined): AIModelConfig | null { - const value = modelRef?.trim(); - if (!value) return null; - return models.find(model => - model.enabled !== false - && (model.id === value || model.name === value || model.model_name === value) - ) ?? null; -} - -function resolveModelForContextWindow( - modelRef: string | null | undefined, - models: AIModelConfig[], - defaultModels: DefaultModelsConfig, -): AIModelConfig | null { - const value = modelRef?.trim(); - if (!value) return null; - - if (value === 'primary') { - return findEnabledModel(models, defaultModels.primary); - } - - if (value === 'fast') { - return findEnabledModel(models, defaultModels.fast) ?? findEnabledModel(models, defaultModels.primary); - } - - if (value === 'auto' || value === 'default') { - return null; - } - - return findEnabledModel(models, value); -} - -export async function getModelMaxTokens(modelName?: string, agentType?: string): Promise { - try { - const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); - const configData = await configManager.getConfigs([ - 'ai.models', - 'ai.default_models', - 'ai.agent_model_defaults', - ]); - const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; - const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - - const normalizedModelName = modelName?.trim(); - const explicitModel = resolveModelForContextWindow(modelName, models, defaultModels); - if (explicitModel?.context_window) { - return explicitModel.context_window; - } - - // Only legacy sessions without a model selector inherit the current mode - // default. Explicit symbolic selectors such as "auto" remain session-owned. - if (!normalizedModelName) { - const modeModel = resolveModelForContextWindow( - agentModelDefaults?.mode, - models, - defaultModels, - ); - if (modeModel?.context_window) { - return modeModel.context_window; - } - } - - const primaryModel = resolveModelForContextWindow('primary', models, defaultModels); - if (primaryModel?.context_window) { - return primaryModel.context_window; - } - - log.debug('Model context_window config not found, using default', { modelName, agentType }); - return 128128; - } catch (error) { - log.warn('Failed to get model max tokens', { modelName, agentType, error }); - return 128128; - } -} - -async function resolveModelForSessionCreation(modelName?: string): Promise { - const explicitModelName = modelName?.trim(); - if (explicitModelName) { - return explicitModelName; - } - - try { - const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); - const configData = await configManager.getConfigs(['ai.agent_model_defaults']); - const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - return agentModelDefaults?.mode?.trim() || 'auto'; - } catch (error) { - log.warn('Failed to resolve model default during session creation', { error }); - return 'auto'; - } -} +export { getModelMaxTokens } from '../../utils/modelResolution'; /** * Create new chat session (managed by backend) @@ -736,146 +599,17 @@ export async function createChatSession( ); const sessionName = titleDescriptor.text; - if (isNonLocalDispatchTarget(config.dispatchTargetRequest)) { - const dispatchTarget: DispatchTarget = config.dispatchTarget - ?? ( - config.dispatchTargetRequest.kind === 'ssh' - ? { - ...config.dispatchTargetRequest, - displayName: config.dispatchTargetRequest.connectionId, - } - : { - ...config.dispatchTargetRequest, - displayName: config.dispatchTargetRequest.deviceId, - } - ); - const sessionId = - globalThis.crypto?.randomUUID?.() - ?? `dispatch-session-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const jobId = - config.dispatchJobId?.trim() - || `dispatch-${globalThis.crypto?.randomUUID?.() - ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`; - const approvalPolicy = config.dispatchApprovalPolicy; - if (!approvalPolicy) { - throw new Error('Dispatch approval policy must be selected before creating a session'); - } - const resolvedConfig: SessionConfig = { - ...config, - // A dispatch projection must not inherit or resolve a controller-side - // provider. The target selection, when explicit, lives in dispatchModel. - modelName: undefined, - workspaceId: workspace?.id ?? config.workspaceId, - workspacePath, - projectWorkspacePath, - dispatchTargetRequest: config.dispatchTargetRequest, - dispatchTarget, - dispatchJobId: jobId, - dispatchApprovalPolicy: approvalPolicy, - dispatchIncludeUncommitted: config.dispatchIncludeUncommitted ?? false, - dispatchBaseRef: config.dispatchBaseRef?.trim() || 'HEAD', - dispatchJobState: 'submitting', - dispatchCursor: 0, - }; - - // This is an observer projection only. In particular, do not call - // agentAPI.createSession: the target CLI owns the durable session. - context.flowChatStore.createSession( - sessionId, - resolvedConfig, - undefined, - sessionName, - DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS, - agentType, - workspacePath, - remoteConnectionId, - remoteSshHost, - titleDescriptor, - ); - dispatchJobStore.getState().registerJob({ - jobId, - sessionId, - targetRequest: config.dispatchTargetRequest, - target: dispatchTarget, - sourceWorkspacePath: workspacePath, - sourceWorkspaceId: resolvedConfig.workspaceId, - title: sessionName, - agentType, - approvalPolicy, - // Do not inherit the controller's model selector. An omitted target - // model lets the probed target use its own configured default. - model: config.dispatchModel?.trim() || undefined, - availableModels: config.dispatchAvailableModels, - defaultModel: config.dispatchDefaultModel, - cursor: 0, - state: 'submitting', - appliedEventIds: [], - pendingPermissions: [], - eventLogComplete: true, - historyTruncated: false, - omittedEventCount: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - }); - return sessionId; - } - - const sessionModelName = await resolveModelForSessionCreation(config.modelName); - const maxContextTokens = await getModelMaxTokens(sessionModelName, agentType); - const mergedConfig: SessionConfig = { - ...config, - modelName: sessionModelName, - workspaceId: workspace?.id ?? config.workspaceId, - }; - - const response = await agentAPI.createSession({ - sessionName, + return driverForCreation(config).createSession(context, { + config, agentType, + sessionName, + titleDescriptor, workspacePath, projectWorkspacePath, - executionTarget: config.executionTargetRequest, - requestId: globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}-${Math.random()}`, - workspaceId: mergedConfig.workspaceId, + workspaceId: workspace?.id, remoteConnectionId, remoteSshHost, - config: { - modelName: sessionModelName, - enableTools: true, - safeMode: true, - autoCompact: true, - maxContextTokens: maxContextTokens, - enableContextCompression: true, - remoteConnectionId, - remoteSshHost, - } }); - - const effectiveWorkspacePath = - response.workspacePath || response.executionTarget?.rootPath || workspacePath; - const effectiveProjectWorkspacePath = - response.projectWorkspacePath || projectWorkspacePath || workspacePath; - const resolvedConfig: SessionConfig = { - ...mergedConfig, - workspacePath: effectiveWorkspacePath, - projectWorkspacePath: effectiveProjectWorkspacePath, - workspaceId: response.workspaceId ?? mergedConfig.workspaceId, - executionTarget: response.executionTarget, - }; - - context.flowChatStore.createSession( - response.sessionId, - resolvedConfig, - undefined, - sessionName, - maxContextTokens, - agentType, - effectiveWorkspacePath, - remoteConnectionId, - remoteSshHost, - titleDescriptor, - ); - - return response.sessionId; }); pendingSessionCreations.set(creationKey, createPromise); @@ -1029,49 +763,9 @@ export async function deleteChatSession( && removedSessionIdSet.has(stateBeforeDelete.activeSessionId) ); const session = stateBeforeDelete.sessions.get(sessionId); - const observerJobIds = Object.values(dispatchJobStore.getState().jobs) - .filter(job => job.sessionId === sessionId) - .map(job => job.jobId); - const deleteAsDispatchProjection = isDispatchObserverProjection(sessionId, session); - log.info('Dispatch diagnostic: session delete evaluated', { - sessionId, - sessionFound: Boolean(session), - dispatchTargetKind: session?.config.dispatchTarget?.kind, - dispatchJobId: session?.config.dispatchJobId, - observerJobIds, - deleteAsDispatchProjection, - cascadeSessionIds: removedSessionIds, - }); - if (deleteAsDispatchProjection) { - dismissDispatchObserverProjection(sessionId, session); - const locallyRemovedSessionIds = context.flowChatStore.removeSession( - sessionId, - removedActiveSession ? { nextActiveSessionId: null } : undefined, - ); - removedSessionIds.forEach(id => { - context.processingManager.clearSessionStatus(id); - cleanupSaveState(context, id); - cleanupSessionBuffers(context, id); - }); - log.info('Dispatch diagnostic: projection removed from flow chat store', { - sessionId, - locallyRemovedSessionIds, - activeSessionId: context.flowChatStore.getState().activeSessionId, - }); - return; - } - log.info('Dispatch diagnostic: delete routed to persisted backend session', { - sessionId, - hasWorkspacePath: Boolean(session && sessionProjectWorkspacePath(session)), - }); - await context.flowChatStore.deleteSession( - sessionId, - removedActiveSession ? { nextActiveSessionId: null } : undefined, - ); - - removedSessionIds.forEach(id => { - context.processingManager.clearSessionStatus(id); - cleanupSaveState(context, id); + await driverForSession(sessionId, session).deleteSession(context, sessionId, { + removedSessionIds, + removedActiveSession, }); } catch (error) { log.error('Failed to delete chat session', { sessionId, error }); @@ -1100,38 +794,9 @@ export async function archiveChatSession( && removedSessionIdSet.has(stateBeforeArchive.activeSessionId) ); - if (isDispatchObserverProjection(sessionId, session)) { - dismissDispatchObserverProjection(sessionId, session); - context.flowChatStore.removeSession( - sessionId, - removedActiveSession ? { nextActiveSessionId: null } : undefined, - ); - removedSessionIds.forEach(id => { - context.processingManager.clearSessionStatus(id); - cleanupSaveState(context, id); - cleanupSessionBuffers(context, id); - }); - return; - } - - await sessionAPI.archiveSession( - sessionId, - requireSessionProjectWorkspacePath(session, sessionId), - session.remoteConnectionId, - session.remoteSshHost, - ); - - const { stateMachineManager } = await import('../../state-machine'); - context.flowChatStore.removeSession( - sessionId, - removedActiveSession ? { nextActiveSessionId: null } : undefined, - ); - - removedSessionIds.forEach(id => { - stateMachineManager.delete(id); - context.processingManager.clearSessionStatus(id); - cleanupSaveState(context, id); - cleanupSessionBuffers(context, id); + await driverForSession(sessionId, session).archiveSession(context, sessionId, { + removedSessionIds, + removedActiveSession, }); } catch (error) { log.error('Failed to archive chat session', { sessionId, error }); @@ -1160,24 +825,7 @@ export async function renameChatSessionTitle( await context.flowChatStore.updateSessionTitle(sessionId, trimmedTitle, 'generated'); return trimmedTitle; } - if (isNonLocalDispatchTarget(session.config.dispatchTarget)) { - await context.flowChatStore.updateSessionTitle(sessionId, trimmedTitle, 'generated'); - if (session.config.dispatchJobId) { - dispatchJobStore.getState().updateTitle(session.config.dispatchJobId, trimmedTitle); - } - return trimmedTitle; - } - - const updatedTitle = await agentAPI.updateSessionTitle({ - sessionId, - title: trimmedTitle, - workspacePath: sessionProjectWorkspacePath(session), - remoteConnectionId: session.remoteConnectionId, - remoteSshHost: session.remoteSshHost, - }); - - await context.flowChatStore.updateSessionTitle(sessionId, updatedTitle, 'generated'); - return updatedTitle; + return driverForSession(sessionId, session).renameSession(context, sessionId, trimmedTitle); } export async function reloadSessionTitle( 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 new file mode 100644 index 0000000000..233a39bc86 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/dispatch/DispatchSessionDriver.ts @@ -0,0 +1,248 @@ +/** + * Dispatch session driver: controller-side observer projections of jobs that + * execute on a detached target (SSH host or paired device). + * + * The projection contract (see `features/dispatch/README.md`): the target CLI + * owns the durable session and event log; this controller owns only the + * outbound observer index and a rendered transcript cache. Nothing here may + * call `agentAPI.createSession`, `start_dialog_turn`, restore, or local + * session persistence for a projection. + */ + +import { createLogger } from '@/shared/utils/logger'; +import type { FlowChatContext, SessionConfig } from '../../services/flow-chat-manager/types'; +import type { Session } from '../../types/flow-chat'; +import type { SessionCascadeRemoval, SessionCreationSeed, SessionDriver } from '../types'; +import { isNonLocalDispatchTarget, type DispatchTarget } from '@/features/dispatch/types'; +import { dispatchApi } from '@/features/dispatch/dispatchApi'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import { forgetDispatchTranscript } from '@/features/dispatch/dispatchTranscriptCache'; +import { requestDispatchJobRefresh } from '@/features/dispatch/DispatchJobObserver'; +import { cleanupSaveState } from '../../services/flow-chat-manager/PersistenceModule'; +import { cleanupSessionBuffers } from '../../services/flow-chat-manager/TextChunkModule'; + +const log = createLogger('DispatchSessionDriver'); + +/** + * Fixed context budget for projections: no controller-side provider is ever + * resolved for them, so there is no model to read a real window from. + */ +const DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS = 128128; + +function dismissDispatchObserverProjection( + sessionId: string, + session: Session | undefined, +): void { + // Collect before dismissing: dismissSession removes the store entries that + // are the only remaining link from this session to its cached transcripts. + const jobIds = new Set( + Object.values(dispatchJobStore.getState().jobs) + .filter(job => job.sessionId === sessionId) + .map(job => job.jobId), + ); + const configuredJobId = session?.config.dispatchJobId?.trim(); + if (configuredJobId) { + jobIds.add(configuredJobId); + } + + dispatchJobStore.getState().dismissSession( + sessionId, + session?.config.dispatchJobId, + ); + + // The projection is gone for good, so its cached transcript must not stay + // readable on disk until retention gets around to it. + jobIds.forEach(jobId => { + void forgetDispatchTranscript(jobId); + }); +} + +function removeProjectionLocally( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, +): void { + const session = context.flowChatStore.getState().sessions.get(sessionId); + dismissDispatchObserverProjection(sessionId, session); + context.flowChatStore.removeSession( + sessionId, + removal.removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + removal.removedSessionIds.forEach(id => { + context.processingManager.clearSessionStatus(id); + cleanupSaveState(context, id); + cleanupSessionBuffers(context, id); + }); +} + +export const dispatchSessionDriver: SessionDriver = { + id: 'dispatch', + + async createSession(context: FlowChatContext, seed: SessionCreationSeed): Promise { + const { + config, + agentType, + sessionName, + titleDescriptor, + workspacePath, + projectWorkspacePath, + workspaceId, + remoteConnectionId, + remoteSshHost, + } = seed; + + if (!isNonLocalDispatchTarget(config.dispatchTargetRequest)) { + throw new Error('Dispatch driver requires a non-local dispatch target request'); + } + const dispatchTarget: DispatchTarget = config.dispatchTarget + ?? ( + config.dispatchTargetRequest.kind === 'ssh' + ? { + ...config.dispatchTargetRequest, + displayName: config.dispatchTargetRequest.connectionId, + } + : { + ...config.dispatchTargetRequest, + displayName: config.dispatchTargetRequest.deviceId, + } + ); + const sessionId = + globalThis.crypto?.randomUUID?.() + ?? `dispatch-session-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const jobId = + config.dispatchJobId?.trim() + || `dispatch-${globalThis.crypto?.randomUUID?.() + ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`; + const approvalPolicy = config.dispatchApprovalPolicy; + if (!approvalPolicy) { + throw new Error('Dispatch approval policy must be selected before creating a session'); + } + const resolvedConfig: SessionConfig = { + ...config, + // A dispatch projection must not inherit or resolve a controller-side + // provider. The target selection, when explicit, lives in dispatchModel. + modelName: undefined, + workspaceId: workspaceId ?? config.workspaceId, + workspacePath, + projectWorkspacePath, + dispatchTargetRequest: config.dispatchTargetRequest, + dispatchTarget, + dispatchJobId: jobId, + dispatchApprovalPolicy: approvalPolicy, + dispatchIncludeUncommitted: config.dispatchIncludeUncommitted ?? false, + dispatchBaseRef: config.dispatchBaseRef?.trim() || 'HEAD', + dispatchJobState: 'submitting', + dispatchCursor: 0, + }; + + // This is an observer projection only. In particular, do not call + // agentAPI.createSession: the target CLI owns the durable session. + context.flowChatStore.createSession( + sessionId, + resolvedConfig, + undefined, + sessionName, + DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS, + agentType, + workspacePath, + remoteConnectionId, + remoteSshHost, + titleDescriptor, + ); + dispatchJobStore.getState().registerJob({ + jobId, + sessionId, + targetRequest: config.dispatchTargetRequest, + target: dispatchTarget, + sourceWorkspacePath: workspacePath, + sourceWorkspaceId: resolvedConfig.workspaceId, + title: sessionName, + agentType, + approvalPolicy, + // Do not inherit the controller's model selector. An omitted target + // model lets the probed target use its own configured default. + model: config.dispatchModel?.trim() || undefined, + availableModels: config.dispatchAvailableModels, + defaultModel: config.dispatchDefaultModel, + cursor: 0, + state: 'submitting', + appliedEventIds: [], + pendingPermissions: [], + eventLogComplete: true, + historyTruncated: false, + omittedEventCount: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + return sessionId; + }, + + async deleteSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + const observerJobIds = Object.values(dispatchJobStore.getState().jobs) + .filter(job => job.sessionId === sessionId) + .map(job => job.jobId); + log.info('Dispatch diagnostic: projection delete evaluated', { + sessionId, + sessionFound: Boolean(session), + dispatchTargetKind: session?.config.dispatchTarget?.kind, + dispatchJobId: session?.config.dispatchJobId, + observerJobIds, + cascadeSessionIds: removal.removedSessionIds, + }); + removeProjectionLocally(context, sessionId, removal); + log.info('Dispatch diagnostic: projection removed from flow chat store', { + sessionId, + activeSessionId: context.flowChatStore.getState().activeSessionId, + }); + }, + + async archiveSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise { + // Archiving an observer projection is a local dismiss: the target keeps + // its durable session; the tombstone stops reconciliation reviving it. + removeProjectionLocally(context, sessionId, removal); + }, + + async renameSession( + context: FlowChatContext, + sessionId: string, + title: string, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + await context.flowChatStore.updateSessionTitle(sessionId, title, 'generated'); + if (session.config.dispatchJobId) { + dispatchJobStore.getState().updateTitle(session.config.dispatchJobId, title); + } + return title; + }, + + async ensureReady(): Promise { + // Nothing to prepare: the target owns the durable session, and submission + // itself is what creates the job. + }, + + async cancel(context: FlowChatContext, sessionId: string): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + const jobId = session?.config.dispatchJobId; + if (!jobId) { + return false; + } + const response = await dispatchApi.cancel(jobId); + if (response.cancelled) { + context.userCancelledSessionIds.add(sessionId); + requestDispatchJobRefresh(jobId); + } + return response.cancelled; + }, +}; 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 new file mode 100644 index 0000000000..016279b1ea --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -0,0 +1,198 @@ +/** + * Local session driver: the default flavor backed by this machine's (or the + * attached peer's) agent runtime via `agentAPI`. + * + * Bodies were moved verbatim from SessionModule/MessageModule; behavior is + * unchanged. Peer Device Mode is invisible here by design — it swaps the + * transport underneath `api.invoke`, so this driver must never consult it. + */ + +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; +import { createLogger } from '@/shared/utils/logger'; +import { stateMachineManager } from '../../state-machine'; +import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; +import type { FlowChatContext, SessionConfig } from '../../services/flow-chat-manager/types'; +import type { SessionCascadeRemoval, SessionCreationSeed, SessionDriver } from '../types'; +import { + getModelMaxTokens, + resolveModelForSessionCreation, +} from '../../utils/modelResolution'; +import { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; +import { + requireSessionProjectWorkspacePath, + sessionProjectWorkspacePath, +} from '../../utils/sessionWorkspace'; +import { cleanupSaveState } from '../../services/flow-chat-manager/PersistenceModule'; +import { cleanupSessionBuffers } from '../../services/flow-chat-manager/TextChunkModule'; + +const log = createLogger('LocalSessionDriver'); + +export const localSessionDriver: SessionDriver = { + id: 'local', + + async createSession(context: FlowChatContext, seed: SessionCreationSeed): Promise { + const { + config, + agentType, + sessionName, + titleDescriptor, + workspacePath, + projectWorkspacePath, + workspaceId, + remoteConnectionId, + remoteSshHost, + } = seed; + + const sessionModelName = await resolveModelForSessionCreation(config.modelName); + const maxContextTokens = await getModelMaxTokens(sessionModelName, agentType); + const mergedConfig: SessionConfig = { + ...config, + modelName: sessionModelName, + workspaceId: workspaceId ?? config.workspaceId, + }; + + const response = await agentAPI.createSession({ + sessionName, + agentType, + workspacePath, + projectWorkspacePath, + executionTarget: config.executionTargetRequest, + requestId: globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}-${Math.random()}`, + workspaceId: mergedConfig.workspaceId, + remoteConnectionId, + remoteSshHost, + config: { + modelName: sessionModelName, + enableTools: true, + safeMode: true, + autoCompact: true, + maxContextTokens: maxContextTokens, + enableContextCompression: true, + remoteConnectionId, + remoteSshHost, + } + }); + + const effectiveWorkspacePath = + response.workspacePath || response.executionTarget?.rootPath || workspacePath; + const effectiveProjectWorkspacePath = + response.projectWorkspacePath || projectWorkspacePath || workspacePath; + const resolvedConfig: SessionConfig = { + ...mergedConfig, + workspacePath: effectiveWorkspacePath, + projectWorkspacePath: effectiveProjectWorkspacePath, + workspaceId: response.workspaceId ?? mergedConfig.workspaceId, + executionTarget: response.executionTarget, + }; + + context.flowChatStore.createSession( + response.sessionId, + resolvedConfig, + undefined, + sessionName, + maxContextTokens, + agentType, + effectiveWorkspacePath, + remoteConnectionId, + remoteSshHost, + titleDescriptor, + ); + + return response.sessionId; + }, + + async deleteSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + log.info('Dispatch diagnostic: delete routed to persisted backend session', { + sessionId, + hasWorkspacePath: Boolean(session && sessionProjectWorkspacePath(session)), + }); + await context.flowChatStore.deleteSession( + sessionId, + removal.removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + + removal.removedSessionIds.forEach(id => { + context.processingManager.clearSessionStatus(id); + cleanupSaveState(context, id); + }); + }, + + async archiveSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + + await sessionAPI.archiveSession( + sessionId, + requireSessionProjectWorkspacePath(session, sessionId), + session.remoteConnectionId, + session.remoteSshHost, + ); + + context.flowChatStore.removeSession( + sessionId, + removal.removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + + removal.removedSessionIds.forEach(id => { + stateMachineManager.delete(id); + context.processingManager.clearSessionStatus(id); + cleanupSaveState(context, id); + cleanupSessionBuffers(context, id); + }); + }, + + async renameSession( + context: FlowChatContext, + sessionId: string, + title: string, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + const updatedTitle = await agentAPI.updateSessionTitle({ + sessionId, + title, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + }); + + await context.flowChatStore.updateSessionTitle(sessionId, updatedTitle, 'generated'); + return updatedTitle; + }, + + async ensureReady(context: FlowChatContext, sessionId: string): Promise { + // Deliberate lazy import: SessionModule routes lifecycle calls through the + // driver registry, so a static import here would create a module cycle. + const { ensureBackendSession } = await import('../../services/flow-chat-manager/SessionModule'); + await ensureBackendSession(context, sessionId); + }, + + async cancel(context: FlowChatContext, sessionId: string): Promise { + const currentState = stateMachineManager.getCurrentState(sessionId); + const success = currentState === SessionExecutionState.PROCESSING + ? await stateMachineManager.transition(sessionId, SessionExecutionEvent.USER_CANCEL) + : false; + + if (success) { + context.userCancelledSessionIds.add(sessionId); + markCurrentTurnItemsAsCancelled(context, sessionId); + cleanupSessionBuffers(context, sessionId); + } + + return success; + }, +}; diff --git a/src/web-ui/src/flow_chat/session-drivers/registry.ts b/src/web-ui/src/flow_chat/session-drivers/registry.ts new file mode 100644 index 0000000000..1ca4e8098b --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/registry.ts @@ -0,0 +1,38 @@ +/** + * Static driver registry. All drivers are registered at module load — no + * dynamic registration, no ordering hazard, and resolution stays synchronous + * so hot paths can use it. + */ + +import type { SessionConfig } from '../services/flow-chat-manager/types'; +import { + resolveSessionDriverId, + resolveSessionDriverIdForCreation, + type DriverResolvableSession, + type SessionDriverId, +} from './resolve'; +import type { SessionDriver } from './types'; +import { localSessionDriver } from './local/LocalSessionDriver'; +import { dispatchSessionDriver } from './dispatch/DispatchSessionDriver'; + +const drivers: Record = { + local: localSessionDriver, + dispatch: dispatchSessionDriver, +}; + +export function sessionDriverById(id: SessionDriverId): SessionDriver { + return drivers[id]; +} + +export function driverForSession( + sessionId: string, + session: DriverResolvableSession | undefined, +): SessionDriver { + return drivers[resolveSessionDriverId(sessionId, session)]; +} + +export function driverForCreation( + config: Pick, +): SessionDriver { + return drivers[resolveSessionDriverIdForCreation(config)]; +} diff --git a/src/web-ui/src/flow_chat/session-drivers/resolve.ts b/src/web-ui/src/flow_chat/session-drivers/resolve.ts index c2dedcf761..bff476ef98 100644 --- a/src/web-ui/src/flow_chat/session-drivers/resolve.ts +++ b/src/web-ui/src/flow_chat/session-drivers/resolve.ts @@ -55,7 +55,10 @@ export function resolveSessionDriverIdWith( } export function dispatchJobStoreObservesSession(sessionId: string): boolean { - return Object.values(dispatchJobStore.getState().jobs) + // `jobs` always exists on the real store; the fallback tolerates partial + // test doubles of dispatchJobStore so an unrelated suite cannot turn a + // resolver lookup into a thrown error. + return Object.values(dispatchJobStore.getState().jobs ?? {}) .some(job => job.sessionId === sessionId); } diff --git a/src/web-ui/src/flow_chat/session-drivers/types.ts b/src/web-ui/src/flow_chat/session-drivers/types.ts new file mode 100644 index 0000000000..1f34d49b00 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -0,0 +1,76 @@ +/** + * SessionDriver — the seam between flow-chat orchestration and a session's + * transport family. + * + * A driver owns everything flavor-specific about a session: how it is + * created, deleted, renamed, readied, submitted to, and cancelled. Shared + * choreography (optimistic turns, queues, title generation, store plumbing) + * stays in flow-chat-manager and calls into the driver at the points where + * flavors diverge. New chat features land for every flavor by adding one + * driver member instead of branching in eight files. + * + * Naming note: this is deliberately not called "backend" — the codebase uses + * "backend session" for the Rust-side agent session (`ensureBackendSession`). + */ + +import type { FlowChatContext, SessionConfig } from '../services/flow-chat-manager/types'; +import type { SessionTitleDescriptor } from '../utils/sessionTitle'; +import type { SessionDriverId } from './resolve'; + +export type { SessionDriverId } from './resolve'; + +/** + * Everything flavor-independent that session creation resolved before the + * driver takes over: workspace identity, agent type, and the initial title. + */ +export interface SessionCreationSeed { + config: SessionConfig; + agentType: string; + sessionName: string; + titleDescriptor: SessionTitleDescriptor; + workspacePath: string; + projectWorkspacePath: string; + workspaceId?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +/** Cascade facts computed once by the shared caller before removal. */ +export interface SessionCascadeRemoval { + removedSessionIds: string[]; + removedActiveSession: boolean; +} + +export interface SessionDriver { + readonly id: SessionDriverId; + + /** Create the flavor's session and return its id. */ + createSession(context: FlowChatContext, seed: SessionCreationSeed): Promise; + + /** Remove the session (and its cascade) from this controller. */ + deleteSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise; + + /** Archive the session; observer flavors treat this as a local dismiss. */ + archiveSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise; + + /** Rename and return the effective title. */ + renameSession( + context: FlowChatContext, + sessionId: string, + title: string, + ): Promise; + + /** Make the session able to accept a submission (backend session, hydration). */ + ensureReady(context: FlowChatContext, sessionId: string): Promise; + + /** Cancel the in-flight work for this session. Returns whether anything was cancelled. */ + cancel(context: FlowChatContext, sessionId: string): Promise; +} diff --git a/src/web-ui/src/flow_chat/utils/modelResolution.ts b/src/web-ui/src/flow_chat/utils/modelResolution.ts new file mode 100644 index 0000000000..0fda729bc8 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/modelResolution.ts @@ -0,0 +1,108 @@ +/** + * Model selector resolution shared by session creation and the composer. + * + * Leaf module: session drivers and flow-chat-manager modules both depend on + * it, so it must not import either. + */ + +import { createLogger } from '@/shared/utils/logger'; +import type { + AIModelConfig, + AgentModelDefaultsConfig, + DefaultModelsConfig, +} from '@/infrastructure/config/types'; + +const log = createLogger('ModelResolution'); + +function findEnabledModel(models: AIModelConfig[], modelRef: string | null | undefined): AIModelConfig | null { + const value = modelRef?.trim(); + if (!value) return null; + return models.find(model => + model.enabled !== false + && (model.id === value || model.name === value || model.model_name === value) + ) ?? null; +} + +function resolveModelForContextWindow( + modelRef: string | null | undefined, + models: AIModelConfig[], + defaultModels: DefaultModelsConfig, +): AIModelConfig | null { + const value = modelRef?.trim(); + if (!value) return null; + + if (value === 'primary') { + return findEnabledModel(models, defaultModels.primary); + } + + if (value === 'fast') { + return findEnabledModel(models, defaultModels.fast) ?? findEnabledModel(models, defaultModels.primary); + } + + if (value === 'auto' || value === 'default') { + return null; + } + + return findEnabledModel(models, value); +} + +export async function getModelMaxTokens(modelName?: string, agentType?: string): Promise { + try { + const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); + const configData = await configManager.getConfigs([ + 'ai.models', + 'ai.default_models', + 'ai.agent_model_defaults', + ]); + const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; + const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; + + const normalizedModelName = modelName?.trim(); + const explicitModel = resolveModelForContextWindow(modelName, models, defaultModels); + if (explicitModel?.context_window) { + return explicitModel.context_window; + } + + // Only legacy sessions without a model selector inherit the current mode + // default. Explicit symbolic selectors such as "auto" remain session-owned. + if (!normalizedModelName) { + const modeModel = resolveModelForContextWindow( + agentModelDefaults?.mode, + models, + defaultModels, + ); + if (modeModel?.context_window) { + return modeModel.context_window; + } + } + + const primaryModel = resolveModelForContextWindow('primary', models, defaultModels); + if (primaryModel?.context_window) { + return primaryModel.context_window; + } + + log.debug('Model context_window config not found, using default', { modelName, agentType }); + return 128128; + } catch (error) { + log.warn('Failed to get model max tokens', { modelName, agentType, error }); + return 128128; + } +} + +export async function resolveModelForSessionCreation(modelName?: string): Promise { + const explicitModelName = modelName?.trim(); + if (explicitModelName) { + return explicitModelName; + } + + try { + const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); + const configData = await configManager.getConfigs(['ai.agent_model_defaults']); + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; + return agentModelDefaults?.mode?.trim() || 'auto'; + } catch (error) { + log.warn('Failed to resolve model default during session creation', { error }); + return 'auto'; + } +} diff --git a/src/web-ui/src/flow_chat/utils/turnCancellation.ts b/src/web-ui/src/flow_chat/utils/turnCancellation.ts new file mode 100644 index 0000000000..a2abf994ed --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/turnCancellation.ts @@ -0,0 +1,46 @@ +/** + * Store choreography for marking a session's in-flight turn as cancelled. + * + * Leaf module shared by the local session driver and flow-chat-manager. + */ + +import type { FlowChatContext } from '../services/flow-chat-manager/types'; + +export function markCurrentTurnItemsAsCancelled( + context: FlowChatContext, + sessionId: string +): void { + const state = context.flowChatStore.getState(); + const session = state.sessions.get(sessionId); + if (!session) return; + + const lastDialogTurn = session.dialogTurns[session.dialogTurns.length - 1]; + if (!lastDialogTurn) return; + + if (lastDialogTurn.status === 'completed' || lastDialogTurn.status === 'cancelled') { + return; + } + + lastDialogTurn.modelRounds.forEach(round => { + round.items.forEach(item => { + if (item.status === 'completed' || item.status === 'cancelled' || item.status === 'error') { + return; + } + + context.flowChatStore.updateModelRoundItem(sessionId, lastDialogTurn.id, item.id, { + status: 'cancelled', + ...(item.type === 'text' && { isStreaming: false }), + ...(item.type === 'tool' && { + isParamsStreaming: false, + endTime: Date.now() + }) + } as any); + }); + }); + + context.flowChatStore.updateDialogTurn(sessionId, lastDialogTurn.id, turn => ({ + ...turn, + status: 'cancelled', + endTime: Date.now() + })); +} From c68c89e4b61fc0782cfe9a526b9809641da2fcd3 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 21:20:03 -0700 Subject: [PATCH 03/12] refactor(web): route submission through SessionDriver planSubmission/startTurn sendMessage becomes shared choreography only: busy-gate planning, queueing, mode switch, conflict retries, and the error path. The three dispatch submission paths (submit / append-steer / continue with idempotent retry ids) move verbatim into DispatchSessionDriver; the local dialog-turn path (optimistic turn, state machine, worktree materialization, model sync, ACP fork, backend-retry fallback) moves into LocalSessionDriver. Load-bearing invariants preserved and now stated in the interface: - planSubmission tests steer-eligibility before queueing, so a steerable dispatch message can never be parked in a queue that only drains for state-machine sessions (new regression test). - ensureReady may return void synchronously; a projection adds its optimistic turn with no microtask boundary after the user's send action (pinned by the existing projects-immediately test). - The dispatch_pending_ turn id, the submit-snapshot cursor CAS arguments, and the per-session retry-id lifecycle are byte-identical. - Steered/continued messages still skip the shared post-submission bookkeeping ('detached' outcome). Idempotent retry ids generalize into session-drivers/idempotency.ts; syncSessionModelSelection moves to utils/modelSync (re-exported). --- .../flow-chat-manager/MessageModule.test.ts | 18 + .../flow-chat-manager/MessageModule.ts | 661 ++---------------- .../dispatch/DispatchSessionDriver.ts | 316 ++++++++- .../flow_chat/session-drivers/idempotency.ts | 59 ++ .../local/LocalSessionDriver.ts | 227 +++++- .../src/flow_chat/session-drivers/shared.ts | 19 + .../src/flow_chat/session-drivers/types.ts | 113 ++- src/web-ui/src/flow_chat/utils/modelSync.ts | 102 +++ 8 files changed, 908 insertions(+), 607 deletions(-) create mode 100644 src/web-ui/src/flow_chat/session-drivers/idempotency.ts create mode 100644 src/web-ui/src/flow_chat/session-drivers/shared.ts create mode 100644 src/web-ui/src/flow_chat/utils/modelSync.ts diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index f80fcfea3b..ed0d2ba709 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -587,6 +587,24 @@ describe('MessageModule detached dispatch', () => { expect(mockDispatchSubmit).toHaveBeenCalledTimes(1); }); + it('steers a busy running dispatch instead of queueing the message', async () => { + const { context, session } = createDispatchContext('remote'); + session.config.dispatchJobState = 'running'; + // Busy state machine + non-empty queue would normally enqueue; a running + // dispatch must steer instead, because queued items only drain for + // sessions that drive the local state machine. + mockGetCurrentState.mockReturnValue('processing'); + mockPendingList.mockReturnValue([{ id: 'queued-1' }]); + + await expect( + sendMessage(context, 'steer the remote turn', 'dispatch-session'), + ).resolves.toBeUndefined(); + + expect(mockDispatchAppend).toHaveBeenCalledTimes(1); + expect(mockPendingEnqueue).not.toHaveBeenCalled(); + expect(mockDispatchSubmit).not.toHaveBeenCalled(); + }); + it('reuses the append message id after an ambiguous transport failure', async () => { const { context, session } = createDispatchContext('remote'); session.config.dispatchJobState = 'running'; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 14d2708488..c7f45776d6 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -1,41 +1,25 @@ /** * Message handling module - * Handles message sending, cancellation, and other operations + * Shared submission choreography: busy-gate planning, queueing, mode + * switching, conflict retries, and the error path. Flavor-specific transport + * work (optimistic turns, dialog-turn start, steering) lives in the session + * drivers. */ -import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; -import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; -import { worktreeAPI } from '@/infrastructure/api/service-api/WorktreeAPI'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; -import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { notificationService } from '../../../shared/notification-system'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; -import { generateTempTitle } from '../../utils/titleUtils'; import { createLogger } from '@/shared/utils/logger'; -import type { FlowChatContext, DialogTurn } from './types'; -import { ensureBackendSession, getModelMaxTokens, retryCreateBackendSession } from './SessionModule'; +import type { FlowChatContext } from './types'; import type { ImageContextData as ImageInputContextData } from '@/infrastructure/api/service-api/ImageContextTypes'; -import { globalEventBus } from '@/infrastructure/event-bus'; -import { - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, - type FlowChatPinTurnToTopRequest, -} from '../../events/flowchatNavigation'; import { pendingQueueManager } from './PendingQueueModule'; -import { sessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; -import { sessionWorktreeMaterializationPlan } from '../../utils/sessionWorktree'; -import { dispatchApi } from '@/features/dispatch/dispatchApi'; -import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; -import { requestDispatchJobRefresh } from '@/features/dispatch/DispatchJobObserver'; -import { isDispatchJobTerminal, isNonLocalDispatchTarget } from '@/features/dispatch/types'; -import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandError'; import { i18nService } from '@/infrastructure/i18n'; import { driverForSession } from '../../session-drivers/registry'; -import { - clearRuntimeStatusState, - showRuntimeStatus, -} from '@/flow_chat/store/runtimeStatusStore'; +import type { SendMessageOptions, SubmissionDraft, TurnTracker } from '../../session-drivers/types'; + +export { syncSessionModelSelection } from '../../utils/modelSync'; +export { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; const log = createLogger('MessageModule'); @@ -74,27 +58,6 @@ function completeSessionSend( retrySuccess?.(); } -interface PendingDispatchAppendRetry { - content: string; - displayContent?: string; - messageId: string; -} - -// Keep the id stable across an ambiguous transport failure. A retry with the -// same message can then ask the target mailbox for the same idempotent append -// instead of injecting the steering text twice. -const pendingDispatchAppendRetries = new Map(); - -interface PendingDispatchContinueRetry { - content: string; - displayContent?: string; - turnId: string; -} - -// Same reasoning as the append retries above: an ambiguous transport failure -// must be retryable without starting the follow-up turn twice on the target. -const pendingDispatchContinueRetries = new Map(); - function acpClientIdFromMode(mode: string | undefined): string | null { const value = mode?.trim(); if (!value?.startsWith('acp:')) return null; @@ -102,92 +65,6 @@ function acpClientIdFromMode(mode: string | undefined): string | null { return clientId || null; } -function normalizeModelSelection( - modelId: string | undefined, - models: AIModelConfig[], - defaultModels: DefaultModelsConfig, -): string { - const value = modelId?.trim(); - if (!value || value === 'auto') return 'auto'; - - if (value === 'primary' || value === 'fast') { - const resolvedDefaultId = value === 'primary' ? defaultModels.primary : defaultModels.fast; - const matchedModel = models.find(model => model.id === resolvedDefaultId); - return matchedModel ? value : 'auto'; - } - - const matchedModel = models.find(model => - model.id === value || model.name === value || model.model_name === value, - ); - return matchedModel ? value : 'auto'; -} - -export async function syncSessionModelSelection( - context: FlowChatContext, - sessionId: string, - agentType: string, -): Promise { - const session = context.flowChatStore.getState().sessions.get(sessionId); - if (!session) { - throw new Error(`Session does not exist: ${sessionId}`); - } - - const sessionModelId = session.config.modelName?.trim(); - - // Any stored selector, including "auto", belongs to the session. Still sync - // it to the backend in case the restored runtime session lost that state. - if (sessionModelId) { - const desiredMaxContextTokens = await getModelMaxTokens(sessionModelId, agentType); - if (session.maxContextTokens !== desiredMaxContextTokens) { - context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); - } - await agentAPI.updateSessionModel({ - sessionId, - modelName: sessionModelId, - workspacePath: sessionProjectWorkspacePath(session), - remoteConnectionId: session.remoteConnectionId, - remoteSshHost: session.remoteSshHost, - includeInternal: session.sessionKind === 'subagent', - }); - return; - } - - const configData = await configManager.getConfigs([ - 'ai.agent_model_defaults', - 'ai.models', - 'ai.default_models', - ]); - const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - const allModels = (configData['ai.models'] as AIModelConfig[] | undefined) || []; - const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - - const desiredModelId = normalizeModelSelection(agentModelDefaults?.mode, allModels, defaultModels); - const shouldForceAutoSync = desiredModelId === 'auto'; - const desiredMaxContextTokens = await getModelMaxTokens(desiredModelId, agentType); - const shouldSyncContextWindow = session.maxContextTokens !== desiredMaxContextTokens; - - context.flowChatStore.updateSessionModelName(sessionId, desiredModelId); - if (shouldSyncContextWindow) { - context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); - } - await agentAPI.updateSessionModel({ - sessionId, - modelName: desiredModelId, - workspacePath: sessionProjectWorkspacePath(session), - remoteConnectionId: session.remoteConnectionId, - remoteSshHost: session.remoteSshHost, - includeInternal: session.sessionKind === 'subagent', - }); - - log.info('Session model synchronized before send', { - sessionId, - agentType, - previousModelId: null, - nextModelId: desiredModelId, - forcedAutoSync: shouldForceAutoSync, - }); -} - /** * Send message and handle response * @param message - Message sent to backend @@ -203,154 +80,17 @@ export async function sendMessage( displayMessage?: string, agentType?: string, switchToMode?: string, - options?: { - imageContexts?: ImageInputContextData[]; - imageDisplayData?: Array<{ id: string; name: string; dataUrl?: string; imagePath?: string; mimeType?: string }>; - /** - * When true, bypass the pending-queue check. Used by the queue drain path - * to actually start a new dialog turn after the previous one finished. - * Callers should not set this directly. - */ - bypassPendingQueue?: boolean; - userMessageMetadata?: Record; - execution?: import('@/infrastructure/api/service-api/AgentAPI').AgentDialogTurnExecution; - turnId?: string; - preserveTurnOnStartError?: boolean; - onSessionConflictRetryStart?: () => void; - onSessionConflictRetrySuccess?: () => void; - fromSessionConflictRetry?: boolean; - } + options?: SendMessageOptions ): Promise { const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session) { throw new Error(`Session does not exist: ${sessionId}`); } const sendAttempt = beginSessionSend(sessionId); - - /** - * Start the next turn of a finished dispatch job. - * - * The optimistic turn mirrors the first-message path so the user sees their - * message immediately; the target's own `DialogTurnStarted` adopts it once - * the follow-up worker starts. - */ - const continueDispatchJob = async (jobId: string): Promise => { - const followUpSession = context.flowChatStore.getState().sessions.get(sessionId) ?? session; - const followUpAgentType = (agentType?.trim() || followUpSession.mode || 'agentic').trim(); - const current = pendingDispatchContinueRetries.get(sessionId); - const retry = - current?.content === message && current.displayContent === displayMessage - ? current - : { - content: message, - displayContent: displayMessage, - // Reused across retries so a lost response cannot start two turns. - turnId: - globalThis.crypto?.randomUUID?.() - ?? `dispatch-turn-${Date.now()}-${Math.random().toString(36).slice(2)}`, - }; - pendingDispatchContinueRetries.set(sessionId, retry); - - const optimisticTurnId = `dispatch_pending_${jobId}`; - context.flowChatStore.addDialogTurn(sessionId, { - id: optimisticTurnId, - sessionId, - agentType: followUpAgentType, - userMessage: { - id: `user_dispatch_${retry.turnId}`, - content: displayMessage || message, - timestamp: Date.now(), - metadata: markOptimisticDispatchTurnMetadata( - options?.userMessageMetadata, - jobId, - ), - }, - modelRounds: [], - status: 'pending', - startTime: Date.now(), - }); - globalEventBus.emit( - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, - { - sessionId, - turnId: optimisticTurnId, - behavior: 'auto', - source: 'send-message', - pinMode: 'sticky-latest', - } satisfies FlowChatPinTurnToTopRequest, - 'MessageModule', - ); - - try { - const response = await dispatchApi.continueJob( - jobId, - retry.turnId, - message, - displayMessage, - ); - if (!response.accepted) { - throw new Error('Dispatch target did not accept the follow-up turn'); - } - // The target owns the job state; the refresh below reads it back rather - // than this side guessing what the follow-up did to it. - } catch (error) { - context.flowChatStore.deleteDialogTurn(sessionId, optimisticTurnId); - throw error; - } finally { - if (pendingDispatchContinueRetries.get(sessionId)?.turnId === retry.turnId) { - pendingDispatchContinueRetries.delete(sessionId); - } - } - requestDispatchJobRefresh(jobId); - }; - - const appendToDispatchJob = async (jobId: string): Promise => { - const current = pendingDispatchAppendRetries.get(sessionId); - const retry = - current?.content === message && current.displayContent === displayMessage - ? current - : { - content: message, - displayContent: displayMessage, - messageId: - globalThis.crypto?.randomUUID?.() - ?? `dispatch-message-${Date.now()}-${Math.random().toString(36).slice(2)}`, - }; - pendingDispatchAppendRetries.set(sessionId, retry); - const response = await dispatchApi.append( - jobId, - message, - displayMessage, - retry.messageId, - ); - if (!response.accepted) { - if (pendingDispatchAppendRetries.get(sessionId)?.messageId === retry.messageId) { - pendingDispatchAppendRetries.delete(sessionId); - } - throw new Error('Dispatch target did not accept the appended message'); - } - if (pendingDispatchAppendRetries.get(sessionId)?.messageId === retry.messageId) { - pendingDispatchAppendRetries.delete(sessionId); - } - requestDispatchJobRefresh(jobId); - }; - - const appendToRunningDispatch = async (): Promise => { - if ( - !isNonLocalDispatchTarget(session.config.dispatchTarget) - || !session.config.dispatchJobId - || ( - session.config.dispatchJobState !== 'queued' - && session.config.dispatchJobState !== 'running' - ) - ) { - return false; - } - if ((options?.imageContexts?.length ?? 0) > 0) { - throw new Error('Image attachments are not supported for detached dispatch yet'); - } - await appendToDispatchJob(session.config.dispatchJobId); - return true; + const draft: SubmissionDraft = { + message, + displayMessage, + hasImages: (options?.imageContexts?.length ?? 0) > 0, }; if (!options?.bypassPendingQueue) { @@ -364,7 +104,16 @@ export async function sendMessage( if (options?.execution?.kind === 'fresh_external_subagent') { throw new Error('External subagent command delegation requires an idle session'); } - if (await appendToRunningDispatch()) { + // Steer-eligibility must be decided before queueing: a steerable + // message that gets queued never drains for flavors that do not drive + // the local state machine. + const plan = driverForSession(sessionId, session) + .planSubmission(context, sessionId, draft); + if (plan.kind === 'reject') { + throw new Error(plan.reason); + } + if (plan.kind === 'steer') { + await driverForSession(sessionId, session).steer(context, sessionId, draft); return; } try { @@ -411,15 +160,17 @@ export async function sendMessage( })); } - let createdLocalTurnId: string | null = null; + const turnTracker: TurnTracker = { createdLocalTurnId: null }; try { const refreshedSession = context.flowChatStore.getState().sessions.get(sessionId) ?? session; const currentAgentType = (agentType?.trim() || refreshedSession.mode || 'agentic').trim(); const acpClientId = acpClientIdFromMode(currentAgentType); - const isDispatched = isNonLocalDispatchTarget(refreshedSession.config.dispatchTarget); - const delegatesExternalSubagent = options?.execution?.kind === 'fresh_external_subagent'; - if (delegatesExternalSubagent && (acpClientId || isDispatched)) { + const driver = driverForSession(sessionId, refreshedSession); + if ( + options?.execution?.kind === 'fresh_external_subagent' + && (acpClientId || driver.id !== 'local') + ) { throw new Error('External subagent command delegation requires the local BitFun runtime'); } @@ -435,8 +186,14 @@ export async function sendMessage( throw new Error('Session history is still restoring, please retry once loading finishes'); } - if (!acpClientId && !isDispatched) { - await ensureBackendSession(context, sessionId); + if (!acpClientId) { + // A driver with nothing to prepare returns void; awaiting only real + // promises keeps the projection's optimistic turn synchronous with the + // user's send action. + const readiness = driver.ensureReady(context, sessionId); + if (readiness) { + await readiness; + } } const readySession = context.flowChatStore.getState().sessions.get(sessionId); @@ -446,305 +203,26 @@ export async function sendMessage( const isFirstMessage = readySession.dialogTurns.length === 0 && readySession.titleStatus !== 'generated'; - if (isDispatched) { - const targetRequest = readySession.config.dispatchTargetRequest; - const jobId = readySession.config.dispatchJobId; - const approvalPolicy = readySession.config.dispatchApprovalPolicy; - if (!targetRequest || targetRequest.kind === 'local' || !jobId || !approvalPolicy) { - throw new Error('Dispatch session is missing its immutable target or approval policy'); - } - if ((options?.imageContexts?.length ?? 0) > 0) { - throw new Error('Image attachments are not supported for detached dispatch yet'); - } - const dispatchState = readySession.config.dispatchJobState; - if (dispatchState === 'queued' || dispatchState === 'running') { - // A turn is already in flight; this message steers it rather than - // starting another one underneath it. - await appendToDispatchJob(jobId); - return; - } - if (dispatchState && isDispatchJobTerminal(dispatchState)) { - // The previous turn finished. A dispatch session is a conversation, so - // this starts the next turn against the same target session, worktree, - // and event log instead of refusing the message. - await continueDispatchJob(jobId); - return; - } - if ( - dispatchState !== 'submitting' && - dispatchState !== 'submission_unknown' - ) { - throw new Error('This dispatch session is not ready to accept a message'); - } - if (isFirstMessage) { - handleTitleGeneration(context, sessionId, message); - } - - const optimisticTurnId = `dispatch_pending_${jobId}`; - const optimisticTurn: DialogTurn = { - id: optimisticTurnId, - sessionId, - agentType: currentAgentType, - userMessage: { - id: `user_dispatch_${Date.now()}`, - content: displayMessage || message, - timestamp: Date.now(), - metadata: markOptimisticDispatchTurnMetadata( - options?.userMessageMetadata, - jobId, - ), - }, - modelRounds: [], - status: 'pending', - startTime: Date.now(), - }; - context.flowChatStore.addDialogTurn(sessionId, optimisticTurn); - createdLocalTurnId = optimisticTurnId; - globalEventBus.emit( - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, - { - sessionId, - turnId: optimisticTurnId, - behavior: 'auto', - source: 'send-message', - pinMode: 'sticky-latest', - } satisfies FlowChatPinTurnToTopRequest, - 'MessageModule', - ); - - const includeUncommitted = readySession.config.dispatchIncludeUncommitted ?? false; - const baseRef = readySession.config.dispatchBaseRef?.trim() || 'HEAD'; - const sourceWorkspacePath = sessionProjectWorkspacePath(readySession); - const sourceWorkspaceId = - readySession.workspaceId || readySession.config.workspaceId; - const transferRoundId = `dispatch-transfer:${jobId}`; - // Provisioning is always more than a submit now — a worktree is created, - // the target checks out the baseline, and objects may be transferred — - // so the transfer label always applies. - showRuntimeStatus({ - sessionId, - turnId: optimisticTurnId, - roundId: transferRoundId, - label: i18nService.t('flow-chat:chatInput.dispatch.transferInProgress'), - }); - let response: Awaited>; - try { - response = await dispatchApi.submit({ - target: targetRequest, - baseRef, - includeUncommitted, - jobId, - sessionId, - agentType: currentAgentType, - prompt: message, - approvalPolicy, - model: readySession.config.dispatchModel?.trim() || undefined, - ...(sourceWorkspacePath ? { sourceWorkspacePath } : {}), - ...(sourceWorkspaceId ? { sourceWorkspaceId } : {}), - }); - } finally { - clearRuntimeStatusState({ - sessionId, - turnId: optimisticTurnId, - roundId: transferRoundId, - }); - } - if (!response.accepted || response.jobId !== jobId || response.sessionId !== sessionId) { - throw new Error('Dispatch target returned a mismatched acknowledgement'); - } - context.flowChatStore.applyDispatchSnapshot(sessionId, { - jobId, - state: response.state, - cursor: readySession.config.dispatchCursor ?? 0, - expectedCursor: readySession.config.dispatchCursor ?? 0, - }); - dispatchJobStore.getState().updateProgress(jobId, { - state: response.state, - }); - context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); - requestDispatchJobRefresh(jobId); - completeSessionSend( + const outcome = await driver.startTurn( + context, + { sessionId, - sendAttempt, - options?.fromSessionConflictRetry - ? options.onSessionConflictRetrySuccess - : undefined, - ); - return; - } - - const dialogTurnId = options?.turnId?.trim() || - `dialog_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const hasImages = (options?.imageContexts?.length ?? 0) > 0; - - const dialogTurn: DialogTurn = { - id: dialogTurnId, - sessionId: sessionId, - agentType: currentAgentType, - userMessage: { - id: `user_${Date.now()}`, - content: displayMessage || message, - timestamp: Date.now(), - hasImages, - images: options?.imageDisplayData, - metadata: options?.userMessageMetadata, + message, + displayMessage, + currentAgentType, + acpClientId, + isFirstMessage, + readySession, + options, }, - modelRounds: [], - // Images are attached for multimodal primary models or reduced to text placeholders for text-only models. - // We don't run a separate frontend "image pre-analysis" phase here. - status: 'pending', - startTime: Date.now() - }; - - context.flowChatStore.addDialogTurn(sessionId, dialogTurn); - createdLocalTurnId = dialogTurnId; - const pinRequest: FlowChatPinTurnToTopRequest = { - sessionId, - turnId: dialogTurnId, - behavior: 'auto', - source: 'send-message', - pinMode: 'sticky-latest', - }; - globalEventBus.emit(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, pinRequest, 'MessageModule'); - - const isRestoringHistoricalSession = - readySession.isHistorical || context.pendingHistoryLoads.has(sessionId); - if (isRestoringHistoricalSession) { - context.processingManager.clearSessionStatus(sessionId); - context.flowChatStore.deleteDialogTurn(sessionId, dialogTurnId); - throw new Error('Session history is still restoring, please retry once loading finishes'); - } - - const startOk = await stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { - taskId: sessionId, - dialogTurnId, - }); - if (!startOk) { - const currentState = stateMachineManager.getCurrentState(sessionId); - throw new Error(`Session is still busy finishing the previous turn (current state: ${currentState})`); - } - - context.processingManager.registerStatus({ - sessionId: sessionId, - status: 'thinking', - message: '', - metadata: { sessionId: sessionId, dialogTurnId } - }); - - if (readySession.config.worktreeIsolationRequested !== undefined) { - const materialization = sessionWorktreeMaterializationPlan(readySession); - if (materialization) { - log.info('Materializing requested worktree after prompt submission', { - sessionId, - enabled: materialization.enabled, - projectWorkspacePath: materialization.projectWorkspacePath, - }); - const result = await worktreeAPI.bindSession( - sessionId, - materialization.enabled, - globalThis.crypto?.randomUUID?.() ?? `worktree-first-turn-${Date.now()}`, - materialization.projectWorkspacePath, - ); - context.flowChatStore.updateSessionExecutionTarget(sessionId, { - workspacePath: result.workspacePath, - projectWorkspacePath: result.projectWorkspacePath, - workspaceId: result.workspaceId, - executionTarget: result.executionTarget, - }); - if (result.retainedWorktreePath) { - log.warn('Released worktree retained because it contains local work', { - sessionId, - retainedWorktreePath: result.retainedWorktreePath, - }); - } - } - context.flowChatStore.setSessionWorktreeIsolationRequested(sessionId, undefined); - } - - if (isFirstMessage) { - handleTitleGeneration(context, sessionId, message); - } - - if (!acpClientId) { - await syncSessionModelSelection(context, sessionId, currentAgentType); - } - - const updatedSession = context.flowChatStore.getState().sessions.get(sessionId); - if (!updatedSession) { - throw new Error(`Session lost after adding dialog turn: ${sessionId}`); - } - - context.contentBuffers.set(sessionId, new Map()); - context.activeTextItems.set(sessionId, new Map()); - - const workspacePath = updatedSession.workspacePath; - const projectWorkspacePath = sessionProjectWorkspacePath(updatedSession); - - if (acpClientId) { - await ACPClientAPI.startDialogTurn({ - sessionId, - clientId: acpClientId, - userInput: message, - originalUserInput: displayMessage || message, - turnId: dialogTurnId, - workspacePath, - imageContexts: options?.imageContexts, - userMessageMetadata: options?.userMessageMetadata, - remoteConnectionId: updatedSession.remoteConnectionId, - remoteSshHost: updatedSession.remoteSshHost, - }); - context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); - } else { - try { - await agentAPI.startDialogTurn({ - sessionId: sessionId, - userInput: message, - originalUserInput: displayMessage || message, - turnId: dialogTurnId, - agentType: currentAgentType, - workspacePath, - projectWorkspacePath, - remoteConnectionId: updatedSession.remoteConnectionId, - remoteSshHost: updatedSession.remoteSshHost, - imageContexts: options?.imageContexts, - userMessageMetadata: options?.userMessageMetadata, - execution: options?.execution, - }); - context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); - } catch (error: any) { - if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { - log.warn('Backend session still not found, retrying creation', { - sessionId: sessionId, - dialogTurnsCount: updatedSession.dialogTurns.length - }); - - await retryCreateBackendSession(context, sessionId); - - await agentAPI.startDialogTurn({ - sessionId: sessionId, - userInput: message, - originalUserInput: displayMessage || message, - turnId: dialogTurnId, - agentType: currentAgentType, - workspacePath, - projectWorkspacePath, - remoteConnectionId: updatedSession.remoteConnectionId, - remoteSshHost: updatedSession.remoteSshHost, - imageContexts: options?.imageContexts, - userMessageMetadata: options?.userMessageMetadata, - execution: options?.execution, - }); - context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); - } else { - throw error; - } - } + turnTracker, + ); + if (outcome === 'detached') { + // The message steered or continued target-owned work; the shared + // post-submission bookkeeping does not apply. + return; } - const sessionStateMachine = stateMachineManager.get(sessionId); - if (sessionStateMachine) { - sessionStateMachine.getContext().taskId = sessionId; - } completeSessionSend( sessionId, sendAttempt, @@ -755,29 +233,29 @@ export async function sendMessage( } catch (error) { log.error('Failed to send message', { sessionId: sessionId, error }); - + const errorMessage = error instanceof Error ? error.message : 'Failed to send message'; - + const currentState = stateMachineManager.getCurrentState(sessionId); const activeDialogTurnId = stateMachineManager .get(sessionId) ?.getContext().currentDialogTurnId; const ownsProcessingTurn = - createdLocalTurnId !== null && - activeDialogTurnId === createdLocalTurnId; + turnTracker.createdLocalTurnId !== null && + activeDialogTurnId === turnTracker.createdLocalTurnId; if (currentState === SessionExecutionState.PROCESSING && ownsProcessingTurn) { await stateMachineManager.transition(sessionId, SessionExecutionEvent.ERROR_OCCURRED, { error: errorMessage }); await stateMachineManager.transition(sessionId, SessionExecutionEvent.RESET); } - + const state = context.flowChatStore.getState(); const currentSession = state.sessions.get(sessionId); - if (createdLocalTurnId && currentSession && !options?.preserveTurnOnStartError) { - context.flowChatStore.deleteDialogTurn(sessionId, createdLocalTurnId); + if (turnTracker.createdLocalTurnId && currentSession && !options?.preserveTurnOnStartError) { + context.flowChatStore.deleteDialogTurn(sessionId, turnTracker.createdLocalTurnId); } - + if (!options?.preserveTurnOnStartError) { if (isSessionInUseError(error)) { if (latestSendBySession.get(sessionId) !== sendAttempt) { @@ -832,22 +310,11 @@ export async function sendMessage( } else if (latestSendBySession.get(sessionId) === sendAttempt) { latestSendBySession.delete(sessionId); } - + throw error; } } -function handleTitleGeneration( - context: FlowChatContext, - sessionId: string, - message: string -): void { - const tempTitle = generateTempTitle(message, 20); - // Show a readable placeholder immediately; backend later confirms the - // authoritative title via AI or local fallback generation. - context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generating'); -} - export async function cancelSessionTask(context: FlowChatContext, requestedSessionId?: string): Promise { try { const state = context.flowChatStore.getState(); @@ -961,5 +428,3 @@ export function installPendingQueueDrainListener(context: FlowChatContext): void void drainPendingQueue(queueDrainContext, sessionId); }); } - -export { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; 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 233a39bc86..73dddbde89 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 @@ -10,19 +10,165 @@ */ import { createLogger } from '@/shared/utils/logger'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import { i18nService } from '@/infrastructure/i18n'; import type { FlowChatContext, SessionConfig } from '../../services/flow-chat-manager/types'; -import type { Session } from '../../types/flow-chat'; -import type { SessionCascadeRemoval, SessionCreationSeed, SessionDriver } from '../types'; -import { isNonLocalDispatchTarget, type DispatchTarget } from '@/features/dispatch/types'; +import type { DialogTurn, Session } from '../../types/flow-chat'; +import type { + SessionCascadeRemoval, + SessionCreationSeed, + SessionDriver, + StartTurnInput, + StartTurnResult, + SubmissionDraft, + SubmissionPlan, + TurnTracker, +} from '../types'; +import { + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + type FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; +import { + isDispatchJobTerminal, + isNonLocalDispatchTarget, + type DispatchTarget, +} from '@/features/dispatch/types'; import { dispatchApi } from '@/features/dispatch/dispatchApi'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; import { forgetDispatchTranscript } from '@/features/dispatch/dispatchTranscriptCache'; import { requestDispatchJobRefresh } from '@/features/dispatch/DispatchJobObserver'; +import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; import { cleanupSaveState } from '../../services/flow-chat-manager/PersistenceModule'; import { cleanupSessionBuffers } from '../../services/flow-chat-manager/TextChunkModule'; +import { sessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; +import { + clearRuntimeStatusState, + showRuntimeStatus, +} from '../../store/runtimeStatusStore'; +import { claimSubmissionRetry, releaseSubmissionRetry } from '../idempotency'; +import { applyGeneratingTitlePlaceholder } from '../shared'; const log = createLogger('DispatchSessionDriver'); +const IMAGES_UNSUPPORTED_MESSAGE = 'Image attachments are not supported for detached dispatch yet'; +const APPEND_RETRY_SCOPE = 'dispatch-append'; +const CONTINUE_RETRY_SCOPE = 'dispatch-continue'; + +function pinTurnToTop(sessionId: string, turnId: string): void { + globalEventBus.emit( + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + { + sessionId, + turnId, + behavior: 'auto', + source: 'send-message', + pinMode: 'sticky-latest', + } satisfies FlowChatPinTurnToTopRequest, + 'DispatchSessionDriver', + ); +} + +async function appendToDispatchJob( + sessionId: string, + jobId: string, + message: string, + displayMessage: string | undefined, +): Promise { + // Keep the id stable across an ambiguous transport failure. A retry with + // the same message can then ask the target mailbox for the same idempotent + // append instead of injecting the steering text twice. + const retry = claimSubmissionRetry( + APPEND_RETRY_SCOPE, + sessionId, + message, + displayMessage, + () => + globalThis.crypto?.randomUUID?.() + ?? `dispatch-message-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const response = await dispatchApi.append( + jobId, + message, + displayMessage, + retry.id, + ); + if (!response.accepted) { + releaseSubmissionRetry(APPEND_RETRY_SCOPE, sessionId, retry.id); + throw new Error('Dispatch target did not accept the appended message'); + } + releaseSubmissionRetry(APPEND_RETRY_SCOPE, sessionId, retry.id); + requestDispatchJobRefresh(jobId); +} + +/** + * Start the next turn of a finished dispatch job. + * + * The optimistic turn mirrors the first-message path so the user sees their + * message immediately; the target's own `DialogTurnStarted` adopts it once + * the follow-up worker starts. + */ +async function continueDispatchJob( + context: FlowChatContext, + input: StartTurnInput, + jobId: string, +): Promise { + const { sessionId, message, displayMessage } = input; + const followUpSession = + context.flowChatStore.getState().sessions.get(sessionId) ?? input.readySession; + const followUpAgentType = + (input.currentAgentType?.trim() || followUpSession.mode || 'agentic').trim(); + // Reused across retries so a lost response cannot start two turns. + const retry = claimSubmissionRetry( + CONTINUE_RETRY_SCOPE, + sessionId, + message, + displayMessage, + () => + globalThis.crypto?.randomUUID?.() + ?? `dispatch-turn-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + const optimisticTurnId = `dispatch_pending_${jobId}`; + context.flowChatStore.addDialogTurn(sessionId, { + id: optimisticTurnId, + sessionId, + agentType: followUpAgentType, + userMessage: { + id: `user_dispatch_${retry.id}`, + content: displayMessage || message, + timestamp: Date.now(), + metadata: markOptimisticDispatchTurnMetadata( + input.options?.userMessageMetadata, + jobId, + ), + }, + modelRounds: [], + status: 'pending', + startTime: Date.now(), + }); + pinTurnToTop(sessionId, optimisticTurnId); + + try { + const response = await dispatchApi.continueJob( + jobId, + retry.id, + message, + displayMessage, + ); + if (!response.accepted) { + throw new Error('Dispatch target did not accept the follow-up turn'); + } + // The target owns the job state; the refresh below reads it back rather + // than this side guessing what the follow-up did to it. + } catch (error) { + context.flowChatStore.deleteDialogTurn(sessionId, optimisticTurnId); + throw error; + } finally { + releaseSubmissionRetry(CONTINUE_RETRY_SCOPE, sessionId, retry.id); + } + requestDispatchJobRefresh(jobId); +} + /** * Fixed context budget for projections: no controller-side provider is ever * resolved for them, so there is no model to read a real window from. @@ -227,9 +373,10 @@ export const dispatchSessionDriver: SessionDriver = { return title; }, - async ensureReady(): Promise { + ensureReady(): void { // Nothing to prepare: the target owns the durable session, and submission - // itself is what creates the job. + // itself is what creates the job. Deliberately synchronous — see the + // interface note about the optimistic-projection timing invariant. }, async cancel(context: FlowChatContext, sessionId: string): Promise { @@ -245,4 +392,163 @@ export const dispatchSessionDriver: SessionDriver = { } return response.cancelled; }, + + planSubmission( + context: FlowChatContext, + sessionId: string, + draft: SubmissionDraft, + ): SubmissionPlan { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if ( + !isNonLocalDispatchTarget(session?.config.dispatchTarget) + || !session?.config.dispatchJobId + || ( + session.config.dispatchJobState !== 'queued' + && session.config.dispatchJobState !== 'running' + ) + ) { + return { kind: 'queue' }; + } + if (draft.hasImages) { + return { kind: 'reject', reason: IMAGES_UNSUPPORTED_MESSAGE }; + } + return { kind: 'steer' }; + }, + + async steer( + context: FlowChatContext, + sessionId: string, + draft: SubmissionDraft, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + const jobId = session?.config.dispatchJobId; + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + await appendToDispatchJob(sessionId, jobId, draft.message, draft.displayMessage); + }, + + async startTurn( + context: FlowChatContext, + input: StartTurnInput, + tracker: TurnTracker, + ): Promise { + const { + sessionId, + message, + displayMessage, + currentAgentType, + isFirstMessage, + readySession, + options, + } = input; + + const targetRequest = readySession.config.dispatchTargetRequest; + const jobId = readySession.config.dispatchJobId; + const approvalPolicy = readySession.config.dispatchApprovalPolicy; + if (!targetRequest || targetRequest.kind === 'local' || !jobId || !approvalPolicy) { + throw new Error('Dispatch session is missing its immutable target or approval policy'); + } + if ((options?.imageContexts?.length ?? 0) > 0) { + throw new Error(IMAGES_UNSUPPORTED_MESSAGE); + } + const dispatchState = readySession.config.dispatchJobState; + if (dispatchState === 'queued' || dispatchState === 'running') { + // A turn is already in flight; this message steers it rather than + // starting another one underneath it. + await appendToDispatchJob(sessionId, jobId, message, displayMessage); + return 'detached'; + } + if (dispatchState && isDispatchJobTerminal(dispatchState)) { + // The previous turn finished. A dispatch session is a conversation, so + // this starts the next turn against the same target session, worktree, + // and event log instead of refusing the message. + await continueDispatchJob(context, input, jobId); + return 'detached'; + } + if ( + dispatchState !== 'submitting' && + dispatchState !== 'submission_unknown' + ) { + throw new Error('This dispatch session is not ready to accept a message'); + } + if (isFirstMessage) { + applyGeneratingTitlePlaceholder(context, sessionId, message); + } + + const optimisticTurnId = `dispatch_pending_${jobId}`; + const optimisticTurn: DialogTurn = { + id: optimisticTurnId, + sessionId, + agentType: currentAgentType, + userMessage: { + id: `user_dispatch_${Date.now()}`, + content: displayMessage || message, + timestamp: Date.now(), + metadata: markOptimisticDispatchTurnMetadata( + options?.userMessageMetadata, + jobId, + ), + }, + modelRounds: [], + status: 'pending', + startTime: Date.now(), + }; + context.flowChatStore.addDialogTurn(sessionId, optimisticTurn); + tracker.createdLocalTurnId = optimisticTurnId; + pinTurnToTop(sessionId, optimisticTurnId); + + const includeUncommitted = readySession.config.dispatchIncludeUncommitted ?? false; + const baseRef = readySession.config.dispatchBaseRef?.trim() || 'HEAD'; + const sourceWorkspacePath = sessionProjectWorkspacePath(readySession); + const sourceWorkspaceId = + readySession.workspaceId || readySession.config.workspaceId; + const transferRoundId = `dispatch-transfer:${jobId}`; + // Provisioning is always more than a submit now — a worktree is created, + // the target checks out the baseline, and objects may be transferred — + // so the transfer label always applies. + showRuntimeStatus({ + sessionId, + turnId: optimisticTurnId, + roundId: transferRoundId, + label: i18nService.t('flow-chat:chatInput.dispatch.transferInProgress'), + }); + let response: Awaited>; + try { + response = await dispatchApi.submit({ + target: targetRequest, + baseRef, + includeUncommitted, + jobId, + sessionId, + agentType: currentAgentType, + prompt: message, + approvalPolicy, + model: readySession.config.dispatchModel?.trim() || undefined, + ...(sourceWorkspacePath ? { sourceWorkspacePath } : {}), + ...(sourceWorkspaceId ? { sourceWorkspaceId } : {}), + }); + } finally { + clearRuntimeStatusState({ + sessionId, + turnId: optimisticTurnId, + roundId: transferRoundId, + }); + } + if (!response.accepted || response.jobId !== jobId || response.sessionId !== sessionId) { + throw new Error('Dispatch target returned a mismatched acknowledgement'); + } + context.flowChatStore.applyDispatchSnapshot(sessionId, { + jobId, + state: response.state, + cursor: readySession.config.dispatchCursor ?? 0, + expectedCursor: readySession.config.dispatchCursor ?? 0, + }); + dispatchJobStore.getState().updateProgress(jobId, { + state: response.state, + }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); + requestDispatchJobRefresh(jobId); + return 'completed'; + }, }; diff --git a/src/web-ui/src/flow_chat/session-drivers/idempotency.ts b/src/web-ui/src/flow_chat/session-drivers/idempotency.ts new file mode 100644 index 0000000000..c96d306262 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/idempotency.ts @@ -0,0 +1,59 @@ +/** + * Per-session idempotent submission ids. + * + * A transport call whose response was lost must be retryable without running + * the operation twice on the other side. The id is minted once per + * (session, scope, content) and reused verbatim by retries with the same + * content; the durable target dedupes on it. + */ + +export interface PendingSubmissionRetry { + content: string; + displayContent?: string; + id: string; +} + +const pendingByScope = new Map>(); + +function scopeMap(scope: string): Map { + let map = pendingByScope.get(scope); + if (!map) { + map = new Map(); + pendingByScope.set(scope, map); + } + return map; +} + +/** + * Return the pending retry for this content, or mint and remember a new id. + * Different content for the same session replaces the pending entry — only + * the latest attempt is retryable. + */ +export function claimSubmissionRetry( + scope: string, + sessionId: string, + content: string, + displayContent: string | undefined, + mintId: () => string, +): PendingSubmissionRetry { + const map = scopeMap(scope); + const current = map.get(sessionId); + const retry = + current?.content === content && current.displayContent === displayContent + ? current + : { content, displayContent, id: mintId() }; + map.set(sessionId, retry); + return retry; +} + +/** Forget the retry, but only if it is still the current one for the session. */ +export function releaseSubmissionRetry( + scope: string, + sessionId: string, + id: string, +): void { + const map = scopeMap(scope); + if (map.get(sessionId)?.id === id) { + map.delete(sessionId); + } +} 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 016279b1ea..16934d20f5 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 @@ -8,23 +8,41 @@ */ import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; +import { worktreeAPI } from '@/infrastructure/api/service-api/WorktreeAPI'; +import { globalEventBus } from '@/infrastructure/event-bus'; import { createLogger } from '@/shared/utils/logger'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; -import type { FlowChatContext, SessionConfig } from '../../services/flow-chat-manager/types'; -import type { SessionCascadeRemoval, SessionCreationSeed, SessionDriver } from '../types'; +import type { FlowChatContext, SessionConfig, DialogTurn } from '../../services/flow-chat-manager/types'; +import type { + SessionCascadeRemoval, + SessionCreationSeed, + SessionDriver, + StartTurnInput, + StartTurnResult, + SubmissionPlan, + TurnTracker, +} from '../types'; +import { + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + type FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; import { getModelMaxTokens, resolveModelForSessionCreation, } from '../../utils/modelResolution'; +import { syncSessionModelSelection } from '../../utils/modelSync'; import { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; import { requireSessionProjectWorkspacePath, sessionProjectWorkspacePath, } from '../../utils/sessionWorkspace'; +import { sessionWorktreeMaterializationPlan } from '../../utils/sessionWorktree'; import { cleanupSaveState } from '../../services/flow-chat-manager/PersistenceModule'; import { cleanupSessionBuffers } from '../../services/flow-chat-manager/TextChunkModule'; +import { applyGeneratingTitlePlaceholder } from '../shared'; const log = createLogger('LocalSessionDriver'); @@ -195,4 +213,209 @@ export const localSessionDriver: SessionDriver = { return success; }, + + planSubmission(): SubmissionPlan { + // Local sessions park messages while busy; the drain listener replays + // them when the state machine returns to IDLE. + return { kind: 'queue' }; + }, + + async steer(): Promise { + throw new Error('Local sessions queue messages while a turn is running'); + }, + + async startTurn( + context: FlowChatContext, + input: StartTurnInput, + tracker: TurnTracker, + ): Promise { + const { + sessionId, + message, + displayMessage, + currentAgentType, + acpClientId, + isFirstMessage, + readySession, + options, + } = input; + + const dialogTurnId = options?.turnId?.trim() || + `dialog_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const hasImages = (options?.imageContexts?.length ?? 0) > 0; + + const dialogTurn: DialogTurn = { + id: dialogTurnId, + sessionId: sessionId, + agentType: currentAgentType, + userMessage: { + id: `user_${Date.now()}`, + content: displayMessage || message, + timestamp: Date.now(), + hasImages, + images: options?.imageDisplayData, + metadata: options?.userMessageMetadata, + }, + modelRounds: [], + // Images are attached for multimodal primary models or reduced to text placeholders for text-only models. + // We don't run a separate frontend "image pre-analysis" phase here. + status: 'pending', + startTime: Date.now() + }; + + context.flowChatStore.addDialogTurn(sessionId, dialogTurn); + tracker.createdLocalTurnId = dialogTurnId; + const pinRequest: FlowChatPinTurnToTopRequest = { + sessionId, + turnId: dialogTurnId, + behavior: 'auto', + source: 'send-message', + pinMode: 'sticky-latest', + }; + globalEventBus.emit(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, pinRequest, 'MessageModule'); + + const isRestoringHistoricalSession = + readySession.isHistorical || context.pendingHistoryLoads.has(sessionId); + if (isRestoringHistoricalSession) { + context.processingManager.clearSessionStatus(sessionId); + context.flowChatStore.deleteDialogTurn(sessionId, dialogTurnId); + throw new Error('Session history is still restoring, please retry once loading finishes'); + } + + const startOk = await stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { + taskId: sessionId, + dialogTurnId, + }); + if (!startOk) { + const currentState = stateMachineManager.getCurrentState(sessionId); + throw new Error(`Session is still busy finishing the previous turn (current state: ${currentState})`); + } + + context.processingManager.registerStatus({ + sessionId: sessionId, + status: 'thinking', + message: '', + metadata: { sessionId: sessionId, dialogTurnId } + }); + + if (readySession.config.worktreeIsolationRequested !== undefined) { + const materialization = sessionWorktreeMaterializationPlan(readySession); + if (materialization) { + log.info('Materializing requested worktree after prompt submission', { + sessionId, + enabled: materialization.enabled, + projectWorkspacePath: materialization.projectWorkspacePath, + }); + const result = await worktreeAPI.bindSession( + sessionId, + materialization.enabled, + globalThis.crypto?.randomUUID?.() ?? `worktree-first-turn-${Date.now()}`, + materialization.projectWorkspacePath, + ); + context.flowChatStore.updateSessionExecutionTarget(sessionId, { + workspacePath: result.workspacePath, + projectWorkspacePath: result.projectWorkspacePath, + workspaceId: result.workspaceId, + executionTarget: result.executionTarget, + }); + if (result.retainedWorktreePath) { + log.warn('Released worktree retained because it contains local work', { + sessionId, + retainedWorktreePath: result.retainedWorktreePath, + }); + } + } + context.flowChatStore.setSessionWorktreeIsolationRequested(sessionId, undefined); + } + + if (isFirstMessage) { + applyGeneratingTitlePlaceholder(context, sessionId, message); + } + + if (!acpClientId) { + await syncSessionModelSelection(context, sessionId, currentAgentType); + } + + const updatedSession = context.flowChatStore.getState().sessions.get(sessionId); + if (!updatedSession) { + throw new Error(`Session lost after adding dialog turn: ${sessionId}`); + } + + context.contentBuffers.set(sessionId, new Map()); + context.activeTextItems.set(sessionId, new Map()); + + const workspacePath = updatedSession.workspacePath; + const projectWorkspacePath = sessionProjectWorkspacePath(updatedSession); + + if (acpClientId) { + await ACPClientAPI.startDialogTurn({ + sessionId, + clientId: acpClientId, + userInput: message, + originalUserInput: displayMessage || message, + turnId: dialogTurnId, + workspacePath, + imageContexts: options?.imageContexts, + userMessageMetadata: options?.userMessageMetadata, + remoteConnectionId: updatedSession.remoteConnectionId, + remoteSshHost: updatedSession.remoteSshHost, + }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); + } else { + try { + await agentAPI.startDialogTurn({ + sessionId: sessionId, + userInput: message, + originalUserInput: displayMessage || message, + turnId: dialogTurnId, + agentType: currentAgentType, + workspacePath, + projectWorkspacePath, + remoteConnectionId: updatedSession.remoteConnectionId, + remoteSshHost: updatedSession.remoteSshHost, + imageContexts: options?.imageContexts, + userMessageMetadata: options?.userMessageMetadata, + execution: options?.execution, + }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); + } catch (error: any) { + if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { + log.warn('Backend session still not found, retrying creation', { + sessionId: sessionId, + dialogTurnsCount: updatedSession.dialogTurns.length + }); + + // Lazy import: SessionModule routes lifecycle calls through the + // driver registry, so a static import would create a module cycle. + const { retryCreateBackendSession } = + await import('../../services/flow-chat-manager/SessionModule'); + await retryCreateBackendSession(context, sessionId); + + await agentAPI.startDialogTurn({ + sessionId: sessionId, + userInput: message, + originalUserInput: displayMessage || message, + turnId: dialogTurnId, + agentType: currentAgentType, + workspacePath, + projectWorkspacePath, + remoteConnectionId: updatedSession.remoteConnectionId, + remoteSshHost: updatedSession.remoteSshHost, + imageContexts: options?.imageContexts, + userMessageMetadata: options?.userMessageMetadata, + execution: options?.execution, + }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); + } else { + throw error; + } + } + } + + const sessionStateMachine = stateMachineManager.get(sessionId); + if (sessionStateMachine) { + sessionStateMachine.getContext().taskId = sessionId; + } + return 'completed'; + }, }; diff --git a/src/web-ui/src/flow_chat/session-drivers/shared.ts b/src/web-ui/src/flow_chat/session-drivers/shared.ts new file mode 100644 index 0000000000..76f7f9cfd6 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/shared.ts @@ -0,0 +1,19 @@ +/** + * Small helpers shared by driver implementations. + */ + +import { generateTempTitle } from '../utils/titleUtils'; +import type { FlowChatContext } from '../services/flow-chat-manager/types'; + +/** + * Show a readable placeholder title immediately; the backend later confirms + * the authoritative title via AI or local fallback generation. + */ +export function applyGeneratingTitlePlaceholder( + context: FlowChatContext, + sessionId: string, + message: string, +): void { + const tempTitle = generateTempTitle(message, 20); + context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generating'); +} 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 1f34d49b00..730279a5ee 100644 --- a/src/web-ui/src/flow_chat/session-drivers/types.ts +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -14,11 +14,84 @@ */ import type { FlowChatContext, SessionConfig } from '../services/flow-chat-manager/types'; +import type { Session } from '../types/flow-chat'; import type { SessionTitleDescriptor } from '../utils/sessionTitle'; +import type { ImageContextData as ImageInputContextData } from '@/infrastructure/api/service-api/ImageContextTypes'; import type { SessionDriverId } from './resolve'; export type { SessionDriverId } from './resolve'; +/** Options accepted by FlowChatManager.sendMessage, threaded to the driver. */ +export interface SendMessageOptions { + imageContexts?: ImageInputContextData[]; + imageDisplayData?: Array<{ + id: string; + name: string; + dataUrl?: string; + imagePath?: string; + mimeType?: string; + }>; + /** + * When true, bypass the pending-queue check. Used by the queue drain path + * to actually start a new dialog turn after the previous one finished. + * Callers should not set this directly. + */ + bypassPendingQueue?: boolean; + userMessageMetadata?: Record; + execution?: import('@/infrastructure/api/service-api/AgentAPI').AgentDialogTurnExecution; + turnId?: string; + preserveTurnOnStartError?: boolean; + onSessionConflictRetryStart?: () => void; + onSessionConflictRetrySuccess?: () => void; + fromSessionConflictRetry?: boolean; +} + +/** The message content of one submission, before flavor routing. */ +export interface SubmissionDraft { + message: string; + displayMessage?: string; + hasImages: boolean; +} + +/** + * What to do with a submission while the session is busy or has queued + * messages. `steer` injects into the running work, `queue` parks the message + * for the drain listener, `reject` refuses with a reason. + */ +export type SubmissionPlan = + | { kind: 'queue' } + | { kind: 'steer' } + | { kind: 'reject'; reason: string }; + +export interface StartTurnInput { + sessionId: string; + message: string; + displayMessage?: string; + /** Resolved agent type for this turn (falls back to session mode). */ + currentAgentType: string; + /** Non-null when the session is driven by an external ACP client. */ + acpClientId: string | null; + isFirstMessage: boolean; + /** Session snapshot taken after ensureReady. */ + readySession: Session; + options?: SendMessageOptions; +} + +/** + * Written by the driver as soon as it adds an optimistic turn it wants the + * shared error path to delete on failure. Read by sendMessage's catch block. + */ +export interface TurnTracker { + createdLocalTurnId: string | null; +} + +/** + * `completed` runs the shared post-submission bookkeeping; + * `detached` means the message was steered into (or continued) target-owned + * work and the shared epilogue must be skipped. + */ +export type StartTurnResult = 'completed' | 'detached'; + /** * Everything flavor-independent that session creation resolved before the * driver takes over: workspace identity, agent type, and the initial title. @@ -68,9 +141,45 @@ export interface SessionDriver { title: string, ): Promise; - /** Make the session able to accept a submission (backend session, hydration). */ - ensureReady(context: FlowChatContext, sessionId: string): Promise; + /** + * Make the session able to accept a submission (backend session, + * hydration). Drivers with nothing to prepare return void — synchronously — + * so the caller introduces no microtask boundary before the optimistic + * turn: a projection must show the user's message before any async work. + */ + ensureReady(context: FlowChatContext, sessionId: string): Promise | void; /** Cancel the in-flight work for this session. Returns whether anything was cancelled. */ cancel(context: FlowChatContext, sessionId: string): Promise; + + /** + * Decide the fate of a submission arriving while the session is busy or + * already has queued messages. Must test steer-eligibility before falling + * back to queue: a steerable message that gets queued never drains for + * flavors that do not drive the local state machine. + */ + planSubmission( + context: FlowChatContext, + sessionId: string, + draft: SubmissionDraft, + ): SubmissionPlan; + + /** Inject a message into the session's currently running work. */ + steer( + context: FlowChatContext, + sessionId: string, + draft: SubmissionDraft, + ): Promise; + + /** + * Start (or continue) a turn for an idle session. Owns the flavor-specific + * optimistic turn, transport call, and post-acknowledgement bookkeeping. + * Reports any optimistic turn to `tracker` so the shared error path can + * clean it up when this throws. + */ + startTurn( + context: FlowChatContext, + input: StartTurnInput, + tracker: TurnTracker, + ): Promise; } diff --git a/src/web-ui/src/flow_chat/utils/modelSync.ts b/src/web-ui/src/flow_chat/utils/modelSync.ts new file mode 100644 index 0000000000..4af908cf08 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/modelSync.ts @@ -0,0 +1,102 @@ +/** + * Session model-selection synchronization before a local dialog turn. + * + * Leaf module: shared by the local session driver and re-exported by + * MessageModule for existing callers. + */ + +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; +import { createLogger } from '@/shared/utils/logger'; +import type { FlowChatContext } from '../services/flow-chat-manager/types'; +import { getModelMaxTokens } from './modelResolution'; +import { sessionProjectWorkspacePath } from './sessionWorkspace'; + +const log = createLogger('ModelSync'); + +function normalizeModelSelection( + modelId: string | undefined, + models: AIModelConfig[], + defaultModels: DefaultModelsConfig, +): string { + const value = modelId?.trim(); + if (!value || value === 'auto') return 'auto'; + + if (value === 'primary' || value === 'fast') { + const resolvedDefaultId = value === 'primary' ? defaultModels.primary : defaultModels.fast; + const matchedModel = models.find(model => model.id === resolvedDefaultId); + return matchedModel ? value : 'auto'; + } + + const matchedModel = models.find(model => + model.id === value || model.name === value || model.model_name === value, + ); + return matchedModel ? value : 'auto'; +} + +export async function syncSessionModelSelection( + context: FlowChatContext, + sessionId: string, + agentType: string, +): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + + const sessionModelId = session.config.modelName?.trim(); + + // Any stored selector, including "auto", belongs to the session. Still sync + // it to the backend in case the restored runtime session lost that state. + if (sessionModelId) { + const desiredMaxContextTokens = await getModelMaxTokens(sessionModelId, agentType); + if (session.maxContextTokens !== desiredMaxContextTokens) { + context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); + } + await agentAPI.updateSessionModel({ + sessionId, + modelName: sessionModelId, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + includeInternal: session.sessionKind === 'subagent', + }); + return; + } + + const configData = await configManager.getConfigs([ + 'ai.agent_model_defaults', + 'ai.models', + 'ai.default_models', + ]); + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; + const allModels = (configData['ai.models'] as AIModelConfig[] | undefined) || []; + const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; + + const desiredModelId = normalizeModelSelection(agentModelDefaults?.mode, allModels, defaultModels); + const shouldForceAutoSync = desiredModelId === 'auto'; + const desiredMaxContextTokens = await getModelMaxTokens(desiredModelId, agentType); + const shouldSyncContextWindow = session.maxContextTokens !== desiredMaxContextTokens; + + context.flowChatStore.updateSessionModelName(sessionId, desiredModelId); + if (shouldSyncContextWindow) { + context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); + } + await agentAPI.updateSessionModel({ + sessionId, + modelName: desiredModelId, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + includeInternal: session.sessionKind === 'subagent', + }); + + log.info('Session model synchronized before send', { + sessionId, + agentType, + previousModelId: null, + nextModelId: desiredModelId, + forcedAutoSync: shouldForceAutoSync, + }); +} From 275dc0ce096e42e66668ed4d149358bccb38b84d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 21:25:29 -0700 Subject: [PATCH 04/12] refactor(web): de-specialize turn adoption and permission routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimistic-turn adoption in EventHandlerModule now keys purely off turn metadata (generic adoption key) instead of dispatch config checks — any driver that marks a projected turn gets in-place adoption when the executor's DialogTurnStarted arrives. The metadata key stays byte-identical for cached-transcript compatibility; features/dispatch keeps thin wrappers. usePermissionRequests drops its dispatchJobId parameter: the driver now supplies the request source ('live' runtime subscription vs an external-store pair over the polled mailbox) and both respond paths. Dispatch answers resolve their job id from bound config with an observer-store fallback, covering the pre-binding startup window. --- .../dispatch/optimisticDispatchTurn.ts | 28 +++--- .../modern/ModernFlowChatContainer.tsx | 5 +- .../modern/usePermissionRequests.ts | 93 ++++++++++--------- .../flow-chat-manager/EventHandlerModule.ts | 30 +++--- .../dispatch/DispatchSessionDriver.ts | 78 ++++++++++++++++ .../local/LocalSessionDriver.ts | 22 +++++ .../src/flow_chat/session-drivers/types.ts | 46 +++++++++ .../flow_chat/utils/optimisticTurnAdoption.ts | 52 +++++++++++ 8 files changed, 277 insertions(+), 77 deletions(-) create mode 100644 src/web-ui/src/flow_chat/utils/optimisticTurnAdoption.ts diff --git a/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts b/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts index 2f77c84f38..f8446cfe7d 100644 --- a/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts +++ b/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts @@ -1,29 +1,29 @@ -import type { DialogTurn } from '@/flow_chat/types/flow-chat'; +/** + * Dispatch-named wrappers over the generic optimistic-turn adoption helpers. + * The metadata key and behavior are unchanged; see + * `flow_chat/utils/optimisticTurnAdoption.ts`. + */ -const OPTIMISTIC_DISPATCH_JOB_ID_KEY = '__bitfunOptimisticDispatchJobId'; +import type { DialogTurn } from '@/flow_chat/types/flow-chat'; +import { + markOptimisticTurnAdoption, + optimisticTurnAdoptionKey, + stripOptimisticTurnAdoption, +} from '@/flow_chat/utils/optimisticTurnAdoption'; export function markOptimisticDispatchTurnMetadata( metadata: Record | undefined, jobId: string, ): Record { - return { - ...metadata, - [OPTIMISTIC_DISPATCH_JOB_ID_KEY]: jobId, - }; + return markOptimisticTurnAdoption(metadata, jobId); } export function optimisticDispatchTurnJobId(turn: DialogTurn): string | undefined { - const value = turn.userMessage.metadata?.[OPTIMISTIC_DISPATCH_JOB_ID_KEY]; - return typeof value === 'string' && value ? value : undefined; + return optimisticTurnAdoptionKey(turn); } export function stripOptimisticDispatchTurnMetadata( metadata: Record | undefined, ): Record | undefined { - if (!metadata) { - return undefined; - } - const next = { ...metadata }; - delete next[OPTIMISTIC_DISPATCH_JOB_ID_KEY]; - return Object.keys(next).length > 0 ? next : undefined; + return stripOptimisticTurnAdoption(metadata); } diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 79e48f0180..38d28bfa17 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -252,10 +252,7 @@ export const ModernFlowChatContainer: React.FC = ( activeBatch: activePermissionBatch, respond: respondPermission, respondBatch: respondPermissionBatch, - } = usePermissionRequests( - activeSession?.sessionId, - activeSession?.config.dispatchJobId, - ); + } = usePermissionRequests(activeSession?.sessionId); const visibleTurnInfo = useVisibleTurnInfo(); const [queuedTurnPinId, setQueuedTurnPinId] = useState(null); const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(null); diff --git a/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts b/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts index 4fb8c25027..5dd210b727 100644 --- a/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts +++ b/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { agentAPI, type PermissionReplyKind, @@ -11,30 +11,43 @@ import { selectActivePermissionBatch, selectPermissionRequestsForSession, } from './permissionRequestRouting'; -import { dispatchApi } from '@/features/dispatch/dispatchApi'; -import { useDispatchJobStore } from '@/features/dispatch/dispatchJobStore'; -import { requestDispatchJobRefresh } from '@/features/dispatch/DispatchJobObserver'; +import { FlowChatStore } from '../../store/FlowChatStore'; +import { driverForSession } from '../../session-drivers/registry'; -const EMPTY_DISPATCH_PERMISSIONS: Array> = []; +const EMPTY_EXTERNAL_REQUESTS: PermissionRequest[] = []; +const noopSubscribe = () => () => {}; +const emptyExternalSnapshot = () => EMPTY_EXTERNAL_REQUESTS; -export function usePermissionRequests(sessionId?: string, dispatchJobId?: string) { - const [requests, setRequests] = useState([]); +export function usePermissionRequests(sessionId?: string) { + const [liveRequests, setLiveRequests] = useState([]); const resolvedIds = useRef(new Set()); - const dispatchRequests = useDispatchJobStore(state => ( - dispatchJobId - ? state.jobs[dispatchJobId]?.pendingPermissions ?? EMPTY_DISPATCH_PERMISSIONS - : EMPTY_DISPATCH_PERMISSIONS - )) as unknown as PermissionRequest[]; + + // Driver resolution is per-render: the caller re-renders on any session + // change, so a projection whose dispatch config binds late is picked up. + const session = sessionId + ? FlowChatStore.getInstance().getState().sessions.get(sessionId) + : undefined; + const driver = driverForSession(sessionId ?? '', session); + const source = useMemo( + () => driver.permissionRequestSource(sessionId ?? ''), + [driver, sessionId], + ); + const isLiveSource = source === 'live'; + + const externalRequests = useSyncExternalStore( + isLiveSource ? noopSubscribe : source.subscribe, + isLiveSource ? emptyExternalSnapshot : source.getSnapshot, + ) as unknown as PermissionRequest[]; useEffect(() => { - if (dispatchJobId) { - setRequests([]); + if (!isLiveSource) { + setLiveRequests([]); return undefined; } let disposed = false; const unlisten = agentAPI.onPermissionRequestEvent((event: PermissionRequestEvent) => { if (disposed) return; - setRequests((current) => { + setLiveRequests((current) => { if (event.event === 'asked') { resolvedIds.current.delete(event.request.requestId); } else { @@ -49,12 +62,12 @@ export function usePermissionRequests(sessionId?: string, dispatchJobId?: string await agentAPI.subscribePermissionRequests(); const pending = await agentAPI.listPendingPermissionRequests(); if (!disposed) { - setRequests((current) => + setLiveRequests((current) => reconcilePermissionRequestSnapshot(current, pending, resolvedIds.current), ); } } catch { - if (!disposed) setRequests([]); + if (!disposed) setLiveRequests([]); } })(); @@ -62,47 +75,37 @@ export function usePermissionRequests(sessionId?: string, dispatchJobId?: string disposed = true; unlisten(); }; - }, [dispatchJobId]); + }, [isLiveSource]); const respond = useCallback( async (requestId: string, reply: PermissionReplyKind, feedback?: string) => { - if (dispatchJobId) { - await dispatchApi.answerPermission(dispatchJobId, requestId, reply, feedback); - requestDispatchJobRefresh(dispatchJobId); - return; + await driver.respondPermission(sessionId ?? '', requestId, reply, feedback); + if (isLiveSource) { + resolvedIds.current.add(requestId); + setLiveRequests((current) => current.filter((request) => request.requestId !== requestId)); } - await agentAPI.respondPermission(requestId, reply, feedback); - resolvedIds.current.add(requestId); - setRequests((current) => current.filter((request) => request.requestId !== requestId)); }, - [dispatchJobId], + [driver, sessionId, isLiveSource], ); const respondBatch = useCallback( async (requestId: string, reply: PermissionReplyKind, feedback?: string) => { - if (dispatchJobId) { - const batch = selectActivePermissionBatch(dispatchRequests as PermissionRequest[], sessionId); - const requestIds = batch?.requests.map(request => request.requestId) ?? [requestId]; - for (const pendingRequestId of requestIds) { - await dispatchApi.answerPermission( - dispatchJobId, - pendingRequestId, - reply, - feedback, - ); - } - requestDispatchJobRefresh(dispatchJobId); - return; + const resolvedRequestIds = await driver.respondPermissionBatch( + sessionId ?? '', + requestId, + reply, + feedback, + ); + if (isLiveSource) { + const resolved = new Set(resolvedRequestIds); + resolvedRequestIds.forEach((id) => resolvedIds.current.add(id)); + setLiveRequests((current) => current.filter((request) => !resolved.has(request.requestId))); } - const resolvedRequestIds = await agentAPI.respondPermissionBatch(requestId, reply, feedback); - const resolved = new Set(resolvedRequestIds); - resolvedRequestIds.forEach((id) => resolvedIds.current.add(id)); - setRequests((current) => current.filter((request) => !resolved.has(request.requestId))); }, - [dispatchJobId, dispatchRequests, sessionId], + [driver, sessionId, isLiveSource], ); - const effectiveRequests = dispatchJobId ? dispatchRequests : requests; + const effectiveRequests = isLiveSource ? liveRequests : externalRequests; const sessionRequests = useMemo( () => selectPermissionRequestsForSession(effectiveRequests, sessionId), [effectiveRequests, sessionId], 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 926e0dadcc..a03342752d 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 @@ -79,10 +79,10 @@ import { import { requestPeerSessionRefresh } from './PeerSessionRefreshModule'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { - optimisticDispatchTurnJobId, - stripOptimisticDispatchTurnMetadata, -} from '@/features/dispatch/optimisticDispatchTurn'; -import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; + optimisticTurnAdoptionKey, + sessionPendingTurnAdoptionKey, + stripOptimisticTurnAdoption, +} from '../../utils/optimisticTurnAdoption'; const log = createLogger('EventHandlerModule'); const TURN_COMPLETION_QUIET_WINDOW_MS = 500; @@ -1573,18 +1573,20 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { let dialogTurn = freshSession?.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); let projectedNewTurn = false; - if ( - !dialogTurn - && freshSession - && isNonLocalDispatchTarget(freshSession.config.dispatchTarget) - && freshSession.config.dispatchJobId - ) { - const optimisticTurn = freshSession.dialogTurns.find( - turn => optimisticDispatchTurnJobId(turn) === freshSession.config.dispatchJobId, - ); + if (!dialogTurn && freshSession) { + // Adoption is keyed purely by turn metadata: a driver that projected an + // optimistic turn marked it with the session's pending adoption key, and + // the executor's own DialogTurnStarted adopts it in place. No transport + // check — a session whose turns carry no key never matches. + const pendingAdoptionKey = sessionPendingTurnAdoptionKey(freshSession); + const optimisticTurn = pendingAdoptionKey + ? freshSession.dialogTurns.find( + turn => optimisticTurnAdoptionKey(turn) === pendingAdoptionKey, + ) + : undefined; if (optimisticTurn) { store.updateDialogTurn(sessionId, optimisticTurn.id, turn => { - const optimisticMetadata = stripOptimisticDispatchTurnMetadata( + const optimisticMetadata = stripOptimisticTurnAdoption( turn.userMessage.metadata, ); const mergedMetadata = 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 73dddbde89..50029f54f1 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 @@ -12,8 +12,14 @@ import { createLogger } from '@/shared/utils/logger'; import { globalEventBus } from '@/infrastructure/event-bus'; import { i18nService } from '@/infrastructure/i18n'; +import type { + PermissionReplyKind, + PermissionRequest, +} from '@/infrastructure/api/service-api/AgentAPI'; import type { FlowChatContext, SessionConfig } from '../../services/flow-chat-manager/types'; import type { DialogTurn, Session } from '../../types/flow-chat'; +import { FlowChatStore } from '../../store/FlowChatStore'; +import { selectActivePermissionBatch } from '../../components/modern/permissionRequestRouting'; import type { SessionCascadeRemoval, SessionCreationSeed, @@ -53,6 +59,22 @@ const log = createLogger('DispatchSessionDriver'); const IMAGES_UNSUPPORTED_MESSAGE = 'Image attachments are not supported for detached dispatch yet'; const APPEND_RETRY_SCOPE = 'dispatch-append'; const CONTINUE_RETRY_SCOPE = 'dispatch-continue'; +const EMPTY_PENDING_PERMISSIONS: ReadonlyArray> = []; + +/** + * The durable job observed by a projection session. The bound config id wins; + * the observer-store fallback covers the startup window before + * `ensureProjection` binds the config. + */ +function jobIdForSession(sessionId: string): string | undefined { + const session = FlowChatStore.getInstance().getState().sessions.get(sessionId); + const configured = session?.config.dispatchJobId?.trim(); + if (configured) { + return configured; + } + return Object.values(dispatchJobStore.getState().jobs) + .find(job => job.sessionId === sessionId)?.jobId; +} function pinTurnToTop(sessionId: string, turnId: string): void { globalEventBus.emit( @@ -393,6 +415,62 @@ export const dispatchSessionDriver: SessionDriver = { return response.cancelled; }, + permissionRequestSource(sessionId: string) { + return { + subscribe: (listener: () => void) => dispatchJobStore.subscribe(listener), + getSnapshot: () => { + const jobId = jobIdForSession(sessionId); + if (!jobId) { + return EMPTY_PENDING_PERMISSIONS; + } + return dispatchJobStore.getState().jobs[jobId]?.pendingPermissions + ?? EMPTY_PENDING_PERMISSIONS; + }, + }; + }, + + async respondPermission( + sessionId: string, + requestId: string, + reply: PermissionReplyKind, + feedback?: string, + ): Promise { + const jobId = jobIdForSession(sessionId); + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + await dispatchApi.answerPermission(jobId, requestId, reply, feedback); + requestDispatchJobRefresh(jobId); + }, + + async respondPermissionBatch( + sessionId: string, + requestId: string, + reply: PermissionReplyKind, + feedback?: string, + ): Promise { + const jobId = jobIdForSession(sessionId); + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + const pending = (dispatchJobStore.getState().jobs[jobId]?.pendingPermissions + ?? []) as unknown as PermissionRequest[]; + const batch = selectActivePermissionBatch(pending, sessionId); + const requestIds = batch?.requests.map(request => request.requestId) ?? [requestId]; + for (const pendingRequestId of requestIds) { + await dispatchApi.answerPermission( + jobId, + pendingRequestId, + reply, + feedback, + ); + } + requestDispatchJobRefresh(jobId); + // The observer store refreshes from the target; there is no local + // subscription state to reconcile. + return []; + }, + planSubmission( context: FlowChatContext, sessionId: string, 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 16934d20f5..f00fec2fa5 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 @@ -220,6 +220,28 @@ export const localSessionDriver: SessionDriver = { return { kind: 'queue' }; }, + permissionRequestSource(): 'live' { + return 'live'; + }, + + async respondPermission( + _sessionId: string, + requestId: string, + reply: 'once' | 'always' | 'reject', + feedback?: string, + ): Promise { + await agentAPI.respondPermission(requestId, reply, feedback); + }, + + async respondPermissionBatch( + _sessionId: string, + requestId: string, + reply: 'once' | 'always' | 'reject', + feedback?: string, + ): Promise { + return agentAPI.respondPermissionBatch(requestId, reply, feedback); + }, + async steer(): Promise { throw new Error('Local sessions queue messages while a turn is running'); }, 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 730279a5ee..53efff5d4c 100644 --- a/src/web-ui/src/flow_chat/session-drivers/types.ts +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -92,6 +92,29 @@ export interface TurnTracker { */ export type StartTurnResult = 'completed' | 'detached'; +/** + * Where a session's pending permission requests come from. + * + * `'live'` — the runtime pushes permission events over the transport; the + * shared hook owns the subscription and reconciliation. + * Otherwise — an external-store pair (`useSyncExternalStore`-shaped) over the + * driver's own state; used when requests arrive by polling a durable mailbox. + */ +export type PermissionRequestSource = + | 'live' + | { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => readonly PermissionRequestLike[]; + }; + +/** + * Structural stand-in for AgentAPI's PermissionRequest wire shape. `object` + * so both the typed interface and raw store records assign without casts. + */ +export type PermissionRequestLike = object; + +export type PermissionReplyKindLike = 'once' | 'always' | 'reject'; + /** * Everything flavor-independent that session creation resolved before the * driver takes over: workspace identity, agent type, and the initial title. @@ -182,4 +205,27 @@ export interface SessionDriver { input: StartTurnInput, tracker: TurnTracker, ): Promise; + + /** How pending permission requests for this session are obtained. */ + permissionRequestSource(sessionId: string): PermissionRequestSource; + + /** Answer one permission request over this session's transport. */ + respondPermission( + sessionId: string, + requestId: string, + reply: PermissionReplyKindLike, + feedback?: string, + ): Promise; + + /** + * Answer a request's whole batch. Returns the ids the transport reports as + * resolved so a live-subscription caller can reconcile its local state; + * external-store flavors return an empty list (their store refreshes). + */ + respondPermissionBatch( + sessionId: string, + requestId: string, + reply: PermissionReplyKindLike, + feedback?: string, + ): Promise; } diff --git a/src/web-ui/src/flow_chat/utils/optimisticTurnAdoption.ts b/src/web-ui/src/flow_chat/utils/optimisticTurnAdoption.ts new file mode 100644 index 0000000000..1e742e8ab6 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/optimisticTurnAdoption.ts @@ -0,0 +1,52 @@ +/** + * Optimistic-turn adoption. + * + * A driver that projects a user turn before the executing side confirms it + * marks that turn with an adoption key. When the executor's own + * DialogTurnStarted arrives with a different turn id, the event handler + * adopts the marked turn in place instead of duplicating the message. + * + * The metadata key is wire-compatible with the original dispatch-only + * implementation and must not change: cached transcripts persist it. + */ + +import type { DialogTurn, Session } from '../types/flow-chat'; + +export const OPTIMISTIC_TURN_ADOPTION_KEY = '__bitfunOptimisticDispatchJobId'; + +export function markOptimisticTurnAdoption( + metadata: Record | undefined, + adoptionKey: string, +): Record { + return { + ...metadata, + [OPTIMISTIC_TURN_ADOPTION_KEY]: adoptionKey, + }; +} + +export function optimisticTurnAdoptionKey(turn: DialogTurn): string | undefined { + const value = turn.userMessage.metadata?.[OPTIMISTIC_TURN_ADOPTION_KEY]; + return typeof value === 'string' && value ? value : undefined; +} + +export function stripOptimisticTurnAdoption( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) { + return undefined; + } + const next = { ...metadata }; + delete next[OPTIMISTIC_TURN_ADOPTION_KEY]; + return Object.keys(next).length > 0 ? next : undefined; +} + +/** + * The adoption key a not-yet-confirmed turn of this session would carry. + * Currently only dispatch projections mark turns (their durable job id); any + * future driver that sets an adoption key gets in-place adoption for free. + */ +export function sessionPendingTurnAdoptionKey( + session: Pick, +): string | undefined { + return session.config.dispatchJobId?.trim() || undefined; +} From 517cacc867e9db837a5e2838c7b3a0e729a1e372 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 21:30:26 -0700 Subject: [PATCH 05/12] refactor(web): drive ChatInput affordances from composer capabilities useComposerCapabilities replaces the scattered usesDispatchTransport / dispatchSubmissionInFlight branches with one derivation: slash-command availability, usage-report and thread-goal visibility, transfer-in-flight composer busy state, submission-options lock, session-scoped approval, baseline-locked worktree chip, and target-fed model selection are now semantic flags read at each site. Capability resolution uses the driver resolver, so an unbound projection no longer briefly offers local-only affordances during the startup race. --- .../src/flow_chat/components/ChatInput.tsx | 83 +++++++----------- .../useComposerCapabilities.ts | 85 +++++++++++++++++++ 2 files changed, 118 insertions(+), 50 deletions(-) create mode 100644 src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index cdb34a8fc1..c60c2e69d7 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -134,7 +134,7 @@ import { import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; -import { useRuntimeStatusStore } from '../store/runtimeStatusStore'; +import { useComposerCapabilities } from '../session-drivers/useComposerCapabilities'; import { ComposerVoiceInputButton } from './voice/ComposerVoiceInputButton'; import { useComposerVoiceInput } from './voice/useComposerVoiceInput'; import { expandWidgetPromptReferenceTokens } from '@/tools/generative-widget/widgetPromptReference'; @@ -509,25 +509,20 @@ export const ChatInput: React.FC = ({ const jobId = effectiveTargetSession?.config.dispatchJobId; return jobId ? state.jobs[jobId] : undefined; }); - const isDispatchInputSession = isNonLocalDispatchTarget( - effectiveTargetSession?.config.dispatchTarget, - ); - const usesDispatchTransport = !registration && isDispatchInputSession; - const dispatchSubmissionInFlight = useRuntimeStatusStore(state => { - const status = effectiveTargetSessionId - ? state.bySessionId.get(effectiveTargetSessionId) - : undefined; - return usesDispatchTransport - && status?.roundId.startsWith('dispatch-transfer:') === true; + const effectiveTargetRelationship = resolveSessionRelationship(effectiveTargetSession); + const isBtwSession = effectiveTargetRelationship.displayAsChild; + const isSubagentInputTarget = effectiveTargetRelationship.isSubagent; + const caps = useComposerCapabilities({ + sessionId: effectiveTargetSessionId, + session: effectiveTargetSession, + hostMasksDispatch: !!registration, + displayAsChild: isBtwSession, }); const historySessionOpenTransition = useSyncExternalStore( subscribeHistorySessionOpenTransition, getHistorySessionOpenTransitionSnapshot, getHistorySessionOpenTransitionSnapshot, ); - const effectiveTargetRelationship = resolveSessionRelationship(effectiveTargetSession); - const isBtwSession = effectiveTargetRelationship.displayAsChild; - const isSubagentInputTarget = effectiveTargetRelationship.isSubagent; const acpSessionForInput = useMemo( () => acpSessionRef(effectiveTargetSession), [effectiveTargetSession], @@ -537,7 +532,7 @@ export const ChatInput: React.FC = ({ const reloadContextSupported = supportsLocalReloadContext({ desktopRuntime: isTauriRuntime(), acpSession: isAcpInputSession, - dispatchTransport: usesDispatchTransport, + dispatchTransport: caps.dispatchTransport, }); const canReloadContext = reloadContextSupported && Boolean(effectiveTargetSessionId); const { entries: acpPlanEntries } = useAcpPlan(acpSessionForInput?.sessionId ?? null); @@ -2015,12 +2010,7 @@ export const ChatInput: React.FC = ({ : effectiveTargetSession?.config.dispatchApprovalPolicy === 'reject-and-report' ? 'reject' : 'ask'; - const dispatchSubmissionOptionsLocked = - dispatchSubmissionInFlight - || ( - effectiveTargetSession?.config.dispatchJobState !== 'submitting' - && effectiveTargetSession?.config.dispatchJobState !== 'submission_unknown' - ); + const dispatchSubmissionOptionsLocked = caps.submissionOptionsLocked; const handleDispatchPermissionModeChange = useCallback(( nextMode: Exclude, ) => { @@ -2061,7 +2051,7 @@ export const ChatInput: React.FC = ({ // A dispatch always executes against a managed worktree baseline of this // repository, so the chip reports that state instead of disappearing. It is // never togglable: the baseline is chosen with the target, not after. - if (usesDispatchTransport) { + if (caps.worktreeBaselineLocked) { return { enabled: true, locked: true, @@ -2104,7 +2094,7 @@ export const ChatInput: React.FC = ({ isSubagentInputTarget, remoteWorkspaceSession, tWorktrees, - usesDispatchTransport, + caps.worktreeBaselineLocked, ]); const handleSelectDispatchTarget = useCallback(async (selection: DispatchSelection) => { @@ -2189,7 +2179,7 @@ export const ChatInput: React.FC = ({ ]); const dispatchModelSelection = useMemo(() => { - if (!usesDispatchTransport || !effectiveTargetSession) { + if (!caps.targetModelSelection || !effectiveTargetSession) { return undefined; } const target = effectiveTargetSession.config.dispatchTarget; @@ -2199,18 +2189,12 @@ export const ChatInput: React.FC = ({ : t('chatInput.dispatch.remoteTarget'); const sessionId = effectiveTargetSession.sessionId; const jobId = effectiveTargetSession.config.dispatchJobId; - const state = effectiveTargetSession.config.dispatchJobState; return { models: effectiveTargetSession.config.dispatchAvailableModels ?? [], selectedModelId: effectiveTargetSession.config.dispatchModel, defaultModelId: effectiveTargetSession.config.dispatchDefaultModel, providerLabel, - disabled: - dispatchSubmissionInFlight - || ( - state !== 'submitting' - && state !== 'submission_unknown' - ), + disabled: caps.submissionOptionsLocked, onSelect: (modelId: string) => { FlowChatStore.getInstance().updateSessionDispatchModel(sessionId, modelId); if (jobId) { @@ -2218,7 +2202,7 @@ export const ChatInput: React.FC = ({ } }, }; - }, [dispatchSubmissionInFlight, effectiveTargetSession, t, usesDispatchTransport]); + }, [caps.submissionOptionsLocked, caps.targetModelSelection, effectiveTargetSession, t]); const handleHidePermissionModeControl = useCallback(async () => { try { @@ -2928,7 +2912,7 @@ export const ChatInput: React.FC = ({ } const promptSlashCommandsEnabled = !isAcpInputSession; - const localSlashCommandsEnabled = promptSlashCommandsEnabled && !usesDispatchTransport; + const localSlashCommandsEnabled = promptSlashCommandsEnabled && caps.localSlashCommands; const trimmed = text.trim(); const isBtwCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/btw'); const isCompactCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/compact'); @@ -3006,7 +2990,7 @@ export const ChatInput: React.FC = ({ selectedIndex: 0, }); } - }, [contexts, derivedState, dispatchInput, externalPromptCommands, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, selectedExternalPromptCandidateId, selectedNonExternalSlashCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind, usesDispatchTransport]); + }, [contexts, derivedState, dispatchInput, externalPromptCommands, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, selectedExternalPromptCandidateId, selectedNonExternalSlashCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind, caps.localSlashCommands]); const submitBtwFromInput = useCallback(async () => { if (!derivedState) return; @@ -3930,7 +3914,7 @@ export const ChatInput: React.FC = ({ const handleSendOrCancel = useCallback(async (messageOverride?: string) => { if (!derivedState) return; - if (dispatchSubmissionInFlight) return; + if (caps.transferInFlight) return; const { sendButtonMode } = derivedState; const draftTrimmed = (messageOverride ?? inputState.value).trim(); @@ -3976,7 +3960,7 @@ export const ChatInput: React.FC = ({ messageOverride === undefined; const localSlashCommandsEnabled = promptSlashCommandsEnabled && - !usesDispatchTransport; + caps.localSlashCommands; const parsedReload = messageOverride === undefined ? parseReloadCommand(message) : null; @@ -4140,7 +4124,7 @@ export const ChatInput: React.FC = ({ } }, [ isModelSwitching, - dispatchSubmissionInFlight, + caps.transferInFlight, inputState.value, derivedState, dispatchInput, @@ -4173,7 +4157,7 @@ export const ChatInput: React.FC = ({ t, resolveTypedMcpPromptCommand, submitExternalPromptCommandFromInput, - usesDispatchTransport, + caps.localSlashCommands, composerMutationRevision, ]); @@ -5012,7 +4996,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={isModelSwitching || dispatchSubmissionInFlight} + disabled={isModelSwitching || caps.transferInFlight} tooltip={t('input.retry')} size="small" > @@ -5038,7 +5022,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching || dispatchSubmissionInFlight} + disabled={!inputState.value.trim() || isModelSwitching || caps.transferInFlight} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -5053,7 +5037,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching || dispatchSubmissionInFlight} + disabled={!inputState.value.trim() || isModelSwitching || caps.transferInFlight} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -5090,9 +5074,9 @@ export const ChatInput: React.FC = ({ >
{recommendationContext && ( = ({ onCompositionStart={handleImeCompositionStart} onCompositionEnd={handleImeCompositionEnd} placeholder="" - disabled={dispatchSubmissionInFlight} + disabled={caps.transferInFlight} contexts={contexts} onRemoveContext={removeContext} onMentionStateChange={setMentionState} @@ -5767,7 +5751,7 @@ export const ChatInput: React.FC = ({
) : null} - {!dispatchSubmissionInFlight ? ( + {!caps.transferInFlight ? ( ) : null} {voiceInput.phase === 'idle' ? renderActionButton() : null} @@ -5784,7 +5768,7 @@ export const ChatInput: React.FC = ({ worktreeControl={worktreeControl} deferPassiveGitRefresh={deferChatStripPassiveGitRefresh} permissionControl={showPermissionModeControl - ? usesDispatchTransport + ? caps.sessionScopedApproval ? { mode: dispatchPermissionMode, disabled: dispatchSubmissionOptionsLocked, @@ -5801,15 +5785,14 @@ export const ChatInput: React.FC = ({ } : undefined} usageReport={ - effectiveTargetSessionId && effectiveTargetSession && !usesDispatchTransport + effectiveTargetSessionId && effectiveTargetSession && caps.usageReport ? { visible: true, onOpen: handleToolbarUsageReport } : undefined } threadGoal={ effectiveTargetSessionId && effectiveTargetSession && - !isBtwSession && - !usesDispatchTransport + caps.threadGoal ? { visible: true, goal: threadGoalController.goal, @@ -5820,7 +5803,7 @@ export const ChatInput: React.FC = ({ : undefined } /> - {effectiveTargetSession && !isBtwSession && !usesDispatchTransport ? ( + {effectiveTargetSession && caps.threadGoal ? ( { + const status = sessionId ? state.bySessionId.get(sessionId) : undefined; + return dispatchTransport + && status?.roundId.startsWith(DISPATCH_TRANSFER_ROUND_PREFIX) === true; + }); + + const dispatchJobState = session?.config.dispatchJobState; + const submissionOptionsLocked = + dispatchTransport + && ( + transferInFlight + || ( + dispatchJobState !== 'submitting' + && dispatchJobState !== 'submission_unknown' + ) + ); + + return { + driverId, + dispatchTransport, + localSlashCommands: !dispatchTransport, + usageReport: !dispatchTransport, + threadGoal: !displayAsChild && !dispatchTransport, + transferInFlight, + submissionOptionsLocked, + sessionScopedApproval: dispatchTransport, + worktreeBaselineLocked: dispatchTransport, + targetModelSelection: dispatchTransport, + }; +} From a9b0398b830df8ecc6bfef25ca4e8a0d51914eab Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 21:33:07 -0700 Subject: [PATCH 06/12] refactor(web): fence flow_chat from dispatch internals behind session-drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SessionModule's three remaining transport checks (activity touch, fork refusal, ensure-backend guard) resolve through the driver registry. - DispatchJobObserver installation routes through a session-drivers/dispatch shim so FlowChatManager no longer imports the feature module. - eslint no-restricted-imports fence: flow_chat/** may not import @/features/dispatch/* outside session-drivers, with four documented exceptions (type-only config fields, FlowChatStore's snapshot mutations, and the two components rendering the dispatch target-picker feature chip — all queued for the config.dispatch collapse follow-up). Remaining dispatch references in flow_chat outside the fence exceptions: 0. Decision points inside the exceptions: ChatInput's target-picker chip memo and FlowChatStore's cursor-guarded mutations, both feature machinery rather than transport branches. --- src/web-ui/eslint.config.mjs | 37 +++++++++++++++++++ .../src/flow_chat/services/FlowChatManager.ts | 2 +- .../flow-chat-manager/SessionModule.ts | 7 ++-- .../session-drivers/dispatch/install.ts | 7 ++++ 4 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 src/web-ui/src/flow_chat/session-drivers/dispatch/install.ts diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 265f08c63e..426751c7cf 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -99,4 +99,41 @@ export default tseslint.config( parser: tseslint.parser, }, }, + { + // SessionDriver seam fence: flow_chat must reach dispatch machinery only + // through session-drivers. This is the executable form of "no dispatch + // branches in flow_chat" — reintroducing one fails the build. + // + // Exceptions, each with an owner: + // - session-drivers/**: the dispatch driver's own implementation. + // - types/flow-chat.ts: type-only SessionConfig fields (collapses into a + // single `config.dispatch` member in a follow-up). + // - store/FlowChatStore.ts: dispatch snapshot/cursor mutations kept + // in-place by design during the seam refactor (same follow-up). + // - ChatInput.tsx / ChatInputWorkspaceStrip.tsx: render the dispatch + // target-picker feature chip — feature UI, not transport branching. + files: ['src/flow_chat/**/*.{ts,tsx}'], + ignores: [ + 'src/flow_chat/session-drivers/**', + 'src/flow_chat/types/flow-chat.ts', + 'src/flow_chat/store/FlowChatStore.ts', + 'src/flow_chat/components/ChatInput.tsx', + 'src/flow_chat/components/ChatInputWorkspaceStrip.tsx', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/features/dispatch/*'], + message: + 'flow_chat reaches dispatch only through session-drivers. ' + + 'Add the behavior to the SessionDriver interface instead.', + }, + ], + }, + ], + }, + }, ); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 662e77a9fa..e61b2a11e7 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -58,7 +58,7 @@ import { } from './flow-chat-manager'; import { ensureBackendSession } from './flow-chat-manager/SessionModule'; import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefreshModule'; -import { installDispatchJobObserver } from '@/features/dispatch/DispatchJobObserver'; +import { installDispatchJobObserver } from '../session-drivers/dispatch/install'; const log = createLogger('FlowChatManager'); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index d75d263b21..213dbce8fa 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -48,7 +48,6 @@ import { requireSessionProjectWorkspacePath, sessionProjectWorkspacePath, } from '../../utils/sessionWorkspace'; -import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; import { driverForCreation, driverForSession } from '../../session-drivers/registry'; const log = createLogger('SessionModule'); @@ -656,7 +655,7 @@ export async function switchChatSession( }); const touchActiveSessionInBackground = () => { - if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { + if (driverForSession(sessionId, session).id === 'dispatch') { return; } scheduleSessionActivityTouch(() => { @@ -867,7 +866,7 @@ export async function forkChatSession( if (!sourceSession) { throw new Error(`Session does not exist: ${sourceSessionId}`); } - if (isNonLocalDispatchTarget(sourceSession.config.dispatchTarget)) { + if (driverForSession(sourceSessionId, sourceSession).id === 'dispatch') { throw new Error('Forking a detached dispatch session is not supported'); } @@ -940,7 +939,7 @@ export async function ensureBackendSession( if (session.isTransient) { return; } - if (isNonLocalDispatchTarget(session.config.dispatchTarget)) { + if (driverForSession(sessionId, session).id === 'dispatch') { return; } diff --git a/src/web-ui/src/flow_chat/session-drivers/dispatch/install.ts b/src/web-ui/src/flow_chat/session-drivers/dispatch/install.ts new file mode 100644 index 0000000000..7a89269dd1 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/dispatch/install.ts @@ -0,0 +1,7 @@ +/** + * Boot-time installation of the dispatch driver's background machinery. + * FlowChatManager installs this once; the observer itself lives in + * features/dispatch and stays private to the dispatch driver. + */ + +export { installDispatchJobObserver } from '@/features/dispatch/DispatchJobObserver'; From a3caa712351b677c174f6db2aa35e3e7000a185a Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 21:50:08 -0700 Subject: [PATCH 07/12] feat(dispatch): protocol v4 per-turn model and approval-policy overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dispatch session's model and approval policy were fixed at submit because every worker read job.request. Follow-up turns can now carry overrides: - DispatchContinueRequest gains optional model/approvalPolicy; a retried turnId bound to different options is refused (same idempotency contract as the prompt). - The worker peeks the queued turn before runtime bootstrap (the approval policy is baked into initialize_core_services), persists the effective options back onto job.json under the job lock — list/status and any replacement worker observe what the turn actually ran with — and emits approvalPolicySelected/modelSelected audit events on change. Restored sessions get the turn's model via update_session_model. - Submit idempotency is unaffected: the stored intent fingerprint is compared, never recomputed from the mutated request (pinned by test). - Controller mirrors the options into the outbound observer index so reconciliation cannot revert the UI to pre-override values. - Protocol version 3 -> 4 with a new required per_turn_options capability; stale SSH targets fail probe and go through the existing signed auto-install; device targets get the version-mismatch error. --- src/apps/cli/src/dispatch/mod.rs | 10 ++ src/apps/cli/src/dispatch/protocol.rs | 17 ++- src/apps/cli/src/dispatch/store.rs | 133 +++++++++++++++++- src/apps/cli/src/dispatch/worker.rs | 55 +++++++- .../core/src/service/dispatch/controller.rs | 50 ++++++- .../src/service/dispatch/device_controller.rs | 2 +- .../assembly/core/src/service/dispatch/mod.rs | 32 +++++ .../src/remote_ssh/dispatch_ssh.rs | 6 +- .../dispatch/DispatchInstallDialog.test.tsx | 11 +- .../dispatch/dispatch.contract.test.ts | 5 +- .../dispatch/dispatchPreflight.test.ts | 4 +- .../features/dispatch/dispatchPreflight.ts | 3 +- 12 files changed, 302 insertions(+), 26 deletions(-) diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index edbc69121d..6d8c1ad9f9 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -141,6 +141,8 @@ async fn probe(request: DispatchProbeRequest) -> Result { // accept a job but every detached worker then fails before execution. // Advertise the behavioral fix explicitly so controllers fail closed. "dispatch_worker_cli_profile".to_string(), + // v4: follow-up turns may override model and approval policy. + "per_turn_options".to_string(), ]; if runner::is_supported() { capabilities.push("detached_worker".to_string()); @@ -231,6 +233,14 @@ fn continue_job(request: DispatchContinueRequest) -> Result MAX_DISPATCH_TEXT_BYTES { bail!("dispatch follow-up prompt exceeds the 32 KiB safety limit"); } + if let Some(model) = &request.model { + if model.trim().is_empty() { + bail!("dispatch model override cannot be empty"); + } + if model.len() > 256 { + bail!("dispatch model override exceeds the 256 byte limit"); + } + } if !runner::is_supported() { bail!("dispatch detached workers are supported only on Linux and macOS"); } diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index 0ef4faaa1f..6cbc7c95ed 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; -pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 3; +pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 4; pub(crate) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] @@ -338,6 +338,13 @@ pub(crate) struct DispatchContinueRequest { pub(crate) prompt: String, #[serde(default)] pub(crate) display_content: Option, + /// Per-turn model override. Absent keeps the job's current model; present + /// it also becomes the job's model for later turns. + #[serde(default)] + pub(crate) model: Option, + /// Per-turn approval-policy override with the same carry-forward rule. + #[serde(default)] + pub(crate) approval_policy: Option, } #[derive(Clone, Debug, Serialize, PartialEq, Eq)] @@ -406,6 +413,14 @@ impl DispatchEvent { } } + pub(crate) fn model_selected(model: Option<&str>) -> Self { + Self::Audit { + timestamp: chrono::Utc::now().to_rfc3339(), + action: "modelSelected".to_string(), + details: serde_json::json!({ "model": model }), + } + } + pub(crate) fn cancel_requested() -> Self { Self::Audit { timestamp: chrono::Utc::now().to_rfc3339(), diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs index 0c64ac392a..17bc516c44 100644 --- a/src/apps/cli/src/dispatch/store.rs +++ b/src/apps/cli/src/dispatch/store.rs @@ -7,8 +7,8 @@ use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; use serde::{Deserialize, Serialize}; use super::protocol::{ - DispatchAppendRequest, DispatchContinueRequest, DispatchEvent, DispatchJobListEntry, - DispatchJobState, DispatchSubmitRequest, DISPATCH_PROTOCOL_VERSION, + DispatchAppendRequest, DispatchApprovalPolicy, DispatchContinueRequest, DispatchEvent, + DispatchJobListEntry, DispatchJobState, DispatchSubmitRequest, DISPATCH_PROTOCOL_VERSION, }; const JOB_RECORD_FILE: &str = "job.json"; @@ -161,9 +161,25 @@ pub(crate) struct StoredFollowUpTurn { pub(crate) prompt: String, #[serde(default)] pub(crate) display_content: Option, + /// Per-turn overrides. The worker applies them to the job record when it + /// claims the turn, so they carry forward to later turns and restarts. + #[serde(default)] + pub(crate) model: Option, + #[serde(default)] + pub(crate) approval_policy: Option, pub(crate) created_at: String, } +impl StoredFollowUpTurn { + /// A retried turnId must carry the same submission, options included. + fn same_submission(&self, other: &Self) -> bool { + self.prompt == other.prompt + && self.display_content == other.display_content + && self.model == other.model + && self.approval_policy == other.approval_policy + } +} + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RepoCacheRetentionRecord { @@ -712,20 +728,22 @@ impl DispatchStore { turn_id: request.turn_id.clone(), prompt: request.prompt.clone(), display_content: request.display_content.clone(), + model: request.model.clone(), + approval_policy: request.approval_policy, created_at: chrono::Utc::now().to_rfc3339(), }; // A retried request must not start a second turn. Both mailboxes are // checked because the worker may already have claimed this one. let consumed_path = mailbox_path(&job_dir, CONSUMED_TURNS_DIR, &request.turn_id)?; if let Some(existing) = read_optional_regular_json::(&consumed_path)? { - if existing.prompt != request.prompt { + if !existing.same_submission(&stored) { bail!("dispatch turnId is already bound to different content"); } return self.load_state_unlocked(&job_dir); } let pending_path = mailbox_path(&job_dir, PENDING_TURNS_DIR, &request.turn_id)?; if let Some(existing) = read_optional_regular_json::(&pending_path)? { - if existing.prompt != request.prompt { + if !existing.same_submission(&stored) { bail!("dispatch turnId is already bound to different content"); } return self.load_state_unlocked(&job_dir); @@ -752,6 +770,50 @@ impl DispatchStore { Ok(state) } + /// Read the next queued turn without consuming it. + /// + /// The worker peeks before initializing the runtime because the approval + /// policy is baked into runtime bootstrap; the later claim takes the same + /// earliest turn (identical ordering), and nothing can enqueue in between + /// — `queue_follow_up_turn` requires a terminal state and the job is + /// already Queued/Running by then. + pub(crate) fn peek_follow_up_turn(&self, job_id: &str) -> Result> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let mut pending = + read_json_directory::(&job_dir.join(PENDING_TURNS_DIR))?; + pending.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.turn_id.cmp(&right.turn_id)) + }); + Ok(pending.into_iter().next()) + } + + /// Persist the effective per-turn options onto the job record so `list`, + /// `status`, and any replacement worker observe the same choices the turn + /// runs with. Returns (model_changed, approval_policy_changed). + pub(crate) fn update_job_request_options( + &self, + job_id: &str, + model: Option<&str>, + approval_policy: DispatchApprovalPolicy, + ) -> Result<(bool, bool)> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let mut job = self.load_job(job_id)?; + let model = model.map(str::to_string); + let model_changed = job.request.model != model; + let policy_changed = job.request.approval_policy != approval_policy; + if !model_changed && !policy_changed { + return Ok((false, false)); + } + job.request.model = model; + job.request.approval_policy = approval_policy; + atomic_write_json(&job_dir.join(JOB_RECORD_FILE), &job)?; + Ok((model_changed, policy_changed)) + } + /// Take the next queued turn and bind it to the runtime turn the worker is /// about to submit. /// @@ -2161,6 +2223,8 @@ mod tests { turn_id: turn_id.to_string(), prompt: prompt.to_string(), display_content: None, + model: None, + approval_policy: None, } } @@ -2202,6 +2266,67 @@ mod tests { .is_none()); } + #[test] + fn per_turn_options_are_peeked_applied_and_kept_idempotent() { + let (_dir, store) = store(); + store + .create_job(request("job-1"), "job title".to_string()) + .expect("create job"); + store + .mark_state("job-1", DispatchJobState::Succeeded, Some("turn-1"), None) + .expect("succeeded"); + + let mut follow_up = continue_request("job-1", "turn-2", "with new options"); + follow_up.model = Some("model-2".to_string()); + follow_up.approval_policy = Some(DispatchApprovalPolicy::Remote); + store + .queue_follow_up_turn(&follow_up) + .expect("queue follow-up"); + + // The worker reads the overrides before runtime bootstrap. + let peeked = store + .peek_follow_up_turn("job-1") + .expect("peek") + .expect("a queued turn"); + assert_eq!(peeked.model.as_deref(), Some("model-2")); + assert_eq!(peeked.approval_policy, Some(DispatchApprovalPolicy::Remote)); + + // A retried turnId bound to different options must be refused. + let mut conflicting = follow_up.clone(); + conflicting.model = Some("model-3".to_string()); + assert!(store.queue_follow_up_turn(&conflicting).is_err()); + + // Applying the effective options rewrites the job record... + let (model_changed, policy_changed) = store + .update_job_request_options("job-1", Some("model-2"), DispatchApprovalPolicy::Remote) + .expect("apply options"); + assert!(model_changed); + assert!(policy_changed); + let job = store.load_job("job-1").expect("job"); + assert_eq!(job.request.model.as_deref(), Some("model-2")); + assert_eq!(job.request.approval_policy, DispatchApprovalPolicy::Remote); + + // ...without breaking submit idempotency: the ORIGINAL submit retry + // still matches its stored fingerprint after the rewrite. + let existing = store + .load_existing_job_for_intent(&request("job-1")) + .expect("intent lookup") + .expect("existing job"); + assert_eq!(existing.0.request.model.as_deref(), Some("model-2")); + + // Re-applying identical options reports no change. + assert_eq!( + store + .update_job_request_options( + "job-1", + Some("model-2"), + DispatchApprovalPolicy::Remote + ) + .expect("idempotent apply"), + (false, false) + ); + } + #[test] fn a_retried_follow_up_request_never_starts_a_second_turn() { let (_dir, store) = store(); diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index 8a03e3ec34..efcc866b99 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -9,7 +9,10 @@ use bitfun_agent_runtime::sdk::{ PermissionReplySource, PermissionRequest, PermissionRequestEvent, }; use bitfun_events::{project_agentic_frontend_event, AgenticEvent}; -use bitfun_runtime_ports::{AgentSubmissionSource, DialogSubmissionPolicy, SessionExecutionTarget}; +use bitfun_runtime_ports::{ + AgentSessionModelUpdateRequest, AgentSubmissionSource, DialogSubmissionPolicy, + SessionExecutionTarget, +}; use crate::{shutdown_mcp_servers, BootstrapProfile}; @@ -77,7 +80,20 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { workspace.display() ); } - super::ensure_selected_model_ready(job.request.model.as_deref()).await?; + // Per-turn overrides are read before runtime bootstrap because the + // approval policy is baked into initialize_core_services. Nothing can + // enqueue another turn between this peek and the claim below: queueing + // requires a terminal state and the job is already non-terminal here. + let pending_turn = store.peek_follow_up_turn(job_id)?; + let effective_model = pending_turn + .as_ref() + .and_then(|turn| turn.model.clone()) + .or_else(|| job.request.model.clone()); + let effective_policy = pending_turn + .as_ref() + .and_then(|turn| turn.approval_policy) + .unwrap_or(job.request.approval_policy); + super::ensure_selected_model_ready(effective_model.as_deref()).await?; // Every detached worker takes the same stable lock for a canonical target // workspace. Waiting workers remain Queued and are visible/cancellable. @@ -98,9 +114,23 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { } store.mark_state(job_id, DispatchJobState::Running, None, None)?; + // Persist the effective options before execution so `list`/`status` and + // any replacement worker observe the same choices this turn runs with. + let (model_changed, policy_changed) = + store.update_job_request_options(job_id, effective_model.as_deref(), effective_policy)?; + if policy_changed { + store.append_event( + job_id, + &DispatchEvent::approval_policy_selected(effective_policy), + )?; + } + if model_changed { + store.append_event(job_id, &DispatchEvent::model_selected(effective_model.as_deref()))?; + } + let runtime = crate::initialize_core_services( workspace, - permissions::cli_policy(job.request.approval_policy), + permissions::cli_policy(effective_policy), BootstrapProfile::Execution, ) .await?; @@ -145,7 +175,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { workspace_id: None, remote_connection_id: None, remote_ssh_host: None, - model_id: job.request.model.clone(), + model_id: effective_model.clone(), metadata: serde_json::Map::new(), }, ) @@ -161,6 +191,17 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { } None => "create target-owned dispatch session".to_string(), })?; + } else if let Some(model) = effective_model.clone() { + // A restored session keeps the model of its previous turn; apply this + // turn's effective choice before submitting. + agent_runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id: job.request.session_id.clone(), + model_id: model, + }) + .await + .map_err(|error| anyhow!(error.into_message())) + .context("apply dispatch turn model to restored session")?; } let turn_id = uuid::Uuid::new_v4().to_string(); @@ -187,7 +228,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { reply_route: None, prepended_reminders: Vec::new(), attachments: Vec::new(), - metadata: permissions::metadata(job.request.approval_policy), + metadata: permissions::metadata(effective_policy), }) .await .map_err(|error| anyhow!(error.into_message())) @@ -214,7 +255,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { &job.request.session_id, &turn_id, request, - job.request.approval_policy, + effective_policy, ) .await? { @@ -291,7 +332,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { &job.request.session_id, &turn_id, request, - job.request.approval_policy, + effective_policy, ) .await? { diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index 75f8544614..bbc038e3c3 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -19,7 +19,7 @@ use super::{ OutboundDispatchStore, }; -pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = 3; +pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = 4; pub(super) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -135,6 +135,12 @@ pub struct DispatchContinueRequest { pub prompt: String, #[serde(default)] pub display_content: Option, + /// Per-turn model override; carries forward as the job's model. + #[serde(default)] + pub model: Option, + /// Per-turn approval-policy override with the same carry-forward rule. + #[serde(default)] + pub approval_policy: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -953,7 +959,7 @@ pub async fn continue_job( }; let response = dispatch_ssh::continue_job(manager, connection_id, &continue_payload(&request)).await?; - record_follow_up_state(store, &record, &response).await; + record_follow_up_state(store, &record, &request, &response).await; Ok(response) } @@ -973,6 +979,22 @@ pub(super) fn continue_payload(request: &DispatchContinueRequest) -> Value { { payload["displayContent"] = Value::String(display.to_string()); } + if let Some(model) = request + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + payload["model"] = Value::String(model.to_string()); + } + if let Some(policy) = request + .approval_policy + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + payload["approvalPolicy"] = Value::String(policy.to_string()); + } payload } @@ -984,8 +1006,22 @@ pub(super) fn continue_payload(request: &DispatchContinueRequest) -> Value { pub(super) async fn record_follow_up_state( store: &OutboundDispatchStore, record: &OutboundDispatchRecord, + request: &DispatchContinueRequest, response: &Value, ) { + if let Err(error) = store + .update_submission_options( + &record.job_id, + request.model.as_deref(), + request.approval_policy.as_deref(), + ) + .await + { + log::warn!( + "Failed to record dispatch follow-up options: job_id={} error={error}", + record.job_id + ); + } let Some(state) = response.get("state").and_then(Value::as_str) else { return; }; @@ -1094,6 +1130,16 @@ pub(super) fn validate_continue_request(request: &DispatchContinueRequest) -> an if total_bytes > MAX_DISPATCH_TEXT_BYTES { anyhow::bail!("Dispatch follow-up exceeds the 32 KiB request limit"); } + if let Some(model) = &request.model { + if model.trim().is_empty() || model.len() > 256 { + anyhow::bail!("Dispatch model override must contain 1-256 bytes"); + } + } + if let Some(policy) = &request.approval_policy { + if !matches!(policy.as_str(), "auto" | "reject-and-report" | "remote") { + anyhow::bail!("Dispatch approval policy override is not recognized"); + } + } Ok(()) } diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs index 68e602d958..608baae35f 100644 --- a/src/crates/assembly/core/src/service/dispatch/device_controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -460,7 +460,7 @@ pub async fn continue_device_job( continue_payload(&request), ) .await?; - record_follow_up_state(store, &record, &response).await; + record_follow_up_state(store, &record, &request, &response).await; Ok(response) } diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index a21cd4d121..238ae48217 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -347,6 +347,38 @@ impl OutboundDispatchStore { Ok(record) } + /// Reflect per-turn option overrides in the observer index so a later + /// reconciliation cannot revert the UI to the pre-override values. + pub async fn update_submission_options( + &self, + job_id: &str, + model: Option<&str>, + approval_policy: Option<&str>, + ) -> Result<(), DispatchStoreError> { + if model.is_none() && approval_policy.is_none() { + return Ok(()); + } + let path = self.record_path(job_id)?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + let Some(mut record) = self + .json_store + .read_optional::(&path) + .await? + else { + return Ok(()); + }; + if let Some(model) = model { + record.model = Some(model.to_string()).filter(|value| !value.trim().is_empty()); + } + if let Some(policy) = approval_policy { + record.approval_policy = Some(policy.to_string()); + } + record.updated_at = Utc::now(); + self.json_store.write_atomic_strict(&path, &record).await?; + harden_file_permissions(&path).await?; + Ok(()) + } + pub async fn list(&self) -> Result, DispatchStoreError> { #[cfg(feature = "ssh-remote")] if let Err(error) = self.reconcile_expired_preparations().await { diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index ac392143b6..2a96b41425 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -84,7 +84,7 @@ const GLIBC_FLOOR: &str = "2.35"; /// Same figure the relay source build uses. const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; -const DISPATCH_PROTOCOL_VERSION: u64 = 3; +const DISPATCH_PROTOCOL_VERSION: u64 = 4; const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profile"; /// First stable release whose CLI is known to contain every capability below. /// @@ -93,7 +93,7 @@ const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profil /// previous release, so comparing only the installed and controller version /// strings is not a sound compatibility test. const FIRST_COMPATIBLE_STABLE_DISPATCH_RELEASE: (u64, u64, u64) = (0, 2, 16); -const REQUIRED_DISPATCH_CAPABILITIES: [&str; 14] = [ +const REQUIRED_DISPATCH_CAPABILITIES: [&str; 15] = [ "persistent_jobs", "cursor_events", "detached_worker", @@ -110,6 +110,8 @@ const REQUIRED_DISPATCH_CAPABILITIES: [&str; 14] = [ "workspace_git_bundle_upload", "workspace_git_sync", DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, + // v4: follow-up turns may override model and approval policy. + "per_turn_options", ]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 997b815e23..bf631aed50 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -411,7 +411,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { expect(container.textContent).not.toContain('dispatch.snapshotResultLocationHint'); }); - it('preserves protocol v3 target model facts without a delivery-mode choice', async () => { + it('preserves protocol v4 target model facts without a delivery-mode choice', async () => { const onReady = vi.fn(); mocks.probeTarget.mockResolvedValue({ cliInstalled: true, @@ -419,7 +419,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { arch: 'x86_64', installSupported: false, protocol: { - protocolVersion: 3, + protocolVersion: 4, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', @@ -434,6 +434,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { 'workspace_git_bundle_upload', 'workspace_git_sync', 'approval_remote', + 'per_turn_options', ], modelConfigured: true, availableModels: ['model-a', 'model-b'], @@ -577,7 +578,7 @@ describe('DispatchInstallDialog model configuration sync', () => { arch: 'x86_64', installSupported: true, protocol: { - protocolVersion: 3, + protocolVersion: 4, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', @@ -591,6 +592,7 @@ describe('DispatchInstallDialog model configuration sync', () => { 'workspace_git_bundle_upload', 'workspace_git_sync', 'dispatch_worker_cli_profile', + 'per_turn_options', ], modelConfigured, availableModels: modelConfigured ? ['claude'] : [], @@ -735,7 +737,7 @@ describe('DispatchInstallDialog target model readout', () => { arch: 'x86_64', installSupported: true, protocol: { - protocolVersion: 3, + protocolVersion: 4, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', @@ -749,6 +751,7 @@ describe('DispatchInstallDialog target model readout', () => { 'workspace_git_bundle_upload', 'workspace_git_sync', 'dispatch_worker_cli_profile', + 'per_turn_options', ], modelConfigured: true, availableModels, diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts index 6dbb4344f7..7195775077 100644 --- a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts +++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts @@ -58,8 +58,9 @@ describe('dispatch controller-only routing contract', () => { }); describe('dispatch preflight contract', () => { - it('fails closed on protocol v3 Git worktree delivery', () => { - expect(DISPATCH_PROTOCOL_VERSION).toBe(3); + it('fails closed on protocol v4 Git worktree delivery with per-turn options', () => { + expect(DISPATCH_PROTOCOL_VERSION).toBe(4); + expect(BASE_DISPATCH_CAPABILITIES).toContain('per_turn_options'); expect(BASE_DISPATCH_CAPABILITIES).toEqual(expect.arrayContaining([ 'workspace_serialization', 'workspace_git_worktree', diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts index 8d5db4d1d3..1c0987d6e1 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts @@ -5,8 +5,8 @@ import { } from './dispatchPreflight'; describe('dispatch preflight', () => { - it('requires protocol v3 Git worktree delivery without a snapshot fallback', () => { - expect(DISPATCH_PROTOCOL_VERSION).toBe(3); + it('requires protocol v4 Git worktree delivery without a snapshot fallback', () => { + expect(DISPATCH_PROTOCOL_VERSION).toBe(4); expect(BASE_DISPATCH_CAPABILITIES).toEqual(expect.arrayContaining([ 'workspace_git_worktree', 'workspace_git_bundle_upload', diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.ts index af0f58169a..a2aa0f3db7 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.ts @@ -1,4 +1,4 @@ -export const DISPATCH_PROTOCOL_VERSION = 3; +export const DISPATCH_PROTOCOL_VERSION = 4; /** * Capabilities every dispatch target must advertise. @@ -17,4 +17,5 @@ export const BASE_DISPATCH_CAPABILITIES = [ 'workspace_git_bundle_upload', 'workspace_git_sync', 'dispatch_worker_cli_profile', + 'per_turn_options', ] as const; From ac8d85fe08aef332a8d202ea7b406d3e0c9ef133 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 21:52:18 -0700 Subject: [PATCH 08/12] feat(web): unlock per-turn model and approval-policy for dispatch sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer's model and approval controls were locked permanently after the first submit — coherent when a job was one exchange, wrong now that a dispatch session is a conversation. With protocol v4: - capabilities lock the options only while a turn is in flight (transfer/queued/running); they reopen on terminal states. - The dispatch driver's follow-up turn carries the session's current dispatchModel/dispatchApprovalPolicy as per-turn overrides, and the retry-idempotency key includes them so changed options mint a fresh turn id instead of colliding with the target's content binding. --- src/web-ui/src/features/dispatch/dispatchApi.ts | 15 ++++++++++++++- .../dispatch/DispatchSessionDriver.ts | 14 ++++++++++++-- .../session-drivers/useComposerCapabilities.ts | 8 ++++---- src/web-ui/src/flow_chat/store/FlowChatStore.ts | 2 +- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts index 02f93fee0d..8b234b1c2e 100644 --- a/src/web-ui/src/features/dispatch/dispatchApi.ts +++ b/src/web-ui/src/features/dispatch/dispatchApi.ts @@ -108,9 +108,22 @@ export const dispatchApi = { turnId: string, prompt: string, displayContent?: string, + options?: { + /** Per-turn model override; carries forward as the job's model. */ + model?: string; + /** Per-turn approval-policy override with the same carry-forward rule. */ + approvalPolicy?: DispatchApprovalPolicy; + }, ): Promise { return api.invoke('dispatch_continue', { - request: { jobId, turnId, prompt, displayContent }, + request: { + jobId, + turnId, + prompt, + displayContent, + model: options?.model, + approvalPolicy: options?.approvalPolicy, + }, }); }, 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 50029f54f1..3705930255 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 @@ -139,11 +139,17 @@ async function continueDispatchJob( context.flowChatStore.getState().sessions.get(sessionId) ?? input.readySession; const followUpAgentType = (input.currentAgentType?.trim() || followUpSession.mode || 'agentic').trim(); - // Reused across retries so a lost response cannot start two turns. + // The composer edits these between turns; the follow-up carries them as + // per-turn overrides which the target persists onto the job. + const turnModel = followUpSession.config.dispatchModel?.trim() || undefined; + const turnApprovalPolicy = followUpSession.config.dispatchApprovalPolicy; + // Reused across retries so a lost response cannot start two turns. The + // match key includes the per-turn options: the target refuses a turnId + // bound to different content, so changed options must mint a new turn id. const retry = claimSubmissionRetry( CONTINUE_RETRY_SCOPE, sessionId, - message, + JSON.stringify([message, turnModel ?? null, turnApprovalPolicy ?? null]), displayMessage, () => globalThis.crypto?.randomUUID?.() @@ -176,6 +182,10 @@ async function continueDispatchJob( retry.id, message, displayMessage, + { + model: turnModel, + approvalPolicy: turnApprovalPolicy, + }, ); if (!response.accepted) { throw new Error('Dispatch target did not accept the follow-up turn'); diff --git a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts index a91eaf58e1..82e1c8e1ee 100644 --- a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts +++ b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts @@ -59,15 +59,15 @@ export function useComposerCapabilities(input: ComposerCapabilityInput): Compose && status?.roundId.startsWith(DISPATCH_TRANSFER_ROUND_PREFIX) === true; }); + // Options are editable whenever no turn is in flight: before the first + // submit and between turns. Protocol v4 carries them per follow-up turn. const dispatchJobState = session?.config.dispatchJobState; const submissionOptionsLocked = dispatchTransport && ( transferInFlight - || ( - dispatchJobState !== 'submitting' - && dispatchJobState !== 'submission_unknown' - ) + || dispatchJobState === 'queued' + || dispatchJobState === 'running' ); return { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 6316bd6783..b86d752bf3 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2214,7 +2214,7 @@ export class FlowChatStore { }); } - /** Update the immutable-at-submit approval policy while the job is still local. */ + /** Update the approval policy; the next turn carries it to the target. */ public updateSessionDispatchApprovalPolicy( sessionId: string, approvalPolicy: NonNullable, From edb9b64b5124766aceaebf119dcc221b5ee1e3a2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 22:16:25 -0700 Subject: [PATCH 09/12] feat(dispatch): compact turns, usage-report queries, and the missing continue subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /compact and /usage now work for dispatch sessions end to end: - Follow-up turns carry a kind (prompt | compact). A compact turn runs manual context compaction bound to the worker's own turn id, so its DialogTurn/ContextCompression events flow through the existing event loop, project into the transcript, and settle the job like any turn. New compatibility facade start_manual_compaction exposes the coordinator's caller-identified compaction without awaiting completion. - New read-only 'query' verb answers persisted-state questions (usage report) from PersistenceManager in a short-lived process — no runtime bootstrap, no workspace runtime ownership, safe next to a running worker. Wired through SSH, device RPC, desktop dispatch_query command, and all peer-deny/workspace-policy/contract lists. - Fixes a latent bug: the CLI had no 'dispatch continue' subcommand, so SSH targets and desktop-as-target hosts could not receive follow-up turns at all (only the CLI-daemon in-process path worked). 'continue' and 'query' are now registered clap verbs. - Composer capabilities gain a per-command ops set: dispatch sessions execute /compact and /usage (driver-routed via FlowChatManager facades), the usage toolbar entry is visible again, and the remaining local-only commands stay hidden with the pickers. - New session_query capability required alongside per_turn_options. --- src/apps/cli/src/dispatch/mod.rs | 66 +++++++++++++++++-- src/apps/cli/src/dispatch/protocol.rs | 32 +++++++++ src/apps/cli/src/dispatch/store.rs | 8 ++- src/apps/cli/src/dispatch/worker.rs | 65 +++++++++++------- src/apps/cli/src/main.rs | 4 ++ src/apps/cli/src/peer_host/deny.rs | 2 + src/apps/cli/src/peer_host/dispatch.rs | 1 + src/apps/cli/src/root_handlers.rs | 2 + src/apps/desktop/src/api/dispatch_api.rs | 39 +++++++++-- src/apps/desktop/src/api/dispatch_host.rs | 1 + src/apps/desktop/src/api/peer_host_invoke.rs | 1 + .../src/api/remote_workspace_policy.rs | 1 + src/apps/desktop/src/lib.rs | 1 + .../src/agentic/coordination/coordinator.rs | 13 ++++ .../assembly/core/src/product_runtime.rs | 16 +++++ .../core/src/service/dispatch/controller.rs | 66 ++++++++++++++++++- .../src/service/dispatch/device_controller.rs | 27 ++++++-- .../assembly/core/src/service/dispatch/mod.rs | 6 +- .../src/remote_ssh/dispatch_ssh.rs | 12 +++- .../dispatch/DispatchInstallDialog.test.tsx | 3 + .../dispatch/dispatch.contract.test.ts | 1 + .../src/features/dispatch/dispatchApi.ts | 10 +++ .../features/dispatch/dispatchPreflight.ts | 1 + .../src/flow_chat/components/ChatInput.tsx | 61 ++++++++--------- .../src/flow_chat/services/FlowChatManager.ts | 16 +++++ .../flow_chat/services/usageReportService.ts | 28 +++++--- .../dispatch/DispatchSessionDriver.ts | 66 +++++++++++++++++++ .../local/LocalSessionDriver.ts | 28 ++++++++ .../src/flow_chat/session-drivers/types.ts | 27 ++++++++ .../useComposerCapabilities.ts | 16 ++++- .../api/adapters/peer-device-adapter.ts | 1 + 31 files changed, 535 insertions(+), 86 deletions(-) diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index 6d8c1ad9f9..f5769aa30c 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -17,10 +17,12 @@ use protocol::{ DispatchAnswerRequest, DispatchAnswerResponse, DispatchAppendRequest, DispatchAppendResponse, DispatchCancelRequest, DispatchCancelResponse, DispatchContinueRequest, DispatchContinueResponse, DispatchJobListEntry, DispatchJobState, DispatchListRequest, - DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest, DispatchStatusResponse, - DispatchSubmitRequest, DispatchSubmitResponse, DispatchWorkspaceBundleBeginRequest, - DispatchWorkspaceBundleChunkRequest, DispatchWorkspaceBundleCommitRequest, - DispatchWorkspaceProbe, DispatchWorkspaceProvisionRequest, DispatchWorkspaceSyncChunkRequest, + DispatchProbeRequest, DispatchProbeResponse, DispatchQueryKind, DispatchQueryRequest, + DispatchStatusRequest, DispatchStatusResponse, + DispatchSubmitRequest, DispatchSubmitResponse, DispatchTurnKind, + DispatchWorkspaceBundleBeginRequest, DispatchWorkspaceBundleChunkRequest, + DispatchWorkspaceBundleCommitRequest, DispatchWorkspaceProbe, + DispatchWorkspaceProvisionRequest, DispatchWorkspaceSyncChunkRequest, DispatchWorkspaceSyncRequest, DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES, }; use store::{CreateJobOutcome, DispatchStateRecord, DispatchStore}; @@ -63,6 +65,7 @@ pub(crate) async fn run_dispatch_verb( "append" => serde_json::to_value(append(parse(input)?)?).context("encode appended message"), "continue" => serde_json::to_value(continue_job(parse(input)?)?) .context("encode follow-up turn response"), + "query" => query(parse(input)?).await.context("encode query response"), "workspace-provision" => serde_json::to_value(workspace::provision(parse::< DispatchWorkspaceProvisionRequest, >(input)?)?) @@ -143,6 +146,9 @@ async fn probe(request: DispatchProbeRequest) -> Result { "dispatch_worker_cli_profile".to_string(), // v4: follow-up turns may override model and approval policy. "per_turn_options".to_string(), + // v4: read-only persisted-state queries (usage report) and compact + // turns delivered through the continue mailbox. + "session_query".to_string(), ]; if runner::is_supported() { capabilities.push("detached_worker".to_string()); @@ -227,8 +233,17 @@ fn continue_job(request: DispatchContinueRequest) -> Result { + if request.prompt.trim().is_empty() { + bail!("dispatch follow-up requires a prompt"); + } + } + DispatchTurnKind::Compact => { + if !request.prompt.trim().is_empty() { + bail!("dispatch compact turns do not take a prompt"); + } + } } if request.prompt.len() > MAX_DISPATCH_TEXT_BYTES { bail!("dispatch follow-up prompt exceeds the 32 KiB safety limit"); @@ -259,6 +274,45 @@ fn continue_job(request: DispatchContinueRequest) -> Result Result { + let store = DispatchStore::open_default()?; + let job = store.load_job(&request.job_id)?; + match request.kind { + DispatchQueryKind::UsageReport => { + let path_manager = bitfun_core::infrastructure::PathManager::new() + .map_err(|error| anyhow::anyhow!("resolve BitFun storage root: {error}"))?; + let persistence = bitfun_core::agentic::persistence::PersistenceManager::new( + std::sync::Arc::new(path_manager), + ) + .map_err(|error| anyhow::anyhow!("open session persistence: {error}"))?; + let report = bitfun_core::service::session_usage::generate_session_usage_report( + &persistence, + None, + bitfun_core::service::session_usage::SessionUsageReportRequest { + session_id: job.request.session_id.clone(), + workspace_path: Some(job.request.workspace_path.clone()), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: false, + }, + ) + .await + .map_err(|error| anyhow::anyhow!("generate dispatch usage report: {error}"))?; + serde_json::to_value(serde_json::json!({ + "kind": "usageReport", + "sessionId": job.request.session_id, + "report": report, + })) + .context("encode dispatch usage report") + } + } +} + fn ensure_worker_spawned( store: &DispatchStore, job_id: &str, diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index 6cbc7c95ed..4198d85831 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -55,6 +55,17 @@ pub(crate) enum DispatchApprovalPolicy { Remote, } +/// What a queued follow-up turn asks the worker to run. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DispatchTurnKind { + /// An ordinary user prompt submitted as a dialog turn. + #[default] + Prompt, + /// Manual context compaction, run as a turn so its events attribute. + Compact, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DispatchSubmitRequest { @@ -345,6 +356,9 @@ pub(crate) struct DispatchContinueRequest { /// Per-turn approval-policy override with the same carry-forward rule. #[serde(default)] pub(crate) approval_policy: Option, + /// Operation the worker runs; defaults to an ordinary prompt turn. + #[serde(default)] + pub(crate) kind: DispatchTurnKind, } #[derive(Clone, Debug, Serialize, PartialEq, Eq)] @@ -363,6 +377,24 @@ pub(crate) struct DispatchCancelRequest { pub(crate) job_id: String, } +/// Read-only question about a job's persisted session state. +/// +/// Served by a short-lived process straight from persistence — no runtime is +/// initialized and no workspace runtime ownership is taken, so a query is +/// always safe next to a running detached worker. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchQueryRequest { + pub(crate) job_id: String, + pub(crate) kind: DispatchQueryKind, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DispatchQueryKind { + UsageReport, +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DispatchListRequest {} diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs index 17bc516c44..b8f378c731 100644 --- a/src/apps/cli/src/dispatch/store.rs +++ b/src/apps/cli/src/dispatch/store.rs @@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize}; use super::protocol::{ DispatchAppendRequest, DispatchApprovalPolicy, DispatchContinueRequest, DispatchEvent, - DispatchJobListEntry, DispatchJobState, DispatchSubmitRequest, DISPATCH_PROTOCOL_VERSION, + DispatchJobListEntry, DispatchJobState, DispatchSubmitRequest, DispatchTurnKind, + DISPATCH_PROTOCOL_VERSION, }; const JOB_RECORD_FILE: &str = "job.json"; @@ -167,6 +168,8 @@ pub(crate) struct StoredFollowUpTurn { pub(crate) model: Option, #[serde(default)] pub(crate) approval_policy: Option, + #[serde(default)] + pub(crate) kind: DispatchTurnKind, pub(crate) created_at: String, } @@ -177,6 +180,7 @@ impl StoredFollowUpTurn { && self.display_content == other.display_content && self.model == other.model && self.approval_policy == other.approval_policy + && self.kind == other.kind } } @@ -730,6 +734,7 @@ impl DispatchStore { display_content: request.display_content.clone(), model: request.model.clone(), approval_policy: request.approval_policy, + kind: request.kind, created_at: chrono::Utc::now().to_rfc3339(), }; // A retried request must not start a second turn. Both mailboxes are @@ -2225,6 +2230,7 @@ mod tests { display_content: None, model: None, approval_policy: None, + kind: DispatchTurnKind::Prompt, } } diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index efcc866b99..be534c0bb6 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -17,7 +17,7 @@ use bitfun_runtime_ports::{ use crate::{shutdown_mcp_servers, BootstrapProfile}; use super::permissions::{self, REJECT_AND_REPORT_REASON}; -use super::protocol::{DispatchApprovalPolicy, DispatchEvent, DispatchJobState}; +use super::protocol::{DispatchApprovalPolicy, DispatchEvent, DispatchJobState, DispatchTurnKind}; use super::store::{DispatchStore, WorkspaceLock}; const TURN_SETTLEMENT_TIMEOUT_MS: u64 = 5_000; @@ -209,30 +209,45 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { // after the Runtime accepts the turn must never make a replacement worker // submit the prompt a second time. let follow_up = store.claim_follow_up_turn(job_id, &turn_id)?; - let prompt = follow_up - .as_ref() - .map(|turn| turn.prompt.clone()) - .unwrap_or_else(|| job.request.prompt.clone()); - agent_runtime - .submit_dialog_turn(AgentDialogTurnRequest { - session_id: job.request.session_id.clone(), - message: prompt, - original_message: None, - turn_id: Some(turn_id.clone()), - execution: Default::default(), - agent_type: job.request.agent_type.clone(), - workspace_path: Some(workspace_path), - remote_connection_id: None, - remote_ssh_host: None, - policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), - reply_route: None, - prepended_reminders: Vec::new(), - attachments: Vec::new(), - metadata: permissions::metadata(effective_policy), - }) - .await - .map_err(|error| anyhow!(error.into_message())) - .context("submit dispatch dialog turn")?; + let turn_kind = follow_up.as_ref().map(|turn| turn.kind).unwrap_or_default(); + match turn_kind { + DispatchTurnKind::Prompt => { + let prompt = follow_up + .as_ref() + .map(|turn| turn.prompt.clone()) + .unwrap_or_else(|| job.request.prompt.clone()); + agent_runtime + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: job.request.session_id.clone(), + message: prompt, + original_message: None, + turn_id: Some(turn_id.clone()), + execution: Default::default(), + agent_type: job.request.agent_type.clone(), + workspace_path: Some(workspace_path), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: permissions::metadata(effective_policy), + }) + .await + .map_err(|error| anyhow!(error.into_message())) + .context("submit dispatch dialog turn")?; + } + DispatchTurnKind::Compact => { + // The compaction runs as a turn with this worker's turn id, so + // its DialogTurn/ContextCompression events flow through the same + // event loop and settle the job like any other turn. + compatibility + .start_manual_compaction(job.request.session_id.clone(), turn_id.clone()) + .await + .map_err(anyhow::Error::msg) + .context("start dispatch manual compaction")?; + } + } let mut initial_permissions = agent_runtime .pending_permission_requests() diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index d0e23fbdc0..2a76277f34 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -598,6 +598,10 @@ pub(crate) enum DispatchAction { Answer, /// Append a steering message to a queued or running job Append, + /// Queue the next turn of a dispatch session whose previous turn finished + Continue, + /// Read persisted session facts (usage report) without starting a turn + Query, #[command(name = "__workspace_provision", hide = true)] WorkspaceProvision, #[command(name = "__workspace_bundle_begin", hide = true)] diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 69396397e5..c83b4a015b 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -81,6 +81,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", + "dispatch_query", "dispatch_sync_result", "dispatch_cancel", "dispatch_list_jobs", @@ -142,6 +143,7 @@ mod tests { "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", + "dispatch_query", "dispatch_sync_result", "dispatch_cancel", "dispatch_list_jobs", diff --git a/src/apps/cli/src/peer_host/dispatch.rs b/src/apps/cli/src/peer_host/dispatch.rs index a3bb692c1f..b42e16e6fa 100644 --- a/src/apps/cli/src/peer_host/dispatch.rs +++ b/src/apps/cli/src/peer_host/dispatch.rs @@ -129,6 +129,7 @@ fn dispatch_target_verb(command: &str) -> Option<&'static str> { "dispatch_target_answer" => Some("answer"), "dispatch_target_append" => Some("append"), "dispatch_target_continue" => Some("continue"), + "dispatch_target_query" => Some("query"), "dispatch_target_workspace_provision" => Some("workspace-provision"), "dispatch_target_workspace_bundle_begin" => Some("workspace-bundle-begin"), "dispatch_target_workspace_bundle_chunk" => Some("workspace-bundle-chunk"), diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 61de0020e6..856550f71a 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -68,6 +68,8 @@ pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()> DispatchAction::List => "list", DispatchAction::Answer => "answer", DispatchAction::Append => "append", + DispatchAction::Continue => "continue", + DispatchAction::Query => "query", DispatchAction::WorkspaceProvision => "workspace-provision", DispatchAction::WorkspaceBundleBegin => "workspace-bundle-begin", DispatchAction::WorkspaceBundleChunk => "workspace-bundle-chunk", diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index 68a714cd16..b34056f95c 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -17,14 +17,15 @@ use bitfun_core::service::dispatch::{ continue_device_dispatch_job, continue_dispatch_job, get_device_dispatch_status, get_dispatch_status, list_device_dispatch_jobs, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, probe_device_dispatch_target, probe_dispatch_target, - start_dispatch_cli_install, start_dispatch_cli_source_build, submit_device_dispatch, - submit_dispatch, sync_device_dispatch_result, sync_dispatch_model_config, sync_dispatch_result, + query_device_dispatch_job, query_dispatch_job, start_dispatch_cli_install, + start_dispatch_cli_source_build, submit_device_dispatch, submit_dispatch, + sync_device_dispatch_result, sync_dispatch_model_config, sync_dispatch_result, DeviceDispatchRpc, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchContinueRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, - DispatchProbeTargetRequest, DispatchSaveTranscriptRequest, DispatchStatusRequest, - DispatchSubmitRequest, DispatchSyncResultRequest, DispatchTarget, DispatchTargetOption, - DispatchTargetRequest, DispatchTranscriptRequest, OutboundDispatchStore, + DispatchProbeTargetRequest, DispatchQueryJobRequest, DispatchSaveTranscriptRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, DispatchTarget, + DispatchTargetOption, DispatchTargetRequest, DispatchTranscriptRequest, OutboundDispatchStore, }; use bitfun_core::service::remote_ssh::dispatch_ssh::{ DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, @@ -426,6 +427,34 @@ pub async fn dispatch_continue( .map_err(|error| error.to_string()) } +#[tauri::command] +pub async fn dispatch_query( + state: State<'_, AppState>, + path_manager: State<'_, Arc>, + request: DispatchQueryJobRequest, +) -> Result { + let store = OutboundDispatchStore::new(path_manager.as_ref()); + if matches!( + store + .get(&request.job_id) + .await + .map_err(|error| error.to_string())? + .map(|record| record.target), + Some(DispatchTarget::Device { .. }) + ) { + return query_device_dispatch_job(&AccountDeviceDispatchRpc, &store, request) + .await + .map_err(|error| error.to_string()); + } + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + query_dispatch_job(&manager, &store, request) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn dispatch_cancel( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/dispatch_host.rs b/src/apps/desktop/src/api/dispatch_host.rs index 9c66eed426..71ebb3775e 100644 --- a/src/apps/desktop/src/api/dispatch_host.rs +++ b/src/apps/desktop/src/api/dispatch_host.rs @@ -40,6 +40,7 @@ fn target_cli_verb(command: &str) -> Option<&'static str> { "dispatch_target_answer" => Some("answer"), "dispatch_target_append" => Some("append"), "dispatch_target_continue" => Some("continue"), + "dispatch_target_query" => Some("query"), "dispatch_target_workspace_provision" => Some("__workspace_provision"), "dispatch_target_workspace_bundle_begin" => Some("__workspace_bundle_begin"), "dispatch_target_workspace_bundle_chunk" => Some("__workspace_bundle_chunk"), diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index aaead92a47..c00cde3205 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -104,6 +104,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", + "dispatch_query", "dispatch_cancel", "dispatch_sync_result", "dispatch_list_jobs", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index b8e4a662f8..c2284bfca5 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -392,6 +392,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::WorkspaceAgnostic, ), ("dispatch_status", RemoteWorkspacePolicy::WorkspaceAgnostic), + ("dispatch_query", RemoteWorkspacePolicy::WorkspaceAgnostic), ("dispatch_submit", RemoteWorkspacePolicy::WorkspaceAgnostic), ( "dismiss_announcement", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index b7ddc7726b..1be84e089c 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1761,6 +1761,7 @@ pub async fn run() { api::dispatch_api::dispatch_answer, api::dispatch_api::dispatch_append, api::dispatch_api::dispatch_continue, + api::dispatch_api::dispatch_query, api::dispatch_api::dispatch_load_transcript, api::dispatch_api::dispatch_save_transcript, // Relay self-deploy API diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 33730b28f4..c448632d18 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -4797,6 +4797,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet })? } + /// Start a manual compaction bound to a caller-supplied turn id without + /// awaiting completion. The caller observes the outcome through the + /// turn's DialogTurn/ContextCompression events. + pub async fn start_manual_compaction_turn( + &self, + session_id: String, + turn_id: String, + ) -> BitFunResult<()> { + self.start_manual_compaction_task(session_id, Some(turn_id)) + .await + .map(|_task| ()) + } + #[allow(clippy::too_many_arguments)] async fn start_dialog_turn_internal( &self, diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index f57eacb911..fd5d4b9ff6 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -616,6 +616,22 @@ impl CoreAgentRuntimeCompatibility { .await } + /// Start a manual context compaction as a caller-identified turn. + /// + /// Detached dispatch supplies its own turn id so the compaction's + /// DialogTurn/ContextCompression events can be attributed in its event + /// log; completion is observed through those events, not awaited here. + pub async fn start_manual_compaction( + &self, + session_id: String, + turn_id: String, + ) -> Result<(), String> { + self.coordinator + .start_manual_compaction_turn(session_id, turn_id) + .await + .map_err(|error| error.to_string()) + } + /// Applies the same Core deployment owner before a product compatibility /// path attaches to or mutates a structured workspace scope. pub fn ensure_workspace_runtime_ownership( diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index bbc038e3c3..e104981e0a 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -141,6 +141,9 @@ pub struct DispatchContinueRequest { /// Per-turn approval-policy override with the same carry-forward rule. #[serde(default)] pub approval_policy: Option, + /// Operation kind understood by the target (`prompt` default, `compact`). + #[serde(default)] + pub kind: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -153,6 +156,16 @@ pub struct DispatchAppendRequest { pub display_content: Option, } +/// Read-only persisted-state question answered by the target without +/// starting a turn or initializing a runtime. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchQueryJobRequest { + pub job_id: String, + /// Query kind understood by the target (currently `usageReport`). + pub kind: String, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DispatchListJobsRequest { @@ -789,6 +802,37 @@ pub async fn status( Ok(response) } +pub async fn query_job( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + request: DispatchQueryJobRequest, +) -> anyhow::Result { + validate_query_request(&request)?; + let record = store + .get(&request.job_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Outbound dispatch job was not found"))?; + let DispatchTarget::Ssh { connection_id, .. } = &record.target else { + anyhow::bail!("SSH dispatch query requires an SSH target"); + }; + dispatch_ssh::query( + manager, + connection_id, + &json!({ "jobId": request.job_id, "kind": request.kind }), + ) + .await +} + +pub(super) fn validate_query_request(request: &DispatchQueryJobRequest) -> anyhow::Result<()> { + if request.job_id.trim().is_empty() { + anyhow::bail!("Dispatch query requires a jobId"); + } + if !matches!(request.kind.as_str(), "usageReport") { + anyhow::bail!("Dispatch query kind is not recognized"); + } + Ok(()) +} + /// Bring the target's work back into this controller's baseline worktree. /// /// One button, two halves: the target commits and bundles its branch, then the @@ -995,6 +1039,14 @@ pub(super) fn continue_payload(request: &DispatchContinueRequest) -> Value { { payload["approvalPolicy"] = Value::String(policy.to_string()); } + if let Some(kind) = request + .kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + payload["kind"] = Value::String(kind.to_string()); + } payload } @@ -1120,8 +1172,18 @@ pub(super) fn validate_continue_request(request: &DispatchContinueRequest) -> an if request.turn_id.trim().is_empty() || request.turn_id.len() > 128 { anyhow::bail!("Dispatch turnId must contain 1-128 bytes"); } - if request.prompt.trim().is_empty() { - anyhow::bail!("Dispatch follow-up prompt cannot be empty"); + match request.kind.as_deref().unwrap_or("prompt") { + "prompt" => { + if request.prompt.trim().is_empty() { + anyhow::bail!("Dispatch follow-up prompt cannot be empty"); + } + } + "compact" => { + if !request.prompt.trim().is_empty() { + anyhow::bail!("Dispatch compact turns do not take a prompt"); + } + } + _ => anyhow::bail!("Dispatch follow-up kind is not recognized"), } let total_bytes = request .prompt diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs index 608baae35f..a29ea27548 100644 --- a/src/crates/assembly/core/src/service/dispatch/device_controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -17,10 +17,11 @@ use super::controller::{ bind_outbound_record, continue_payload, finish_sync, provisioned_path, record_follow_up_state, release_unbound_preparation_baseline, result_bundle_path, same_target_identity, target_have_tips, validate_answer_request, validate_append_request, validate_continue_request, - validate_submission_preflight, validate_submit_ack, validate_submit_request, - DispatchAnswerRequest, DispatchAppendRequest, DispatchContinueRequest, DispatchJobRequest, - DispatchListJobsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, - DispatchSubmitRequest, DispatchSyncResultRequest, DISPATCH_PROTOCOL_VERSION, + validate_query_request, validate_submission_preflight, validate_submit_ack, + validate_submit_request, DispatchAnswerRequest, DispatchAppendRequest, DispatchContinueRequest, + DispatchJobRequest, DispatchListJobsRequest, DispatchProbeTargetRequest, DispatchQueryJobRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, + DISPATCH_PROTOCOL_VERSION, }; use super::preparation::{DispatchPreparationRequest, DispatchPreparationTarget}; use super::{ @@ -344,6 +345,24 @@ pub async fn submit_device( Ok(response) } +pub async fn query_device_job( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + request: DispatchQueryJobRequest, +) -> anyhow::Result { + validate_query_request(&request)?; + let record = load_device_record(store, &request.job_id).await?; + let DispatchTarget::Device { device_id, .. } = &record.target else { + unreachable!("load_device_record validates target kind") + }; + rpc.invoke( + device_id, + "dispatch_target_query", + json!({ "jobId": request.job_id, "kind": request.kind }), + ) + .await +} + pub async fn status_device( rpc: &dyn DeviceDispatchRpc, store: &OutboundDispatchStore, diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index 238ae48217..049e2fd46b 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -28,12 +28,13 @@ pub use controller::{ install_cli_source_start as start_dispatch_cli_source_build, install_cli_start as start_dispatch_cli_install, list_jobs as list_dispatch_jobs, list_targets as list_dispatch_targets, probe_target as probe_dispatch_target, - status as get_dispatch_status, submit as submit_dispatch, + query_job as query_dispatch_job, status as get_dispatch_status, submit as submit_dispatch, sync_model_config as sync_dispatch_model_config, sync_result as sync_dispatch_result, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchContinueRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, - DispatchPermissionReplyKind, DispatchProbeTargetRequest, DispatchStatusRequest, + DispatchPermissionReplyKind, DispatchProbeTargetRequest, DispatchQueryJobRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, DispatchTargetOption, }; #[cfg(feature = "ssh-remote")] @@ -41,6 +42,7 @@ pub use device_controller::{ answer_device as answer_device_dispatch, append_device as append_device_dispatch, cancel_device as cancel_device_dispatch, continue_device_job as continue_device_dispatch_job, list_device_jobs as list_device_dispatch_jobs, probe_device as probe_device_dispatch_target, + query_device_job as query_device_dispatch_job, status_device as get_device_dispatch_status, submit_device as submit_device_dispatch, sync_device_result as sync_device_dispatch_result, DeviceDispatchRpc, }; diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index 2a96b41425..b7ef03c3f1 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -93,7 +93,7 @@ const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profil /// previous release, so comparing only the installed and controller version /// strings is not a sound compatibility test. const FIRST_COMPATIBLE_STABLE_DISPATCH_RELEASE: (u64, u64, u64) = (0, 2, 16); -const REQUIRED_DISPATCH_CAPABILITIES: [&str; 15] = [ +const REQUIRED_DISPATCH_CAPABILITIES: [&str; 16] = [ "persistent_jobs", "cursor_events", "detached_worker", @@ -112,6 +112,8 @@ const REQUIRED_DISPATCH_CAPABILITIES: [&str; 15] = [ DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, // v4: follow-up turns may override model and approval policy. "per_turn_options", + // v4: read-only persisted-state queries and compact turns. + "session_query", ]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1396,6 +1398,14 @@ pub async fn continue_job( invoke_json(manager, connection_id, "continue", request).await } +pub async fn query( + manager: &SSHConnectionManager, + connection_id: &str, + request: &Value, +) -> Result { + invoke_json(manager, connection_id, "query", request).await +} + /// Commit the target's worktree and fetch the Git bundle it produced. /// /// Downloads only. The controller decides separately whether to fast-forward diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index bf631aed50..4b967a00c9 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -435,6 +435,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { 'workspace_git_sync', 'approval_remote', 'per_turn_options', + 'session_query', ], modelConfigured: true, availableModels: ['model-a', 'model-b'], @@ -593,6 +594,7 @@ describe('DispatchInstallDialog model configuration sync', () => { 'workspace_git_sync', 'dispatch_worker_cli_profile', 'per_turn_options', + 'session_query', ], modelConfigured, availableModels: modelConfigured ? ['claude'] : [], @@ -752,6 +754,7 @@ describe('DispatchInstallDialog target model readout', () => { 'workspace_git_sync', 'dispatch_worker_cli_profile', 'per_turn_options', + 'session_query', ], modelConfigured: true, availableModels, diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts index 7195775077..16e7cea2e3 100644 --- a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts +++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts @@ -16,6 +16,7 @@ const OUTBOUND_DISPATCH_COMMANDS = [ 'dispatch_sync_model_config', 'dispatch_submit', 'dispatch_status', + 'dispatch_query', 'dispatch_cancel', 'dispatch_list_jobs', 'dispatch_answer', diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts index 8b234b1c2e..e5f81713f1 100644 --- a/src/web-ui/src/features/dispatch/dispatchApi.ts +++ b/src/web-ui/src/features/dispatch/dispatchApi.ts @@ -113,6 +113,8 @@ export const dispatchApi = { model?: string; /** Per-turn approval-policy override with the same carry-forward rule. */ approvalPolicy?: DispatchApprovalPolicy; + /** Operation kind; defaults to an ordinary prompt turn. */ + kind?: 'prompt' | 'compact'; }, ): Promise { return api.invoke('dispatch_continue', { @@ -123,10 +125,18 @@ export const dispatchApi = { displayContent, model: options?.model, approvalPolicy: options?.approvalPolicy, + kind: options?.kind, }, }); }, + /** Read-only persisted-state question answered without starting a turn. */ + async query(jobId: string, kind: 'usageReport'): Promise<{ kind: string; report: unknown }> { + return api.invoke<{ kind: string; report: unknown }>('dispatch_query', { + request: { jobId, kind }, + }); + }, + async status(jobId: string, cursor: number): Promise { return api.invoke('dispatch_status', { request: { jobId, cursor }, diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.ts index a2aa0f3db7..c69f413957 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.ts @@ -18,4 +18,5 @@ export const BASE_DISPATCH_CAPABILITIES = [ 'workspace_git_sync', 'dispatch_worker_cli_profile', 'per_turn_options', + 'session_query', ] as const; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index c60c2e69d7..9aa1e125af 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -66,7 +66,6 @@ import { successfulRetryCleanupTarget, } from './chatInputDraftRecovery'; import { startBtwThread } from '../services/BtwThreadService'; -import { runUsageReportCommand } from '../services/usageReportService'; import { buildImagePayload } from '../utils/imagePayload'; import { isGoalSlashCommand, parseGoalCommand } from '../services/goalService'; import { @@ -2914,11 +2913,16 @@ export const ChatInput: React.FC = ({ const promptSlashCommandsEnabled = !isAcpInputSession; const localSlashCommandsEnabled = promptSlashCommandsEnabled && caps.localSlashCommands; const trimmed = text.trim(); - const isBtwCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/btw'); - const isCompactCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/compact'); - const isGoalCommand = localSlashCommandsEnabled && isGoalSlashCommand(text); - const isUsageCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/usage'); - const isReviewCommand = localSlashCommandsEnabled && isReviewSlashCommand(text); + const isBtwCommand = + promptSlashCommandsEnabled && caps.ops.has('btw') && isSlashCommand(trimmed, '/btw'); + const isCompactCommand = + promptSlashCommandsEnabled && caps.ops.has('compact') && isSlashCommand(trimmed, '/compact'); + const isGoalCommand = + promptSlashCommandsEnabled && caps.ops.has('goal') && isGoalSlashCommand(text); + const isUsageCommand = + promptSlashCommandsEnabled && caps.ops.has('usage') && isSlashCommand(trimmed, '/usage'); + const isReviewCommand = + promptSlashCommandsEnabled && caps.ops.has('review') && isReviewSlashCommand(text); const isProcessing = !!derivedState?.isProcessing; // Don't queue /btw or /goal while the main session is processing; they have dedicated flows. @@ -3099,12 +3103,7 @@ export const ChatInput: React.FC = ({ setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); try { - await agentAPI.compactSession({ - sessionId: effectiveTargetSessionId, - workspacePath: effectiveTargetSession.workspacePath, - remoteConnectionId: effectiveTargetSession.remoteConnectionId, - remoteSshHost: effectiveTargetSession.remoteSshHost, - }); + await FlowChatManager.getInstance().compactSession(effectiveTargetSessionId); } catch (error) { log.error('Failed to trigger /compact', { error, @@ -3139,15 +3138,17 @@ export const ChatInput: React.FC = ({ } try { - const result = await runUsageReportCommand({ - session: effectiveTargetSession, - isProcessing: !!derivedState?.isProcessing, - busyMessage: t('chatInput.usageBusy'), - noWorkspaceMessage: t('chatInput.usageNoWorkspace'), - failedTitle: t('chatInput.usageFailed'), - unknownErrorMessage: t('error.unknown'), - loadingMarkdown: t('usage.loading.markdown'), - }); + const result = await FlowChatManager.getInstance().runSessionUsageReport( + effectiveTargetSessionId, + { + isProcessing: !!derivedState?.isProcessing, + busyMessage: t('chatInput.usageBusy'), + noWorkspaceMessage: t('chatInput.usageNoWorkspace'), + failedTitle: t('chatInput.usageFailed'), + unknownErrorMessage: t('error.unknown'), + loadingMarkdown: t('usage.loading.markdown'), + }, + ); if (result.inserted) { dispatchInput({ type: 'DEACTIVATE' }); @@ -3973,33 +3974,33 @@ export const ChatInput: React.FC = ({ return; } - if (localSlashCommandsEnabled && isSlashCommand(message, '/btw')) { + if (promptSlashCommandsEnabled && caps.ops.has('btw') && isSlashCommand(message, '/btw')) { // When idle, /btw can be sent via the normal send button. await submitBtwFromInput(); return; } - if (localSlashCommandsEnabled && isGoalSlashCommand(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('goal') && isGoalSlashCommand(message)) { await submitGoalFromInput(); return; } - if (localSlashCommandsEnabled && /^\/compact\s*$/i.test(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('compact') && /^\/compact\s*$/i.test(message)) { await submitCompactFromInput(); return; } - if (localSlashCommandsEnabled && /^\/usage\s*$/i.test(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('usage') && /^\/usage\s*$/i.test(message)) { await submitUsageFromInput(); return; } - if (localSlashCommandsEnabled && /^\/init\s*$/i.test(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('init') && /^\/init\s*$/i.test(message)) { await submitInitFromInput(); return; } - if (localSlashCommandsEnabled && isReviewSlashCommand(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('review') && isReviewSlashCommand(message)) { await submitReviewFromInput(); return; } @@ -4018,21 +4019,21 @@ export const ChatInput: React.FC = ({ return; } - if (localSlashCommandsEnabled && isSlashCommand(message, '/compact')) { + if (promptSlashCommandsEnabled && caps.ops.has('compact') && isSlashCommand(message, '/compact')) { notificationService.warning( t('chatInput.compactUsage') ); return; } - if (localSlashCommandsEnabled && isSlashCommand(message, '/usage')) { + if (promptSlashCommandsEnabled && caps.ops.has('usage') && isSlashCommand(message, '/usage')) { notificationService.warning( t('chatInput.usageCommandUsage') ); return; } - if (localSlashCommandsEnabled && isSlashCommand(message, '/init')) { + if (promptSlashCommandsEnabled && caps.ops.has('init') && isSlashCommand(message, '/init')) { notificationService.warning( t('chatInput.initUsage') ); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index e61b2a11e7..17e7eb777f 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -59,6 +59,7 @@ import { import { ensureBackendSession } from './flow-chat-manager/SessionModule'; import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefreshModule'; import { installDispatchJobObserver } from '../session-drivers/dispatch/install'; +import { driverForSession } from '../session-drivers/registry'; const log = createLogger('FlowChatManager'); @@ -694,6 +695,21 @@ export class FlowChatManager { return cancelSessionTaskModule(this.context, sessionId); } + /** Manually compact a session's context through its driver. */ + async compactSession(sessionId: string): Promise { + const session = this.context.flowChatStore.getState().sessions.get(sessionId); + return driverForSession(sessionId, session).compactSession(this.context, sessionId); + } + + /** Generate and insert the session usage report through its driver. */ + async runSessionUsageReport( + sessionId: string, + uiParams: import('../session-drivers/types').UsageReportUiParams, + ): Promise<{ inserted: boolean }> { + const session = this.context.flowChatStore.getState().sessions.get(sessionId); + return driverForSession(sessionId, session).runUsageReport(this.context, sessionId, uiParams); + } + public async saveAllInProgressTurns(): Promise { return saveAllInProgressTurns(this.context); } diff --git a/src/web-ui/src/flow_chat/services/usageReportService.ts b/src/web-ui/src/flow_chat/services/usageReportService.ts index 983da658d0..fb2402e0df 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.ts @@ -19,6 +19,16 @@ export interface UsageReportCommandParams { failedTitle: string; unknownErrorMessage: string; loadingMarkdown: string; + /** + * Where the report comes from. Defaults to the local backend; a dispatch + * projection supplies the target's `query` verb instead. + */ + fetchReport?: () => Promise; + /** + * Persist the rendered turn to the backend session. Observer projections + * must not persist locally — their transcript cache captures the turn. + */ + persistTurn?: boolean; } export interface UsageReportCommandResult { @@ -58,13 +68,15 @@ export async function runUsageReportCommand( let finalizedPendingTurn = false; try { - const rawReport = await sessionAPI.getSessionUsageReport({ - sessionId: params.session.sessionId, - workspacePath: projectWorkspacePath, - remoteConnectionId: params.session.remoteConnectionId, - remoteSshHost: params.session.remoteSshHost, - includeHiddenSubagents: true, - }); + const rawReport = params.fetchReport + ? await params.fetchReport() + : await sessionAPI.getSessionUsageReport({ + sessionId: params.session.sessionId, + workspacePath: projectWorkspacePath, + remoteConnectionId: params.session.remoteConnectionId, + remoteSshHost: params.session.remoteSshHost, + includeHiddenSubagents: true, + }); const report = enrichUsageReportModelIdentity(rawReport, params.session); const markdown = renderUsageReportMarkdown(report); const turn = pendingTurn @@ -84,7 +96,7 @@ export async function runUsageReportCommand( }); finalizedPendingTurn = !!pendingTurn; - if (turn) { + if (turn && params.persistTurn !== false) { await sessionAPI.saveSessionTurn( toPersistedLocalReportTurn(turn), projectWorkspacePath, 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 3705930255..891bd9361a 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 @@ -16,6 +16,7 @@ import type { PermissionReplyKind, PermissionRequest, } from '@/infrastructure/api/service-api/AgentAPI'; +import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; import type { FlowChatContext, SessionConfig } from '../../services/flow-chat-manager/types'; import type { DialogTurn, Session } from '../../types/flow-chat'; import { FlowChatStore } from '../../store/FlowChatStore'; @@ -29,6 +30,7 @@ import type { SubmissionDraft, SubmissionPlan, TurnTracker, + UsageReportUiParams, } from '../types'; import { FLOWCHAT_PIN_TURN_TO_TOP_EVENT, @@ -59,6 +61,7 @@ const log = createLogger('DispatchSessionDriver'); const IMAGES_UNSUPPORTED_MESSAGE = 'Image attachments are not supported for detached dispatch yet'; const APPEND_RETRY_SCOPE = 'dispatch-append'; const CONTINUE_RETRY_SCOPE = 'dispatch-continue'; +const COMPACT_RETRY_SCOPE = 'dispatch-compact'; const EMPTY_PENDING_PERMISSIONS: ReadonlyArray> = []; /** @@ -425,6 +428,69 @@ export const dispatchSessionDriver: SessionDriver = { return response.cancelled; }, + async compactSession(context: FlowChatContext, sessionId: string): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + const jobId = session?.config.dispatchJobId; + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + const state = session?.config.dispatchJobState; + if (!isDispatchJobTerminal(state)) { + throw new Error('Wait for the current dispatch turn to finish before compacting'); + } + // Same idempotency contract as a prompt follow-up: a retried request + // reuses the turn id so a lost response cannot start two compactions. + const retry = claimSubmissionRetry( + COMPACT_RETRY_SCOPE, + sessionId, + 'compact', + undefined, + () => + globalThis.crypto?.randomUUID?.() + ?? `dispatch-compact-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + try { + const response = await dispatchApi.continueJob(jobId, retry.id, '', undefined, { + kind: 'compact', + }); + if (!response.accepted) { + throw new Error('Dispatch target did not accept the compact turn'); + } + } finally { + releaseSubmissionRetry(COMPACT_RETRY_SCOPE, sessionId, retry.id); + } + requestDispatchJobRefresh(jobId); + }, + + async runUsageReport( + context: FlowChatContext, + sessionId: string, + uiParams: UsageReportUiParams, + ): Promise<{ inserted: boolean }> { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + const jobId = jobIdForSession(sessionId); + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + const { runUsageReportCommand } = await import('../../services/usageReportService'); + const result = await runUsageReportCommand({ + session, + ...uiParams, + // The target computes the report from its persisted session; the + // rendered turn stays in the projection (transcript cache), never in + // local session persistence. + fetchReport: async () => { + const response = await dispatchApi.query(jobId, 'usageReport'); + return response.report as SessionUsageReport; + }, + persistTurn: false, + }); + return { inserted: result.inserted }; + }, + permissionRequestSource(sessionId: string) { return { subscribe: (listener: () => void) => dispatchJobStore.subscribe(listener), 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 f00fec2fa5..3db883bb70 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 @@ -24,6 +24,7 @@ import type { StartTurnResult, SubmissionPlan, TurnTracker, + UsageReportUiParams, } from '../types'; import { FLOWCHAT_PIN_TURN_TO_TOP_EVENT, @@ -220,6 +221,33 @@ export const localSessionDriver: SessionDriver = { return { kind: 'queue' }; }, + async compactSession(context: FlowChatContext, sessionId: string): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + await agentAPI.compactSession({ + sessionId, + workspacePath: session.workspacePath, + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + }); + }, + + async runUsageReport( + context: FlowChatContext, + sessionId: string, + uiParams: UsageReportUiParams, + ): Promise<{ inserted: boolean }> { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + const { runUsageReportCommand } = await import('../../services/usageReportService'); + const result = await runUsageReportCommand({ session, ...uiParams }); + return { inserted: result.inserted }; + }, + permissionRequestSource(): 'live' { return 'live'; }, 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 53efff5d4c..9854d83be9 100644 --- a/src/web-ui/src/flow_chat/session-drivers/types.ts +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -92,6 +92,16 @@ export interface TurnTracker { */ export type StartTurnResult = 'completed' | 'detached'; +/** Localized strings the usage-report flow surfaces; supplied by the caller. */ +export interface UsageReportUiParams { + isProcessing: boolean; + busyMessage: string; + noWorkspaceMessage: string; + failedTitle: string; + unknownErrorMessage: string; + loadingMarkdown: string; +} + /** * Where a session's pending permission requests come from. * @@ -206,6 +216,23 @@ export interface SessionDriver { tracker: TurnTracker, ): Promise; + /** + * 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; + + /** + * Generate and insert the session usage report turn. `uiParams` carries the + * caller's localized strings; the driver decides where the report data + * comes from and whether the rendered turn persists. + */ + runUsageReport( + context: FlowChatContext, + sessionId: string, + uiParams: UsageReportUiParams, + ): Promise<{ inserted: boolean }>; + /** How pending permission requests for this session are obtained. */ permissionRequestSource(sessionId: string): PermissionRequestSource; diff --git a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts index 82e1c8e1ee..1ca0c893b9 100644 --- a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts +++ b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts @@ -16,6 +16,15 @@ import { resolveSessionDriverId, type SessionDriverId } from './resolve'; export const DISPATCH_TRANSFER_ROUND_PREFIX = 'dispatch-transfer:'; +/** Renderer-side slash commands the composer can offer for a session. */ +export type ComposerSlashOp = 'btw' | 'compact' | 'goal' | 'usage' | 'init' | 'review'; + +const LOCAL_SLASH_OPS: ReadonlySet = new Set([ + 'btw', 'compact', 'goal', 'usage', 'init', 'review', +]); +/** Ops a detached target serves via its durable turn mailbox / query verb. */ +const DISPATCH_SLASH_OPS: ReadonlySet = new Set(['compact', 'usage']); + export interface ComposerCapabilities { driverId: SessionDriverId; /** @@ -23,8 +32,10 @@ export interface ComposerCapabilities { * Prefer the semantic flags below for new gates. */ dispatchTransport: boolean; - /** Renderer-side slash commands (/btw /goal /compact /usage /init /review). */ + /** Renderer-side slash-command pickers (full local command surface). */ localSlashCommands: boolean; + /** Individual slash commands this session can execute when typed. */ + ops: ReadonlySet; usageReport: boolean; threadGoal: boolean; /** The submission is being transferred to the target; composer is inert. */ @@ -74,7 +85,8 @@ export function useComposerCapabilities(input: ComposerCapabilityInput): Compose driverId, dispatchTransport, localSlashCommands: !dispatchTransport, - usageReport: !dispatchTransport, + ops: dispatchTransport ? DISPATCH_SLASH_OPS : LOCAL_SLASH_OPS, + usageReport: true, threadGoal: !displayAsChild && !dispatchTransport, transferInFlight, submissionOptionsLocked, diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 88206c1a75..a016f9a7e2 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -68,6 +68,7 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'dispatch_sync_model_config', 'dispatch_submit', 'dispatch_status', + 'dispatch_query', 'dispatch_cancel', 'dispatch_list_jobs', 'dispatch_answer', From 1e0756373061daa664c9624554e17edb78ffa52a Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 22:28:53 -0700 Subject: [PATCH 10/12] feat(dispatch): inline image attachments on submit and follow-up turns Images now reach dispatch sessions. Submit and continue carry inline data-URL attachments which the worker converts to runtime image attachments on the dialog turn; the optimistic projection turn renders the same previews as a local send, and the target's DialogTurnStarted metadata replays them after reconnect. Transport budgets are explicit rather than silently truncated: SSH stages the request file over SFTP (16 MiB total, 8 MiB per image, stdin cap raised to 24 MiB), while the account-device envelope enforces a 192 KiB inline budget with a clear use-an-SSH-target error. Steering has no attachment channel, so images sent while a turn runs are refused with a wait-for-this-turn message instead of the old blanket unsupported error. New inline_attachments capability required at preflight. --- src/apps/cli/src/dispatch/mod.rs | 34 +++++++++ src/apps/cli/src/dispatch/protocol.rs | 23 ++++++ src/apps/cli/src/dispatch/store.rs | 10 ++- src/apps/cli/src/dispatch/worker.rs | 24 ++++++- src/apps/cli/src/root_handlers.rs | 6 +- .../core/src/service/dispatch/controller.rs | 71 +++++++++++++++++++ .../src/service/dispatch/device_controller.rs | 16 +++-- .../src/remote_ssh/dispatch_ssh.rs | 4 +- .../dispatch/DispatchInstallDialog.test.tsx | 3 + .../src/features/dispatch/dispatchApi.ts | 11 +++ .../features/dispatch/dispatchPreflight.ts | 1 + .../src/flow_chat/components/ChatInput.tsx | 3 +- .../dispatch/DispatchSessionDriver.ts | 71 ++++++++++++++++--- 13 files changed, 257 insertions(+), 20 deletions(-) diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index f5769aa30c..1acce66cee 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -149,6 +149,8 @@ async fn probe(request: DispatchProbeRequest) -> Result { // v4: read-only persisted-state queries (usage report) and compact // turns delivered through the continue mailbox. "session_query".to_string(), + // v4: inline image attachments on submit and follow-up turns. + "inline_attachments".to_string(), ]; if runner::is_supported() { capabilities.push("detached_worker".to_string()); @@ -243,8 +245,12 @@ fn continue_job(request: DispatchContinueRequest) -> Result MAX_DISPATCH_TEXT_BYTES { bail!("dispatch follow-up prompt exceeds the 32 KiB safety limit"); } @@ -770,6 +776,32 @@ fn canonical_workspace(workspace_path: &str) -> Result { Ok(canonical) } +fn validate_attachments(attachments: &[protocol::DispatchAttachment]) -> Result<()> { + if attachments.len() > protocol::MAX_DISPATCH_ATTACHMENTS { + bail!( + "dispatch accepts at most {} attachments per turn", + protocol::MAX_DISPATCH_ATTACHMENTS + ); + } + let mut total = 0usize; + for attachment in attachments { + if attachment.id.trim().is_empty() || attachment.id.len() > 128 { + bail!("dispatch attachment id must contain 1-128 bytes"); + } + if !attachment.data_url.starts_with("data:image/") { + bail!("dispatch attachments must be image data URLs"); + } + if attachment.data_url.len() > protocol::MAX_DISPATCH_ATTACHMENT_BYTES { + bail!("a dispatch attachment exceeds the 8 MiB limit"); + } + total = total.saturating_add(attachment.data_url.len()); + } + if total > protocol::MAX_DISPATCH_ATTACHMENTS_TOTAL_BYTES { + bail!("dispatch attachments exceed the 16 MiB total limit"); + } + Ok(()) +} + fn validate_submit_request(request: &DispatchSubmitRequest) -> Result<()> { if request.protocol_version != DISPATCH_PROTOCOL_VERSION { bail!( @@ -793,6 +825,7 @@ fn validate_submit_request(request: &DispatchSubmitRequest) -> Result<()> { if request.prompt.len() > MAX_DISPATCH_TEXT_BYTES { bail!("dispatch prompt exceeds the 32 KiB request limit"); } + validate_attachments(&request.attachments)?; if request.setup_audit.len() > 32 { bail!("dispatch setup audit exceeds the 32-event safety limit"); } @@ -839,6 +872,7 @@ mod tests { approval_policy: DispatchApprovalPolicy::RejectAndReport, model: Some("model-1".to_string()), title: Some("Task".to_string()), + attachments: Vec::new(), setup_audit: Vec::new(), } } diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index 4198d85831..f8f4e5b9dd 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -4,6 +4,25 @@ use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 4; pub(crate) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; +pub(crate) const MAX_DISPATCH_ATTACHMENTS: usize = 8; +pub(crate) const MAX_DISPATCH_ATTACHMENT_BYTES: usize = 8 * 1024 * 1024; +pub(crate) const MAX_DISPATCH_ATTACHMENTS_TOTAL_BYTES: usize = 16 * 1024 * 1024; + +/// One inline image attachment for a submit/continue turn. +/// +/// v4 carries images as data URLs inside the request: SSH stages the request +/// as a file over SFTP so size is a policy choice, while the account-device +/// envelope keeps a much smaller controller-enforced budget. Staged chunked +/// transfer for larger payloads is a follow-up capability. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchAttachment { + pub(crate) id: String, + #[serde(default)] + pub(crate) name: Option, + pub(crate) mime_type: String, + pub(crate) data_url: String, +} #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -80,6 +99,8 @@ pub(crate) struct DispatchSubmitRequest { pub(crate) model: Option, #[serde(default)] pub(crate) title: Option, + #[serde(default)] + pub(crate) attachments: Vec, /// Controller-side setup actions that happened before the target job could /// exist (currently the signed CLI auto-install). They are replayed into /// the durable job event log at creation time and are deliberately excluded @@ -359,6 +380,8 @@ pub(crate) struct DispatchContinueRequest { /// Operation the worker runs; defaults to an ordinary prompt turn. #[serde(default)] pub(crate) kind: DispatchTurnKind, + #[serde(default)] + pub(crate) attachments: Vec, } #[derive(Clone, Debug, Serialize, PartialEq, Eq)] diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs index b8f378c731..d9ad3e60dc 100644 --- a/src/apps/cli/src/dispatch/store.rs +++ b/src/apps/cli/src/dispatch/store.rs @@ -7,8 +7,8 @@ use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; use serde::{Deserialize, Serialize}; use super::protocol::{ - DispatchAppendRequest, DispatchApprovalPolicy, DispatchContinueRequest, DispatchEvent, - DispatchJobListEntry, DispatchJobState, DispatchSubmitRequest, DispatchTurnKind, + DispatchAppendRequest, DispatchApprovalPolicy, DispatchAttachment, DispatchContinueRequest, + DispatchEvent, DispatchJobListEntry, DispatchJobState, DispatchSubmitRequest, DispatchTurnKind, DISPATCH_PROTOCOL_VERSION, }; @@ -170,6 +170,8 @@ pub(crate) struct StoredFollowUpTurn { pub(crate) approval_policy: Option, #[serde(default)] pub(crate) kind: DispatchTurnKind, + #[serde(default)] + pub(crate) attachments: Vec, pub(crate) created_at: String, } @@ -181,6 +183,7 @@ impl StoredFollowUpTurn { && self.model == other.model && self.approval_policy == other.approval_policy && self.kind == other.kind + && self.attachments == other.attachments } } @@ -735,6 +738,7 @@ impl DispatchStore { model: request.model.clone(), approval_policy: request.approval_policy, kind: request.kind, + attachments: request.attachments.clone(), created_at: chrono::Utc::now().to_rfc3339(), }; // A retried request must not start a second turn. Both mailboxes are @@ -2116,6 +2120,7 @@ mod tests { approval_policy: DispatchApprovalPolicy::RejectAndReport, model: Some("model-1".to_string()), title: None, + attachments: Vec::new(), setup_audit: Vec::new(), } } @@ -2231,6 +2236,7 @@ mod tests { model: None, approval_policy: None, kind: DispatchTurnKind::Prompt, + attachments: Vec::new(), } } diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index be534c0bb6..184bb09972 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -216,6 +216,10 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { .as_ref() .map(|turn| turn.prompt.clone()) .unwrap_or_else(|| job.request.prompt.clone()); + let turn_attachments = follow_up + .as_ref() + .map(|turn| runtime_attachments(&turn.attachments)) + .unwrap_or_else(|| runtime_attachments(&job.request.attachments)); agent_runtime .submit_dialog_turn(AgentDialogTurnRequest { session_id: job.request.session_id.clone(), @@ -230,7 +234,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), reply_route: None, prepended_reminders: Vec::new(), - attachments: Vec::new(), + attachments: turn_attachments, metadata: permissions::metadata(effective_policy), }) .await @@ -523,6 +527,24 @@ async fn cancel_turn( } } +fn runtime_attachments( + attachments: &[super::protocol::DispatchAttachment], +) -> Vec { + attachments + .iter() + .map(|attachment| { + bitfun_runtime_ports::AgentInputAttachment::remote_image( + attachment.id.clone(), + attachment + .name + .clone() + .unwrap_or_else(|| attachment.id.clone()), + attachment.data_url.clone(), + ) + }) + .collect() +} + fn permission_targets_job(request: &PermissionRequest, session_id: &str) -> bool { crate::runtime::approval::permission_request_targets_session(request, session_id) } diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 856550f71a..3c876b9447 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -27,7 +27,9 @@ use crate::{ ExternalPolicyModeArg, ExternalPolicyScopeArg, SessionAction, }; -const MAX_DISPATCH_STDIN_BYTES: u64 = 2 * 1024 * 1024; +/// Sized for submit/continue requests carrying inline image attachments +/// (16 MiB of data URLs) plus headroom for the rest of the payload. +const MAX_DISPATCH_STDIN_BYTES: u64 = 24 * 1024 * 1024; pub(crate) struct ExecCommandArgs { pub message: Option, @@ -87,7 +89,7 @@ pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()> .read_to_string(&mut raw) .context("read dispatch JSON from stdin")?; if raw.len() as u64 > MAX_DISPATCH_STDIN_BYTES { - anyhow::bail!("dispatch JSON input exceeds the 2 MiB safety limit"); + anyhow::bail!("dispatch JSON input exceeds the 24 MiB safety limit"); } } let input = if raw.trim().is_empty() { diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index e104981e0a..99ba32912b 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -78,6 +78,8 @@ pub struct DispatchSubmitRequest { pub source_workspace_path: Option, #[serde(default)] pub source_workspace_id: Option, + #[serde(default)] + pub attachments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -144,6 +146,8 @@ pub struct DispatchContinueRequest { /// Operation kind understood by the target (`prompt` default, `compact`). #[serde(default)] pub kind: Option, + #[serde(default)] + pub attachments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -156,6 +160,64 @@ pub struct DispatchAppendRequest { pub display_content: Option, } +/// One inline image attachment forwarded verbatim to the target. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DispatchAttachmentPayload { + pub id: String, + #[serde(default)] + pub name: Option, + pub mime_type: String, + pub data_url: String, +} + +/// Inline budget for the account-device envelope. SSH stages the request as +/// an SFTP file, so only relay-carried requests need this much smaller cap. +pub(super) const MAX_DEVICE_ATTACHMENTS_TOTAL_BYTES: usize = 192 * 1024; +pub(super) const MAX_ATTACHMENTS: usize = 8; +pub(super) const MAX_ATTACHMENT_BYTES: usize = 8 * 1024 * 1024; +pub(super) const MAX_ATTACHMENTS_TOTAL_BYTES: usize = 16 * 1024 * 1024; + +pub(super) fn validate_attachment_payloads( + attachments: &[DispatchAttachmentPayload], +) -> anyhow::Result<()> { + if attachments.len() > MAX_ATTACHMENTS { + anyhow::bail!("Dispatch accepts at most 8 image attachments per turn"); + } + let mut total = 0usize; + for attachment in attachments { + if attachment.id.trim().is_empty() || attachment.id.len() > 128 { + anyhow::bail!("Dispatch attachment id must contain 1-128 bytes"); + } + if !attachment.data_url.starts_with("data:image/") { + anyhow::bail!("Dispatch attachments must be image data URLs"); + } + if attachment.data_url.len() > MAX_ATTACHMENT_BYTES { + anyhow::bail!("A dispatch attachment exceeds the 8 MiB limit"); + } + total = total.saturating_add(attachment.data_url.len()); + } + if total > MAX_ATTACHMENTS_TOTAL_BYTES { + anyhow::bail!("Dispatch attachments exceed the 16 MiB total limit"); + } + Ok(()) +} + +pub(super) fn validate_device_attachment_budget( + attachments: &[DispatchAttachmentPayload], +) -> anyhow::Result<()> { + let total: usize = attachments + .iter() + .map(|attachment| attachment.data_url.len()) + .sum(); + if total > MAX_DEVICE_ATTACHMENTS_TOTAL_BYTES { + anyhow::bail!( + "Device dispatch carries at most 192 KiB of inline images; use an SSH target for larger screenshots" + ); + } + Ok(()) +} + /// Read-only persisted-state question answered by the target without /// starting a turn or initializing a runtime. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -536,6 +598,9 @@ pub async fn submit( if let Some(title) = request.title.filter(|value| !value.trim().is_empty()) { protocol_request["title"] = Value::String(title); } + if !request.attachments.is_empty() { + protocol_request["attachments"] = serde_json::to_value(&request.attachments)?; + } let response = match dispatch_ssh::submit(manager, connection_id, &protocol_request).await { Ok(response) => response, @@ -1047,6 +1112,10 @@ pub(super) fn continue_payload(request: &DispatchContinueRequest) -> Value { { payload["kind"] = Value::String(kind.to_string()); } + if !request.attachments.is_empty() { + payload["attachments"] = serde_json::to_value(&request.attachments) + .unwrap_or(Value::Null); + } payload } @@ -1148,6 +1217,7 @@ pub(super) fn validate_submit_request(request: &DispatchSubmitRequest) -> anyhow }) { anyhow::bail!("Dispatch baseRef is invalid"); } + validate_attachment_payloads(&request.attachments)?; Ok(()) } @@ -1202,6 +1272,7 @@ pub(super) fn validate_continue_request(request: &DispatchContinueRequest) -> an anyhow::bail!("Dispatch approval policy override is not recognized"); } } + validate_attachment_payloads(&request.attachments)?; Ok(()) } diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs index a29ea27548..0399af5eb4 100644 --- a/src/crates/assembly/core/src/service/dispatch/device_controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -17,11 +17,11 @@ use super::controller::{ bind_outbound_record, continue_payload, finish_sync, provisioned_path, record_follow_up_state, release_unbound_preparation_baseline, result_bundle_path, same_target_identity, target_have_tips, validate_answer_request, validate_append_request, validate_continue_request, - validate_query_request, validate_submission_preflight, validate_submit_ack, - validate_submit_request, DispatchAnswerRequest, DispatchAppendRequest, DispatchContinueRequest, - DispatchJobRequest, DispatchListJobsRequest, DispatchProbeTargetRequest, DispatchQueryJobRequest, - DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, - DISPATCH_PROTOCOL_VERSION, + validate_device_attachment_budget, validate_query_request, validate_submission_preflight, + validate_submit_ack, validate_submit_request, DispatchAnswerRequest, DispatchAppendRequest, + DispatchContinueRequest, DispatchJobRequest, DispatchListJobsRequest, + DispatchProbeTargetRequest, DispatchQueryJobRequest, DispatchStatusRequest, + DispatchSubmitRequest, DispatchSyncResultRequest, DISPATCH_PROTOCOL_VERSION, }; use super::preparation::{DispatchPreparationRequest, DispatchPreparationTarget}; use super::{ @@ -301,6 +301,11 @@ pub async fn submit_device( if let Some(title) = request.title.filter(|value| !value.trim().is_empty()) { payload["title"] = Value::String(title); } + if !request.attachments.is_empty() { + validate_device_attachment_budget(&request.attachments)?; + payload["attachments"] = + serde_json::to_value(&request.attachments).unwrap_or(Value::Null); + } let response = match rpc .invoke(device_id, "dispatch_target_submit", payload) @@ -468,6 +473,7 @@ pub async fn continue_device_job( request: DispatchContinueRequest, ) -> anyhow::Result { validate_continue_request(&request)?; + validate_device_attachment_budget(&request.attachments)?; let record = load_device_record(store, &request.job_id).await?; let DispatchTarget::Device { device_id, .. } = &record.target else { unreachable!("load_device_record validates target kind") diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index b7ef03c3f1..d9637652c4 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -93,7 +93,7 @@ const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profil /// previous release, so comparing only the installed and controller version /// strings is not a sound compatibility test. const FIRST_COMPATIBLE_STABLE_DISPATCH_RELEASE: (u64, u64, u64) = (0, 2, 16); -const REQUIRED_DISPATCH_CAPABILITIES: [&str; 16] = [ +const REQUIRED_DISPATCH_CAPABILITIES: [&str; 17] = [ "persistent_jobs", "cursor_events", "detached_worker", @@ -114,6 +114,8 @@ const REQUIRED_DISPATCH_CAPABILITIES: [&str; 16] = [ "per_turn_options", // v4: read-only persisted-state queries and compact turns. "session_query", + // v4: inline image attachments on submit and follow-up turns. + "inline_attachments", ]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 4b967a00c9..7d96a42199 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -436,6 +436,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { 'approval_remote', 'per_turn_options', 'session_query', + 'inline_attachments', ], modelConfigured: true, availableModels: ['model-a', 'model-b'], @@ -595,6 +596,7 @@ describe('DispatchInstallDialog model configuration sync', () => { 'dispatch_worker_cli_profile', 'per_turn_options', 'session_query', + 'inline_attachments', ], modelConfigured, availableModels: modelConfigured ? ['claude'] : [], @@ -755,6 +757,7 @@ describe('DispatchInstallDialog target model readout', () => { 'dispatch_worker_cli_profile', 'per_turn_options', 'session_query', + 'inline_attachments', ], modelConfigured: true, availableModels, diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts index e5f81713f1..892934bd30 100644 --- a/src/web-ui/src/features/dispatch/dispatchApi.ts +++ b/src/web-ui/src/features/dispatch/dispatchApi.ts @@ -17,6 +17,14 @@ import type { OutboundDispatchRecord, } from './types'; +/** One inline image attachment forwarded to the target with the turn. */ +export interface DispatchInlineAttachment { + id: string; + name?: string; + mimeType: string; + dataUrl: string; +} + export const dispatchApi = { async listTargets(): Promise { return api.invoke('dispatch_list_targets', { @@ -90,6 +98,7 @@ export const dispatchApi = { title?: string; sourceWorkspacePath?: string; sourceWorkspaceId?: string; + attachments?: DispatchInlineAttachment[]; }): Promise { return api.invoke('dispatch_submit', { request, @@ -115,6 +124,7 @@ export const dispatchApi = { approvalPolicy?: DispatchApprovalPolicy; /** Operation kind; defaults to an ordinary prompt turn. */ kind?: 'prompt' | 'compact'; + attachments?: DispatchInlineAttachment[]; }, ): Promise { return api.invoke('dispatch_continue', { @@ -126,6 +136,7 @@ export const dispatchApi = { model: options?.model, approvalPolicy: options?.approvalPolicy, kind: options?.kind, + attachments: options?.attachments, }, }); }, diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.ts index c69f413957..7899023425 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.ts @@ -19,4 +19,5 @@ export const BASE_DISPATCH_CAPABILITIES = [ 'dispatch_worker_cli_profile', 'per_turn_options', 'session_query', + 'inline_attachments', ] as const; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 9aa1e125af..072f4839a0 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -2994,7 +2994,7 @@ export const ChatInput: React.FC = ({ selectedIndex: 0, }); } - }, [contexts, derivedState, dispatchInput, externalPromptCommands, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, selectedExternalPromptCandidateId, selectedNonExternalSlashCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind, caps.localSlashCommands]); + }, [contexts, derivedState, dispatchInput, externalPromptCommands, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, selectedExternalPromptCandidateId, selectedNonExternalSlashCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind, caps.localSlashCommands, caps.ops]); const submitBtwFromInput = useCallback(async () => { if (!derivedState) return; @@ -4159,6 +4159,7 @@ export const ChatInput: React.FC = ({ resolveTypedMcpPromptCommand, submitExternalPromptCommandFromInput, caps.localSlashCommands, + caps.ops, composerMutationRevision, ]); 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 891bd9361a..c4ccbd612d 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 @@ -58,7 +58,9 @@ import { applyGeneratingTitlePlaceholder } from '../shared'; const log = createLogger('DispatchSessionDriver'); -const IMAGES_UNSUPPORTED_MESSAGE = 'Image attachments are not supported for detached dispatch yet'; +const IMAGES_WHILE_RUNNING_MESSAGE = + 'Images join the next turn; wait for the current dispatch turn to finish'; +const DEVICE_ATTACHMENT_BUDGET_BYTES = 192 * 1024; const APPEND_RETRY_SCOPE = 'dispatch-append'; const CONTINUE_RETRY_SCOPE = 'dispatch-continue'; const COMPACT_RETRY_SCOPE = 'dispatch-compact'; @@ -79,6 +81,42 @@ function jobIdForSession(sessionId: string): string | undefined { .find(job => job.sessionId === sessionId)?.jobId; } +/** + * Convert composer image contexts into inline wire attachments. Throws when a + * context has no data URL (path-only) or a device target's inline budget is + * exceeded — both need the user to adjust, not silent truncation. + */ +function dispatchAttachments( + session: Session | undefined, + imageContexts: readonly { id: string; data_url?: string; mime_type: string; metadata?: Record }[] | undefined, +): import('@/features/dispatch/dispatchApi').DispatchInlineAttachment[] | undefined { + if (!imageContexts?.length) { + return undefined; + } + const attachments = imageContexts.map(image => { + const dataUrl = image.data_url?.trim(); + if (!dataUrl) { + throw new Error('This image has no inline data and cannot be sent to a dispatch target'); + } + const name = typeof image.metadata?.name === 'string' ? image.metadata.name : undefined; + return { + id: image.id, + name, + mimeType: image.mime_type, + dataUrl, + }; + }); + if (session?.config.dispatchTarget?.kind === 'device') { + const total = attachments.reduce((sum, attachment) => sum + attachment.dataUrl.length, 0); + if (total > DEVICE_ATTACHMENT_BUDGET_BYTES) { + throw new Error( + 'Device dispatch carries at most 192 KiB of inline images; use an SSH target for larger screenshots', + ); + } + } + return attachments; +} + function pinTurnToTop(sessionId: string, turnId: string): void { globalEventBus.emit( FLOWCHAT_PIN_TURN_TO_TOP_EVENT, @@ -146,13 +184,20 @@ async function continueDispatchJob( // per-turn overrides which the target persists onto the job. const turnModel = followUpSession.config.dispatchModel?.trim() || undefined; const turnApprovalPolicy = followUpSession.config.dispatchApprovalPolicy; + const turnAttachments = dispatchAttachments(followUpSession, input.options?.imageContexts); // Reused across retries so a lost response cannot start two turns. The - // match key includes the per-turn options: the target refuses a turnId - // bound to different content, so changed options must mint a new turn id. + // match key includes the per-turn options and attachment ids: the target + // refuses a turnId bound to different content, so changed inputs must mint + // a new turn id. const retry = claimSubmissionRetry( CONTINUE_RETRY_SCOPE, sessionId, - JSON.stringify([message, turnModel ?? null, turnApprovalPolicy ?? null]), + JSON.stringify([ + message, + turnModel ?? null, + turnApprovalPolicy ?? null, + turnAttachments?.map(attachment => attachment.id) ?? null, + ]), displayMessage, () => globalThis.crypto?.randomUUID?.() @@ -168,6 +213,8 @@ async function continueDispatchJob( id: `user_dispatch_${retry.id}`, content: displayMessage || message, timestamp: Date.now(), + hasImages: (input.options?.imageDisplayData?.length ?? 0) > 0, + images: input.options?.imageDisplayData, metadata: markOptimisticDispatchTurnMetadata( input.options?.userMessageMetadata, jobId, @@ -188,6 +235,7 @@ async function continueDispatchJob( { model: turnModel, approvalPolicy: turnApprovalPolicy, + ...(turnAttachments ? { attachments: turnAttachments } : {}), }, ); if (!response.accepted) { @@ -564,7 +612,9 @@ export const dispatchSessionDriver: SessionDriver = { return { kind: 'queue' }; } if (draft.hasImages) { - return { kind: 'reject', reason: IMAGES_UNSUPPORTED_MESSAGE }; + // Steering has no attachment channel; the runtime accepts images only + // at turn boundaries. + return { kind: 'reject', reason: IMAGES_WHILE_RUNNING_MESSAGE }; } return { kind: 'steer' }; }, @@ -603,11 +653,12 @@ export const dispatchSessionDriver: SessionDriver = { if (!targetRequest || targetRequest.kind === 'local' || !jobId || !approvalPolicy) { throw new Error('Dispatch session is missing its immutable target or approval policy'); } - if ((options?.imageContexts?.length ?? 0) > 0) { - throw new Error(IMAGES_UNSUPPORTED_MESSAGE); - } const dispatchState = readySession.config.dispatchJobState; if (dispatchState === 'queued' || dispatchState === 'running') { + if ((options?.imageContexts?.length ?? 0) > 0) { + // Steering has no attachment channel; images ride turn boundaries. + throw new Error(IMAGES_WHILE_RUNNING_MESSAGE); + } // A turn is already in flight; this message steers it rather than // starting another one underneath it. await appendToDispatchJob(sessionId, jobId, message, displayMessage); @@ -630,6 +681,7 @@ export const dispatchSessionDriver: SessionDriver = { applyGeneratingTitlePlaceholder(context, sessionId, message); } + const submitAttachments = dispatchAttachments(readySession, options?.imageContexts); const optimisticTurnId = `dispatch_pending_${jobId}`; const optimisticTurn: DialogTurn = { id: optimisticTurnId, @@ -639,6 +691,8 @@ export const dispatchSessionDriver: SessionDriver = { id: `user_dispatch_${Date.now()}`, content: displayMessage || message, timestamp: Date.now(), + hasImages: (options?.imageDisplayData?.length ?? 0) > 0, + images: options?.imageDisplayData, metadata: markOptimisticDispatchTurnMetadata( options?.userMessageMetadata, jobId, @@ -681,6 +735,7 @@ export const dispatchSessionDriver: SessionDriver = { model: readySession.config.dispatchModel?.trim() || undefined, ...(sourceWorkspacePath ? { sourceWorkspacePath } : {}), ...(sourceWorkspaceId ? { sourceWorkspaceId } : {}), + ...(submitAttachments ? { attachments: submitAttachments } : {}), }); } finally { clearRuntimeStatusState({ From 34415337475586a8f2b4f72b03fc9e122da8dae8 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 22:33:47 -0700 Subject: [PATCH 11/12] feat(dispatch): project subagent sessions through the job event log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker's event filter becomes a JobEventScope: SubagentSessionLinked events whose parent is the job session (or an already-admitted child, recursively) register the child and are published, all further events of admitted children flow into the log, and child permission requests route through the same durable mailbox. Parent turn discipline is unchanged and foreign links are still refused (pinned by test). On the controller, the observer stops suppressing the link event and the driver resolver gains a fourth signal: a session whose parent chain leads to a dispatch projection is itself dispatch-driven — never persisted locally, never treated as a local backend session — with the session lookup injected by FlowChatManager to keep the resolver a leaf module. Child transcripts now render under dispatch projections exactly like local subagent runs. --- src/apps/cli/src/dispatch/worker.rs | 136 ++++++++++++++---- .../dispatch/DispatchJobObserver.test.ts | 9 +- .../features/dispatch/DispatchJobObserver.ts | 9 +- src/web-ui/src/features/dispatch/README.md | 19 +-- .../src/flow_chat/services/FlowChatManager.ts | 4 + .../flow_chat/session-drivers/resolve.test.ts | 31 ++++ .../src/flow_chat/session-drivers/resolve.ts | 39 +++++ 7 files changed, 203 insertions(+), 44 deletions(-) diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index 184bb09972..740e6f90e9 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -253,6 +253,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { } } + let mut event_scope = JobEventScope::new(job.request.session_id.clone(), turn_id.clone()); let mut initial_permissions = agent_runtime .pending_permission_requests() .unwrap_or_default() @@ -264,7 +265,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { let (terminal_state, terminal_error) = loop { if let Some(request) = initial_permissions.pop_front() { - if permission_targets_job(&request, &job.request.session_id) + if event_scope.permission_targets_job(&request) && handled_permissions.insert(request.request_id.clone()) { if let Some(reason) = handle_permission( @@ -303,7 +304,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { ); } }; - if !event_belongs_to_job(&envelope.event, &job.request.session_id, &turn_id) { + if !event_scope.admit(&envelope.event) { continue; } let projection = project_agentic_frontend_event(envelope.event.clone()) @@ -339,7 +340,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { let PermissionRequestEvent::Asked { request } = event else { continue; }; - if !permission_targets_job(&request, &job.request.session_id) + if !event_scope.permission_targets_job(&request) || !handled_permissions.insert(request.request_id.clone()) { continue; @@ -545,24 +546,61 @@ fn runtime_attachments( .collect() } -fn permission_targets_job(request: &PermissionRequest, session_id: &str) -> bool { - crate::runtime::approval::permission_request_targets_session(request, session_id) +/// Which sessions' events belong in this job's log. +/// +/// The job session's turn-scoped events must match the worker's turn, and any +/// subagent session linked under it (recursively) is admitted wholesale so +/// the controller can project child transcripts. +struct JobEventScope { + session_id: String, + turn_id: String, + children: std::collections::HashSet, } -fn event_belongs_to_job(event: &AgenticEvent, session_id: &str, turn_id: &str) -> bool { - if matches!(event, AgenticEvent::SubagentSessionLinked { .. }) { - // Detached dispatch has no child-session observer or dispatch marker. Publishing - // this link would create an empty local-looking child in the Web UI, - // while every later child event is correctly outside the parent scope. - return false; +impl JobEventScope { + fn new(session_id: String, turn_id: String) -> Self { + Self { + session_id, + turn_id, + children: std::collections::HashSet::new(), + } } - if event - .session_id() - .is_some_and(|event_session| event_session != session_id) - { - return false; + + fn admit(&mut self, event: &AgenticEvent) -> bool { + if let AgenticEvent::SubagentSessionLinked { + session_id: child_session, + parent_session_id, + .. + } = event + { + if parent_session_id == &self.session_id + || self.children.contains(parent_session_id) + { + self.children.insert(child_session.clone()); + return true; + } + return false; + } + match event.session_id() { + Some(event_session) if event_session == self.session_id => { + event_turn_id(event).is_none_or(|event_turn| event_turn == self.turn_id) + } + Some(event_session) => self.children.contains(event_session), + None => true, + } + } + + fn permission_targets_job(&self, request: &PermissionRequest) -> bool { + if crate::runtime::approval::permission_request_targets_session(request, &self.session_id) + { + return true; + } + self.children + .iter() + .any(|child| { + crate::runtime::approval::permission_request_targets_session(request, child) + }) } - event_turn_id(event).is_none_or(|event_turn| event_turn == turn_id) } fn event_turn_id(event: &AgenticEvent) -> Option<&str> { @@ -668,7 +706,20 @@ mod tests { } #[test] - fn dispatch_does_not_publish_subagent_sessions_before_child_observers_exist() { + fn linked_subagent_sessions_flow_into_the_job_event_scope() { + let mut scope = JobEventScope::new("session-1".to_string(), "turn-1".to_string()); + + let child_chunk = AgenticEvent::TextChunk { + session_id: "child-session".to_string(), + turn_id: "child-turn".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "child output".to_string(), + }; + // A child that was never linked stays outside the scope. + assert!(!scope.admit(&child_chunk)); + let linked = AgenticEvent::SubagentSessionLinked { session_id: "child-session".to_string(), subagent_dialog_turn_id: "child-turn".to_string(), @@ -679,18 +730,36 @@ mod tests { model_id: None, focused_review_display_label: None, }; - assert!(!event_belongs_to_job(&linked, "session-1", "turn-1")); - - let child_chunk = AgenticEvent::TextChunk { - session_id: "child-session".to_string(), - turn_id: "child-turn".to_string(), - round_id: "round-1".to_string(), - attempt_id: None, - attempt_index: None, - text: "child output".to_string(), + assert!(scope.admit(&linked)); + assert!(scope.admit(&child_chunk)); + + // Grandchildren link recursively through an admitted child. + let grandchild_link = AgenticEvent::SubagentSessionLinked { + session_id: "grandchild-session".to_string(), + subagent_dialog_turn_id: "grandchild-turn".to_string(), + parent_session_id: "child-session".to_string(), + parent_dialog_turn_id: "child-turn".to_string(), + parent_tool_call_id: "tool-2".to_string(), + agent_type: None, + model_id: None, + focused_review_display_label: None, + }; + assert!(scope.admit(&grandchild_link)); + + // A link from an unrelated parent is refused. + let foreign_link = AgenticEvent::SubagentSessionLinked { + session_id: "other-child".to_string(), + subagent_dialog_turn_id: "t".to_string(), + parent_session_id: "unrelated-session".to_string(), + parent_dialog_turn_id: "t".to_string(), + parent_tool_call_id: "tool-3".to_string(), + agent_type: None, + model_id: None, + focused_review_display_label: None, }; - assert!(!event_belongs_to_job(&child_chunk, "session-1", "turn-1")); + assert!(!scope.admit(&foreign_link)); + // Parent turn discipline is unchanged. let parent_chunk = AgenticEvent::TextChunk { session_id: "session-1".to_string(), turn_id: "turn-1".to_string(), @@ -699,6 +768,15 @@ mod tests { attempt_index: None, text: "parent output".to_string(), }; - assert!(event_belongs_to_job(&parent_chunk, "session-1", "turn-1")); + assert!(scope.admit(&parent_chunk)); + let stale_parent_chunk = AgenticEvent::TextChunk { + session_id: "session-1".to_string(), + turn_id: "turn-0".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "stale output".to_string(), + }; + assert!(!scope.admit(&stale_parent_chunk)); } } diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts index 99225e9372..bcdf961cd4 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -387,7 +387,9 @@ describe('DispatchJobObserver', () => { }); }); - it('ignores subagent links until child dispatch projections have an owner', () => { + it('projects subagent links so child sessions render under the projection', () => { + // Child ownership is driver-resolved through the parent chain, so the + // link event flows into the normal pipeline like any other event. expect(projectDispatchAgentEvent({ type: 'agentEvent', timestamp: '2026-07-28T00:00:00Z', @@ -404,7 +406,10 @@ describe('DispatchJobObserver', () => { child_session_id: 'child-1', }, }, - })).toBeNull(); + })).toMatchObject({ + eventName: 'agentic://subagent-session-linked', + envelopeId: 'event-child', + }); }); it('keeps the cursor until an event applies, then deduplicates it on replay', async () => { diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index b1ef69ca02..f007de09b6 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -68,8 +68,10 @@ const RAW_EVENT_NAMES: Record = { ImageAnalysisStarted: 'agentic://image-analysis-started', ImageAnalysisCompleted: 'agentic://image-analysis-completed', DialogTurnStarted: 'agentic://dialog-turn-started', - // Detached dispatch has no child-observer ownership. Ignoring this link prevents an - // unmarked child projection from being mistaken for a local session. + // v4: the target admits linked subagent sessions into the job event log, + // and the driver resolver treats child sessions of a projection as + // observer-only through the parent chain. + SubagentSessionLinked: 'agentic://subagent-session-linked', ModelRoundStarted: 'agentic://model-round-started', ModelRoundCompleted: 'agentic://model-round-completed', ModelRoundAttemptSuperseded: 'agentic://model-round-attempt-superseded', @@ -124,9 +126,6 @@ export function projectDispatchAgentEvent( || (envelope?.frontendPayload && typeof envelope.frontendPayload === 'object' ? envelope.frontendPayload : undefined); - if (projectedName === 'agentic://subagent-session-linked') { - return null; - } if (projectedName && projectedPayload) { return { eventName: projectedName, diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index 68712a93e8..913604ef26 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -6,7 +6,9 @@ dispatch. ## Invariants 1. A dispatch target is selected while creating a session and is immutable after - the first turn. + the first turn. The model and approval policy are not: protocol v4 carries + them per follow-up turn, and the target persists the effective values onto + the job. 1a. A dispatch session accepts follow-up messages. While a turn runs, a message is an `append` that steers it; once it has finished, a message is a `dispatch_continue` that queues the next turn against the same target @@ -67,10 +69,10 @@ dispatch. `dispatch_target_*` commands. They never attach Peer Device Mode and an offline or incompatible target never falls back to local execution. Device dispatch does not install software through the Relay. -14. Approval policy is explicit per job: `auto`, `reject-and-report`, or - `remote`. `remote` projects pending requests into the normal permission - panel. The selected policy is visible in the normal session controls; submit - must not add a second confirmation dialog. +14. Approval policy is explicit and editable between turns: `auto`, + `reject-and-report`, or `remote`. `remote` projects pending requests into + the normal permission panel. The selected policy is visible in the normal + session controls; submit must not add a second confirmation dialog. 15. MiniApp and quick-input hosts do not expose the dispatch picker. 16. Controller-side model settings never leak into an SSH dispatch. The submit omits `model` unless preflight recorded an explicit target model choice. @@ -89,9 +91,10 @@ dispatch. `knownHead` unchanged, and can be repeated after the lock clears. 19. Deleting or archiving a projection writes a local job tombstone so outbound reconciliation cannot silently reopen it. -20. The observer ignores `SubagentSessionLinked`. Child observer ownership is not - implemented, so creating an unmarked child projection would violate the - observer-only persistence and cancellation boundary. +20. Subagent sessions linked under a dispatch job flow through the event log + and render as child projections. Ownership is driver-resolved through the + parent chain: a child of a projection is itself observer-only (never + persisted locally, never driven as a local backend session). 21. Cursor reads are multi-observer safe. Truncation and omitted events are visible completeness facts and must not be rendered as a full transcript. 22. The observer continues bounded polling while the window is hidden so diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 17e7eb777f..67faa592fe 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -60,6 +60,7 @@ import { ensureBackendSession } from './flow-chat-manager/SessionModule'; import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefreshModule'; import { installDispatchJobObserver } from '../session-drivers/dispatch/install'; import { driverForSession } from '../session-drivers/registry'; +import { registerDriverSessionLookup } from '../session-drivers/resolve'; const log = createLogger('FlowChatManager'); @@ -101,6 +102,9 @@ export class FlowChatManager { }; this.agentService = AgentService.getInstance(); + registerDriverSessionLookup( + sessionId => this.context.flowChatStore.getState().sessions.get(sessionId), + ); installPendingQueueDrainListener(this.context); this.peerSessionRefreshCleanup = installPeerSessionRefresh(this.context); this.dispatchJobObserverCleanup = installDispatchJobObserver(this.context); diff --git a/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts b/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts index d8ff71c6c0..3f4bc7d6ba 100644 --- a/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts +++ b/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts @@ -102,6 +102,37 @@ describe('resolveSessionDriverId (dispatchJobStore integration)', () => { }); }); +describe('parent-chain inheritance', () => { + it('treats a child of a dispatch projection as dispatch-driven', () => { + const parent = { + config: { + dispatchTarget: { + kind: 'ssh' as const, + connectionId: 'ssh-user@host', + workspacePath: '/repo', + displayName: 'host', + }, + }, + }; + const child = { parentSessionId: 'parent-1', config: {} }; + const grandchild = { parentSessionId: 'child-1', config: {} }; + const sessions = new Map([ + ['parent-1', parent], + ['child-1', child], + ]); + const lookup = (id: string) => sessions.get(id); + + expect(resolveSessionDriverIdWith('child-1', child, never, lookup)).toBe('dispatch'); + expect(resolveSessionDriverIdWith('grandchild-1', grandchild, never, lookup)).toBe('dispatch'); + // A child of a local parent stays local. + const localChild = { parentSessionId: 'local-parent', config: {} }; + const localSessions = new Map([['local-parent', { config: {} }]]); + expect( + resolveSessionDriverIdWith('c', localChild, never, id => localSessions.get(id)), + ).toBe('local'); + }); +}); + describe('resolveSessionDriverIdForCreation', () => { it('keys off the requested dispatch target', () => { expect(resolveSessionDriverIdForCreation({})).toBe('local'); diff --git a/src/web-ui/src/flow_chat/session-drivers/resolve.ts b/src/web-ui/src/flow_chat/session-drivers/resolve.ts index bff476ef98..09f69aaa90 100644 --- a/src/web-ui/src/flow_chat/session-drivers/resolve.ts +++ b/src/web-ui/src/flow_chat/session-drivers/resolve.ts @@ -19,12 +19,29 @@ export type SessionDriverId = 'local' | 'dispatch'; * store modules and tests can pass lightweight objects. */ export interface DriverResolvableSession { + /** Child sessions (subagents, /btw) inherit the driver of their parent. */ + parentSessionId?: string; config?: { dispatchTarget?: DispatchTarget; dispatchJobId?: string; }; } +type SessionLookup = (sessionId: string) => DriverResolvableSession | undefined; + +let parentSessionLookup: SessionLookup | null = null; + +/** + * Register how the resolver walks a session's parent chain. Installed once by + * FlowChatManager; kept as an injected function because the store module + * itself depends on this resolver. + */ +export function registerDriverSessionLookup(lookup: SessionLookup): void { + parentSessionLookup = lookup; +} + +const MAX_PARENT_CHAIN_DEPTH = 4; + /** * Three-signal core. All three are required because each covers a window the * others miss: @@ -41,6 +58,7 @@ export function resolveSessionDriverIdWith( sessionId: string, session: DriverResolvableSession | undefined, isDispatchObservedSession: (sessionId: string) => boolean, + lookupSession: SessionLookup | null = parentSessionLookup, ): SessionDriverId { if (isNonLocalDispatchTarget(session?.config?.dispatchTarget)) { return 'dispatch'; @@ -51,6 +69,27 @@ export function resolveSessionDriverIdWith( if (isDispatchObservedSession(sessionId)) { return 'dispatch'; } + // Fourth signal: a child session (subagent projection) inherits the driver + // of its parent chain — it has no dispatch config of its own, but its + // events come from the target and it must never be persisted or driven as + // a local backend session. + let parentId = session?.parentSessionId?.trim(); + for (let depth = 0; parentId && lookupSession && depth < MAX_PARENT_CHAIN_DEPTH; depth += 1) { + if (isDispatchObservedSession(parentId)) { + return 'dispatch'; + } + const parent = lookupSession(parentId); + if (!parent) { + break; + } + if ( + isNonLocalDispatchTarget(parent.config?.dispatchTarget) + || parent.config?.dispatchJobId?.trim() + ) { + return 'dispatch'; + } + parentId = parent.parentSessionId?.trim(); + } return 'local'; } From 8f9cf9221cd7bb8fdc26843a768badd892f74126 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 23:26:18 -0700 Subject: [PATCH 12/12] refactor(dispatch): single-source the wire contract; picker offers only executable commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-audit of the refactor found four surfaces that would have grown into exactly the maintenance burden this work exists to prevent: - The protocol version lived in 4 copies (CLI u32, controller u64, SSH transport u64, Web UI) and the capability list in 3 authoritative copies plus a weaker partial inside validate_dispatch_protocol whose submission path silently lacked the v4 entries. All Rust copies now derive from one services-core dispatch_contract module (version, capability names, attachment shape, structural limits, shared validator); the submission gate is now the full surface narrowed to the selected approval policy, strictly stronger than before. - The controller duplicated the target's semantic whitelists (query kind, turn kind/prompt rules). Those are the target's contract; a stale controller copy would reject valid requests after a target upgrade. The controller now enforces only transport-owned checks (sizes, budgets, idempotency-id format) plus the pre-baseline approval-policy fail-fast. - The Web UI's contract test now reads the shared Rust contract file and pins version + capability names cross-language, and dialog fixtures spread BASE_DISPATCH_CAPABILITIES instead of hand-copied lists — a capability bump is one Rust edit, one frontend edit, and CI fails on any drift. The SSH protocol tests (feature-gated, previously not exercised in this branch's runs) now derive their fixtures from the same single list. - The slash-command picker now offers exactly the ops the session can execute. Previously (predating this refactor) a dispatch user could pick /goal from the list and have it sent to the remote agent as literal prompt text. --- src/apps/cli/src/dispatch/mod.rs | 63 ++------- src/apps/cli/src/dispatch/protocol.rs | 26 +--- .../core/src/service/dispatch/controller.rs | 71 +++------- .../services-core/src/dispatch_contract.rs | 119 +++++++++++++++++ src/crates/services/services-core/src/lib.rs | 1 + .../src/remote_ssh/dispatch_ssh.rs | 121 +++++++----------- .../dispatch/DispatchInstallDialog.test.tsx | 47 +------ .../dispatch/dispatch.contract.test.ts | 34 +++++ .../src/flow_chat/components/ChatInput.tsx | 15 ++- 9 files changed, 245 insertions(+), 252 deletions(-) create mode 100644 src/crates/services/services-core/src/dispatch_contract.rs diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index 1acce66cee..07376c359d 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -123,37 +123,16 @@ async fn probe(request: DispatchProbeRequest) -> Result { .as_deref() .map(inspect_workspace) .transpose()?; - let mut capabilities = vec![ - "persistent_jobs".to_string(), - "cursor_events".to_string(), - "workspace_serialization".to_string(), - "approval_auto".to_string(), - "approval_reject_and_report".to_string(), - "approval_remote".to_string(), - "frontend_event_projection".to_string(), - "append_message".to_string(), - "event_log_completeness".to_string(), - // Git-worktree delivery. A target without these cannot be provisioned - // at all — there is no snapshot fallback left — so controllers fail - // preflight rather than degrade. - "workspace_git_worktree".to_string(), - "workspace_git_bundle_upload".to_string(), - "workspace_git_sync".to_string(), - // A target may share the same package version while predating the - // dispatch entrypoint's early CLI-profile selection. Such a binary can - // accept a job but every detached worker then fails before execution. - // Advertise the behavioral fix explicitly so controllers fail closed. - "dispatch_worker_cli_profile".to_string(), - // v4: follow-up turns may override model and approval policy. - "per_turn_options".to_string(), - // v4: read-only persisted-state queries (usage report) and compact - // turns delivered through the continue mailbox. - "session_query".to_string(), - // v4: inline image attachments on submit and follow-up turns. - "inline_attachments".to_string(), - ]; + let mut capabilities: Vec = + bitfun_services_core::dispatch_contract::DISPATCH_BASE_TARGET_CAPABILITIES + .iter() + .map(|capability| capability.to_string()) + .collect(); if runner::is_supported() { - capabilities.push("detached_worker".to_string()); + capabilities.push( + bitfun_services_core::dispatch_contract::DISPATCH_DETACHED_WORKER_CAPABILITY + .to_string(), + ); } Ok(DispatchProbeResponse { protocol_version: DISPATCH_PROTOCOL_VERSION, @@ -777,29 +756,7 @@ fn canonical_workspace(workspace_path: &str) -> Result { } fn validate_attachments(attachments: &[protocol::DispatchAttachment]) -> Result<()> { - if attachments.len() > protocol::MAX_DISPATCH_ATTACHMENTS { - bail!( - "dispatch accepts at most {} attachments per turn", - protocol::MAX_DISPATCH_ATTACHMENTS - ); - } - let mut total = 0usize; - for attachment in attachments { - if attachment.id.trim().is_empty() || attachment.id.len() > 128 { - bail!("dispatch attachment id must contain 1-128 bytes"); - } - if !attachment.data_url.starts_with("data:image/") { - bail!("dispatch attachments must be image data URLs"); - } - if attachment.data_url.len() > protocol::MAX_DISPATCH_ATTACHMENT_BYTES { - bail!("a dispatch attachment exceeds the 8 MiB limit"); - } - total = total.saturating_add(attachment.data_url.len()); - } - if total > protocol::MAX_DISPATCH_ATTACHMENTS_TOTAL_BYTES { - bail!("dispatch attachments exceed the 16 MiB total limit"); - } - Ok(()) + protocol::validate_dispatch_attachments(attachments).map_err(anyhow::Error::msg) } fn validate_submit_request(request: &DispatchSubmitRequest) -> Result<()> { diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index f8f4e5b9dd..5ababa9882 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -2,27 +2,13 @@ use serde::{Deserialize, Serialize}; use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; -pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 4; -pub(crate) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; -pub(crate) const MAX_DISPATCH_ATTACHMENTS: usize = 8; -pub(crate) const MAX_DISPATCH_ATTACHMENT_BYTES: usize = 8 * 1024 * 1024; -pub(crate) const MAX_DISPATCH_ATTACHMENTS_TOTAL_BYTES: usize = 16 * 1024 * 1024; +// The wire contract (version, capability names, attachment shape and +// limits) has one source of truth shared with the controller side. +pub(crate) use bitfun_services_core::dispatch_contract::{ + validate_dispatch_attachments, DispatchAttachment, DISPATCH_PROTOCOL_VERSION, +}; -/// One inline image attachment for a submit/continue turn. -/// -/// v4 carries images as data URLs inside the request: SSH stages the request -/// as a file over SFTP so size is a policy choice, while the account-device -/// envelope keeps a much smaller controller-enforced budget. Staged chunked -/// transfer for larger payloads is a follow-up capability. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DispatchAttachment { - pub(crate) id: String, - #[serde(default)] - pub(crate) name: Option, - pub(crate) mime_type: String, - pub(crate) data_url: String, -} +pub(crate) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index 99ba32912b..27db58d920 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -19,7 +19,8 @@ use super::{ OutboundDispatchStore, }; -pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = 4; +pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = + bitfun_services_core::dispatch_contract::DISPATCH_PROTOCOL_VERSION as u64; pub(super) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -160,47 +161,15 @@ pub struct DispatchAppendRequest { pub display_content: Option, } -/// One inline image attachment forwarded verbatim to the target. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct DispatchAttachmentPayload { - pub id: String, - #[serde(default)] - pub name: Option, - pub mime_type: String, - pub data_url: String, -} - -/// Inline budget for the account-device envelope. SSH stages the request as -/// an SFTP file, so only relay-carried requests need this much smaller cap. -pub(super) const MAX_DEVICE_ATTACHMENTS_TOTAL_BYTES: usize = 192 * 1024; -pub(super) const MAX_ATTACHMENTS: usize = 8; -pub(super) const MAX_ATTACHMENT_BYTES: usize = 8 * 1024 * 1024; -pub(super) const MAX_ATTACHMENTS_TOTAL_BYTES: usize = 16 * 1024 * 1024; +/// The wire shape and structural limits come from the shared contract; the +/// controller only adds transport-owned policy (the device inline budget). +pub use bitfun_services_core::dispatch_contract::DispatchAttachment as DispatchAttachmentPayload; pub(super) fn validate_attachment_payloads( attachments: &[DispatchAttachmentPayload], ) -> anyhow::Result<()> { - if attachments.len() > MAX_ATTACHMENTS { - anyhow::bail!("Dispatch accepts at most 8 image attachments per turn"); - } - let mut total = 0usize; - for attachment in attachments { - if attachment.id.trim().is_empty() || attachment.id.len() > 128 { - anyhow::bail!("Dispatch attachment id must contain 1-128 bytes"); - } - if !attachment.data_url.starts_with("data:image/") { - anyhow::bail!("Dispatch attachments must be image data URLs"); - } - if attachment.data_url.len() > MAX_ATTACHMENT_BYTES { - anyhow::bail!("A dispatch attachment exceeds the 8 MiB limit"); - } - total = total.saturating_add(attachment.data_url.len()); - } - if total > MAX_ATTACHMENTS_TOTAL_BYTES { - anyhow::bail!("Dispatch attachments exceed the 16 MiB total limit"); - } - Ok(()) + bitfun_services_core::dispatch_contract::validate_dispatch_attachments(attachments) + .map_err(|error| anyhow::anyhow!(error)) } pub(super) fn validate_device_attachment_budget( @@ -210,7 +179,9 @@ pub(super) fn validate_device_attachment_budget( .iter() .map(|attachment| attachment.data_url.len()) .sum(); - if total > MAX_DEVICE_ATTACHMENTS_TOTAL_BYTES { + if total + > bitfun_services_core::dispatch_contract::MAX_DEVICE_DISPATCH_ATTACHMENTS_TOTAL_BYTES + { anyhow::bail!( "Device dispatch carries at most 192 KiB of inline images; use an SSH target for larger screenshots" ); @@ -892,9 +863,11 @@ pub(super) fn validate_query_request(request: &DispatchQueryJobRequest) -> anyho if request.job_id.trim().is_empty() { anyhow::bail!("Dispatch query requires a jobId"); } - if !matches!(request.kind.as_str(), "usageReport") { - anyhow::bail!("Dispatch query kind is not recognized"); + if request.kind.trim().is_empty() || request.kind.len() > 64 { + anyhow::bail!("Dispatch query kind is invalid"); } + // Which kinds exist is the target's contract; an unknown kind comes back + // as a clear target-side error instead of drifting a second list here. Ok(()) } @@ -1242,19 +1215,9 @@ pub(super) fn validate_continue_request(request: &DispatchContinueRequest) -> an if request.turn_id.trim().is_empty() || request.turn_id.len() > 128 { anyhow::bail!("Dispatch turnId must contain 1-128 bytes"); } - match request.kind.as_deref().unwrap_or("prompt") { - "prompt" => { - if request.prompt.trim().is_empty() { - anyhow::bail!("Dispatch follow-up prompt cannot be empty"); - } - } - "compact" => { - if !request.prompt.trim().is_empty() { - anyhow::bail!("Dispatch compact turns do not take a prompt"); - } - } - _ => anyhow::bail!("Dispatch follow-up kind is not recognized"), - } + // Kind/prompt semantics (which kinds exist, which take a prompt) are the + // target's contract; duplicating that list here would drift. Only + // transport-owned limits are enforced below. let total_bytes = request .prompt .len() diff --git a/src/crates/services/services-core/src/dispatch_contract.rs b/src/crates/services/services-core/src/dispatch_contract.rs new file mode 100644 index 0000000000..68a59ff2f8 --- /dev/null +++ b/src/crates/services/services-core/src/dispatch_contract.rs @@ -0,0 +1,119 @@ +//! Single source of truth for the detached-dispatch wire contract. +//! +//! The target CLI advertises these capabilities, the controller requires +//! them, and the Web UI pins its own copy against this file in +//! `dispatch.contract.test.ts`. A protocol evolution is one edit here plus +//! that cross-language test — never a hunt across advertise/require lists. + +use serde::{Deserialize, Serialize}; + +pub const DISPATCH_PROTOCOL_VERSION: u32 = 4; + +/// Capabilities every v4 target advertises unconditionally. +pub const DISPATCH_BASE_TARGET_CAPABILITIES: &[&str] = &[ + "persistent_jobs", + "cursor_events", + "workspace_serialization", + "approval_auto", + "approval_reject_and_report", + "approval_remote", + "frontend_event_projection", + "append_message", + "event_log_completeness", + // Git-worktree delivery. A target without these cannot be provisioned at + // all — there is no snapshot fallback left — so controllers fail + // preflight rather than degrade. + "workspace_git_worktree", + "workspace_git_bundle_upload", + "workspace_git_sync", + // A target may share the same package version while predating the + // dispatch entrypoint's early CLI-profile selection. Such a binary can + // accept a job but every detached worker then fails before execution. + // Advertise the behavioral fix explicitly so controllers fail closed. + "dispatch_worker_cli_profile", + // v4: follow-up turns may override model and approval policy. + "per_turn_options", + // v4: read-only persisted-state queries (usage report) and compact turns + // delivered through the continue mailbox. + "session_query", + // v4: inline image attachments on submit and follow-up turns. + "inline_attachments", +]; + +/// Advertised only where detached workers can run (Linux/macOS), and +/// required by every controller: a target that cannot detach cannot dispatch. +pub const DISPATCH_DETACHED_WORKER_CAPABILITY: &str = "detached_worker"; + +/// Everything a controller refuses to submit without: the unconditional set +/// plus the platform-conditional detached worker. +pub fn dispatch_required_target_capabilities() -> impl Iterator { + DISPATCH_BASE_TARGET_CAPABILITIES + .iter() + .copied() + .chain(std::iter::once(DISPATCH_DETACHED_WORKER_CAPABILITY)) +} + +/// One inline image attachment on a submit/continue turn. +/// +/// v4 carries images as data URLs inside the request: SSH stages the request +/// as a file over SFTP so size is a policy choice, while the account-device +/// envelope keeps a much smaller controller-enforced budget. Staged chunked +/// transfer for larger payloads is a follow-up capability. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DispatchAttachment { + pub id: String, + #[serde(default)] + pub name: Option, + pub mime_type: String, + pub data_url: String, +} + +pub const MAX_DISPATCH_ATTACHMENTS: usize = 8; +pub const MAX_DISPATCH_ATTACHMENT_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_DISPATCH_ATTACHMENTS_TOTAL_BYTES: usize = 16 * 1024 * 1024; +/// Inline budget for the account-device envelope. SSH stages the request as +/// an SFTP file, so only relay-carried requests need this much smaller cap. +pub const MAX_DEVICE_DISPATCH_ATTACHMENTS_TOTAL_BYTES: usize = 192 * 1024; + +/// Shared structural validation, used verbatim by the controller (fail fast +/// before a transport round trip) and the target (authoritative). +pub fn validate_dispatch_attachments(attachments: &[DispatchAttachment]) -> Result<(), String> { + if attachments.len() > MAX_DISPATCH_ATTACHMENTS { + return Err(format!( + "dispatch accepts at most {MAX_DISPATCH_ATTACHMENTS} attachments per turn" + )); + } + let mut total = 0usize; + for attachment in attachments { + if attachment.id.trim().is_empty() || attachment.id.len() > 128 { + return Err("dispatch attachment id must contain 1-128 bytes".to_string()); + } + if !attachment.data_url.starts_with("data:image/") { + return Err("dispatch attachments must be image data URLs".to_string()); + } + if attachment.data_url.len() > MAX_DISPATCH_ATTACHMENT_BYTES { + return Err("a dispatch attachment exceeds the 8 MiB limit".to_string()); + } + total = total.saturating_add(attachment.data_url.len()); + } + if total > MAX_DISPATCH_ATTACHMENTS_TOTAL_BYTES { + return Err("dispatch attachments exceed the 16 MiB total limit".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_capabilities_are_base_plus_detached_worker() { + let required: Vec<&str> = dispatch_required_target_capabilities().collect(); + assert_eq!(required.len(), DISPATCH_BASE_TARGET_CAPABILITIES.len() + 1); + assert!(required.contains(&DISPATCH_DETACHED_WORKER_CAPABILITY)); + for capability in DISPATCH_BASE_TARGET_CAPABILITIES { + assert!(required.contains(capability)); + } + } +} diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 1e427037e8..6f965d23a4 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod bounded_fs; pub mod diagnostics; pub mod diff; +pub mod dispatch_contract; #[cfg(feature = "dispatch-workspace")] pub mod dispatch_workspace; mod file_lock; diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index d9637652c4..84094b03d0 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -84,8 +84,8 @@ const GLIBC_FLOOR: &str = "2.35"; /// Same figure the relay source build uses. const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; -const DISPATCH_PROTOCOL_VERSION: u64 = 4; -const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profile"; +const DISPATCH_PROTOCOL_VERSION: u64 = + bitfun_services_core::dispatch_contract::DISPATCH_PROTOCOL_VERSION as u64; /// First stable release whose CLI is known to contain every capability below. /// /// Development builds can require capabilities before their next stable @@ -93,30 +93,12 @@ const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profil /// previous release, so comparing only the installed and controller version /// strings is not a sound compatibility test. const FIRST_COMPATIBLE_STABLE_DISPATCH_RELEASE: (u64, u64, u64) = (0, 2, 16); -const REQUIRED_DISPATCH_CAPABILITIES: [&str; 17] = [ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "approval_auto", - "approval_reject_and_report", - "approval_remote", - "append_message", - "event_log_completeness", - // Git-worktree delivery. There is no snapshot fallback, so these are hard - // requirements rather than feature-detected extras. - "workspace_git_worktree", - "workspace_git_bundle_upload", - "workspace_git_sync", - DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, - // v4: follow-up turns may override model and approval policy. - "per_turn_options", - // v4: read-only persisted-state queries and compact turns. - "session_query", - // v4: inline image attachments on submit and follow-up turns. - "inline_attachments", -]; +/// Derived once from the shared contract: the unconditional target surface +/// plus the platform-conditional detached worker. +static REQUIRED_DISPATCH_CAPABILITIES: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + bitfun_services_core::dispatch_contract::dispatch_required_target_capabilities().collect() + }); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -516,31 +498,25 @@ pub fn validate_dispatch_protocol(protocol: &Value, approval_policy: Option<&str let Some(capabilities) = protocol.get("capabilities").and_then(Value::as_array) else { return Err(anyhow!("dispatch target returned no capability list")); }; - let mut required = vec![ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "workspace_git_worktree", - "workspace_git_bundle_upload", - "workspace_git_sync", - DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, - ]; + // One source list: submission narrows the approval_* entries to the + // selected policy; probing (None) requires the complete surface. + let mut required: Vec<&'static str> = REQUIRED_DISPATCH_CAPABILITIES + .iter() + .copied() + .filter(|capability| !capability.starts_with("approval_")) + .collect(); match approval_policy { - Some("auto") => &["approval_auto"], - Some("reject-and-report") => &["approval_reject_and_report"], - Some("remote") => &["approval_remote"], + Some("auto") => required.push("approval_auto"), + Some("reject-and-report") => required.push("approval_reject_and_report"), + Some("remote") => required.push("approval_remote"), Some(_) => return Err(anyhow!("unsupported dispatch approval policy")), - None => REQUIRED_DISPATCH_CAPABILITIES.as_slice(), + None => required.extend( + REQUIRED_DISPATCH_CAPABILITIES + .iter() + .copied() + .filter(|capability| capability.starts_with("approval_")), + ), } - .iter() - .copied() - .for_each(|capability| { - if !required.contains(&capability) { - required.push(capability); - } - }); let missing = required .iter() .copied() @@ -2515,7 +2491,7 @@ COMMITTED=1 echo "Installed $installed at $HOME/.local/bin/bitfun" echo {INSTALL_DONE_MARKER} "#, - worker_profile_capability = DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, + worker_profile_capability = "dispatch_worker_cli_profile", ) } @@ -3273,7 +3249,7 @@ mod tests { "{name} must keep rollback" ); assert!( - script.contains(DISPATCH_WORKER_CLI_PROFILE_CAPABILITY), + script.contains("dispatch_worker_cli_profile"), "{name} must reject a CLI whose detached worker can select the wrong profile" ); } @@ -4066,7 +4042,7 @@ mod tests { #[test] fn incompatible_dispatch_protocols_require_an_upgrade() { - let capabilities = REQUIRED_DISPATCH_CAPABILITIES; + let capabilities = REQUIRED_DISPATCH_CAPABILITIES.clone(); let compatible = serde_json::json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, "capabilities": capabilities, @@ -4085,45 +4061,36 @@ mod tests { }); assert!(!dispatch_protocol_is_compatible(&missing)); + let mut reject_capabilities: Vec<&str> = REQUIRED_DISPATCH_CAPABILITIES + .iter() + .copied() + .filter(|capability| !capability.starts_with("approval_")) + .collect(); + reject_capabilities.push("approval_reject_and_report"); let reject_only = serde_json::json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, - "capabilities": [ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "approval_reject_and_report", - "workspace_git_worktree", - "workspace_git_bundle_upload", - "workspace_git_sync", - DISPATCH_WORKER_CLI_PROFILE_CAPABILITY - ], + "capabilities": reject_capabilities, }); validate_dispatch_protocol(&reject_only, Some("reject-and-report")) .expect("selected policy is supported"); assert!(validate_dispatch_protocol(&reject_only, Some("auto")).is_err()); + let mut unsafe_capabilities: Vec<&str> = REQUIRED_DISPATCH_CAPABILITIES + .iter() + .copied() + .filter(|capability| { + !capability.starts_with("approval_") && *capability != "dispatch_worker_cli_profile" + }) + .collect(); + unsafe_capabilities.push("approval_reject_and_report"); let unsafe_worker = serde_json::json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, - "capabilities": [ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "approval_reject_and_report", - "workspace_git_worktree", - "workspace_git_bundle_upload", - "workspace_git_sync" - ], + "capabilities": unsafe_capabilities, }); let error = validate_dispatch_protocol(&unsafe_worker, Some("reject-and-report")) .expect_err("a worker that can select product-full first must be rejected"); assert!( - error - .to_string() - .contains(DISPATCH_WORKER_CLI_PROFILE_CAPABILITY), + error.to_string().contains("dispatch_worker_cli_profile"), "{error}" ); } diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 7d96a42199..4632eb79dd 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -1,3 +1,4 @@ +import { BASE_DISPATCH_CAPABILITIES } from './dispatchPreflight'; // @vitest-environment jsdom import React, { act } from 'react'; @@ -423,21 +424,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [ - 'persistent_jobs', - 'cursor_events', - 'detached_worker', - 'frontend_event_projection', - 'workspace_serialization', - 'dispatch_worker_cli_profile', - 'workspace_git_worktree', - 'workspace_git_bundle_upload', - 'workspace_git_sync', - 'approval_remote', - 'per_turn_options', - 'session_query', - 'inline_attachments', - ], + capabilities: [...BASE_DISPATCH_CAPABILITIES, 'approval_remote'], modelConfigured: true, availableModels: ['model-a', 'model-b'], defaultModel: 'model-b', @@ -584,20 +571,7 @@ describe('DispatchInstallDialog model configuration sync', () => { cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [ - 'persistent_jobs', - 'cursor_events', - 'detached_worker', - 'frontend_event_projection', - 'workspace_serialization', - 'workspace_git_worktree', - 'workspace_git_bundle_upload', - 'workspace_git_sync', - 'dispatch_worker_cli_profile', - 'per_turn_options', - 'session_query', - 'inline_attachments', - ], + capabilities: [...BASE_DISPATCH_CAPABILITIES], modelConfigured, availableModels: modelConfigured ? ['claude'] : [], defaultModel: modelConfigured ? 'claude' : undefined, @@ -745,20 +719,7 @@ describe('DispatchInstallDialog target model readout', () => { cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [ - 'persistent_jobs', - 'cursor_events', - 'detached_worker', - 'frontend_event_projection', - 'workspace_serialization', - 'workspace_git_worktree', - 'workspace_git_bundle_upload', - 'workspace_git_sync', - 'dispatch_worker_cli_profile', - 'per_turn_options', - 'session_query', - 'inline_attachments', - ], + capabilities: [...BASE_DISPATCH_CAPABILITIES], modelConfigured: true, availableModels, defaultModel, diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts index 16e7cea2e3..86a687321c 100644 --- a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts +++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts @@ -58,6 +58,40 @@ describe('dispatch controller-only routing contract', () => { } }); +describe('dispatch wire contract single source', () => { + // The Rust side has exactly one contract file; the Web UI's copies must + // track it. A capability or version bump that misses one side fails here + // instead of at runtime probe. + const contractSource = read( + '../../../../../src/crates/services/services-core/src/dispatch_contract.rs', + ); + + it('pins the protocol version to the shared Rust contract', () => { + expect(contractSource).toContain( + `pub const DISPATCH_PROTOCOL_VERSION: u32 = ${DISPATCH_PROTOCOL_VERSION};`, + ); + }); + + it('requires only capabilities the shared Rust contract defines', () => { + for (const capability of BASE_DISPATCH_CAPABILITIES) { + if (capability === 'detached_worker') { + expect(contractSource).toContain( + `DISPATCH_DETACHED_WORKER_CAPABILITY: &str = "${capability}"`, + ); + continue; + } + expect(contractSource).toContain(`"${capability}",`); + } + }); + + it('keeps the v4 feature capabilities required on both sides', () => { + for (const capability of ['per_turn_options', 'session_query', 'inline_attachments']) { + expect(BASE_DISPATCH_CAPABILITIES).toContain(capability); + expect(contractSource).toContain(`"${capability}",`); + } + }); +}); + describe('dispatch preflight contract', () => { it('fails closed on protocol v4 Git worktree delivery with per-turn options', () => { expect(DISPATCH_PROTOCOL_VERSION).toBe(4); diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 072f4839a0..ad0802d8f3 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -2722,17 +2722,22 @@ export const ChatInput: React.FC = ({ : []), ]; const q = (slashCommandState.query || '').trim().toLowerCase(); - const visibleItems = items.filter(item => isChatInputActionVisibleForTarget({ - actionId: item.id, - isSubagentTarget: isSubagentInputTarget, - })); + // The picker offers exactly what this session can execute. Without this, + // an unsupported command picked from the list falls through the per-op + // submit gates and is sent to the agent as literal prompt text. + const visibleItems = items.filter(item => + (item.id === 'reload' || caps.ops.has(item.id)) + && isChatInputActionVisibleForTarget({ + actionId: item.id, + isSubagentTarget: isSubagentInputTarget, + })); if (!q) return visibleItems; return visibleItems.filter(i => { const cmd = i.command.slice(1).toLowerCase(); return cmd.includes(q) || i.label.toLowerCase().includes(q); }); - }, [canLaunchReview, canReloadContext, derivedState?.isProcessing, isAcpInputSession, isBtwSession, isSubagentInputTarget, slashCommandState.query, t]); + }, [canLaunchReview, canReloadContext, caps.ops, derivedState?.isProcessing, isAcpInputSession, isBtwSession, isSubagentInputTarget, slashCommandState.query, t]); const getFilteredMcpPromptCommands = useCallback((): SlashMcpPromptItem[] => { if (isAcpInputSession) {