diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 56ca8b3e97..d578b782bd 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -228,6 +228,68 @@ test('merges a configuration patch into each fresh CAS projection', async () => ); }); +test('merges active-turn configuration edits from the Host pending projection', async () => { + const { client, requests } = clientWithResponses([ + { + kind: 'session', + session: session('session-1', 10, { + model: 'model-a', + thinkingLevel: 'high', + pendingConfiguration: { + llmConnectionSlug: 'test-connection', + model: 'model-b', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }), + }, + { + kind: 'committed', + session: session('session-1', 11, { + model: 'model-a', + thinkingLevel: 'high', + }), + }, + ]); + + await client.updateSessionConfiguration('session-1', { + modelTarget: { kind: 'explicit', connectionSlug: 'test-connection', model: 'model-a' }, + thinkingLevel: null, + }); + + const request = requests.find(({ operation }) => operation === 'session.configuration.update'); + assert.deepEqual(request?.input, { + sessionId: 'session-1', + expectedRevision: 10, + configuration: { + modelTarget: { kind: 'explicit', connectionSlug: 'test-connection', model: 'model-a' }, + thinkingLevel: 'high', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }); +}); + +test('resets thinking when a model selection moves away from the effective model', async () => { + const { client, requests } = clientWithResponses([ + { + kind: 'session', + session: session('session-1', 10, { model: 'model-a', thinkingLevel: 'high' }), + }, + { kind: 'committed', session: session('session-1', 11, { model: 'model-b' }) }, + ]); + + await client.updateSessionConfiguration('session-1', { + modelTarget: { kind: 'explicit', connectionSlug: 'test-connection', model: 'model-b' }, + thinkingLevel: null, + }); + + const input = requests.find(({ operation }) => operation === 'session.configuration.update')?.input; + assert.equal((input as { configuration: { thinkingLevel: unknown } }).configuration.thinkingLevel, null); +}); + test('retries a Session update through transient revision churn', async () => { const responses: unknown[] = []; for (let revision = 10; revision < 14; revision += 1) { diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 7fd5c647d0..3442cff0d7 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -867,29 +867,58 @@ export class DesktopRuntimeHostClient { ) as DesktopSessionConfigurationPatch; if (Object.keys(definedPatch).length === 0) return this.#requireSession(sessionId); - return this.#updateSession(sessionId, (current) => - this.request("session.configuration.update", { + return this.#updateSession(sessionId, (current) => { + const effectiveModelTarget = { + kind: "explicit" as const, + connectionSlug: current.llmConnectionSlug, + model: current.model, + }; + const base = current.pendingConfiguration ?? { + ...effectiveModelTarget, + thinkingLevel: current.thinkingLevel, + permissionMode: current.permissionMode, + collaborationMode: current.collaborationMode, + orchestrationMode: current.orchestrationMode, + }; + const defaultModelTarget = current.pendingConfiguration + ? { + kind: "explicit" as const, + connectionSlug: current.pendingConfiguration.llmConnectionSlug, + model: current.pendingConfiguration.model, + } + : current.connectionLocked + ? effectiveModelTarget + : { kind: "default" as const }; + const modelTarget = definedPatch.modelTarget ?? defaultModelTarget; + const selectingModel = definedPatch.modelTarget !== undefined; + const selectedModelIsEffective = + selectingModel && + definedPatch.modelTarget?.kind === "explicit" && + definedPatch.modelTarget.connectionSlug === current.llmConnectionSlug && + definedPatch.modelTarget.model === current.model; + const thinkingLevel = + selectingModel && + definedPatch.thinkingLevel === null + ? selectedModelIsEffective + ? (current.pendingConfiguration?.thinkingLevel ?? current.thinkingLevel ?? null) + : null + : (base.thinkingLevel ?? null); + return this.request("session.configuration.update", { sessionId, expectedRevision: current.revision, configuration: { // An unlocked Session still follows the Host-owned default route. // Once execution or an explicit model change locks it, the resolved // catalog route is the explicit target that must survive this patch. - modelTarget: current.connectionLocked - ? { - kind: "explicit", - connectionSlug: current.llmConnectionSlug, - model: current.model, - } - : { kind: "default" }, - thinkingLevel: current.thinkingLevel ?? null, - permissionMode: current.permissionMode, - collaborationMode: current.collaborationMode, - orchestrationMode: current.orchestrationMode, + permissionMode: base.permissionMode, + collaborationMode: base.collaborationMode, + orchestrationMode: base.orchestrationMode, ...definedPatch, + modelTarget, + thinkingLevel, }, - }), - ); + }); + }); } async setSessionReadMarker( diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 3140d55a26..4c49601f39 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -114,7 +114,10 @@ export function createAppShellSessionSettingsActions(deps: { if (mode !== 'ask' && mode !== 'bypass') return false; const sessionId = activeIdRef.current; const currentMode = sessionId - ? sessionsRef.current.find((session) => session.id === sessionId)?.permissionMode + ? (() => { + const session = sessionsRef.current.find((entry) => entry.id === sessionId); + return session?.pendingConfiguration?.permissionMode ?? session?.permissionMode; + })() : undefined; if (currentMode === mode) return true; const pendingKey = sessionId ?? '__global_permission_mode__'; @@ -218,7 +221,10 @@ export function createAppShellSessionSettingsActions(deps: { const sessionId = activeIdRef.current; if (!sessionId) return; const current = sessionsRef.current.find((session) => session.id === sessionId); - if (current && current.thinkingLevel === level) return; + if ( + current && + (current.pendingConfiguration?.thinkingLevel ?? current.thinkingLevel) === level + ) return; if (pendingSessionModelChangesRef.current.has(sessionId)) return; pendingSessionModelChangesRef.current.add(sessionId); setPendingSessionModelBySession((currentPending) => ({ diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 29a97fb25b..f62825c467 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -787,6 +787,17 @@ function AppShellContent({ activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const activeSession = sessions.find((session) => session.id === activeId); + const activeSessionSelection = activeSession?.pendingConfiguration + ? { + ...activeSession, + llmConnectionSlug: activeSession.pendingConfiguration.llmConnectionSlug, + model: activeSession.pendingConfiguration.model, + ...(activeSession.pendingConfiguration.thinkingLevel === undefined + ? { thinkingLevel: undefined } + : { thinkingLevel: activeSession.pendingConfiguration.thinkingLevel }), + permissionMode: activeSession.pendingConfiguration.permissionMode, + } + : activeSession; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; const activeDesktopSession = activeSession; // The shell's reading of the active live turn: streaming/settled flags, the @@ -858,7 +869,7 @@ function AppShellContent({ activationCandidate: modelSettingsOwnsComposerHost ? onboardingActivationCandidate : undefined, - activeSession, + activeSession: activeSessionSelection, persistedComposerDefaults, usePersistedComposerDefaults: modelSettingsOwnsComposerHost, defaultThinkingLevel: newTask.selectedHost?.chatDefaults.thinkingLevel, @@ -1222,6 +1233,11 @@ function AppShellContent({ permissionMode: newTaskPermissionMode, }) : undefined); + // The Host keeps the effective Session configuration immutable for the + // active AgentRun and projects an optional next-Turn selection alongside it. + // Controls show that latest selection, while execution-boundary reads below + // continue to use the effective configuration. + const activeSessionSelectionView = activeSessionSelection ?? activeSessionForView; // Each control reads its own field. There is nothing to project and nothing // to keep in sync: a Session in Plan with Swarm as its orchestration default // says both, because it is both. @@ -1294,7 +1310,8 @@ function AppShellContent({ activeExecutionBoundary, activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newTaskPermissionMode, ); - const activePermissionMode = activeBoundarySurface.permissionMode; + const activePermissionMode = + activeSessionSelectionView?.permissionMode ?? activeBoundarySurface.permissionMode; const planMode = usePlanModeState(activeSessionForView); const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({ id: proposal.proposalId, @@ -3053,7 +3070,7 @@ function AppShellContent({ : attachFilePaths } modelLabel={activeModelLabel ?? newChatModelLabel ?? undefined} - activeSession={activeSessionForView} + activeSession={activeSessionSelectionView} activeModel={activeModel} activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} @@ -3088,22 +3105,14 @@ function AppShellContent({ } permissionMode={activePermissionMode} permissionModePending={activeId ? pendingPermissionModeBySession[activeId] === true : false} - // Every "cannot change this mid-turn" gate reads `turnActive`, - // the same witness Stop reads. Reading the persisted status - // here instead left these toggles live through the whole - // send→run-start window — long enough on a cold backend for a - // mode change to land before the run registers and alter the - // execution config of the turn already sent. + // The Host owns active-turn configuration as pending + // next-Turn state. The short local pending flag only blocks + // duplicate writes; it never changes the active Run's + // permission boundary. permissionModeDisabledReason={ activeId && pendingPermissionModeBySession[activeId] === true - ? shellCopy.permissionModeChanging - : activeStreamingLive - ? shellCopy.permissionModeStreaming - : activeId && turnActive - ? shellCopy.permissionModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.permissionModeWaiting - : undefined + ? shellCopy.permissionModeChanging + : undefined } onPermissionModeChange={ activeBoundarySurface.localInteractionAvailable @@ -3157,7 +3166,7 @@ function AppShellContent({ onStreamingSettled={ activeId ? (messageId) => settleAssistantStreaming(activeId, messageId) : undefined } - activeSession={activeSessionForView} + activeSession={activeSessionSelectionView} activeConnectionLabel={activeConnectionLabel} activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 48b545754f..8e1b80ff6e 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -206,6 +206,23 @@ export interface SessionExternalOrigin { readonly sourceSessionId: string; } +/** + * Host-owned configuration selected while the current root Turn is active. + * + * The active AgentRun keeps its own immutable configuration snapshot. This + * projection is the configuration the next root Turn should use; it is + * cleared when that Turn is admitted or when the user returns to the current + * effective configuration. + */ +export interface PendingSessionConfiguration { + readonly llmConnectionSlug: string; + readonly model: string; + readonly thinkingLevel?: import('./model-thinking.js').ThinkingLevel; + readonly permissionMode: PermissionMode; + readonly collaborationMode: CollaborationMode; + readonly orchestrationMode: OrchestrationMode; +} + export interface SessionHeader { // Identity id: string; @@ -270,6 +287,8 @@ export interface SessionHeader { /** Per-model reasoning-depth variant; `undefined` = model default. Cleared on model switch. */ thinkingLevel?: import('./model-thinking.js').ThinkingLevel; permissionMode: PermissionMode; + /** Configuration selected for the next root Turn while one is active. */ + pendingConfiguration?: PendingSessionConfiguration; /** Defaults to `agent` when absent on legacy session records. */ collaborationMode?: CollaborationMode; /** Defaults to `default` when absent on legacy session records. */ @@ -369,6 +388,8 @@ export interface SessionSummary { /** Per-model reasoning-depth variant; `undefined` = model default. Cleared on model switch. */ thinkingLevel?: import('./model-thinking.js').ThinkingLevel; permissionMode: PermissionMode; + /** Configuration selected for the next root Turn while one is active. */ + pendingConfiguration?: PendingSessionConfiguration; /** Defaults to `agent` when absent on legacy summaries. */ collaborationMode?: CollaborationMode; /** Defaults to `default` when absent on legacy summaries. */ diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index aad3fdb039..b997c34751 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -24,7 +24,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { agentGraphIdForRootSession } from '@maka/runtime/stream-graph-coordinator'; -import { BackendRegistry, SessionManager } from '@maka/runtime/session-manager'; +import { + BackendRegistry, + SessionConfigurationTransitionError, + SessionManager, +} from '@maka/runtime/session-manager'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, @@ -154,6 +158,38 @@ test('prepares a fresh Agent Graph epoch before durable external Turn admission' } }); +test('returns a typed outcome when pending configuration cannot be applied', async () => { + const fixture = await createFailureFixture({ + registerBackend: (backends) => { + backends.register('ai-sdk', (context) => new FakeBackend(context)); + }, + }); + const message = 'Selected model is no longer available'; + fixture.manager.applyPendingSessionConfiguration = async () => { + throw new SessionConfigurationTransitionError('operation_unavailable', message); + }; + + try { + const outcome = await fixture.interactiveTurns.handlers['turn.start']( + { + sessionId: fixture.sessionId, + turnId: 'turn-pending-configuration-failure', + content: { text: 'Start after a staged configuration change.' }, + }, + operationContext(fixture.hostEpoch, fixture.acquireResidency), + ); + + assert.deepEqual(outcome, { + ok: false, + error: { code: 'operation_unavailable', message }, + }); + } finally { + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + test('turn.start enforces the admitted step cap at the backend boundary', async () => { let backend: StepCapProbeBackend | undefined; const fixture = await createFailureFixture({ diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 1da01903a6..6b49a7b535 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -49,7 +49,9 @@ import { HostProjectMembershipGate } from '../server/project-membership-gate.js' import { HostWorkspaceResolver } from '../server/workspace-resolver.js'; import { HostSessionCatalogCoordinator, + SessionOperationFailure, type HostSessionCatalogCoordinatorOptions, + validatePendingSessionConfiguration, } from '../server/session-catalog-coordinator.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; @@ -462,6 +464,60 @@ test('creation on a relay connection honours declared levels via the catalog pro assert.equal(persistedThinkingLevel, 'low'); }); +test('pending configuration validation rechecks model readiness and thinking support', async () => { + const runtimePolicy = runtimePolicyFixture({ + providerType: 'openai-compatible', + enabledModelIds: ['relay-model'], + models: [{ id: 'relay-model' }], + relayModelProfiles: { 'relay-model': { thinkingLevels: ['low'] } }, + }); + const pending = { + llmConnectionSlug: 'test', + model: 'relay-model', + thinkingLevel: 'low' as const, + permissionMode: 'ask' as const, + collaborationMode: 'agent' as const, + orchestrationMode: 'default' as const, + }; + + await validatePendingSessionConfiguration(runtimePolicy, pending); + await assert.rejects( + validatePendingSessionConfiguration(runtimePolicy, { + ...pending, + thinkingLevel: 'high', + }), + (error: unknown) => { + assert.ok(error instanceof SessionOperationFailure); + assert.equal(error.code, 'invalid_request'); + assert.match(error.message, /does not support thinking level high/); + return true; + }, + ); + + await assert.rejects( + validatePendingSessionConfiguration( + runtimePolicyFixture({ + executionResolution: { + kind: 'credential_not_configured', + status: { + locator: { scope: 'connection', connectionId: 'connection-1', kind: 'api_key' }, + configured: false, + credentialId: null, + revision: null, + updatedAt: null, + }, + }, + }), + { ...pending, model: 'model-1', thinkingLevel: undefined }, + ), + (error: unknown) => { + assert.ok(error instanceof SessionOperationFailure); + assert.equal(error.code, 'operation_unavailable'); + return true; + }, + ); +}); + test('creation admits the enabled bootstrap DeepSeek model before discovery', async () => { const modelId = 'deepseek-v4-flash'; let createAttempts = 0; diff --git a/packages/runtime-host/src/client/session-catalog-summary.ts b/packages/runtime-host/src/client/session-catalog-summary.ts index f7a114f460..d68d3efe64 100644 --- a/packages/runtime-host/src/client/session-catalog-summary.ts +++ b/packages/runtime-host/src/client/session-catalog-summary.ts @@ -65,6 +65,9 @@ export function projectSessionCatalogSummary( model: session.model, ...(session.thinkingLevel === undefined ? {} : { thinkingLevel: session.thinkingLevel }), permissionMode: session.permissionMode, + ...(session.pendingConfiguration === undefined + ? {} + : { pendingConfiguration: session.pendingConfiguration }), collaborationMode: session.collaborationMode, orchestrationMode: session.orchestrationMode, }; diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index 2c38605979..d9db66d10b 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -29,6 +29,7 @@ import { type SessionStatus, type SessionSubagentProjection, type SessionToolProfile, + type PendingSessionConfiguration, } from '@maka/core/session'; import { isThinkingLevel, type ThinkingLevel } from '@maka/core/model-thinking'; import type { ExecutionBoundarySummary } from '@maka/core/sandbox-boundary'; @@ -123,6 +124,7 @@ const PROJECTION_FIELDS = [ 'revisionIndex', 'revisionState', 'thinkingLevel', + 'pendingConfiguration', 'lastReadMessageId', 'liveRunState', ] as const; @@ -239,6 +241,7 @@ export interface SessionCatalogProjection { readonly model: string; readonly thinkingLevel?: ThinkingLevel; readonly permissionMode: PermissionMode; + readonly pendingConfiguration?: PendingSessionConfiguration; readonly collaborationMode: CollaborationMode; readonly orchestrationMode: OrchestrationMode; } @@ -678,6 +681,7 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr model: requireUtf8String(record.model, 'Session model', SESSION_CATALOG_MODEL_MAX_BYTES), ...optionalThinkingLevel(record), permissionMode: permissionMode(record.permissionMode), + ...optionalPendingConfiguration(record), collaborationMode: collaborationMode(record.collaborationMode), orchestrationMode: orchestrationMode(record.orchestrationMode), }; @@ -886,6 +890,51 @@ function optionalThinkingLevel( return { thinkingLevel: thinkingLevel(record.thinkingLevel) }; } +function optionalPendingConfiguration( + record: Record, +): Pick | Record { + if (!Object.hasOwn(record, 'pendingConfiguration')) return {}; + const value = requireRecord(record.pendingConfiguration, 'Pending Session configuration'); + assertAllowedKeys(value, 'Pending Session configuration', [ + 'llmConnectionSlug', + 'model', + 'thinkingLevel', + 'permissionMode', + 'collaborationMode', + 'orchestrationMode', + ]); + for (const field of [ + 'llmConnectionSlug', + 'model', + 'permissionMode', + 'collaborationMode', + 'orchestrationMode', + ]) { + if (!Object.hasOwn(value, field)) + throw invalidProtocolFrame('Invalid Pending Session configuration fields'); + } + return { + pendingConfiguration: { + llmConnectionSlug: requireUtf8String( + value.llmConnectionSlug, + 'Pending Session connection slug', + SESSION_CATALOG_CONNECTION_SLUG_MAX_BYTES, + ), + model: requireUtf8String( + value.model, + 'Pending Session model', + SESSION_CATALOG_MODEL_MAX_BYTES, + ), + ...(Object.hasOwn(value, 'thinkingLevel') + ? { thinkingLevel: thinkingLevel(value.thinkingLevel) } + : {}), + permissionMode: permissionMode(value.permissionMode), + collaborationMode: collaborationMode(value.collaborationMode), + orchestrationMode: orchestrationMode(value.orchestrationMode), + }, + }; +} + // `'fake'` stays accepted on decode: the projection carries the session // header's durable backend, and rows written by builds that shipped // FakeBackend still hold it (#3211). diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 153468a1e2..27698e7d19 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -150,7 +150,10 @@ import { notifySandboxBoundaryGraphWake } from './sandbox-boundary-graph-wake.js import { HostRuntimePolicyCoordinator } from './runtime-policy-coordinator.js'; import { HostRuntimeResourceCoordinator } from './runtime-resource-coordinator.js'; import { SessionAdmissionGate } from './session-admission-gate.js'; -import { HostSessionCatalogCoordinator } from './session-catalog-coordinator.js'; +import { + HostSessionCatalogCoordinator, + validatePendingSessionConfiguration, +} from './session-catalog-coordinator.js'; import { HostWorkspaceResolver } from './workspace-resolver.js'; import { HostSessionRetirementCoordinator } from './session-retirement-coordinator.js'; import { HostSessionRevisionCoordinator } from './session-revision-coordinator.js'; @@ -873,6 +876,8 @@ export async function createExecutionRuntimeHostComposition( runtimeEventStore: stores.runtimeEventStore, toolBoundaryProtocol: stores.runtimeEventStore.toolBoundaryProtocol, backends, + validatePendingSessionConfiguration: (configuration) => + validatePendingSessionConfiguration(runtimePolicyStores, configuration), subagentCatalog, newId: randomUUID, now: Date.now, diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index c05a8a019d..1a7c705cea 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -47,7 +47,11 @@ import { RuntimeInteractionFailStopError, RuntimeInteractionInvariantError, } from '@maka/runtime/interaction-authority'; -import { RuntimeRegenerateTurnError, type SessionManager } from '@maka/runtime/session-manager'; +import { + RuntimeRegenerateTurnError, + SessionConfigurationTransitionError, + type SessionManager, +} from '@maka/runtime/session-manager'; import { RuntimeOwnerCleanupError } from '@maka/runtime/runtime-kernel'; import { parseSkillInvocationTokens, @@ -1932,6 +1936,26 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { throw new Error('Admitted non-terminal Turn has no active Runtime Host execution'); } + // A staged Session configuration is consumed only when a fresh user root + // Turn is admitted. The current AgentRun never observes this transition; + // the Host admission lease serializes the commit with successor creation. + if ( + admission.execution.kind === 'external_message' || + admission.execution.kind === 'regenerate' + ) { + try { + await this.manager.applyPendingSessionConfiguration(input.sessionId); + } catch (error) { + if (error instanceof SessionConfigurationTransitionError) { + return completedStart({ + ok: false, + error: { code: error.code, message: error.message }, + }); + } + throw error; + } + } + const active = this.#executions.get(input.sessionId); const currentReservation = this.#admissions.get(input.sessionId); if (currentReservation && currentReservation !== rootReservation) { diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 722c48a85d..8869953ead 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -33,7 +33,11 @@ import { isSessionStartModeLabel as isExecutionSemanticLabel, sessionStartModeSpec, } from '@maka/core/explore-agent'; -import type { SessionHeader, SessionHeaderPatch } from '@maka/core/session'; +import type { + PendingSessionConfiguration, + SessionHeader, + SessionHeaderPatch, +} from '@maka/core/session'; import { isSessionNotFoundError, SessionMetadataConflictError, @@ -99,7 +103,7 @@ type SessionCatalogStores = Pick< | 'updateHeaderVersioned' >; -type SessionRuntimePolicyStores = { +export type SessionRuntimePolicyStores = { readonly connectionCatalog: Pick; readonly runtimePolicy: Pick; readonly operations: Pick; @@ -142,6 +146,19 @@ interface ResolvedSessionModel { readonly model: string; } +/** Re-check a staged explicit model target at the next-Turn commit boundary. */ +export async function validatePendingSessionConfiguration( + runtimePolicy: SessionRuntimePolicyStores, + pending: PendingSessionConfiguration, +): Promise { + await resolveExplicitSessionModel( + runtimePolicy, + pending.llmConnectionSlug, + pending.model, + pending.thinkingLevel, + ); +} + /** Host-owned Session catalog, creation, and configuration authority. */ export class HostSessionCatalogCoordinator { readonly handlers: SessionCatalogOperationHandlerMap = { @@ -471,6 +488,7 @@ export class HostSessionCatalogCoordinator { ); const clearsConnectionBlock = current.header.blockedReason === 'NO_REAL_CONNECTION'; if ( + current.header.pendingConfiguration === undefined && !clearsConnectionBlock && sessionConfigurationMatches(current.header, model, input.configuration) ) { @@ -672,76 +690,13 @@ export class HostSessionCatalogCoordinator { thinkingLevel: SessionCreateInput['thinkingLevel'], ): Promise { const selected = await this.#selectModelTarget(target); - const readiness = await this.#runtimePolicy.operations.resolveExecutionConnection( + return resolveExplicitSessionModel( + this.#runtimePolicy, selected.connectionSlug, + selected.modelId, + thinkingLevel, + selected.connectionId, ); - if (readiness.kind === 'not_found' || readiness.kind === 'disabled') { - throw new SessionOperationFailure( - 'invalid_request', - 'Session model connection is unavailable', - ); - } - if (readiness.kind === 'credential_not_configured') { - throw new SessionOperationFailure( - 'operation_unavailable', - 'Session model connection is not ready', - ); - } - // Refused before the Session is committed, not when a backend is later - // built for it: an upgraded installation keeps the credential, so nothing - // downstream of here would notice on its own. Covers the default target and - // an explicit one alike, which is what reaches Bot, CLI and scheduled runs. - if (readiness.kind === 'provider_retired') { - throw new SessionOperationFailure( - 'invalid_request', - 'Session model connection uses a sign-in that was removed from Maka', - ); - } - if ( - selected.connectionId !== undefined && - readiness.connection.connectionId !== selected.connectionId - ) { - throw new SessionOperationFailure( - 'operation_conflict', - 'Default Session model changed during selection', - ); - } - const connection = readiness.connection; - const model = authorizeConnectionModel(connection, selected.modelId); - if (!model) { - throw new SessionOperationFailure('invalid_request', 'Session model is not enabled'); - } - if (isModelExplicitlyUnsupportedForChat(model)) { - throw new SessionOperationFailure('invalid_request', 'Session model is not chat-capable'); - } - if (Buffer.byteLength(selected.modelId, 'utf8') > SESSION_CATALOG_MODEL_MAX_BYTES) { - throw new SessionOperationFailure( - 'invalid_request', - 'Session model identifier exceeds the wire limit', - ); - } - // Fail-closed for undeclared levels only: the catalog entry carries the - // typed `relayModelProfiles` table, so a relay's user-declared levels DO - // reach this gate. A level outside the resolved variants is still - // rejected — execution-model-authority rebuilds the runtime connection - // from the same table, so whatever passes here is exactly what the wire - // can send. - if ( - thinkingLevel !== undefined && - !thinkingVariantsForConnection( - { - providerType: connection.providerType, - relayModelProfiles: connection.relayModelProfiles, - }, - selected.modelId, - ).includes(thinkingLevel) - ) { - throw new SessionOperationFailure( - 'invalid_request', - `Session model does not support thinking level ${thinkingLevel}`, - ); - } - return { connectionSlug: connection.slug, model: selected.modelId }; } async #selectModelTarget(target: SessionModelTarget): Promise<{ @@ -794,6 +749,80 @@ export class HostSessionCatalogCoordinator { } } +async function resolveExplicitSessionModel( + runtimePolicy: SessionRuntimePolicyStores, + connectionSlug: string, + modelId: string, + thinkingLevel: SessionCreateInput['thinkingLevel'], + expectedConnectionId?: string, +): Promise { + const readiness = await runtimePolicy.operations.resolveExecutionConnection(connectionSlug); + if (readiness.kind === 'not_found' || readiness.kind === 'disabled') { + throw new SessionOperationFailure('invalid_request', 'Session model connection is unavailable'); + } + if (readiness.kind === 'credential_not_configured') { + throw new SessionOperationFailure( + 'operation_unavailable', + 'Session model connection is not ready', + ); + } + // Refused before the Session is committed, not when a backend is later + // built for it: an upgraded installation keeps the credential, so nothing + // downstream of here would notice on its own. Covers the default target and + // an explicit one alike, which is what reaches Bot, CLI and scheduled runs. + if (readiness.kind === 'provider_retired') { + throw new SessionOperationFailure( + 'invalid_request', + 'Session model connection uses a sign-in that was removed from Maka', + ); + } + if ( + expectedConnectionId !== undefined && + readiness.connection.connectionId !== expectedConnectionId + ) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Default Session model changed during selection', + ); + } + const connection = readiness.connection; + const model = authorizeConnectionModel(connection, modelId); + if (!model) { + throw new SessionOperationFailure('invalid_request', 'Session model is not enabled'); + } + if (isModelExplicitlyUnsupportedForChat(model)) { + throw new SessionOperationFailure('invalid_request', 'Session model is not chat-capable'); + } + if (Buffer.byteLength(modelId, 'utf8') > SESSION_CATALOG_MODEL_MAX_BYTES) { + throw new SessionOperationFailure( + 'invalid_request', + 'Session model identifier exceeds the wire limit', + ); + } + // Fail-closed for undeclared levels only: the catalog entry carries the + // typed `relayModelProfiles` table, so a relay's user-declared levels DO + // reach this gate. A level outside the resolved variants is still + // rejected — execution-model-authority rebuilds the runtime connection + // from the same table, so whatever passes here is exactly what the wire + // can send. + if ( + thinkingLevel !== undefined && + !thinkingVariantsForConnection( + { + providerType: connection.providerType, + relayModelProfiles: connection.relayModelProfiles, + }, + modelId, + ).includes(thinkingLevel) + ) { + throw new SessionOperationFailure( + 'invalid_request', + `Session model does not support thinking level ${thinkingLevel}`, + ); + } + return { connectionSlug: connection.slug, model: modelId }; +} + function sessionConfigurationMatches( header: SessionHeader, model: ResolvedSessionModel, @@ -933,6 +962,9 @@ export function projectSessionCatalogRecord( model: header.model, ...(header.thinkingLevel === undefined ? {} : { thinkingLevel: header.thinkingLevel }), permissionMode: header.permissionMode, + ...(header.pendingConfiguration === undefined + ? {} + : { pendingConfiguration: header.pendingConfiguration }), collaborationMode: header.collaborationMode ?? 'agent', orchestrationMode: header.orchestrationMode ?? 'default', }; diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 5545a5e44e..b2abfd04fb 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -71,7 +71,13 @@ import type { RuntimeEventStore, } from '@maka/core/runtime-event-store'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; +import type { + PendingSessionConfiguration, + SessionHeader, + SessionSummary, + StoredMessage, + TurnRecord, +} from '@maka/core/session'; import type { BackendSendInput, BackendStopMode } from '@maka/core/backend-types'; import { PlanConflictError, emptyPlanSessionState, type PlanStore } from '@maka/core/plan'; import { expect } from '../test-helpers.js'; @@ -4413,6 +4419,99 @@ describe('SessionManager manual compaction and quiescent session changes', () => assert.deepEqual(kernel.disposed, [session.id]); }); + test('stages configuration during a turn and applies it at the next admission', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const kernel = new DelegatingRuntimeKernel(); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(26_420), + runtimeKernel: kernel, + }); + const session = await manager.createSession(makeInput({ model: 'model-a' })); + const nextConfiguration = { + backend: session.backend, + llmConnectionSlug: session.llmConnectionSlug, + connectionLocked: true, + model: 'model-b', + thinkingLevel: undefined, + permissionMode: session.permissionMode, + collaborationMode: session.collaborationMode ?? 'agent', + orchestrationMode: session.orchestrationMode ?? 'default', + } as const; + + kernel.activeRuns = true; + const staged = await manager.transitionSessionConfiguration(session.id, { + expectedRevision: 1, + configuration: nextConfiguration, + }); + expect(staged.revision).toBe(2); + expect(staged.header.model).toBe('model-a'); + expect(staged.header.pendingConfiguration).toMatchObject({ model: 'model-b' }); + expect(headerToSummary(staged.header).pendingConfiguration).toEqual( + staged.header.pendingConfiguration, + ); + expect(kernel.disposed).toEqual([]); + + kernel.activeRuns = false; + const applied = await manager.applyPendingSessionConfiguration(session.id); + expect(applied.header.model).toBe('model-b'); + expect(applied.header.pendingConfiguration).toBeUndefined(); + expect(kernel.disposed).toEqual([session.id]); + }); + + test('preserves a staged configuration when next-Turn validation fails', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const kernel = new DelegatingRuntimeKernel(); + let validated: PendingSessionConfiguration | undefined; + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(26_422), + runtimeKernel: kernel, + validatePendingSessionConfiguration: async (pending) => { + validated = pending; + throw new Error('the selected connection is no longer ready'); + }, + }); + const session = await manager.createSession(makeInput({ model: 'model-a' })); + + kernel.activeRuns = true; + await manager.transitionSessionConfiguration(session.id, { + expectedRevision: 1, + configuration: { + backend: session.backend, + llmConnectionSlug: session.llmConnectionSlug, + connectionLocked: true, + model: 'model-b', + thinkingLevel: undefined, + permissionMode: session.permissionMode, + collaborationMode: session.collaborationMode ?? 'agent', + orchestrationMode: session.orchestrationMode ?? 'default', + }, + }); + + kernel.activeRuns = false; + await assert.rejects(manager.applyPendingSessionConfiguration(session.id), (error: unknown) => { + assert.ok(error instanceof SessionConfigurationTransitionError); + assert.equal(error.code, 'operation_unavailable'); + assert.match(error.message, /no longer available/); + return true; + }); + assert.deepEqual(validated, (await store.readHeader(session.id)).pendingConfiguration); + assert.deepEqual((await store.readHeader(session.id)).pendingConfiguration, { + llmConnectionSlug: session.llmConnectionSlug, + model: 'model-b', + permissionMode: session.permissionMode, + collaborationMode: session.collaborationMode ?? 'agent', + orchestrationMode: session.orchestrationMode ?? 'default', + }); + assert.equal((await store.readHeader(session.id)).model, 'model-a'); + assert.deepEqual(kernel.disposed, []); + }); + test('workspace relocation uses the same quiescent revision fence as execution configuration', async () => { const store = new VersionedConfigurationMemorySessionStore(); const kernel = new DelegatingRuntimeKernel(); @@ -17712,6 +17811,9 @@ class VersionedConfigurationMemorySessionStore extends MemorySessionStore { const header = await super.updateHeader(sessionId, { ...input.configuration, labels: [...input.configuration.labels], + ...(input.pendingConfiguration === undefined + ? {} + : { pendingConfiguration: input.pendingConfiguration ?? undefined }), ...(input.lifecycle.kind === 'clear_connection_block' ? { status: 'active', diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 968b54db95..37d7aa4fc3 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -177,6 +177,8 @@ export interface RuntimeKernelLike { /** Take back every queued message (both queues) as one `\n\n`-joined string. */ retractQueue(sessionId: string): string; hasActiveRuns(sessionId: string): boolean; + /** True while a root/child execution has been claimed but not yet attached. */ + hasPendingExecutionClaims?(sessionId: string): boolean; /** * The turns of the runs in flight for this session. The same fact * `hasActiveRuns` reports, named — which is what lets a client tell a turn @@ -2568,6 +2570,10 @@ export class RuntimeKernel implements RuntimeKernelLike { return this.backendGenerationsFor(sessionId).some((active) => active.activeRuns.size > 0); } + hasPendingExecutionClaims(sessionId: string): boolean { + return (this.executionClaims.get(sessionId)?.size ?? 0) > 0; + } + runningTurnIds(sessionId: string): string[] { const turnIds: string[] = []; for (const active of this.backendGenerationsFor(sessionId)) { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 0707b96998..4b697e95ba 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -49,6 +49,7 @@ import { messageContentsEqual, normalizeMessageContent } from '@maka/core/events import type { SessionHeader, SessionHeaderPatch, + PendingSessionConfiguration, SessionBlockedReason, SessionStatus, SessionSummary, @@ -574,6 +575,8 @@ export interface VersionedSessionHeader { export interface SessionConfigurationStoreUpdate { readonly expectedVersion: number; + /** `null` clears a staged next-Turn configuration; omitted preserves it. */ + readonly pendingConfiguration?: PendingSessionConfiguration | null; readonly configuration: { readonly backend: SessionHeader['backend']; readonly llmConnectionSlug: string; @@ -845,6 +848,10 @@ interface SessionManagerBaseDeps { runtimeSource?: InvocationSource; runtimeInvocationObserver?: (result: InvocationResult) => void | Promise; runtimeKernel?: RuntimeKernelLike; + /** Host-owned validation for a staged model target before next-Turn commit. */ + validatePendingSessionConfiguration?: ( + configuration: PendingSessionConfiguration, + ) => Promise; /** Optional host-owned parent run authority for runtimes that execute the parent externally. */ isParentRunActive?: (sessionId: string, runId: string, turnId: string) => boolean; shellRuns?: ShellRunProcessManager; @@ -1146,6 +1153,63 @@ export class SessionManager { input: SessionConfigurationTransitionRequest, ): Promise { const store = this.requireSessionConfigurationStore(); + const current = await store.readHeaderRecordSnapshot(sessionId); + if (current.revision !== input.expectedRevision) { + throw new SessionConfigurationRevisionConflictError(input.expectedRevision, current.revision); + } + if (current.header.isArchived) { + throw new SessionConfigurationTransitionError( + 'operation_conflict', + 'Archived Session configuration cannot be changed', + ); + } + await this.assertCollaborationTransition(current.header, input.configuration.collaborationMode); + const desired: PendingSessionConfiguration = { + llmConnectionSlug: input.configuration.llmConnectionSlug, + model: input.configuration.model, + ...(input.configuration.thinkingLevel === undefined + ? {} + : { thinkingLevel: input.configuration.thinkingLevel }), + permissionMode: input.configuration.permissionMode, + collaborationMode: input.configuration.collaborationMode, + orchestrationMode: input.configuration.orchestrationMode, + }; + + // The active AgentRun owns its immutable configuration snapshot. A + // configuration change during that run is therefore a Host-owned next-Turn + // selection, not a resource transition for the live backend. + if (sessionHasActiveExecution(this.runtimeKernel, sessionId)) { + if ( + (current.header.collaborationMode ?? 'agent') !== input.configuration.collaborationMode || + (current.header.orchestrationMode ?? 'default') !== input.configuration.orchestrationMode + ) { + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Collaboration and orchestration configuration cannot change while a Turn is active', + ); + } + const updateHeaderVersioned = store.updateHeaderVersioned?.bind(store); + if (!updateHeaderVersioned) { + throw new SessionConfigurationTransitionError( + 'operation_unavailable', + 'Session configuration authority is unavailable', + ); + } + const next = sessionConfigurationMatches(current.header, input.configuration) + ? await updateHeaderVersioned( + sessionId, + { pendingConfiguration: undefined }, + input.expectedRevision, + ) + : await updateHeaderVersioned( + sessionId, + { pendingConfiguration: desired }, + input.expectedRevision, + ); + this.runtimeKernel.updateCachedHeader(sessionId, next.header); + return next; + } + const next = await this.commitExecutionResourceTransition( sessionId, input.configuration.permissionMode, @@ -1193,6 +1257,7 @@ export class SessionManager { statusUpdatedAt: this.deps.now(), } : { kind: 'preserve' }, + pendingConfiguration: null, }); }, ); @@ -1200,6 +1265,45 @@ export class SessionManager { return next; } + /** Apply a Host-owned next-Turn configuration at the root admission boundary. */ + async applyPendingSessionConfiguration(sessionId: string): Promise { + const store = this.requireSessionConfigurationStore(); + const current = await store.readHeaderRecordSnapshot(sessionId); + const pending = current.header.pendingConfiguration; + if (!pending) return current; + if (sessionHasActiveExecution(this.runtimeKernel, sessionId)) { + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Session configuration cannot be applied while a Turn is active', + ); + } + if (this.deps.validatePendingSessionConfiguration) { + try { + await this.deps.validatePendingSessionConfiguration(pending); + } catch (error) { + if (error instanceof SessionConfigurationTransitionError) throw error; + const detail = error instanceof Error && error.message ? `: ${error.message}` : ''; + throw new SessionConfigurationTransitionError( + 'operation_unavailable', + `Pending Session configuration is no longer available${detail}`, + ); + } + } + return this.transitionSessionConfiguration(sessionId, { + expectedRevision: current.revision, + configuration: { + backend: 'ai-sdk', + llmConnectionSlug: pending.llmConnectionSlug, + model: pending.model, + thinkingLevel: pending.thinkingLevel, + connectionLocked: true, + permissionMode: pending.permissionMode, + collaborationMode: pending.collaborationMode, + orchestrationMode: pending.orchestrationMode, + }, + }); + } + async relocateSessionWorkspace( sessionId: string, input: { @@ -6198,6 +6302,9 @@ export function headerToSummary(h: SessionHeader): SessionSummary { connectionLocked: h.connectionLocked, model: h.model, permissionMode: h.permissionMode ?? 'ask', + ...(h.pendingConfiguration === undefined + ? {} + : { pendingConfiguration: h.pendingConfiguration }), collaborationMode: h.collaborationMode ?? 'agent', orchestrationMode: h.orchestrationMode ?? 'default', }; @@ -6328,6 +6435,29 @@ function executionBoundaryMatchesPermissionMode( : boundary.profile.name !== 'read-only'; } +function sessionConfigurationMatches( + header: SessionHeader, + configuration: SessionConfigurationTransitionRequest['configuration'], +): boolean { + return ( + header.backend === 'ai-sdk' && + header.llmConnectionSlug === configuration.llmConnectionSlug && + header.model === configuration.model && + header.thinkingLevel === configuration.thinkingLevel && + header.connectionLocked === configuration.connectionLocked && + header.permissionMode === configuration.permissionMode && + (header.collaborationMode ?? 'agent') === configuration.collaborationMode && + (header.orchestrationMode ?? 'default') === configuration.orchestrationMode + ); +} + +function sessionHasActiveExecution(runtimeKernel: RuntimeKernelLike, sessionId: string): boolean { + return ( + runtimeKernel.hasActiveRuns(sessionId) === true || + runtimeKernel.hasPendingExecutionClaims?.(sessionId) === true + ); +} + function narrowsExecutionAuthority( boundary: ExecutionBoundary, nextPermissionMode: PermissionMode, diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index a731808e92..316aa05146 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -50,6 +50,7 @@ import { import { isCollaborationMode } from '@maka/core/collaboration'; import { isOrchestrationMode } from '@maka/core/orchestration'; import { decodePersistedPermissionMode, isPermissionMode } from '@maka/core/permission'; +import { isThinkingLevel } from '@maka/core/model-thinking'; import type { PersistedValue } from '@maka/core/persisted-value'; import { isSubagentWorkspaceBinding } from '@maka/core/subagent-workspace'; import { WORKSPACE_AUTHORITY_SESSION_ID } from '@maka/core/workspace-version-authority'; @@ -74,6 +75,7 @@ import { type SessionHeaderPatch, type SessionConversationCopy, type SessionExternalOrigin, + type PendingSessionConfiguration, type SessionSummary, type StoredMessage, type TurnRecord, @@ -1117,6 +1119,8 @@ export function normalizeSessionHeader( typeof header.model === 'string' && (header.toolProfile === undefined || isSessionToolProfile(header.toolProfile)) && isPermissionMode(header.permissionMode) && + (header.pendingConfiguration === undefined || + isValidPendingSessionConfiguration(header.pendingConfiguration)) && isCollaborationMode(header.collaborationMode) && isOrchestrationMode(header.orchestrationMode) && (header.transcriptLedgerVersion === undefined || @@ -1134,6 +1138,21 @@ export function normalizeSessionHeader( return { ...header, name: normalizedName }; } +function isValidPendingSessionConfiguration(value: unknown): value is PendingSessionConfiguration { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const pending = value as Record; + return ( + typeof pending.llmConnectionSlug === 'string' && + pending.llmConnectionSlug.length > 0 && + typeof pending.model === 'string' && + pending.model.length > 0 && + (pending.thinkingLevel === undefined || isThinkingLevel(pending.thinkingLevel)) && + isPermissionMode(pending.permissionMode) && + isCollaborationMode(pending.collaborationMode) && + isOrchestrationMode(pending.orchestrationMode) + ); +} + export function decodePersistedSessionHeader( persisted: PersistedValue, sessionId?: string, @@ -1344,6 +1363,9 @@ function toSummary(header: SessionHeader, messages: StoredMessage[] = []): Sessi connectionLocked: header.connectionLocked, model: header.model, permissionMode: header.permissionMode, + ...(header.pendingConfiguration === undefined + ? {} + : { pendingConfiguration: header.pendingConfiguration }), collaborationMode: header.collaborationMode ?? 'agent', orchestrationMode: header.orchestrationMode ?? 'default', ...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}), diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 3e13d67460..1a7ed17daf 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -90,6 +90,7 @@ import { isSubagentSessionSpawn, type SessionHeader, type SessionHeaderPatch, + type PendingSessionConfiguration, type StoredMessage, type SubagentSessionParent, decodeCanonicalMessage, @@ -276,6 +277,8 @@ export type StableSessionMetadataCreateResult = export interface SessionConfigurationMetadataUpdate { readonly expectedVersion: number; + /** `null` clears a staged next-Turn configuration; omitted preserves it. */ + readonly pendingConfiguration?: PendingSessionConfiguration | null; readonly configuration: { readonly backend: SessionHeader['backend']; readonly llmConnectionSlug: string; @@ -729,6 +732,9 @@ export class SqliteSessionMetadataStore { headerPatch: { ...input.configuration, labels: [...input.configuration.labels], + ...(input.pendingConfiguration === undefined + ? {} + : { pendingConfiguration: input.pendingConfiguration ?? undefined }), ...lifecyclePatch, }, }, diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 8ea830e990..9ec32d3754 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -1336,26 +1336,11 @@ export const Composer = forwardRef< // already handed to the active Turn and leave the plate at that moment. const queueCount = props.queuedMessages?.length ?? 0; const modelChipLabel = props.modelLabel?.trim() || copy.selectModel; - // Mid-turn the model and thinking menus stay mounted but locked, each - // carrying the reason in its own words (model vs thinking level) — the - // lock is one state with two wordings, not two locks. - const switchLock = props.streaming - ? 'streaming' - : props.activeSession?.status === 'running' - ? 'running' - : props.activeSession?.status === 'waiting_for_user' - ? 'permission' - : undefined; - const modelSwitcherDisabledReason = - switchLock === 'streaming' ? copy.switchDisabledStreaming - : switchLock === 'running' ? copy.switchDisabledRunning - : switchLock === 'permission' ? copy.switchDisabledPermission - : undefined; - const thinkingSwitcherDisabledReason = - switchLock === 'streaming' ? copy.thinkingDisabledStreaming - : switchLock === 'running' ? copy.thinkingDisabledRunning - : switchLock === 'permission' ? copy.thinkingDisabledPermission - : undefined; + // Model and thinking selections are Host-owned next-Turn state. They stay + // interactive while the active AgentRun is running; only the short local + // write-in-flight flag prevents duplicate submissions. + const modelSwitcherDisabledReason = undefined; + const thinkingSwitcherDisabledReason = undefined; /** * The drawer's contract is context staged for the *next send*: quotes and @@ -1908,10 +1893,9 @@ export const Composer = forwardRef< ) : null} {/* Model + thinking sit left after permission (adjacent pair), not in the send cluster. Thinking is its own menu, only when the - active/new-chat model offers levels. Mid-turn the pair stays - mounted — `modelSwitcherDisabledReason` carries the lock and - its explanation, so the footer never reflows when a turn - starts or ends. */} + active/new-chat model offers levels. The Host keeps the + controls mounted and applies active-turn changes to the next + root Turn. */}
{props.activeSession ? (