From 3ba49ed5a25e13b74253e4a74f98358c171c514a Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Mon, 31 Aug 2026 22:54:32 -0600 Subject: [PATCH 1/5] fix(prime): preserve provider admission across restarts Closes #222 --- .github/workflows/ci.yml | 2 +- .../features/threads/NewTaskDraftScreen.tsx | 74 +- .../threads/ProviderUnavailableNotice.tsx | 31 + .../features/threads/ThreadComposer.logic.ts | 80 ++ .../features/threads/ThreadComposer.test.ts | 189 ++++ .../src/features/threads/ThreadComposer.tsx | 117 ++- .../features/threads/ThreadDetailScreen.tsx | 2 + .../features/threads/ThreadRouteScreen.tsx | 1 + .../threads/new-task-flow-provider.tsx | 55 +- .../features/threads/thread-list-items.tsx | 8 +- .../features/threads/thread-list-v2-items.tsx | 8 +- apps/mobile/src/lib/modelOptions.test.ts | 129 +++ apps/mobile/src/lib/modelOptions.ts | 138 ++- apps/mobile/src/state/thread-outbox-model.ts | 339 +++++- apps/mobile/src/state/thread-outbox.test.ts | 503 ++++++++- .../state/use-thread-composer-state.logic.ts | 75 ++ .../state/use-thread-composer-state.test.ts | 140 +++ .../src/state/use-thread-composer-state.ts | 234 ++++- .../src/state/use-thread-outbox-drain.ts | 401 ++++---- .../Layers/OrchestrationEngine.test.ts | 39 +- .../Layers/OrchestrationEngine.ts | 3 + .../Layers/ProjectionPipeline.ts | 20 + .../Layers/ProjectionSnapshotQuery.ts | 38 + .../Layers/ProviderCommandReactor.test.ts | 969 ++++++++++++++++-- .../Layers/ProviderCommandReactor.ts | 641 +++++++++--- .../Layers/ProviderRuntimeIngestion.test.ts | 220 +++- .../Layers/ProviderRuntimeIngestion.ts | 72 ++ .../Services/ProviderCommandReactor.ts | 8 + .../decider.sessionLifecycle.test.ts | 303 ++++++ apps/server/src/orchestration/decider.ts | 197 +++- .../Layers/ProjectionThreadSessions.ts | 20 + apps/server/src/persistence/Migrations.ts | 2 + ...ionThreadSessionPendingTurnRequest.test.ts | 3 + ...ProjectionThreadSessionPendingStop.test.ts | 114 +++ .../049_ProjectionThreadSessionPendingStop.ts | 27 + .../Services/ProjectionThreadSessions.ts | 5 + .../provider/Drivers/PrimeAgentDriver.test.ts | 9 +- .../src/provider/Drivers/PrimeAgentDriver.ts | 25 +- .../src/provider/Layers/CodexAdapter.test.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 1 + .../ProviderInstanceRegistryLive.test.ts | 138 +++ .../Layers/ProviderInstanceRegistryLive.ts | 2 +- .../provider/Layers/ProviderService.test.ts | 169 ++- .../src/provider/Layers/ProviderService.ts | 253 ++++- .../Layers/ProviderSessionDirectory.ts | 137 ++- .../src/provider/Services/ProviderService.ts | 8 + .../Services/ProviderSessionDirectory.ts | 8 + .../prime/PrimeAgentDaemonBridge.test.ts | 36 - .../provider/prime/PrimeAgentDaemonBridge.ts | 92 -- .../src/provider/primeAgentBackends.test.ts | 38 +- .../server/src/provider/primeAgentBackends.ts | 61 +- .../src/provider/providerUnavailable.ts | 20 + .../serverRuntimeStartup.reconcile.test.ts | 4 + .../PrimeAgentTextGeneration.platform.test.ts | 15 +- .../PrimeAgentTextGeneration.test.ts | 60 -- .../PrimeAgentTextGeneration.ts | 89 +- .../src/textGeneration/TextGeneration.ts | 24 +- .../web/src/components/ChatView.logic.test.ts | 111 +- apps/web/src/components/ChatView.logic.ts | 83 +- apps/web/src/components/ChatView.tsx | 427 ++++---- apps/web/src/components/chat/ChatComposer.tsx | 140 ++- .../chat/ComposerPrimaryActions.test.tsx | 50 +- .../chat/ComposerPrimaryActions.tsx | 28 +- .../components/chat/ModelPickerContent.tsx | 28 +- .../chat/ModelPickerSidebar.test.tsx | 79 ++ .../components/chat/ModelPickerSidebar.tsx | 22 +- .../components/chat/ProviderModelPicker.tsx | 2 + .../chat/ProviderStatusBanner.test.tsx | 47 + .../components/chat/ProviderStatusBanner.tsx | 72 +- .../settings/ProviderInstanceCard.test.ts | 88 ++ .../settings/ProviderInstanceCard.tsx | 32 +- .../src/components/settings/providerStatus.ts | 4 + apps/web/src/composerDraftStore.test.ts | 17 + apps/web/src/composerDraftStore.ts | 36 + .../web/src/composerInstanceSelection.test.ts | 136 ++- apps/web/src/composerInstanceSelection.ts | 127 ++- apps/web/src/providerInstances.test.ts | 104 +- apps/web/src/providerInstances.ts | 27 +- docs/internals/prime-agent-daemon-parity.md | 13 +- docs/internals/prime-agent-native-parity.md | 7 +- docs/internals/providers.md | 13 +- docs/user/providers-prime-agent.md | 8 +- packages/client-runtime/package.json | 8 + .../src/providerAvailability.test.ts | 39 + .../src/providerAvailability.ts | 98 ++ .../src/providerContinuation.test.ts | 110 ++ .../src/providerContinuation.ts | 64 ++ packages/contracts/src/orchestration.ts | 41 +- packages/contracts/src/provider.ts | 8 + packages/contracts/src/server.ts | 24 +- 90 files changed, 7025 insertions(+), 1465 deletions(-) create mode 100644 apps/mobile/src/features/threads/ProviderUnavailableNotice.tsx create mode 100644 apps/mobile/src/features/threads/ThreadComposer.logic.ts create mode 100644 apps/mobile/src/features/threads/ThreadComposer.test.ts create mode 100644 apps/mobile/src/state/use-thread-composer-state.logic.ts create mode 100644 apps/mobile/src/state/use-thread-composer-state.test.ts create mode 100644 apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts create mode 100644 apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.ts create mode 100644 apps/server/src/provider/providerUnavailable.ts create mode 100644 apps/web/src/components/chat/ModelPickerSidebar.test.tsx create mode 100644 packages/client-runtime/src/providerAvailability.test.ts create mode 100644 packages/client-runtime/src/providerAvailability.ts create mode 100644 packages/client-runtime/src/providerContinuation.test.ts create mode 100644 packages/client-runtime/src/providerContinuation.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a5f61232..f3fbe1974 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-24.04, windows-2025] + os: [ubuntu-24.04] steps: - name: Checkout uses: actions/checkout@v6 diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e9b621c6f..776026e56 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -17,6 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useFontFamily } from "../../lib/useFontFamily"; +import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -38,6 +39,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; +import { ProviderUnavailableNotice } from "./ProviderUnavailableNotice"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { ComposerDictationCancelAction, @@ -158,6 +160,15 @@ export function NewTaskDraftScreen(props: { connectedEnvironments.find( (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; + const providerAdmissionReason = getProviderAdmissionUnavailableReason({ + provider: flow.selectedProviderStatus, + instanceId: flow.selectedModel ? String(flow.selectedModel.instanceId) : undefined, + providerSnapshotKnown: selectedEnvironmentServerConfig != null, + }); + const providerUnavailable = + providerAdmissionReason === null + ? null + : { headline: "Unavailable" as const, detail: providerAdmissionReason }; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); @@ -794,7 +805,7 @@ export function NewTaskDraftScreen(props: { if (voiceInput.blocksSubmission) return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; - if (!selectedProject || !draftKey) { + if (!selectedProject || !draftKey || providerUnavailable) { return; } const draft = getComposerDraftSnapshot(draftKey); @@ -837,19 +848,23 @@ export function NewTaskDraftScreen(props: { } const editingPendingTask = flow.editingPendingTask; + const retryTurnMetadata = + editingPendingTask?.deliveryHold === undefined ? null : makeTurnCommandMetadata(); if (!environmentConnected) { // Offline: park the task in the outbox; the drain sends it when the - // environment reconnects. Editing an existing pending task re-queues it - // under its original identifiers. - const metadata = editingPendingTask - ? { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - } - : makeTurnCommandMetadata(); + // environment reconnects. Ordinary edits preserve their identifiers; + // explicitly submitting a held retarget uses the fresh metadata above. + const metadata = + retryTurnMetadata ?? + (editingPendingTask + ? { + threadId: editingPendingTask.threadId, + commandId: editingPendingTask.commandId, + messageId: editingPendingTask.messageId, + createdAt: editingPendingTask.createdAt, + } + : makeTurnCommandMetadata()); const message = flow.buildPendingTaskMessage(metadata); if (!message) { return; @@ -857,6 +872,19 @@ export function NewTaskDraftScreen(props: { flow.setSubmitting(true); try { await enqueueThreadOutboxMessage(message); + if ( + editingPendingTask !== null && + editingPendingTask.deliveryHold !== undefined && + editingPendingTask.messageId !== message.messageId + ) { + try { + await removeThreadOutboxMessage(editingPendingTask); + } catch (error) { + // The replacement is already durable and the old entry remains + // held, so neither copy can lose or double-send the content. + console.warn("[new-task] failed to remove retargeted held task", error); + } + } } catch (error) { Alert.alert( "Could not queue task", @@ -912,7 +940,7 @@ export function NewTaskDraftScreen(props: { }, ...(editingPendingTask ? { - turnMetadata: { + turnMetadata: retryTurnMetadata ?? { threadId: editingPendingTask.threadId, commandId: editingPendingTask.commandId, messageId: editingPendingTask.messageId, @@ -972,8 +1000,9 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const canStart = - Boolean(flow.selectedProject) && + Boolean(flow.selectedProject?.workspaceRoot?.trim()) && Boolean(flow.selectedModel) && + providerUnavailable === null && flow.prompt.trim().length > 0 && isIncomingShareReady && !isImportingShare && @@ -1140,6 +1169,11 @@ export function NewTaskDraftScreen(props: { ) : null} {workspaceControls} + + + {title} + {detail} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.logic.ts b/apps/mobile/src/features/threads/ThreadComposer.logic.ts new file mode 100644 index 000000000..ed726c895 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadComposer.logic.ts @@ -0,0 +1,80 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; +import type { + ModelSelection, + OrchestrationSession, + ServerConfig, + ServerProvider, +} from "@t3tools/contracts"; + +/** Resolve every composer surface against the persisted session binding first. */ +export function resolveThreadComposerAuthority(input: { + readonly serverConfig: Pick | null | undefined; + readonly modelSelection: ModelSelection; + readonly sessionProviderInstanceId?: ModelSelection["instanceId"] | undefined; +}): { + readonly modelSelection: ModelSelection | null; + readonly provider: ServerProvider | null; + readonly providerAdmissionAvailable: boolean; + readonly providerAdmissionReason: string | null; + readonly providerBindingMismatch: boolean; +} { + const providers = input.serverConfig?.providers ?? []; + const instanceId = input.modelSelection.instanceId; + const selectedProvider = + providers.find((candidate) => candidate.instanceId === instanceId) ?? null; + const transition = input.sessionProviderInstanceId + ? resolveProviderContinuationTransition({ + providers, + currentInstanceId: input.sessionProviderInstanceId, + targetInstanceId: instanceId, + }) + : ({ compatible: true } as const); + const providerBindingMismatch = !transition.compatible; + const provider = providerBindingMismatch + ? (providers.find((candidate) => candidate.instanceId === input.sessionProviderInstanceId) ?? + null) + : selectedProvider; + const providerAdmissionReason = transition.compatible + ? getProviderAdmissionUnavailableReason({ + provider, + instanceId: String(instanceId), + providerSnapshotKnown: input.serverConfig !== null && input.serverConfig !== undefined, + }) + : transition.reason; + return { + modelSelection: providerBindingMismatch ? null : input.modelSelection, + provider, + providerAdmissionAvailable: providerAdmissionReason === null, + providerAdmissionReason, + providerBindingMismatch, + }; +} + +/** Describe why a turn cannot be admitted immediately, even when it can be saved to the outbox. */ +export function resolveThreadComposerAdmissionReason(input: { + readonly providerReason: string | null; + readonly projectCwd: string | null; + readonly connectionState: EnvironmentConnectionPhase; +}): string | null { + if (input.providerReason !== null) return input.providerReason; + if (input.projectCwd === null) return "This thread's project workspace is unavailable."; + if (input.connectionState !== "connected") { + if (input.connectionState === "connecting" || input.connectionState === "reconnecting") { + return "The environment is still connecting. This send will remain queued."; + } + if (input.connectionState === "error") { + return "The environment connection failed. This send will remain queued."; + } + return "The environment is offline. This send will remain queued."; + } + return null; +} + +/** Provider unavailability must never remove the active turn's escape hatch. */ +export function threadComposerShowsStopAction( + status: OrchestrationSession["status"] | null | undefined, +): boolean { + return status === "running" || status === "starting"; +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.test.ts b/apps/mobile/src/features/threads/ThreadComposer.test.ts new file mode 100644 index 000000000..68aca5e5a --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadComposer.test.ts @@ -0,0 +1,189 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveThreadComposerAdmissionReason, + resolveThreadComposerAuthority, + threadComposerShowsStopAction, +} from "./ThreadComposer.logic"; + +const PRIME_REASON = + "Prime Agent requires WSL2 on native Windows. Connect to a supported environment."; + +function provider(input: { + readonly instanceId: string; + readonly driver: string; + readonly availability?: "available" | "unavailable"; + readonly unavailableReason?: string; + readonly status?: ServerProvider["status"]; + readonly continuationGroupKey?: string; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver), + enabled: input.availability !== "unavailable", + installed: true, + version: null, + status: input.status ?? (input.availability === "unavailable" ? "disabled" : "ready"), + ...(input.availability ? { availability: input.availability } : {}), + ...(input.unavailableReason ? { unavailableReason: input.unavailableReason } : {}), + ...(input.continuationGroupKey + ? { continuation: { groupKey: input.continuationGroupKey } } + : {}), + auth: { status: "authenticated" }, + checkedAt: "2026-08-06T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + +describe("ThreadComposer provider authority", () => { + it("shows and blocks the unavailable Prime binding instead of a local Codex overlay", () => { + const prime = provider({ + instanceId: "primeAgent", + driver: "primeAgent", + availability: "unavailable", + unavailableReason: PRIME_REASON, + }); + const codex = provider({ instanceId: "codex", driver: "codex" }); + + const authority = resolveThreadComposerAuthority({ + serverConfig: { providers: [prime, codex] }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + sessionProviderInstanceId: ProviderInstanceId.make("primeAgent"), + }); + + expect(authority.modelSelection).toBeNull(); + expect(authority.providerBindingMismatch).toBe(true); + expect(authority.provider).toBe(prime); + expect(authority.provider?.unavailableReason).toBe(PRIME_REASON); + expect(authority.providerAdmissionAvailable).toBe(false); + }); + + it("keeps cold offline snapshots queueable and warning snapshots admissible", () => { + const selection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + expect( + resolveThreadComposerAuthority({ serverConfig: undefined, modelSelection: selection }), + ).toMatchObject({ + modelSelection: selection, + providerAdmissionAvailable: true, + providerAdmissionReason: null, + }); + expect( + resolveThreadComposerAuthority({ + serverConfig: { + providers: [provider({ instanceId: "codex", driver: "codex", status: "warning" })], + }, + modelSelection: selection, + }), + ).toMatchObject({ providerAdmissionAvailable: true, providerAdmissionReason: null }); + }); + + it("returns concrete admission reasons for every provider materialization failure", () => { + const selection = { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "default", + }; + expect( + resolveThreadComposerAuthority({ serverConfig: { providers: [] }, modelSelection: selection }) + .providerAdmissionReason, + ).toContain("not configured"); + + const disabled = { + ...provider({ instanceId: "primeAgent", driver: "primeAgent" }), + enabled: false, + }; + expect( + resolveThreadComposerAuthority({ + serverConfig: { providers: [disabled] }, + modelSelection: selection, + }).providerAdmissionReason, + ).toContain("disabled"); + + const uninstalled = { ...disabled, enabled: true, installed: false }; + expect( + resolveThreadComposerAuthority({ + serverConfig: { providers: [uninstalled] }, + modelSelection: selection, + }).providerAdmissionReason, + ).toContain("not installed"); + + const unauthenticated = { + ...uninstalled, + installed: true, + auth: { status: "unauthenticated" as const }, + }; + expect( + resolveThreadComposerAuthority({ + serverConfig: { providers: [unauthenticated] }, + modelSelection: selection, + }).providerAdmissionReason, + ).toContain("Sign in"); + }); + + it("reports project and connection admission reasons without hiding offline queueing", () => { + expect( + resolveThreadComposerAdmissionReason({ + providerReason: null, + projectCwd: null, + connectionState: "connected", + }), + ).toContain("project workspace"); + expect( + resolveThreadComposerAdmissionReason({ + providerReason: null, + projectCwd: "/repo", + connectionState: "offline", + }), + ).toContain("offline"); + expect( + resolveThreadComposerAdmissionReason({ + providerReason: null, + projectCwd: "/repo", + connectionState: "connecting", + }), + ).toContain("connecting"); + }); + + it("keeps an intact model selection owned by a compatible account", () => { + const work = provider({ + instanceId: "codex", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }); + const personal = provider({ + instanceId: "codex_personal", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + } as const; + + const authority = resolveThreadComposerAuthority({ + serverConfig: { providers: [work, personal] }, + modelSelection, + sessionProviderInstanceId: ProviderInstanceId.make("codex"), + }); + + expect(authority.modelSelection).toBe(modelSelection); + expect(authority.provider).toBe(personal); + expect(authority.providerBindingMismatch).toBe(false); + expect(authority.providerAdmissionAvailable).toBe(true); + }); + + it("keeps Stop available for an active turn when provider admission is unavailable", () => { + expect(threadComposerShowsStopAction("running")).toBe(true); + expect(threadComposerShowsStopAction("starting")).toBe(true); + expect(threadComposerShowsStopAction("ready")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d3cc019fb..0404ebcfb 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,4 +1,5 @@ import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { formatSessionGoalStatus, type SessionGoalSnapshot, @@ -124,6 +125,12 @@ import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; +import { ProviderUnavailableNotice } from "./ProviderUnavailableNotice"; +import { + resolveThreadComposerAdmissionReason, + resolveThreadComposerAuthority, + threadComposerShowsStopAction, +} from "./ThreadComposer.logic"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { ComposerDictationCancelAction, @@ -201,6 +208,7 @@ export interface ThreadComposerProps { readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly localOutboxCount: number; + readonly onManagePendingSends: () => void; readonly contextWindow: ContextWindowSnapshot | null; readonly sessionResources: SessionResourcesSnapshot | null; readonly sessionAgentDepth: SessionAgentDepthSnapshot | null; @@ -488,10 +496,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // Opening and presentation count as active so the composer stays expanded // while focus moves between its native editor and the settings picker. const isExpanded = isFocused || settingsSheetPresentation.isActive; - const canSend = - hasContent && - props.sessionCompactionPendingAction !== "compact" && - !isSessionCompactionInProgress(props.sessionCompaction); // Notify the parent from the derived value, not focus events: the parent // sizes the feed inset from this, and blur-during-sheet would otherwise @@ -525,14 +529,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer setIsFocused(false); onEditorFocusChange?.(false); }, [onEditorFocusChange]); + const composerAuthority = resolveThreadComposerAuthority({ + serverConfig: props.serverConfig, + modelSelection: props.selectedThread.modelSelection, + sessionProviderInstanceId: props.selectedThread.session?.providerInstanceId, + }); // #8843: an empty composer shows the interrupt button while the agent works; - // adding text or an attachment swaps it for send. + // adding text or an attachment swaps it for send. Provider admission never + // removes that active turn escape hatch. const showStopAction = - !hasContent && - (props.selectedThread.session?.status === "running" || - props.selectedThread.session?.status === "starting"); - - const currentModelSelection = props.selectedThread.modelSelection; + !hasContent && threadComposerShowsStopAction(props.selectedThread.session?.status); + // A mismatched persisted selection is presentation-only. Admission remains + // blocked until the user selects an exact model for the bound instance. + const currentModelSelection = + composerAuthority.modelSelection ?? props.selectedThread.modelSelection; const currentRuntimeMode = resolveModelSelectionRuntimeMode( props.serverConfig, currentModelSelection, @@ -548,14 +558,26 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer environmentLabel: props.environmentLabel, threadSyncPhase: props.threadSyncPhase, }); - const selectedProviderStatus = useMemo(() => { - if (!props.serverConfig) return null; - return ( - props.serverConfig.providers.find( - (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, - ) ?? null - ); - }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); + const selectedProviderStatus = composerAuthority.provider; + const providerAdmissionReason = composerAuthority.providerAdmissionReason; + const projectAdmissionReason = + props.projectCwd === null ? "This thread's project workspace is unavailable." : null; + const blockingAdmissionReason = providerAdmissionReason ?? projectAdmissionReason; + const composerAdmissionReason = resolveThreadComposerAdmissionReason({ + providerReason: providerAdmissionReason, + projectCwd: props.projectCwd, + connectionState: props.connectionState, + }); + const selectedProviderUnavailable = + blockingAdmissionReason === null + ? null + : { headline: "Unavailable" as const, detail: blockingAdmissionReason }; + const canSend = + hasContent && + composerAuthority.providerAdmissionAvailable && + props.projectCwd !== null && + props.sessionCompactionPendingAction !== "compact" && + !isSessionCompactionInProgress(props.sessionCompaction); const activeSessionProviderStatus = useMemo(() => { const instanceId = props.selectedThread.session?.providerInstanceId; if (!props.serverConfig || instanceId === undefined) return null; @@ -569,6 +591,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer activeSessionProviderStatus?.requiresNewThreadForModelChange === true); const getModelChangeDisabledReason = useCallback( (option: ModelOption) => { + const boundInstanceId = props.selectedThread.session?.providerInstanceId; + if (boundInstanceId) { + const transition = resolveProviderContinuationTransition({ + providers: props.serverConfig?.providers ?? [], + currentInstanceId: boundInstanceId, + targetInstanceId: option.selection.instanceId, + }); + if (!transition.compatible) return transition.reason; + } const isCurrent = option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model; @@ -587,7 +618,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "Start a new thread to use this model" : undefined; }, - [currentModelSelection, modelChangesLocked, props.selectedThread.session], + [currentModelSelection, modelChangesLocked, props.selectedThread.session, props.serverConfig], ); const quickQuestionAvailable = canOpenQuickQuestion({ connectionState: props.connectionState, @@ -754,6 +785,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer supportsSessionInputQueue(activeSessionProviderStatus); const canQueueFollowUp = props.connectionState === "connected" && + composerAuthority.providerAdmissionAvailable && props.selectedThread.session?.status === "running" && !props.sessionInputBlocked && props.localOutboxCount === 0 && @@ -781,17 +813,22 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const canSetSessionInputQueueModes = showSessionInputQueueModes && props.connectionState === "connected" && + composerAuthority.providerAdmissionAvailable && (props.selectedThread.session?.status === "ready" || props.selectedThread.session?.status === "running") && !isMutatingSessionInputQueue; // A busy thread is no longer a reason to hold a message back: the outbox now // delivers while a turn runs so the message steers it. Only a lost connection // or an already-queued message still means "saved rather than sent". - const sendLabel = canQueueFollowUp - ? "Queue follow-up" - : props.connectionState !== "connected" || props.localOutboxCount > 0 - ? "Save pending send" - : "Send"; + const sendLabel = selectedProviderUnavailable + ? `Send unavailable. ${selectedProviderUnavailable.detail}` + : canQueueFollowUp + ? "Queue follow-up" + : props.connectionState !== "connected" + ? `Save pending send. ${composerAdmissionReason ?? "The environment is disconnected."}` + : props.localOutboxCount > 0 + ? "Save pending send" + : "Send"; const showSessionResourceReload = props.selectedThread.session?.runtimeMode === "full-access" && @@ -1138,6 +1175,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.session?.status === "running"); const canCompactSessionContext = sessionCompactionConnected && + composerAuthority.providerAdmissionAvailable && props.sessionCompactionPendingAction === null && canStartSessionCompaction(activeSessionProviderStatus, props.sessionCompaction); const canAbortSessionContext = @@ -1146,6 +1184,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer canAbortSessionCompaction(activeSessionProviderStatus, props.sessionCompaction); const canSetSessionAutoCompaction = sessionCompactionConnected && + composerAuthority.providerAdmissionAvailable && props.sessionCompactionPendingAction === null && canConfigureSessionAutoCompaction(activeSessionProviderStatus, props.sessionCompaction); const sessionCompactionControlRef = useRef({ @@ -1446,12 +1485,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [props.serverConfig, currentModelSelection], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); - // An existing thread is bound to its harness: sessions can't move between - // provider instances, so the picker only offers the thread's own group. - const threadProviderGroups = useMemo( - () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId), - [providerGroups, currentModelSelection.instanceId], - ); + // Keep every configured group visible. `getModelChangeDisabledReason` + // enables exact continuation peers and explains why every other account + // needs a new thread. + const threadProviderGroups = providerGroups; const currentModelOption = modelOptions.find( (option) => @@ -1575,6 +1612,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ) : null} + + 0 ? ( - - {props.localOutboxCount} pending send{props.localOutboxCount === 1 ? "" : "s"} on this - device. - + + + {props.localOutboxCount} pending send{props.localOutboxCount === 1 ? "" : "s"} on + this device · Manage + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index c18358a76..7314392de 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -149,6 +149,7 @@ export interface ThreadDetailScreenProps { readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; readonly localOutboxCount: number; + readonly onManagePendingSends: () => void; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; @@ -931,6 +932,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread selectedThread={props.selectedThread} serverConfig={props.serverConfig} localOutboxCount={props.localOutboxCount} + onManagePendingSends={props.onManagePendingSends} contextWindow={props.contextWindow} sessionResources={props.sessionResources} sessionAgentDepth={props.sessionAgentDepth} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index fd8a46419..70de63b38 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -897,6 +897,7 @@ function ThreadRouteContent( projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} localOutboxCount={composer.selectedThreadQueueCount} + onManagePendingSends={composer.onManagePendingSends} layoutVariant={layout.variant} usesAutomaticContentInsets={usesNativeHeaderGlass} onOpenConnectionEditor={handleOpenConnectionEditor} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 9a8bb0501..16cea568e 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getProviderUnavailablePresentation } from "@t3tools/client-runtime/providerAvailability"; import type { EnvironmentId, ModelSelection, @@ -36,6 +37,7 @@ import { resolveDefaultableModelSelection, resolveModelSelectionRuntimeMode, resolveNewTaskModelSelection, + resolveNewTaskUnavailableProvider, resolveSelectableModelSelection, showModelSelectionInteractionModeToggle, } from "../../lib/modelOptions"; @@ -66,6 +68,7 @@ import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; import { flattenQueuedThreadMessages, + preserveQueuedThreadDeliveryHold, threadOutboxManager, type QueuedThreadMessage, } from "../../state/thread-outbox"; @@ -421,19 +424,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; // Stored selections only count while their provider is usable on the - // server; otherwise the server's default model wins instead of silently - // targeting a disabled provider. The draft selection is an explicit pick - // and passes through as-is; the project default (last used, possibly from - // desktop) is implicit and additionally never resolves to a legacy model. + // server. Ordinary disabled or removed choices keep the existing default + // fallback. An explicitly unavailable choice is different: preserve its + // server status for remediation and require a new pick instead of silently + // switching providers. Project and sticky defaults also reject legacy models. + const storedDraftModelSelection = selectedProjectDraft.modelSelection ?? null; + const storedProjectDefaultModelSelection = selectedProject?.defaultModelSelection ?? null; + const storedStickyModelSelection = useStickyComposerModelSelection(); + const unavailablePreferredProvider = resolveNewTaskUnavailableProvider( + selectedEnvironmentServerConfig, + { + draftSelection: storedDraftModelSelection, + projectDefaultSelection: storedProjectDefaultModelSelection, + stickySelection: storedStickyModelSelection, + }, + ); const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, - selectedProjectDraft.modelSelection ?? null, + storedDraftModelSelection, ); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, + storedProjectDefaultModelSelection, ); - const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, storedStickyModelSelection, @@ -459,6 +472,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { projectDefaultSelection: projectDefaultModelSelection, stickySelection: stickyModelSelection, modelOptions, + unavailablePreferredProvider, }); const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` @@ -484,13 +498,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderStatus = useMemo( - () => - selectedEnvironmentServerConfig?.providers.find( - (provider) => provider.instanceId === selectedModel?.instanceId, - ) ?? null, - [selectedEnvironmentServerConfig, selectedModel?.instanceId], - ); + const selectedProviderStatus = useMemo(() => { + if (unavailablePreferredProvider) return unavailablePreferredProvider; + const selected = selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === selectedModel?.instanceId, + ); + if (selected) return selected; + if (modelOptions.length > 0) return null; + return ( + selectedEnvironmentServerConfig?.providers.find((provider) => + getProviderUnavailablePresentation(provider), + ) ?? null + ); + }, [ + modelOptions.length, + selectedEnvironmentServerConfig, + selectedModel?.instanceId, + unavailablePreferredProvider, + ]); const setSelectedModelKey = useCallback( // Options ride along in the same write: a follow-up setSelectedModelOptions // call would rebuild the selection from the stale pre-switch model. @@ -914,6 +939,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projectCwd = usingPendingSnapshot ? editingPendingTask?.creation?.projectCwd : selectedProject.workspaceRoot; + const preservedDeliveryHold = preserveQueuedThreadDeliveryHold(editingPendingTask, metadata); return { environmentId: selectedProject.environmentId, threadId: ThreadId.make(metadata.threadId), @@ -927,6 +953,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { draftModelSelection, draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, ), + ...(preservedDeliveryHold === undefined ? {} : { deliveryHold: preservedDeliveryHold }), interactionMode: planModeEnabled && showModelSelectionInteractionModeToggle( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 76b7a4a75..8e5e06ed1 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -258,6 +258,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: /* ─── Pending task row ───────────────────────────────────────────────── */ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ + { id: "retarget", title: "Retarget", image: "arrow.triangle.2.circlepath" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -288,14 +289,17 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "retarget") onSelectPendingTask(pendingTask); if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); }, - [onDeletePendingTask, pendingTask], + [onDeletePendingTask, onSelectPendingTask, pendingTask], ); const statusPill = ( - Pending + + {pendingTask.message.deliveryHold ? "Held" : "Pending"} + ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 84b0eee7b..633a4b141 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -189,6 +189,7 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS }); const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ + { id: "retarget", title: "Retarget", image: "arrow.triangle.2.circlepath" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -222,9 +223,10 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "retarget") onSelectPendingTask(pendingTask); if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); }, - [onDeletePendingTask, pendingTask], + [onDeletePendingTask, onSelectPendingTask, pendingTask], ); const rowContent = ( @@ -242,7 +244,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props {projectTitle} - Queued + + {pendingTask.message.deliveryHold ? "Held" : "Queued"} + {/* One line, unlike the two an active row allows: a queued title is derived from the whole prompt rather than written as a title, so the diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 49d0a0e61..2f1d1f5d9 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -4,12 +4,15 @@ import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3t import { buildModelOptions, + canSendToModelSelection, groupByProvider, getModelSelectionSupportedRuntimeModes, + getModelSelectionUnavailablePresentation, resolveDefaultableModelSelection, resolveModelSelectionRuntimeMode, showModelSelectionInteractionModeToggle, resolveNewTaskModelSelection, + resolveNewTaskUnavailableProvider, resolveSelectableModelSelection, type ModelOption, } from "./modelOptions"; @@ -244,6 +247,132 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + it("blocks existing-thread sends and gives the unavailable reason precedence", () => { + const reason = + "Prime Agent is unavailable because this Pylon server is running on native Windows. Run the Pylon server and Prime Agent in WSL2, or connect this client to a Pylon server running in WSL2 or another remote environment."; + const config = { + providers: [ + { + instanceId: "primeAgent", + driver: "primeAgent", + displayName: "Prime Agent", + enabled: true, + installed: true, + availability: "unavailable", + unavailableReason: reason, + message: "Disabled", + auth: { status: "authenticated" }, + models: [ + { + slug: "default", + name: "Prime Agent Default", + isCustom: false, + isDefault: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + const selection = { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "default", + }; + + expect(resolveSelectableModelSelection(config, selection)).toBeNull(); + expect(canSendToModelSelection(config, selection)).toBe(false); + expect(canSendToModelSelection(null, selection)).toBe(true); + expect(getModelSelectionUnavailablePresentation(config, selection)).toEqual({ + headline: "Unavailable", + detail: reason, + }); + expect(buildModelOptions(config, selection)).toEqual([]); + }); + + it("holds an unavailable stored choice instead of silently falling back for a new task", () => { + const unavailable = { + instanceId: "primeAgent", + driver: "primeAgent", + displayName: "Prime Agent", + enabled: false, + installed: false, + availability: "unavailable", + unavailableReason: "Run Prime Agent in WSL2.", + auth: { status: "unknown" }, + models: [], + }; + const config = { + providers: [ + unavailable, + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-default", + name: "GPT Default", + isCustom: false, + isDefault: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + const storedPrime = { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "default", + }; + const options = buildModelOptions(config, null); + + expect(options).toHaveLength(1); + expect(resolveSelectableModelSelection(config, storedPrime)).toBeNull(); + const unavailablePreferredProvider = resolveNewTaskUnavailableProvider(config, { + draftSelection: null, + projectDefaultSelection: storedPrime, + stickySelection: null, + }); + expect(unavailablePreferredProvider).toMatchObject(unavailable); + expect( + resolveNewTaskModelSelection({ + draftSelection: null, + projectDefaultSelection: null, + stickySelection: null, + modelOptions: options, + unavailablePreferredProvider, + }), + ).toBeNull(); + }); + + it("keeps an authoritative disabled stored choice blocked for remediation", () => { + const disabled = { + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: false, + installed: true, + auth: { status: "authenticated" }, + models: [], + }; + const config = { providers: [disabled] } as unknown as ServerConfig; + const selection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "sonnet", + }; + + expect( + resolveNewTaskUnavailableProvider(config, { + draftSelection: selection, + projectDefaultSelection: null, + stickySelection: null, + }), + ).toBe(disabled); + expect(getModelSelectionUnavailablePresentation(config, selection)).toBeNull(); + }); + it("keeps legacy models out of implicit defaults", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 9ad58ae56..e43f36e0e 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,8 +1,14 @@ +import { + getProviderAdmissionAvailability, + getProviderUnavailablePresentation, + type ProviderUnavailablePresentation, +} from "@t3tools/client-runtime/providerAvailability"; import type { ModelCapabilities, ModelSelection, RuntimeMode, ServerConfig as T3ServerConfig, + ServerProvider, } from "@t3tools/contracts"; import { getServerProviderSupportedRuntimeModes, @@ -69,9 +75,10 @@ function normalizeSelectionOptions( /** * A stored model selection is only usable when its provider instance is - * currently enabled, installed, and authenticated on the server. Returns the - * selection unchanged when usable, otherwise `null` so callers fall through to - * the server's default model. A missing config (environment offline) cannot be + * currently enabled, installed, authenticated, and available on the server. + * Returns the selection unchanged when usable, otherwise `null`. Callers can + * either fall through or hold an unavailable choice for explicit remediation. + * A missing config (environment offline) cannot be * validated, so stored selections pass through untouched. */ export function resolveSelectableModelSelection( @@ -84,10 +91,11 @@ export function resolveSelectableModelSelection( const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); - return provider && - provider.enabled && - provider.installed && - provider.auth.status !== "unauthenticated" + return getProviderAdmissionAvailability({ + provider, + instanceId: String(selection.instanceId), + providerSnapshotKnown: true, + }).status === "available" ? selection : null; } @@ -121,6 +129,67 @@ export function getModelSelectionProvider( ); } +export function getModelSelectionUnavailablePresentation( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null | undefined, +): ProviderUnavailablePresentation | null { + return getProviderUnavailablePresentation(getModelSelectionProvider(config, selection)); +} + +/** Keep unavailable provider shadows from reaching turn submission locally. */ +export function canSendToModelSelection( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null | undefined, +): boolean { + if (!selection) return false; + return ( + getProviderAdmissionAvailability({ + provider: getModelSelectionProvider(config, selection), + instanceId: String(selection.instanceId), + providerSnapshotKnown: config !== null && config !== undefined, + }).status !== "unavailable" + ); +} + +/** + * Preserve the highest-priority stored choice when its provider is explicitly + * unavailable. The new-task screen can then show the server's remediation and + * require a new explicit choice instead of silently switching providers. + */ +export function resolveNewTaskUnavailableProvider( + config: T3ServerConfig | null | undefined, + input: { + readonly draftSelection: ModelSelection | null; + readonly projectDefaultSelection: ModelSelection | null; + readonly stickySelection: ModelSelection | null; + }, +): ServerProvider | null { + const candidates = [ + { selection: input.draftSelection, defaultable: false }, + { selection: input.projectDefaultSelection, defaultable: true }, + { selection: input.stickySelection, defaultable: true }, + ] as const; + for (const candidate of candidates) { + if (!candidate.selection) continue; + const provider = getModelSelectionProvider(config, candidate.selection); + if ( + provider && + getProviderAdmissionAvailability({ + provider, + instanceId: String(candidate.selection.instanceId), + providerSnapshotKnown: true, + }).status === "unavailable" + ) { + return provider; + } + const usable = candidate.defaultable + ? resolveDefaultableModelSelection(config, candidate.selection) + : resolveSelectableModelSelection(config, candidate.selection); + if (usable) return null; + } + return null; +} + export function getModelSelectionSupportedRuntimeModes( config: T3ServerConfig | null | undefined, selection: ModelSelection | null | undefined, @@ -151,7 +220,18 @@ export function resolveNewTaskModelSelection(input: { readonly projectDefaultSelection: ModelSelection | null; readonly stickySelection: ModelSelection | null; readonly modelOptions: ReadonlyArray; + readonly unavailablePreferredProvider?: ServerProvider | null; }): ModelSelection | null { + if ( + input.unavailablePreferredProvider && + getProviderAdmissionAvailability({ + provider: input.unavailablePreferredProvider, + instanceId: String(input.unavailablePreferredProvider.instanceId), + providerSnapshotKnown: true, + }).status === "unavailable" + ) { + return null; + } return ( input.draftSelection ?? input.projectDefaultSelection ?? @@ -169,7 +249,13 @@ export function buildModelOptions( const options = new Map(); for (const provider of config?.providers ?? []) { - if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") { + if ( + getProviderAdmissionAvailability({ + provider, + instanceId: String(provider.instanceId), + providerSnapshotKnown: true, + }).status !== "available" + ) { continue; } @@ -211,23 +297,25 @@ export function buildModelOptions( const provider = config?.providers.find( (candidate) => candidate.instanceId === fallbackModelSelection.instanceId, ); - const providerLabel = provider - ? providerDisplayLabel(provider) - : fallbackModelSelection.instanceId; - options.set(key, { - key, - label: fallbackModelSelection.model, - subtitle: "", - providerKey: fallbackModelSelection.instanceId, - providerLabel, - providerDriver: provider?.driver ?? fallbackModelSelection.instanceId, - supportedRuntimeModes: getServerProviderSupportedRuntimeModes(provider), - requiresNewThreadForModelChange: provider?.requiresNewThreadForModelChange === true, - isDefault: false, - isLegacy: false, - capabilities: null, - selection: fallbackModelSelection, - }); + if (getProviderUnavailablePresentation(provider) === null) { + const providerLabel = provider + ? providerDisplayLabel(provider) + : fallbackModelSelection.instanceId; + options.set(key, { + key, + label: fallbackModelSelection.model, + subtitle: "", + providerKey: fallbackModelSelection.instanceId, + providerLabel, + providerDriver: provider?.driver ?? fallbackModelSelection.instanceId, + supportedRuntimeModes: getServerProviderSupportedRuntimeModes(provider), + requiresNewThreadForModelChange: provider?.requiresNewThreadForModelChange === true, + isDefault: false, + isLegacy: false, + capabilities: null, + selection: fallbackModelSelection, + }); + } } } diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index ed1d289ce..7e83713bc 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,4 +1,6 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { getProviderAdmissionAvailability } from "@t3tools/client-runtime/providerAvailability"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { clampFileAttachmentUploadBytes, fileAttachmentTooLargeMessage, @@ -15,9 +17,11 @@ import { RuntimeMode, ThreadId, type ModelSelection as ModelSelectionType, + type OrchestrationSessionStatus, type ProjectId as ProjectIdType, type ProviderInteractionMode as ProviderInteractionModeType, type RuntimeMode as RuntimeModeType, + type ServerProvider, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -25,7 +29,7 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -const THREAD_OUTBOX_SCHEMA_VERSION = 3; +const THREAD_OUTBOX_SCHEMA_VERSION = 6; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; const QueuedThreadCreationSchema = Schema.Struct({ @@ -40,8 +44,22 @@ const QueuedThreadCreationSchema = Schema.Struct({ startFromOrigin: Schema.optional(Schema.Boolean), }); +const ThreadOutboxDeliveryHoldSchema = Schema.Struct({ + kind: Schema.Literals([ + "provider-unavailable", + "provider-binding-mismatch", + "provider-binding-unresolved", + "project-workspace-unavailable", + "thread-missing", + "admission-rejected", + ]), + reason: Schema.String, + boundInstanceId: Schema.optional(Schema.String), + queuedInstanceId: Schema.optional(Schema.String), +}); + export const QueuedThreadMessageSchema = Schema.Struct({ - schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]), + schemaVersion: Schema.Literals([1, 2, 3, 4, 5, THREAD_OUTBOX_SCHEMA_VERSION]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, @@ -51,9 +69,13 @@ export const QueuedThreadMessageSchema = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), + deliveryHold: Schema.optional(ThreadOutboxDeliveryHoldSchema), // Present when the queued item creates a brand-new thread (pending task) // instead of appending a turn to an existing one. creation: Schema.optional(QueuedThreadCreationSchema), + // Existing sends retain enough provider-neutral destination metadata to be + // explicitly retargeted if another device deletes the thread before send. + destination: Schema.optional(QueuedThreadCreationSchema), createdAt: IsoDateTime, }); @@ -70,6 +92,19 @@ export interface QueuedThreadCreation { readonly startFromOrigin?: boolean; } +export interface ThreadOutboxDeliveryHold { + readonly kind: + | "provider-unavailable" + | "provider-binding-mismatch" + | "provider-binding-unresolved" + | "project-workspace-unavailable" + | "thread-missing" + | "admission-rejected"; + readonly reason: string; + readonly boundInstanceId?: string; + readonly queuedInstanceId?: string; +} + export interface QueuedThreadMessage { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -80,7 +115,9 @@ export interface QueuedThreadMessage { readonly modelSelection?: ModelSelectionType; readonly runtimeMode?: RuntimeModeType; readonly interactionMode?: ProviderInteractionModeType; + readonly deliveryHold?: ThreadOutboxDeliveryHold; readonly creation?: QueuedThreadCreation; + readonly destination?: QueuedThreadCreation; readonly createdAt: string; } @@ -88,6 +125,9 @@ export interface ThreadSettingsSnapshot { readonly modelSelection: ModelSelectionType; readonly runtimeMode: RuntimeModeType; readonly interactionMode: ProviderInteractionModeType; + readonly session?: { + readonly providerInstanceId?: ModelSelectionType["instanceId"] | undefined; + } | null; } export function resolveQueuedThreadSettings( @@ -101,6 +141,172 @@ export function resolveQueuedThreadSettings( }; } +export type ThreadOutboxAdmission = + | { readonly action: "send"; readonly settings: ThreadSettingsSnapshot } + | { readonly action: "wait" } + | { readonly action: "hold"; readonly hold: ThreadOutboxDeliveryHold }; + +/** Revalidate the exact live binding before any queued command is dispatched. */ +export function resolveQueuedThreadAdmission(input: { + readonly message: QueuedThreadMessage; + readonly thread: ThreadSettingsSnapshot; + readonly providers: ReadonlyArray | null | undefined; +}): ThreadOutboxAdmission { + const providers = input.providers ?? []; + const boundInstanceId = input.thread.session?.providerInstanceId; + const queuedInstanceId = input.message.modelSelection?.instanceId; + const queuedTransition = + boundInstanceId !== undefined && + queuedInstanceId !== undefined && + queuedInstanceId !== boundInstanceId + ? resolveProviderContinuationTransition({ + providers, + currentInstanceId: boundInstanceId, + targetInstanceId: queuedInstanceId, + }) + : null; + if (queuedTransition?.compatible === false) { + return { + action: "hold", + hold: { + kind: "provider-binding-mismatch", + reason: `${queuedTransition.reason} Reconcile this pending send or delete it.`, + boundInstanceId, + ...(queuedInstanceId === undefined ? {} : { queuedInstanceId }), + }, + }; + } + + const modelSelection = + boundInstanceId === undefined + ? (input.message.modelSelection ?? input.thread.modelSelection) + : input.message.modelSelection !== undefined && + (input.message.modelSelection.instanceId === boundInstanceId || + queuedTransition?.compatible === true) + ? input.message.modelSelection + : input.thread.modelSelection.instanceId === boundInstanceId + ? input.thread.modelSelection + : null; + if (modelSelection === null) { + return { + action: "hold", + hold: { + kind: "provider-binding-unresolved", + reason: `This thread is bound to '${boundInstanceId}', but no model selection exists for that exact provider. Select a bound model or delete the pending send.`, + ...(boundInstanceId === undefined ? {} : { boundInstanceId }), + }, + }; + } + + const provider = providers.find( + (candidate) => candidate.instanceId === modelSelection.instanceId, + ); + const providerAvailability = getProviderAdmissionAvailability({ + provider, + instanceId: String(modelSelection.instanceId), + providerSnapshotKnown: input.providers !== null && input.providers !== undefined, + }); + if (providerAvailability.status === "unknown") { + return { action: "wait" }; + } + if (providerAvailability.status === "unavailable") { + return { + action: "hold", + hold: { + kind: "provider-unavailable", + reason: providerAvailability.reason, + ...(boundInstanceId === undefined ? {} : { boundInstanceId }), + queuedInstanceId: modelSelection.instanceId, + }, + }; + } + + return { + action: "send", + settings: { + modelSelection, + runtimeMode: input.message.runtimeMode ?? input.thread.runtimeMode, + interactionMode: input.message.interactionMode ?? input.thread.interactionMode, + session: input.thread.session, + }, + }; +} + +export function resolveQueuedCreationAdmission(input: { + readonly message: QueuedThreadMessage; + readonly providers: ReadonlyArray | null | undefined; +}): Exclude | { readonly action: "send" } { + const modelSelection = input.message.modelSelection; + if (modelSelection === undefined) return { action: "wait" }; + const provider = input.providers?.find( + (candidate) => candidate.instanceId === modelSelection.instanceId, + ); + const availability = getProviderAdmissionAvailability({ + provider, + instanceId: String(modelSelection.instanceId), + providerSnapshotKnown: input.providers !== null && input.providers !== undefined, + }); + if (availability.status === "unknown") return { action: "wait" }; + if (availability.status === "unavailable") { + return { + action: "hold", + hold: { + kind: "provider-unavailable", + reason: availability.reason, + queuedInstanceId: modelSelection.instanceId, + }, + }; + } + return { action: "send" }; +} + +export function preserveQueuedThreadDeliveryHold( + message: QueuedThreadMessage | null | undefined, + identity: { + readonly threadId: string; + readonly commandId: string; + readonly messageId: string; + readonly createdAt: string; + }, +): ThreadOutboxDeliveryHold | undefined { + return message !== null && + message !== undefined && + identity.threadId === message.threadId && + identity.commandId === message.commandId && + identity.messageId === message.messageId && + identity.createdAt === message.createdAt + ? message.deliveryHold + : undefined; +} + +export function retryQueuedThreadMessage( + message: QueuedThreadMessage, + input: { + readonly commandId: CommandId; + readonly createdAt: string; + readonly modelSelection?: ModelSelectionType; + readonly runtimeMode?: RuntimeModeType; + readonly interactionMode?: ProviderInteractionModeType; + }, +): QueuedThreadMessage { + const { deliveryHold: _hold, ...retry } = message; + return { + ...retry, + commandId: input.commandId, + createdAt: input.createdAt, + ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), + ...(input.runtimeMode === undefined ? {} : { runtimeMode: input.runtimeMode }), + ...(input.interactionMode === undefined ? {} : { interactionMode: input.interactionMode }), + }; +} + +export function threadOutboxDeliveryHoldsEqual( + left: ThreadOutboxDeliveryHold | undefined, + right: ThreadOutboxDeliveryHold | undefined, +): boolean { + return JSON.stringify(left ?? null) === JSON.stringify(right ?? null); +} + export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean { return ( left.instanceId === right.instanceId && @@ -150,15 +356,31 @@ export function threadOutboxRetryDelayMs(attempt: number): number { return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS); } -export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send"; +export type ThreadOutboxDeliveryAction = "confirm" | "wait" | "remove" | "send"; + +export function queuedCreationWorkspaceHold(input: { + readonly message: QueuedThreadMessage; + readonly project: { readonly workspaceRoot: string | null | undefined } | null | undefined; + readonly shellStatus: EnvironmentShellStatus; +}): ThreadOutboxDeliveryHold | null { + if (input.message.creation === undefined || input.shellStatus !== "live") return null; + if (input.project?.workspaceRoot?.trim()) return null; + return { + kind: "project-workspace-unavailable", + reason: + "The queued task's project workspace is missing. Retarget it to a valid project or delete it.", + }; +} export function resolveThreadOutboxDeliveryAction(input: { readonly isCreation: boolean; readonly threadExists: boolean; readonly shellStatus: EnvironmentShellStatus; readonly environmentConnected: boolean; - readonly threadBusy: boolean; + readonly threadStatus: OrchestrationSessionStatus | null; + readonly hasDeliveryHold?: boolean; }): ThreadOutboxDeliveryAction { + if (input.hasDeliveryHold === true) return "wait"; if (input.isCreation) { // A pending task creates its thread on delivery. If the thread already // exists the creation command went through and only cleanup remains. @@ -171,12 +393,107 @@ export function resolveThreadOutboxDeliveryAction(input: { return input.environmentConnected && input.shellStatus === "live" ? "send" : "wait"; } if (!input.threadExists) { - return input.shellStatus === "live" ? "remove" : "wait"; + // A synchronized missing thread still crosses durable confirmation before + // it is converted to a provider-neutral hold. Nothing removes it here. + return input.shellStatus === "live" ? "confirm" : "wait"; + } + if (!input.environmentConnected || input.threadStatus === "starting") { + return "wait"; + } + return "send"; +} + +export type ConfirmedThreadOutboxPlan = + | { readonly action: "wait" } + | { readonly action: "remove" } + | { + readonly action: "hold"; + readonly hold: ThreadOutboxDeliveryHold; + readonly creation?: QueuedThreadCreation; + } + | { readonly action: "send-existing"; readonly settings: ThreadSettingsSnapshot } + | { readonly action: "send-creation"; readonly projectCwd: string }; + +/** Recompute every delivery authority after the durable-confirmation boundary. */ +export function resolveConfirmedThreadOutboxPlan(input: { + readonly message: QueuedThreadMessage; + readonly thread: + | (ThreadSettingsSnapshot & { + readonly session?: + | (NonNullable & { + readonly status: OrchestrationSessionStatus; + }) + | null; + }) + | null + | undefined; + readonly shellStatus: EnvironmentShellStatus; + readonly environmentConnected: boolean; + readonly providers: ReadonlyArray | null | undefined; + readonly project: { readonly workspaceRoot: string | null | undefined } | null | undefined; +}): ConfirmedThreadOutboxPlan { + if (input.message.deliveryHold !== undefined) return { action: "wait" }; + const creation = input.message.creation; + if (creation === undefined && input.thread == null) { + if (input.shellStatus !== "live") return { action: "wait" }; + return { + action: "hold", + hold: { + kind: "thread-missing", + reason: + "This thread was deleted on another device before the pending send landed. Retarget it to a new thread or delete it.", + }, + ...(input.message.destination === undefined ? {} : { creation: input.message.destination }), + }; } - return input.environmentConnected ? "send" : "wait"; + + const deliveryAction = resolveThreadOutboxDeliveryAction({ + isCreation: creation !== undefined, + threadExists: input.thread != null, + shellStatus: input.shellStatus, + environmentConnected: input.environmentConnected, + threadStatus: input.thread?.session?.status ?? null, + }); + if (deliveryAction === "wait") return { action: "wait" }; + if (deliveryAction === "remove") return { action: "remove" }; + + if (creation === undefined) { + const admission = resolveQueuedThreadAdmission({ + message: input.message, + thread: input.thread!, + providers: input.providers, + }); + return admission.action === "send" + ? { action: "send-existing", settings: admission.settings } + : admission; + } + if (!isQueuedThreadCreationSendable(input.message)) return { action: "wait" }; + const workspaceHold = queuedCreationWorkspaceHold({ + message: input.message, + project: input.project, + shellStatus: input.shellStatus, + }); + if (workspaceHold !== null) return { action: "hold", hold: workspaceHold }; + const admission = resolveQueuedCreationAdmission({ + message: input.message, + providers: input.providers, + }); + if (admission.action !== "send") return admission; + const projectCwd = input.project?.workspaceRoot?.trim(); + return projectCwd + ? { action: "send-creation", projectCwd } + : { + action: "hold", + hold: { + kind: "project-workspace-unavailable", + reason: + "The queued task's project workspace is missing. Retarget it to a valid project or delete it.", + }, + }; } export type ThreadOutboxDispatchStep = + | { readonly step: "confirm" } | { readonly step: "wait" } | { readonly step: "remove" } | { readonly step: "retry" } @@ -255,19 +572,15 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { } export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; -export type ThreadOutboxFailureAction = "retry" | "restore"; +export type ThreadOutboxFailureAction = "retry" | "hold"; export function resolveThreadOutboxFailureAction(input: { readonly stage: ThreadOutboxCommandStage; readonly error: unknown; readonly interrupted: boolean; }): ThreadOutboxFailureAction { - if ( - input.stage === "settings-sync" || - input.interrupted || - shouldRetryThreadOutboxDelivery(input.error) - ) { + if (input.interrupted || shouldRetryThreadOutboxDelivery(input.error)) { return "retry"; } - return "restore"; + return "hold"; } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 0069064f3..7d2dc0af9 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -4,8 +4,10 @@ import { EnvironmentId, MessageId, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, + type ServerProvider, } from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -15,9 +17,14 @@ import { groupQueuedThreadMessages, isQueuedThreadCreationSendable, modelSelectionsEqual, + preserveQueuedThreadDeliveryHold, + queuedCreationWorkspaceHold, + resolveConfirmedThreadOutboxPlan, resolveThreadOutboxDeliveryAction, resolveThreadOutboxDispatchStep, resolveThreadOutboxFailureAction, + resolveQueuedThreadAdmission, + retryQueuedThreadMessage, resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, @@ -43,6 +50,34 @@ function queuedMessage(input: { }; } +function provider(input: { + readonly instanceId: string; + readonly availability?: "available" | "unavailable"; + readonly unavailableReason?: string; + readonly status?: ServerProvider["status"]; + readonly driver?: string; + readonly continuationGroupKey?: string; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver ?? "primeAgent"), + enabled: input.availability !== "unavailable", + installed: true, + version: null, + status: input.status ?? (input.availability === "unavailable" ? "disabled" : "ready"), + ...(input.availability ? { availability: input.availability } : {}), + ...(input.unavailableReason ? { unavailableReason: input.unavailableReason } : {}), + ...(input.continuationGroupKey + ? { continuation: { groupKey: input.continuationGroupKey } } + : {}), + auth: { status: "authenticated" }, + checkedAt: "2026-08-06T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + describe("thread outbox", () => { it("groups messages by scoped thread and preserves creation order", () => { const later = queuedMessage({ @@ -134,6 +169,242 @@ describe("thread outbox", () => { }); }); + it("durably holds a queued turn whose snapshot conflicts with the live binding", () => { + const message = { + ...queuedMessage({ + messageId: "message-binding", + createdAt: "2026-06-08T10:00:01.000Z", + }), + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + } satisfies QueuedThreadMessage; + const admission = resolveQueuedThreadAdmission({ + message, + thread: { + modelSelection: { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "kimi-k2.5", + }, + runtimeMode: "full-access", + interactionMode: "default", + session: { providerInstanceId: ProviderInstanceId.make("primeAgent") }, + }, + providers: [provider({ instanceId: "primeAgent" })], + }); + + expect(admission.action).toBe("hold"); + if (admission.action !== "hold") return; + expect(admission.hold.kind).toBe("provider-binding-mismatch"); + expect( + decodeQueuedThreadMessage( + encodeQueuedThreadMessage({ ...message, deliveryHold: admission.hold }), + ).deliveryHold, + ).toEqual(admission.hold); + }); + + it("dispatches a compatible account with the target-owned model and options intact", () => { + const targetSelection = { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + } as const; + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "approval-required" as const, + interactionMode: "default" as const, + session: { providerInstanceId: ProviderInstanceId.make("codex") }, + }; + const admission = resolveQueuedThreadAdmission({ + message: { + ...queuedMessage({ + messageId: "message-compatible-account", + createdAt: "2026-06-08T10:00:01.000Z", + }), + modelSelection: targetSelection, + runtimeMode: "full-access", + interactionMode: "plan", + }, + thread, + providers: [ + provider({ + instanceId: "codex", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }), + provider({ + instanceId: "codex_personal", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }), + ], + }); + + expect(admission).toEqual({ + action: "send", + settings: { + modelSelection: targetSelection, + runtimeMode: "full-access", + interactionMode: "plan", + session: thread.session, + }, + }); + }); + + it("holds exact unavailable bindings with remediation and resumes the same instance", () => { + const selection = { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "kimi-k2.5", + options: [{ id: "thinking", value: true }], + } as const; + const message = { + ...queuedMessage({ + messageId: "message-unavailable", + createdAt: "2026-06-08T10:00:01.000Z", + }), + modelSelection: selection, + } satisfies QueuedThreadMessage; + const thread = { + modelSelection: selection, + runtimeMode: "approval-required" as const, + interactionMode: "plan" as const, + session: { providerInstanceId: selection.instanceId }, + }; + const reason = "Restore Prime Agent inside WSL2."; + + expect( + resolveQueuedThreadAdmission({ + message, + thread, + providers: [ + provider({ + instanceId: "primeAgent", + availability: "unavailable", + unavailableReason: reason, + }), + ], + }), + ).toMatchObject({ + action: "hold", + hold: { kind: "provider-unavailable", reason }, + }); + expect( + resolveQueuedThreadAdmission({ + message, + thread, + providers: [provider({ instanceId: "primeAgent", availability: "available" })], + }), + ).toEqual({ action: "send", settings: thread }); + }); + + it("waits on unknown provider snapshots, admits warnings, and holds hard errors", () => { + const message = queuedMessage({ + messageId: "message-provider-tri-state", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + }; + + expect(resolveQueuedThreadAdmission({ message, thread, providers: undefined })).toEqual({ + action: "wait", + }); + expect( + resolveQueuedThreadAdmission({ + message, + thread, + providers: [provider({ instanceId: "codex", status: "warning" })], + }).action, + ).toBe("send"); + expect( + resolveQueuedThreadAdmission({ + message, + thread, + providers: [provider({ instanceId: "codex", status: "error" })], + }).action, + ).toBe("hold"); + }); + + it("keeps persisted holds inert for existing turns and task creation", () => { + for (const isCreation of [false, true]) { + expect( + resolveThreadOutboxDeliveryAction({ + isCreation, + threadExists: !isCreation, + shellStatus: "live", + environmentConnected: true, + threadStatus: "ready", + hasDeliveryHold: true, + }), + ).toBe("wait"); + } + }); + + it("preserves a held creation only while an edit keeps every durable identifier", () => { + const held = { + ...queuedMessage({ + messageId: "message-held-edit", + createdAt: "2026-06-08T10:00:01.000Z", + }), + deliveryHold: { + kind: "project-workspace-unavailable" as const, + reason: "Retarget this task.", + }, + }; + const identity = { + threadId: held.threadId, + commandId: held.commandId, + messageId: held.messageId, + createdAt: held.createdAt, + }; + + expect(preserveQueuedThreadDeliveryHold(held, identity)).toBe(held.deliveryHold); + expect( + preserveQueuedThreadDeliveryHold(held, { + ...identity, + commandId: CommandId.make("command-explicit-retry"), + }), + ).toBeUndefined(); + expect(preserveQueuedThreadDeliveryHold(null, identity)).toBeUndefined(); + }); + + it("mints a fresh admission request when a held send is retried", () => { + const original = { + ...queuedMessage({ + messageId: "message-held-retry", + createdAt: "2026-06-08T10:00:01.000Z", + }), + text: "preserve me", + deliveryHold: { + kind: "admission-rejected" as const, + reason: "PreviouslyRejected", + }, + }; + const retried = retryQueuedThreadMessage(original, { + commandId: CommandId.make("command-held-retry-fresh"), + createdAt: "2026-06-08T10:00:02.000Z", + }); + + expect(retried).toMatchObject({ + messageId: original.messageId, + commandId: CommandId.make("command-held-retry-fresh"), + text: "preserve me", + createdAt: "2026-06-08T10:00:02.000Z", + }); + expect(retried.commandId).not.toBe(original.commandId); + expect(retried.deliveryHold).toBeUndefined(); + }); + it("compares model options as part of the queued settings change", () => { const base = { instanceId: ProviderInstanceId.make("codex"), @@ -948,14 +1219,14 @@ describe("thread outbox", () => { ).toEqual({ step: "send" }); }); - it("only removes a missing-thread message after shell synchronization is live", () => { + it("only confirms a missing-thread message after shell synchronization is live", () => { expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: false, shellStatus: "synchronizing", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), ).toBe("wait"); expect( @@ -964,28 +1235,37 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "live", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), - ).toBe("remove"); + ).toBe("confirm"); expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), ).toBe("send"); }); - it("sends existing-thread messages whenever connected so queued messages can steer", () => { + it("waits for starting admission but lets running sessions steer", () => { + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: false, + threadExists: true, + shellStatus: "live", + environmentConnected: true, + threadStatus: "starting", + }), + ).toBe("wait"); expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: true, + threadStatus: "running", }), ).toBe("send"); expect( @@ -994,11 +1274,178 @@ describe("thread outbox", () => { threadExists: true, shellStatus: "live", environmentConnected: false, - threadBusy: true, + threadStatus: "running", }), ).toBe("wait"); }); + it("recomputes every live authority after durable confirmation", () => { + const message = queuedMessage({ + messageId: "message-post-confirm-existing", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + session: { status: "ready" as const }, + }; + const destination = { + projectId: ProjectId.make("project-post-confirm"), + projectTitle: "Project", + projectCwd: "/workspace/current", + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + }; + const base = { + message: { ...message, destination }, + thread, + shellStatus: "live" as const, + environmentConnected: true, + providers: [provider({ instanceId: "codex", status: "warning" })], + project: null, + }; + + expect(resolveConfirmedThreadOutboxPlan(base).action).toBe("send-existing"); + expect(resolveConfirmedThreadOutboxPlan({ ...base, environmentConnected: false }).action).toBe( + "wait", + ); + expect( + resolveConfirmedThreadOutboxPlan({ + ...base, + thread: { ...thread, session: { status: "starting" } }, + }).action, + ).toBe("wait"); + expect(resolveConfirmedThreadOutboxPlan({ ...base, thread: undefined })).toMatchObject({ + action: "hold", + hold: { kind: "thread-missing" }, + creation: destination, + }); + expect(resolveConfirmedThreadOutboxPlan({ ...base, providers: undefined }).action).toBe("wait"); + + const creationMessage = { + ...message, + modelSelection: thread.modelSelection, + creation: { + projectId: ProjectId.make("project-post-confirm"), + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + startFromOrigin: false, + }, + }; + const creationBase = { + ...base, + message: creationMessage, + thread: undefined, + project: { workspaceRoot: "/workspace/current" }, + }; + expect(resolveConfirmedThreadOutboxPlan(creationBase)).toMatchObject({ + action: "send-creation", + projectCwd: "/workspace/current", + }); + expect(resolveConfirmedThreadOutboxPlan({ ...creationBase, project: undefined })).toMatchObject( + { action: "hold", hold: { kind: "project-workspace-unavailable" } }, + ); + expect(resolveConfirmedThreadOutboxPlan({ ...creationBase, providers: undefined }).action).toBe( + "wait", + ); + }); + + it("quiesces after a cross-device delete is converted to a durable hold", () => { + const destination = { + projectId: ProjectId.make("project-cross-device-delete"), + projectTitle: "Cross-device project", + projectCwd: "/workspace/cross-device", + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + }; + const message = { + ...queuedMessage({ + messageId: "message-cross-device-delete", + createdAt: "2026-06-08T10:00:01.000Z", + }), + destination, + }; + const confirmed = resolveConfirmedThreadOutboxPlan({ + message, + thread: undefined, + shellStatus: "live", + environmentConnected: true, + providers: [provider({ instanceId: "codex", status: "ready" })], + project: null, + }); + expect(confirmed).toMatchObject({ + action: "hold", + hold: { kind: "thread-missing" }, + creation: destination, + }); + if (confirmed.action !== "hold" || confirmed.creation === undefined) { + throw new Error("Expected retargetable missing-thread hold"); + } + const held = { + ...message, + deliveryHold: confirmed.hold, + creation: confirmed.creation, + }; + + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: true, + threadExists: false, + shellStatus: "live", + environmentConnected: true, + threadStatus: null, + hasDeliveryHold: true, + }), + ).toBe("wait"); + expect( + resolveConfirmedThreadOutboxPlan({ + message: held, + thread: undefined, + shellStatus: "live", + environmentConnected: true, + providers: [provider({ instanceId: "codex", status: "ready" })], + project: { workspaceRoot: "/workspace/cross-device" }, + }), + ).toEqual({ action: "wait" }); + }); + + it("turns a live missing project workspace into a durable creation hold", () => { + const message = { + ...queuedMessage({ + messageId: "message-missing-project", + createdAt: "2026-06-08T10:00:01.000Z", + }), + creation: { + projectId: ProjectId.make("project-missing"), + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + startFromOrigin: false, + }, + }; + + expect( + queuedCreationWorkspaceHold({ message, project: undefined, shellStatus: "cached" }), + ).toBe(null); + expect( + queuedCreationWorkspaceHold({ message, project: undefined, shellStatus: "live" }), + ).toMatchObject({ kind: "project-workspace-unavailable" }); + expect( + queuedCreationWorkspaceHold({ + message, + project: { workspaceRoot: "/workspace/retargeted" }, + shellStatus: "live", + }), + ).toBe(null); + }); + it("sends queued creations once connected and live, removing already-created ones", () => { expect( resolveThreadOutboxDeliveryAction({ @@ -1006,7 +1453,7 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "cached", environmentConnected: false, - threadBusy: false, + threadStatus: null, }), ).toBe("wait"); // Connected but not yet synchronized: a previously delivered creation may @@ -1017,7 +1464,7 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "synchronizing", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), ).toBe("wait"); expect( @@ -1026,7 +1473,7 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "live", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), ).toBe("send"); expect( @@ -1035,7 +1482,7 @@ describe("thread outbox", () => { threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: true, + threadStatus: "running", }), ).toBe("remove"); }); @@ -1093,8 +1540,8 @@ describe("thread outbox", () => { expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); }); - it("retains queued messages when settings synchronization fails before startTurn", () => { - const deterministicFailure = new Error("Thread no longer exists"); + it("holds every domain failure before acceptance without losing content", () => { + const deterministicFailure = new Error("Thread already has pending turn admission"); expect( resolveThreadOutboxFailureAction({ @@ -1102,13 +1549,37 @@ describe("thread outbox", () => { error: deterministicFailure, interrupted: false, }), - ).toBe("retry"); + ).toBe("hold"); expect( resolveThreadOutboxFailureAction({ stage: "start-turn", error: deterministicFailure, interrupted: false, }), - ).toBe("restore"); + ).toBe("hold"); + + const message = { + ...queuedMessage({ + messageId: "message-admission-rejected", + createdAt: "2026-06-08T10:00:01.000Z", + }), + text: "keep this pending prose", + attachments: [ + { + id: "image-1", + previewUri: "file:///preview.png", + type: "image" as const, + name: "preview.png", + mimeType: "image/png", + sizeBytes: 4, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + deliveryHold: { + kind: "admission-rejected" as const, + reason: deterministicFailure.message, + }, + }; + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); }); }); diff --git a/apps/mobile/src/state/use-thread-composer-state.logic.ts b/apps/mobile/src/state/use-thread-composer-state.logic.ts new file mode 100644 index 000000000..e113f4428 --- /dev/null +++ b/apps/mobile/src/state/use-thread-composer-state.logic.ts @@ -0,0 +1,75 @@ +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; +import type { + ModelSelection, + ProviderInteractionMode, + RuntimeMode, + ServerProvider, +} from "@t3tools/contracts"; + +export interface ExistingThreadComposerSettings { + readonly modelSelection: ModelSelection; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +interface DraftComposerSettings { + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; +} + +/** + * A persisted provider session owns the continuation identity for an existing + * thread. Device-local draft settings may use the bound instance or an exact + * same-driver continuation peer. Every accepted selection stays intact; model + * and options are never transplanted onto another instance id. + */ +export function resolveExistingThreadComposerSettings(input: { + readonly thread: ExistingThreadComposerSettings; + readonly sessionProviderInstanceId?: ModelSelection["instanceId"] | undefined; + readonly providers?: ReadonlyArray | undefined; + readonly draft?: DraftComposerSettings | null | undefined; +}): Omit & { + readonly modelSelection: ModelSelection | null; + readonly rejectedDraftProviderSelection: boolean; + readonly providerBindingMismatch: boolean; +} { + const boundInstanceId = input.sessionProviderInstanceId; + const draftSelection = input.draft?.modelSelection; + const draftTransition = + boundInstanceId !== undefined && + draftSelection !== undefined && + draftSelection.instanceId !== boundInstanceId + ? resolveProviderContinuationTransition({ + providers: input.providers ?? [], + currentInstanceId: boundInstanceId, + targetInstanceId: draftSelection.instanceId, + }) + : null; + const rejectedDraftProviderSelection = draftTransition?.compatible === false; + const canApplyDraftSettings = !rejectedDraftProviderSelection; + + const modelSelection = + boundInstanceId === undefined + ? (draftSelection ?? input.thread.modelSelection) + : draftSelection !== undefined && + (draftSelection.instanceId === boundInstanceId || draftTransition?.compatible === true) + ? draftSelection + : input.thread.modelSelection.instanceId === boundInstanceId + ? input.thread.modelSelection + : null; + + return { + modelSelection, + runtimeMode: + canApplyDraftSettings && input.draft?.runtimeMode !== undefined + ? input.draft.runtimeMode + : input.thread.runtimeMode, + interactionMode: + canApplyDraftSettings && input.draft?.interactionMode !== undefined + ? input.draft.interactionMode + : input.thread.interactionMode, + rejectedDraftProviderSelection, + providerBindingMismatch: boundInstanceId !== undefined && modelSelection === null, + }; +} diff --git a/apps/mobile/src/state/use-thread-composer-state.test.ts b/apps/mobile/src/state/use-thread-composer-state.test.ts new file mode 100644 index 000000000..fd6e67f61 --- /dev/null +++ b/apps/mobile/src/state/use-thread-composer-state.test.ts @@ -0,0 +1,140 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveExistingThreadComposerSettings } from "./use-thread-composer-state.logic"; + +const selection = (instanceId: string, model: string) => ({ + instanceId: ProviderInstanceId.make(instanceId), + model, +}); + +const primeThread = { + modelSelection: selection("primeAgent", "prime-model"), + runtimeMode: "full-access" as const, + interactionMode: "default" as const, +}; + +function provider(instanceId: string, continuationGroupKey: string): ServerProvider { + return { + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make("codex"), + continuation: { groupKey: continuationGroupKey }, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + +describe("resolveExistingThreadComposerSettings", () => { + it("rejects a device-local Codex draft on a Prime-bound session", () => { + expect( + resolveExistingThreadComposerSettings({ + thread: primeThread, + sessionProviderInstanceId: ProviderInstanceId.make("primeAgent"), + draft: { + modelSelection: selection("codex", "gpt-5-codex"), + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }), + ).toEqual({ + ...primeThread, + rejectedDraftProviderSelection: true, + providerBindingMismatch: false, + }); + }); + + it("keeps same-instance model and mode changes on the bound session", () => { + expect( + resolveExistingThreadComposerSettings({ + thread: primeThread, + sessionProviderInstanceId: ProviderInstanceId.make("primeAgent"), + draft: { + modelSelection: selection("primeAgent", "prime-model-max"), + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }), + ).toEqual({ + modelSelection: selection("primeAgent", "prime-model-max"), + runtimeMode: "approval-required", + interactionMode: "plan", + rejectedDraftProviderSelection: false, + providerBindingMismatch: false, + }); + }); + + it("keeps the target account's intact model and options for a compatible transition", () => { + const targetSelection = { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + } as const; + expect( + resolveExistingThreadComposerSettings({ + thread: { + modelSelection: selection("codex", "gpt-5.3-codex"), + runtimeMode: "approval-required", + interactionMode: "default", + }, + sessionProviderInstanceId: ProviderInstanceId.make("codex"), + providers: [ + provider("codex", "codex:home:shared"), + provider("codex_personal", "codex:home:shared"), + ], + draft: { + modelSelection: targetSelection, + runtimeMode: "full-access", + interactionMode: "plan", + }, + }), + ).toEqual({ + modelSelection: targetSelection, + runtimeMode: "full-access", + interactionMode: "plan", + rejectedDraftProviderSelection: false, + providerBindingMismatch: false, + }); + }); + + it("blocks instead of transplanting a persisted selection across the live binding", () => { + expect( + resolveExistingThreadComposerSettings({ + thread: { + ...primeThread, + modelSelection: selection("codex", "gpt-5-codex"), + }, + sessionProviderInstanceId: ProviderInstanceId.make("primeAgent"), + }), + ).toMatchObject({ + modelSelection: null, + providerBindingMismatch: true, + }); + }); + + it("keeps the explicit draft selection for an unbound new session", () => { + expect( + resolveExistingThreadComposerSettings({ + thread: primeThread, + draft: { modelSelection: selection("codex", "gpt-5-codex") }, + }), + ).toMatchObject({ + modelSelection: selection("codex", "gpt-5-codex"), + rejectedDraftProviderSelection: false, + }); + }); + + it("keeps the persisted thread fallback when an unbound thread has no draft selection", () => { + expect(resolveExistingThreadComposerSettings({ thread: primeThread })).toEqual({ + ...primeThread, + rejectedDraftProviderSelection: false, + providerBindingMismatch: false, + }); + }); +}); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index fc81dc7b3..8e1d98590 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -15,6 +15,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -66,6 +67,7 @@ import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { prepareTurnAttachments, validateDraftFileAttachments } from "../lib/attachmentUpload"; import { scopedThreadKey } from "../lib/scopedEntities"; import { + canSendToModelSelection, resolveModelSelectionRuntimeMode, showModelSelectionInteractionModeToggle, } from "../lib/modelOptions"; @@ -88,13 +90,19 @@ import { useComposerDraft, } from "./use-composer-drafts"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; -import { useEnvironmentServerConfig } from "../state/entities"; +import { useEnvironmentServerConfig, useProjects } from "../state/entities"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; -import { enqueueThreadOutboxMessage } from "./thread-outbox"; +import { + enqueueThreadOutboxMessage, + removeThreadOutboxMessage, + retryQueuedThreadMessage, + updateThreadOutboxMessage, +} from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; import { useAtomCommand } from "./use-atom-command"; import { threadEnvironment } from "./threads"; +import { resolveExistingThreadComposerSettings } from "./use-thread-composer-state.logic"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -135,7 +143,7 @@ export function useThreadDraftForThread(input: { } export function useThreadComposerState() { - const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection(); + const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const selectedThreadContextWindow = useMemo( () => deriveLatestContextWindowSnapshot(selectedThreadDetail?.activities ?? []), @@ -144,6 +152,15 @@ export function useThreadComposerState() { const selectedThreadServerConfig = useEnvironmentServerConfig( selectedThreadShell?.environmentId ?? null, ); + const projects = useProjects(); + const selectedThreadProject = + selectedThreadShell == null + ? null + : (projects.find( + (project) => + project.environmentId === selectedThreadShell?.environmentId && + project.id === selectedThreadShell.projectId, + ) ?? null); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< @@ -233,7 +250,30 @@ export function useThreadComposerState() { const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadDetail ?? selectedThreadShell; - const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; + const selectedSessionProviderInstanceId = + selectedThreadDetail?.session?.providerInstanceId ?? + selectedThreadShell?.session?.providerInstanceId; + const composerSettings = selectedThread + ? resolveExistingThreadComposerSettings({ + thread: selectedThread, + sessionProviderInstanceId: selectedSessionProviderInstanceId, + providers: selectedThreadServerConfig?.providers ?? [], + draft: selectedDraft, + }) + : null; + const modelSelection = composerSettings?.modelSelection ?? null; + + useEffect(() => { + if (!selectedThreadKey || !composerSettings?.rejectedDraftProviderSelection) return; + // A draft can outlive another device binding the thread. Reject all of its + // provider-shaped settings together so a Codex mode cannot leak onto the + // now-authoritative Prime session; message text and attachments stay put. + updateComposerDraftSettings(selectedThreadKey, { + modelSelection: undefined, + runtimeMode: undefined, + interactionMode: undefined, + }); + }, [composerSettings?.rejectedDraftProviderSelection, selectedThreadKey]); const selectedThreadResources = useMemo(() => { const session = selectedThreadDetail?.session; const instanceId = session?.providerInstanceId; @@ -295,6 +335,9 @@ export function useThreadComposerState() { environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, providerInstanceId: instanceId, + admissionAvailable: + selectedThreadDetail.modelSelection.instanceId === instanceId && + canSendToModelSelection(selectedThreadServerConfig, selectedThreadDetail.modelSelection), } as const; }, [ selectedThreadDetail?.session?.providerInstanceId, @@ -410,7 +453,13 @@ export function useThreadComposerState() { const runSessionCompactionMutation = useCallback( async (action: "compact" | "abort" | "auto-enable" | "auto-disable"): Promise => { const scope = sessionCompactionScopeRef.current; - if (!scope || sessionCompactionMutationRef.current?.scopeKey === scope.key) return false; + if ( + !scope || + sessionCompactionMutationRef.current?.scopeKey === scope.key || + (action !== "abort" && !scope.admissionAvailable) + ) { + return false; + } const id = ++sessionCompactionRequestIdRef.current; const mutation = { scopeKey: scope.key, id, action } as const; sessionCompactionMutationRef.current = mutation; @@ -470,21 +519,18 @@ export function useThreadComposerState() { }, [abortSessionCompaction, compactSession, setSessionAutoCompaction], ); - const selectedRuntimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; - const runtimeMode = selectedRuntimeMode + const runtimeMode = composerSettings ? resolveModelSelectionRuntimeMode( selectedThreadServerConfig, modelSelection, - selectedRuntimeMode, + composerSettings.runtimeMode, ) : null; - const selectedInteractionMode = - selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; const interactionMode = showModelSelectionInteractionModeToggle( selectedThreadServerConfig, modelSelection, ) - ? selectedInteractionMode + ? (composerSettings?.interactionMode ?? null) : "default"; const selectedThreadSessionActivity = useMemo(() => { @@ -541,8 +587,28 @@ export function useThreadComposerState() { return null; } - const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( - (entry) => entry.instanceId === thread.modelSelection.instanceId, + const submissionSettings = resolveExistingThreadComposerSettings({ + thread, + sessionProviderInstanceId: selectedSessionProviderInstanceId, + providers: selectedThreadServerConfig?.providers ?? [], + draft, + }); + const modelSelection = submissionSettings.modelSelection; + if ( + modelSelection === null || + !canSendToModelSelection(selectedThreadServerConfig, modelSelection) + ) { + return null; + } + if (submissionSettings.rejectedDraftProviderSelection) { + updateComposerDraftSettings(threadKey, { + modelSelection: undefined, + runtimeMode: undefined, + interactionMode: undefined, + }); + } + const provider = selectedThreadServerConfig?.providers.find( + (entry) => entry.instanceId === modelSelection.instanceId, ); const feedbackCommand = attachments.length === 0 && @@ -607,17 +673,16 @@ export function useThreadComposerState() { const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); - const modelSelection = draft.modelSelection ?? thread.modelSelection; const runtimeMode = resolveModelSelectionRuntimeMode( selectedThreadServerConfig, modelSelection, - draft.runtimeMode ?? thread.runtimeMode, + submissionSettings.runtimeMode, ); const interactionMode = showModelSelectionInteractionModeToggle( selectedThreadServerConfig, modelSelection, ) - ? (draft.interactionMode ?? thread.interactionMode) + ? submissionSettings.interactionMode : "default"; // Enqueue publishes the queued atom synchronously (the durable write // happens behind it), so clearing the draft here gives send feedback on @@ -634,6 +699,19 @@ export function useThreadComposerState() { modelSelection, runtimeMode, interactionMode, + destination: { + projectId: selectedThreadShell.projectId, + ...(selectedThreadProject?.title === undefined + ? {} + : { projectTitle: selectedThreadProject.title }), + ...(selectedThreadProject?.workspaceRoot === undefined + ? {} + : { projectCwd: selectedThreadProject.workspaceRoot }), + workspaceMode: selectedThreadShell.worktreePath === null ? "local" : "worktree", + branch: selectedThreadShell.branch, + // A deleted worktree cannot be reused. Retargeting creates a fresh one. + worktreePath: null, + }, createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); @@ -658,9 +736,10 @@ export function useThreadComposerState() { ); return messageId; }, [ - selectedEnvironmentRuntime?.serverConfig?.providers, + selectedSessionProviderInstanceId, selectedThreadDetail, sessionCompactionBlocksSubmission, + selectedThreadProject, selectedThreadServerConfig, selectedThreadShell, uploadThreadFeedback, @@ -677,6 +756,25 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); + const submissionSettings = resolveExistingThreadComposerSettings({ + thread: selectedThreadDetail, + sessionProviderInstanceId: selectedSessionProviderInstanceId, + providers: selectedThreadServerConfig?.providers ?? [], + draft, + }); + if ( + submissionSettings.modelSelection === null || + !canSendToModelSelection(selectedThreadServerConfig, submissionSettings.modelSelection) + ) { + return null; + } + if (submissionSettings.rejectedDraftProviderSelection) { + updateComposerDraftSettings(threadKey, { + modelSelection: undefined, + runtimeMode: undefined, + interactionMode: undefined, + }); + } const text = draft.text.trim(); const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) return null; @@ -764,8 +862,9 @@ export function useThreadComposerState() { return messageId; }, [ followUpInputQueue, - selectedThreadDetail?.session?.activeTurnId, - selectedThreadDetail?.session?.status, + selectedSessionProviderInstanceId, + selectedThreadDetail, + selectedThreadServerConfig, selectedThreadShell, sessionCompactionBlocksSubmission, ]); @@ -951,7 +1050,10 @@ export function useThreadComposerState() { ) ?? null); if ( !selectedThreadShell || + !selectedThreadDetail || (session?.status !== "ready" && session?.status !== "running") || + selectedThreadDetail.modelSelection.instanceId !== session.providerInstanceId || + !canSendToModelSelection(selectedThreadServerConfig, selectedThreadDetail.modelSelection) || !supportsSessionInputQueueSetModes(provider) || !hasSessionInputQueueModes(selectedThreadInputQueue) ) { @@ -1001,7 +1103,7 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const capabilities = selectedThreadServerConfig?.environment.capabilities; const result = await pickComposerMedia({ existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, maxVideoBytes: @@ -1019,7 +1121,7 @@ export function useThreadComposerState() { if (problems.length > 0) { Alert.alert("Could not attach photo or video", problems.join("\n\n")); } - }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]); + }, [composerDrafts, selectedThreadServerConfig, selectedThreadShell]); const onPickDraftFiles = useCallback(async () => { if (!selectedThreadShell) { @@ -1120,8 +1222,18 @@ export function useThreadComposerState() { return; } const thread = selectedThreadDetail ?? selectedThreadShell; - const currentRuntimeMode = selectedDraft?.runtimeMode ?? thread?.runtimeMode; - const currentInteractionMode = selectedDraft?.interactionMode ?? thread?.interactionMode; + if ( + selectedSessionProviderInstanceId !== undefined && + !resolveProviderContinuationTransition({ + providers: selectedThreadServerConfig?.providers ?? [], + currentInstanceId: selectedSessionProviderInstanceId, + targetInstanceId: value.instanceId, + }).compatible + ) { + return; + } + const currentRuntimeMode = composerSettings?.runtimeMode ?? thread?.runtimeMode; + const currentInteractionMode = composerSettings?.interactionMode ?? thread?.interactionMode; updateComposerDraftSettings(selectedThreadKey, { modelSelection: value, ...(currentRuntimeMode @@ -1141,8 +1253,9 @@ export function useThreadComposerState() { }); }, [ - selectedDraft?.interactionMode, - selectedDraft?.runtimeMode, + composerSettings?.interactionMode, + composerSettings?.runtimeMode, + selectedSessionProviderInstanceId, selectedThreadDetail, selectedThreadKey, selectedThreadServerConfig, @@ -1170,6 +1283,75 @@ export function useThreadComposerState() { [selectedThreadKey], ); + const onManagePendingSends = useCallback(() => { + const queuedMessage = selectedThreadQueuedMessages[0]; + if (!queuedMessage) return; + const hold = queuedMessage.deliveryHold; + const boundInstanceId = selectedSessionProviderInstanceId; + const exactBoundSelection = + boundInstanceId !== undefined && + composerSettings?.modelSelection?.instanceId === boundInstanceId + ? composerSettings.modelSelection + : null; + const freshRetry = (settings?: { + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + }) => { + const metadata = makeQueuedMessageMetadata(); + return retryQueuedThreadMessage(queuedMessage, { + commandId: CommandId.make(metadata.commandId), + createdAt: metadata.createdAt, + ...settings, + }); + }; + const actions: Array<{ + text: string; + style?: "default" | "cancel" | "destructive"; + onPress?: () => void; + }> = [ + { + text: "Retry", + onPress: () => { + void updateThreadOutboxMessage(freshRetry()); + }, + }, + ]; + if ( + (hold?.kind === "provider-binding-mismatch" || + hold?.kind === "provider-binding-unresolved") && + exactBoundSelection !== null + ) { + actions.push({ + text: "Use bound provider", + onPress: () => { + void updateThreadOutboxMessage( + freshRetry({ + modelSelection: exactBoundSelection, + runtimeMode: composerSettings?.runtimeMode, + interactionMode: composerSettings?.interactionMode, + }), + ); + }, + }); + } + actions.push( + { + text: "Delete pending send", + style: "destructive", + onPress: () => { + void removeThreadOutboxMessage(queuedMessage); + }, + }, + { text: "Cancel", style: "cancel" }, + ); + Alert.alert( + hold ? "Pending send held" : "Pending send", + hold?.reason ?? "This message is saved on this device and waiting to be sent.", + actions, + ); + }, [composerSettings, selectedSessionProviderInstanceId, selectedThreadQueuedMessages]); + return { selectedThreadFeed, selectedThreadAgents, @@ -1185,6 +1367,7 @@ export function useThreadComposerState() { ? sessionCompactionPendingAction : null, selectedThreadQueueCount, + selectedThreadQueueHold: selectedThreadQueuedMessages[0]?.deliveryHold ?? null, activeWorkStartedAt, draftMessage, draftAttachments, @@ -1200,6 +1383,7 @@ export function useThreadComposerState() { onRemoveDraftImage, onSendMessage, onQueueFollowUp, + onManagePendingSends, onClearSessionInputQueue, onRemoveOnlySessionInputQueueItem, onSetSessionInputQueueMode, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index c81cb87a8..0b4b4500a 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -5,7 +5,6 @@ import type { } from "@t3tools/client-runtime/state/shell"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { - CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -21,7 +20,7 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn" import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { useProjects, useServerConfigs, useThreadShells } from "./entities"; +import { useServerConfigs, useThreadShells } from "./entities"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, @@ -32,18 +31,23 @@ import { import { removeThreadOutboxMessage } from "./thread-outbox-removal"; import { isQueuedThreadCreationSendable, - modelSelectionsEqual, + resolveConfirmedThreadOutboxPlan, resolveThreadOutboxDeliveryAction, resolveThreadOutboxDispatchStep, resolveThreadOutboxFailureAction, - resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, + threadOutboxDeliveryHoldsEqual, threadOutboxRetryDelayMs, type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, + type ThreadSettingsSnapshot, } from "./thread-outbox-model"; import { environmentThreadShells, threadEnvironment } from "./threads"; +import { environmentProjects } from "./projects"; +import { environmentPresentations } from "./presentation"; +import { environmentShell } from "./shell"; +import { environmentServerConfigsAtom } from "./server"; import { appendComposerDraftAttachments, composerDraftsAtom, @@ -102,10 +106,6 @@ function findCreationProject( ); } -function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { - return CommandId.make(`${message.commandId}:${setting}`); -} - /** * Uploads a queued message's attachments and persists the uploaded ids back * onto the queued message. The revision-checked update means an edit accepted @@ -486,21 +486,11 @@ async function preserveUploadedAttachmentsForEditor( export function useThreadOutboxDrain(): void { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { - reportFailure: false, - }); - const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { - reportFailure: false, - }); - const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { - reportFailure: false, - }); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const editingQueuedMessageIds = useAtomValue(editingQueuedMessageIdsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); - const projects = useProjects(); const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); @@ -590,7 +580,7 @@ export function useThreadOutboxDrain(): void { const reportFailure = ( commandResult: AtomCommandResult, stage: ThreadOutboxCommandStage, - ): { readonly action: "retry" | "restore"; readonly message: string } | null => { + ): { readonly action: "retry" | "hold"; readonly message: string } | null => { if (!AsyncResult.isFailure(commandResult)) { return null; } @@ -610,95 +600,85 @@ export function useThreadOutboxDrain(): void { }); return { action, - message: error instanceof Error ? error.message : "The message could not be sent.", + message: + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "The server rejected this turn before accepting its message. Retry it or resolve the provider binding manually.", }; }; - return { reportFailure }; - }, []); - - const sendQueuedMessage = useCallback( - async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { - const settings = resolveQueuedThreadSettings(queuedMessage, thread); - const { reportFailure } = makeDeliveryHelpers(queuedMessage); - - if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { - const updateResult = await updateThreadMetadata({ - environmentId: queuedMessage.environmentId, - input: { - commandId: settingsCommandId(queuedMessage, "model-selection"), - threadId: queuedMessage.threadId, - modelSelection: settings.modelSelection, - }, - }); - if (AsyncResult.isFailure(updateResult)) { - reportFailure(updateResult, "settings-sync"); - return false; - } - } - - if (settings.runtimeMode !== thread.runtimeMode) { - const runtimeResult = await setThreadRuntimeMode({ - environmentId: queuedMessage.environmentId, - input: { - commandId: settingsCommandId(queuedMessage, "runtime-mode"), - threadId: queuedMessage.threadId, - runtimeMode: settings.runtimeMode, - createdAt: queuedMessage.createdAt, - }, - }); - if (AsyncResult.isFailure(runtimeResult)) { - reportFailure(runtimeResult, "settings-sync"); - return false; - } - } - - if (settings.interactionMode !== thread.interactionMode) { - const interactionResult = await setThreadInteractionMode({ - environmentId: queuedMessage.environmentId, - input: { - commandId: settingsCommandId(queuedMessage, "interaction-mode"), - threadId: queuedMessage.threadId, - interactionMode: settings.interactionMode, - createdAt: queuedMessage.createdAt, + const persistRejectedAdmissionHold = async ( + message: QueuedThreadMessage, + expectedRevision: number, + reason: string, + ): Promise<"held" | "retry" | "complete"> => { + try { + const updated = await updateThreadOutboxMessage( + { + ...message, + deliveryHold: { + kind: "admission-rejected", + reason, + ...(message.modelSelection === undefined + ? {} + : { queuedInstanceId: message.modelSelection.instanceId }), + }, }, + expectedRevision, + ); + // A failed CAS means an edit or removal won. That newer owner decides + // what happens next; do not retry the rejected stale payload. + return updated ? "held" : "complete"; + } catch (error) { + console.warn("[thread-outbox] failed to persist rejected admission hold", { + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + error, }); - if (AsyncResult.isFailure(interactionResult)) { - reportFailure(interactionResult, "settings-sync"); - return false; - } + return "retry"; } + }; + return { persistRejectedAdmissionHold, reportFailure }; + }, []); + const sendQueuedMessage = useCallback( + async ( + queuedMessage: QueuedThreadMessage, + settings: ThreadSettingsSnapshot, + ): Promise<"complete" | "retry" | "held"> => { + const { persistRejectedAdmissionHold, reportFailure } = makeDeliveryHelpers(queuedMessage); let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; try { const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); if (preparedResult.status === "abandoned") { - return true; + return "complete"; } prepared = preparedResult.prepared; persistedMessage = preparedResult.persistedMessage; deliveryRevision = preparedResult.deliveryRevision; if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - await preserveUploadedAttachmentsForEditor( - queuedMessage, - preparedResult.persistedMessage, - ); - return true; + await preserveUploadedAttachmentsForEditor(queuedMessage, persistedMessage); + return "complete"; } } catch (error) { console.warn("[thread-outbox] failed to upload attachments", error); if (!shouldRetryThreadOutboxDelivery(error)) { - return restoreQueuedMessage( + const restored = await restoreQueuedMessage( queuedMessage, error instanceof Error ? error.message : "An attachment could not upload.", ); + return restored ? "complete" : "retry"; } - return false; + return "retry"; } if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { - return true; + return "complete"; } + const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -718,33 +698,26 @@ export function useThreadOutboxDrain(): void { }); const failure = reportFailure(deliveryResult, "start-turn"); if (failure?.action === "retry") { - return false; + return "retry"; } - if (failure?.action === "restore") { - return restoreQueuedMessage(persistedMessage, failure.message); + if (failure?.action === "hold") { + return persistRejectedAdmissionHold(persistedMessage, deliveryRevision, failure.message); } + acknowledgedExistingThreadMessageIdsRef.current.add(persistedMessage.messageId); - const delivered = - (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; - if (delivered) { + const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); + if (outcome === "removed") { acknowledgedExistingThreadMessageIdsRef.current.delete(persistedMessage.messageId); // The delivered turn holds its own copy of the bytes. A failed delete - // is surfaced (never fails the delivered turn); the server also - // expires leaked pending uploads. + // is surfaced but never fails the delivered turn. await prepared.releaseUploads().catch((error) => { console.warn("[thread-outbox] could not delete consumed pending uploads", error); }); + return "complete"; } - return delivered; + return outcome === "edited" ? "complete" : "retry"; }, - [ - makeDeliveryHelpers, - setThreadInteractionMode, - setThreadRuntimeMode, - startTurn, - updateThreadMetadata, - restoreQueuedMessage, - ], + [makeDeliveryHelpers, restoreQueuedMessage, startTurn], ); const sendQueuedCreation = useCallback( @@ -752,42 +725,42 @@ export function useThreadOutboxDrain(): void { queuedMessage: QueuedThreadMessage, creation: QueuedThreadCreation, projectCwd: string, - ) => { + ): Promise<"complete" | "retry" | "held"> => { const modelSelection = queuedMessage.modelSelection; if (modelSelection === undefined) { - return false; + return "retry"; } + const { persistRejectedAdmissionHold, reportFailure } = makeDeliveryHelpers(queuedMessage); let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; try { const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); if (preparedResult.status === "abandoned") { - return true; + return "complete"; } prepared = preparedResult.prepared; persistedMessage = preparedResult.persistedMessage; deliveryRevision = preparedResult.deliveryRevision; if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - await preserveUploadedAttachmentsForEditor( - queuedMessage, - preparedResult.persistedMessage, - ); - return true; + await preserveUploadedAttachmentsForEditor(queuedMessage, persistedMessage); + return "complete"; } } catch (error) { console.warn("[thread-outbox] failed to upload attachments", error); if (!shouldRetryThreadOutboxDelivery(error)) { - return restoreQueuedMessage( + const restored = await restoreQueuedMessage( queuedMessage, error instanceof Error ? error.message : "An attachment could not upload.", ); + return restored ? "complete" : "retry"; } - return false; + return "retry"; } if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { - return true; + return "complete"; } + const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ @@ -810,33 +783,29 @@ export function useThreadOutboxDrain(): void { worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); - const { reportFailure } = makeDeliveryHelpers(queuedMessage); const failure = reportFailure(deliveryResult, "start-turn"); if (failure?.action === "retry") { - return false; + return "retry"; } - if (failure?.action === "restore") { - return restoreQueuedMessage(persistedMessage, failure.message); + if (failure?.action === "hold") { + return persistRejectedAdmissionHold(persistedMessage, deliveryRevision, failure.message); } + const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); if (outcome === "edited") { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - // The editor holds the entry with unsaved edits; merging the queue - // payload now would duplicate the delivered turn. Once the editor - // saves, the duplicate-creation removal below recovers the edits. - return true; + return "complete"; } - // The thread exists now, so the next drain would remove the edited - // payload as a duplicate creation. Hand it to the thread's composer. - return recoverEditedCreationAfterDelivery(persistedMessage); + const recovered = await recoverEditedCreationAfterDelivery(persistedMessage); + return recovered ? "complete" : "retry"; } if (outcome === "removed") { await prepared.releaseUploads().catch((error) => { console.warn("[thread-outbox] could not delete consumed pending uploads", error); }); - return true; + return "complete"; } - return false; + return "retry"; }, [makeDeliveryHelpers, restoreQueuedMessage, startTurn], ); @@ -922,7 +891,8 @@ export function useThreadOutboxDrain(): void { threadExists: thread !== undefined, shellStatus, environmentConnected: environment?.connectionState === "connected", - threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", + threadStatus: thread?.session?.status ?? null, + hasDeliveryHold: nextQueuedMessage.deliveryHold !== undefined, }); // The delivery action resolves first; the file-capability gate applies // only to a message that will send. Gating earlier would restore a @@ -971,96 +941,142 @@ export function useThreadOutboxDrain(): void { .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId)); return; } - // The live project shell is preferred for the workspace path, with the - // snapshot taken at enqueue time as the fallback so a task never dies - // just because its project shell is not loaded. - const creationProjectCwd = - creation !== undefined - ? (findCreationProject(projects, nextQueuedMessage)?.workspaceRoot ?? - creation.projectCwd ?? - null) - : null; // An incomplete pending task (e.g. worktree mode without a branch) stays // queued until the user finishes it in the editor. if (deliveryAction === "send" && creation !== undefined) { if (!isQueuedThreadCreationSendable(nextQueuedMessage)) { continue; } - if (creationProjectCwd === null && shellStatus !== "live") { - continue; - } } - beginDispatchingQueuedMessage(nextQueuedMessage.messageId); - const removeQueuedMessage = (warning: string) => - removeThreadOutboxMessage(nextQueuedMessage).then( - () => true, - (error) => { - console.warn(warning, { - environmentId: nextQueuedMessage.environmentId, - threadId: nextQueuedMessage.threadId, - messageId: nextQueuedMessage.messageId, - error, - }); - return false; - }, - ); // Enqueues publish optimistically before their durable write settles. // Confirm the write landed (and the message wasn't rolled back) before // sending, so a failed write can never chase an already-delivered turn. const delivery = confirmThreadOutboxMessageQueued(nextQueuedMessage).then((queued) => { if (!queued) { // Rolled back by a failed write; nothing to deliver or retry. - return true; - } - // The guards evaluated before the confirmation await are stale by now: - // the user may have opened this message in the editor. Re-read that - // guard and defer to the next drain pass (returning true skips the - // failure/backoff path) rather than sending a payload being edited. - if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) { - return true; + return "complete" as const; } - // The shell state is equally stale. Re-run the same delivery policy - // against the live thread snapshot so a vanished thread or newly - // created target defers, while busy existing threads can still steer. - if (deliveryAction === "send") { - const liveThread = findThread( - appAtomRegistry.get(environmentThreadShells.threadShellsAtom), - nextQueuedMessage, + const latestQueuedMessage = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ) + .flat() + .find( + (candidate) => + candidate.environmentId === nextQueuedMessage.environmentId && + candidate.messageId === nextQueuedMessage.messageId, ); - const liveThreadBusy = - liveThread?.session?.status === "running" || liveThread?.session?.status === "starting"; - const liveDeliveryAction = resolveThreadOutboxDeliveryAction({ - isCreation: creation !== undefined, - threadExists: liveThread !== undefined, - shellStatus, - environmentConnected: environment?.connectionState === "connected", - threadBusy: liveThreadBusy, - }); - if (liveDeliveryAction !== "send") { - return true; + if (latestQueuedMessage === undefined) return "complete" as const; + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[latestQueuedMessage.messageId]) { + return "complete" as const; + } + // Confirmation is an async boundary. Re-read every dispatch authority + // from live atoms instead of using the optimistic render snapshot that + // selected this item before its durable write settled. + const latestThread = findThread( + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + latestQueuedMessage, + ); + const latestShellStatus = appAtomRegistry.get( + environmentShell.stateValueAtom(latestQueuedMessage.environmentId), + ).status; + const latestEnvironment = appAtomRegistry.get( + environmentPresentations.presentationAtom(latestQueuedMessage.environmentId), + ); + const latestEnvironmentConnected = latestEnvironment?.connection.phase === "connected"; + const latestServerConfig = appAtomRegistry + .get(environmentServerConfigsAtom) + .get(latestQueuedMessage.environmentId); + const latestProviders = latestServerConfig?.providers; + const latestCreation = latestQueuedMessage.creation; + const latestProject = + latestCreation === undefined + ? null + : findCreationProject( + appAtomRegistry.get(environmentProjects.projectsAtom), + latestQueuedMessage, + ); + const confirmedPlan = resolveConfirmedThreadOutboxPlan({ + message: latestQueuedMessage, + thread: latestThread, + shellStatus: latestShellStatus, + environmentConnected: latestEnvironmentConnected, + providers: latestProviders, + project: latestProject, + }); + const persistHold = ( + hold: NonNullable, + retargetCreation?: QueuedThreadCreation, + ) => { + const alreadyPersisted = + threadOutboxDeliveryHoldsEqual(latestQueuedMessage.deliveryHold, hold) && + (retargetCreation === undefined || latestQueuedMessage.creation === retargetCreation); + if (alreadyPersisted) { + return Promise.resolve("held" as const); } + const revision = threadOutboxRevision(latestQueuedMessage.messageId); + return updateThreadOutboxMessage( + { + ...latestQueuedMessage, + deliveryHold: hold, + ...(retargetCreation === undefined ? {} : { creation: retargetCreation }), + }, + revision, + ).then( + (updated) => (updated ? ("held" as const) : ("complete" as const)), + (error) => { + console.warn("[thread-outbox] failed to persist delivery hold", { + messageId: latestQueuedMessage.messageId, + error, + }); + return "retry" as const; + }, + ); + }; + if (confirmedPlan.action === "wait") { + return latestQueuedMessage.deliveryHold === undefined + ? ("complete" as const) + : ("held" as const); + } + if (confirmedPlan.action === "hold") { + return persistHold(confirmedPlan.hold, confirmedPlan.creation); + } + if (confirmedPlan.action === "remove") { + return recoverEditedCreationAfterDelivery(latestQueuedMessage).then((recovered) => + recovered ? ("complete" as const) : ("retry" as const), + ); + } + + // Apply the current Pylon file-capability gate only after the durable + // confirmation boundary, using the same live config as admission. + const liveDispatchStep = resolveThreadOutboxDispatchStep({ + deliveryAction: "send", + fileAttachments: latestQueuedMessage.attachments.filter( + (attachment) => attachment.type === "file", + ), + serverConfig: latestServerConfig + ? { + maxFileUploadBytes: + latestServerConfig.environment.capabilities.fileAttachments?.maxUploadBytes, + } + : null, + }); + if (liveDispatchStep.step === "retry") { + return "retry" as const; + } + if (liveDispatchStep.step === "restore") { + return restoreQueuedMessage(latestQueuedMessage, liveDispatchStep.reason).then( + (restored) => (restored ? ("complete" as const) : ("retry" as const)), + ); + } + if (confirmedPlan.action === "send-existing") { + return sendQueuedMessage(latestQueuedMessage, confirmedPlan.settings); } - return deliveryAction === "remove" - ? creation !== undefined - ? // A creation entry that survived its delivery cleanup either - // holds edits (recover them) or the delivered payload (a - // recovered duplicate the user can delete). Restart loses any - // in-memory distinction, and losing edits is the worse failure, - // so recovery is unconditional here. - recoverEditedCreationAfterDelivery(nextQueuedMessage) - : removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") - : creation !== undefined - ? creationProjectCwd !== null - ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd) - : removeQueuedMessage("[thread-outbox] dropped pending task for a missing project") - : thread !== undefined - ? sendQueuedMessage(nextQueuedMessage, thread) - : Promise.resolve(false); + return sendQueuedCreation(latestQueuedMessage, latestCreation!, confirmedPlan.projectCwd); }); void delivery - .then((sent) => { - if (sent) { + .then((outcome) => { + if (outcome === "complete" || outcome === "held") { retryAttemptRef.current.delete(nextQueuedMessage.messageId); retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); @@ -1082,7 +1098,6 @@ export function useThreadOutboxDrain(): void { connectedEnvironments, dispatchingQueuedMessageId, editingQueuedMessageIds, - projects, queuedMessagesByThreadKey, retryTick, restoreQueuedMessage, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index c2bb3892d..78adee1f5 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -244,7 +244,7 @@ describe("OrchestrationEngine", () => { await runtime.dispose(); }); - it("accepts a stale session lifecycle CAS without producing an event", async () => { + it("accepts exact zero-event updates and stale lifecycle CAS without poisoning retries", async () => { const system = await createOrchestrationSystem(); const { engine } = system; const createdAt = now(); @@ -306,6 +306,43 @@ describe("OrchestrationEngine", () => { }), ); const sequenceBeforeStaleLifecycle = await system.run(engine.latestSequence); + const zeroEventCommands = [ + { + type: "thread.meta.update" as const, + commandId: CommandId.make("cmd-session-stale-meta"), + threadId, + modelSelection: { + instanceId: providerInstanceId, + model: "gpt-5-codex", + }, + createdAt, + }, + { + type: "thread.runtime-mode.set" as const, + commandId: CommandId.make("cmd-session-stale-runtime"), + threadId, + runtimeMode: "full-access" as const, + createdAt, + }, + { + type: "thread.interaction-mode.set" as const, + commandId: CommandId.make("cmd-session-stale-interaction"), + threadId, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt, + }, + ]; + for (const command of zeroEventCommands) { + expect(await system.run(engine.dispatch(command))).toEqual({ + sequence: sequenceBeforeStaleLifecycle, + eventCount: 0, + }); + // The accepted zero-event receipt makes exact delivery retries succeed; + // it must never be rewritten as a poisoned rejection. + expect(await system.run(engine.dispatch(command))).toEqual({ + sequence: sequenceBeforeStaleLifecycle, + }); + } const result = await system.run( engine.dispatch({ diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 7b2ba3e13..17f15deda 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -209,6 +209,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { const lastSavedEvent = committedEvents.at(-1) ?? null; const acceptsStaleNoEvent = + envelope.command.type === "thread.meta.update" || + envelope.command.type === "thread.runtime-mode.set" || + envelope.command.type === "thread.interaction-mode.set" || envelope.command.type === "thread.turn.admission.accept" || envelope.command.type === "thread.turn.admission.fail" || envelope.command.type === "thread.session.bind-pending" || diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 57f339b22..224064d50 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1246,6 +1246,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pendingTurnSessionId: null, activeTurnRequestId: null, failedTurnRequestId: null, + pendingStopRequestId: null, + pendingStopProviderInstanceId: null, + pendingStopSessionIncarnationId: null, + pendingStopTurnRequestId: null, + pendingStopTurnId: null, activeTurnId: null, lastError: null, updatedAt: event.occurredAt, @@ -1313,6 +1318,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti : (incoming.pendingTurnSessionId ?? null), activeTurnRequestId: incoming.activeTurnRequestId ?? null, failedTurnRequestId: incoming.failedTurnRequestId ?? null, + pendingStopRequestId: incoming.pendingStopRequestId ?? null, + pendingStopProviderInstanceId: + incoming.pendingStopRequestId === undefined + ? null + : (incoming.pendingStopProviderInstanceId ?? null), + pendingStopSessionIncarnationId: + incoming.pendingStopRequestId === undefined + ? null + : (incoming.pendingStopSessionIncarnationId ?? null), + pendingStopTurnRequestId: + incoming.pendingStopRequestId === undefined + ? null + : (incoming.pendingStopTurnRequestId ?? null), + pendingStopTurnId: + incoming.pendingStopRequestId === undefined ? null : (incoming.pendingStopTurnId ?? null), activeTurnId: incoming.activeTurnId, lastError: incoming.lastError, updatedAt: incoming.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5a98cb093..6bdbc7c30 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -348,6 +348,15 @@ function mapSessionRow( : {}), ...(row.activeTurnRequestId !== null ? { activeTurnRequestId: row.activeTurnRequestId } : {}), ...(row.failedTurnRequestId !== null ? { failedTurnRequestId: row.failedTurnRequestId } : {}), + ...(row.pendingStopRequestId !== null + ? { + pendingStopRequestId: row.pendingStopRequestId, + pendingStopProviderInstanceId: row.pendingStopProviderInstanceId, + pendingStopSessionIncarnationId: row.pendingStopSessionIncarnationId, + pendingStopTurnRequestId: row.pendingStopTurnRequestId, + pendingStopTurnId: row.pendingStopTurnId, + } + : {}), activeTurnId: row.activeTurnId, lastError: row.lastError, updatedAt: row.updatedAt, @@ -661,6 +670,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_turn_session_id AS "pendingTurnSessionId", active_turn_request_id AS "activeTurnRequestId", failed_turn_request_id AS "failedTurnRequestId", + pending_stop_request_id AS "pendingStopRequestId", + pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + pending_stop_turn_request_id AS "pendingStopTurnRequestId", + pending_stop_turn_id AS "pendingStopTurnId", active_turn_id AS "activeTurnId", last_error AS "lastError", updated_at AS "updatedAt" @@ -694,6 +708,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessions.pending_turn_session_id AS "pendingTurnSessionId", sessions.active_turn_request_id AS "activeTurnRequestId", sessions.failed_turn_request_id AS "failedTurnRequestId", + sessions.pending_stop_request_id AS "pendingStopRequestId", + sessions.pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + sessions.pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + sessions.pending_stop_turn_request_id AS "pendingStopTurnRequestId", + sessions.pending_stop_turn_id AS "pendingStopTurnId", sessions.active_turn_id AS "activeTurnId", sessions.last_error AS "lastError", sessions.updated_at AS "updatedAt" @@ -731,6 +750,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessions.pending_turn_session_id AS "pendingTurnSessionId", sessions.active_turn_request_id AS "activeTurnRequestId", sessions.failed_turn_request_id AS "failedTurnRequestId", + sessions.pending_stop_request_id AS "pendingStopRequestId", + sessions.pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + sessions.pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + sessions.pending_stop_turn_request_id AS "pendingStopTurnRequestId", + sessions.pending_stop_turn_id AS "pendingStopTurnId", sessions.active_turn_id AS "activeTurnId", sessions.last_error AS "lastError", sessions.updated_at AS "updatedAt" @@ -1184,6 +1208,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_turn_session_id AS "pendingTurnSessionId", active_turn_request_id AS "activeTurnRequestId", failed_turn_request_id AS "failedTurnRequestId", + pending_stop_request_id AS "pendingStopRequestId", + pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + pending_stop_turn_request_id AS "pendingStopTurnRequestId", + pending_stop_turn_id AS "pendingStopTurnId", active_turn_id AS "activeTurnId", last_error AS "lastError", updated_at AS "updatedAt" @@ -1834,6 +1863,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ...(row.failedTurnRequestId !== null ? { failedTurnRequestId: row.failedTurnRequestId } : {}), + ...(row.pendingStopRequestId !== null + ? { + pendingStopRequestId: row.pendingStopRequestId, + pendingStopProviderInstanceId: row.pendingStopProviderInstanceId, + pendingStopSessionIncarnationId: row.pendingStopSessionIncarnationId, + pendingStopTurnRequestId: row.pendingStopTurnRequestId, + pendingStopTurnId: row.pendingStopTurnId, + } + : {}), activeTurnId: row.activeTurnId, lastError: row.lastError, updatedAt: row.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 9c72fb712..7b8046f4d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -54,6 +54,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import type { OrchestrationDispatchError } from "../Errors.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { @@ -171,6 +172,7 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; + readonly unavailableProviderReason?: string; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; readonly interruptTurnEffect?: () => Effect.Effect; @@ -184,7 +186,13 @@ describe("ProviderCommandReactor", () => { >; readonly publishTurnStartedSynchronously?: boolean; readonly beforeAdmissionFailureDispatch?: Effect.Effect; - readonly beforeReactorStart?: Effect.Effect; + readonly beforeAdmissionBindDispatch?: Effect.Effect; + readonly beforeReactorStart?: Effect.Effect< + void, + OrchestrationDispatchError, + OrchestrationEngineService + >; + readonly initialRuntimeSessions?: ReadonlyArray; readonly clock?: Clock.Clock; readonly overdueTurnStartBeforeReactor?: { readonly commandId: CommandId; @@ -195,6 +203,10 @@ describe("ProviderCommandReactor", () => { readonly inventoryEffect?: ( instanceId: ProviderInstanceId, ) => Effect.Effect, ProviderAdapterRequestError>; + readonly sessionContinuation?: { + readonly providerInstanceId: ProviderInstanceId; + readonly resumeCursor: unknown; + } | null; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -207,8 +219,8 @@ describe("ProviderCommandReactor", () => { turnInput: ProviderSendTurnInput, turnId: TurnId, ) => Effect.Effect = () => Effect.void; - let nextSessionIndex = 1; - const runtimeSessions: Array = []; + const runtimeSessions: Array = [...(input?.initialRuntimeSessions ?? [])]; + let nextSessionIndex = runtimeSessions.length + 1; const modelSelection = input?.threadModelSelection ?? { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -388,6 +400,13 @@ describe("ProviderCommandReactor", () => { ...(input?.requiresNewThreadForModelChange === true ? { requiresNewThreadForModelChange: true } : {}), + ...(input?.unavailableProviderReason === undefined + ? {} + : { + availability: "unavailable" as const, + unavailableReason: input.unavailableProviderReason, + enabled: false, + }), }, ]; @@ -426,12 +445,25 @@ describe("ProviderCommandReactor", () => { refineSessionHarness: () => unsupported(), stopSession: stopSession as ProviderServiceShape["stopSession"], listSessions: () => Effect.succeed(runtimeSessions), + getSessionContinuation: () => Effect.succeed(input?.sessionContinuation ?? null), listSessionsForInstance, getCapabilities: (_provider) => Effect.succeed({ sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", }), getInstanceInfo: (instanceId) => { + if ( + input?.unavailableProviderReason !== undefined && + instanceId === modelSelection.instanceId + ) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: String(instanceId), + method: "ProviderService.getInstanceInfo", + detail: "Provider instance is not materialized.", + }), + ); + } const raw = String(instanceId); const driverKind = ProviderDriverKind.make( raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, @@ -489,6 +521,14 @@ describe("ProviderCommandReactor", () => { Effect.andThen(engine.dispatch(command)), ); } + if ( + command.type === "thread.session.bind-pending" && + input?.beforeAdmissionBindDispatch !== undefined + ) { + return input.beforeAdmissionBindDispatch.pipe( + Effect.andThen(engine.dispatch(command)), + ); + } if (command.type === "thread.title.regeneration.complete") { titleRegenerationCompletionDispatchAttempts += 1; if ( @@ -722,12 +762,72 @@ describe("ProviderCommandReactor", () => { stateDir, drain, runEffect, + getAdmissionTrackingCounts: reactor.getAdmissionTrackingCounts!, get titleRegenerationCompletionDispatchAttempts() { return titleRegenerationCompletionDispatchAttempts; }, }; } + it("reclaims admission lanes after long historical-thread churn", async () => { + const harness = await createHarness(); + expect(harness.getAdmissionTrackingCounts()).toEqual({ + fibers: 0, + fiberThreads: 0, + stopTokens: 0, + permits: 0, + }); + + for (let index = 0; index < 100; index += 1) { + const threadId = ThreadId.make(`thread-admission-churn-${index}`); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-thread-admission-churn-create-${index}`), + threadId, + projectId: asProjectId("project-1"), + title: `Churn ${index}`, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-thread-admission-churn-start-${index}`), + threadId, + message: { + messageId: asMessageId(`message-admission-churn-${index}`), + role: "user", + text: `churn ${index}`, + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + } + + await waitFor(() => harness.sendTurn.mock.calls.length === 100); + await waitFor(() => + Object.values(harness.getAdmissionTrackingCounts()).every((count) => count === 0), + ); + expect(harness.getAdmissionTrackingCounts()).toEqual({ + fibers: 0, + fiberThreads: 0, + stopTokens: 0, + permits: 0, + }); + }); + it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -768,6 +868,51 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("surfaces exact unavailable remediation before starting a provider runtime", async () => { + const reason = + "Prime Agent requires WSL2 on native Windows. Connect to a supported environment."; + const harness = await createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "default", + }, + unavailableProviderReason: reason, + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unavailable-prime"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unavailable-prime"), + role: "user", + text: "must not start", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.activities.some( + (activity) => + activity.kind === "provider.turn.start.failed" && + typeof activity.payload === "object" && + activity.payload !== null && + "detail" in activity.payload && + activity.payload.detail === reason, + ) ?? false + ); + }); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); @@ -806,6 +951,153 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("stops a slow first-turn admission before it can bind or send", () => + Effect.gen(function* () { + const startEntered = yield* Deferred.make(); + const releaseStart = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => + Deferred.succeed(startEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseStart)), + Effect.as(session), + ), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-stop-slow-first-turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-stop-slow-first-turn"), + role: "user", + text: "do not send after stop", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Deferred.await(startEntered); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-slow-first-turn-stop"), + threadId: ThreadId.make("thread-1"), + createdAt: now, + }); + yield* Effect.promise(() => harness.drain()); + let thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("stopped"); + + yield* Deferred.succeed(releaseStart, undefined); + yield* Effect.promise(() => harness.drain()); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("stopped"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("quarantines the exact runtime when the admission bind CAS loses", () => + Effect.gen(function* () { + const bindEntered = yield* Deferred.make(); + const releaseBind = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + beforeAdmissionBindDispatch: Deferred.succeed(bindEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseBind)), + ), + }), + ); + const threadId = ThreadId.make("thread-1"); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bind-cas-loss-start"), + threadId, + message: { + messageId: asMessageId("message-bind-cas-loss"), + role: "user", + text: "lose the bind", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(bindEntered); + expect(harness.runtimeSessions).toHaveLength(1); + const createdRuntime = harness.runtimeSessions[0]; + + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-bind-cas-loss-stop"), + threadId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* Deferred.succeed(releaseBind, undefined); + yield* Effect.promise(() => + waitFor(() => + harness.stopSession.mock.calls.some( + ([input]) => + typeof input === "object" && + input !== null && + "expectedSessionIncarnationId" in input && + input.expectedSessionIncarnationId === createdRuntime?.sessionIncarnationId, + ), + ), + ); + yield* Effect.promise(() => harness.drain()); + + expect(harness.runtimeSessions).toHaveLength(0); + expect(harness.sendTurn).not.toHaveBeenCalled(); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("stopped"); + expect( + thread?.activities.filter((activity) => activity.kind === "provider.turn.start.failed"), + ).toHaveLength(0); + }), + ); + + effectIt.effect("cancels first-turn admission when Stop lands before provider start runs", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-stop-before-provider-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-stop-before-provider-start"), + role: "user", + text: "cancel before provider start", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-before-provider-start-stop"), + threadId: ThreadId.make("thread-1"), + createdAt: now, + }); + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("stopped"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + effectIt.effect("times out a hung provider admission with a correlated failure", () => Effect.gen(function* () { const testClock = yield* TestClock.make(); @@ -1170,7 +1462,7 @@ describe("ProviderCommandReactor", () => { ); effectIt.effect( - "preserves a pending admission when runtime mode changes during provider start", + "keeps accepted settings authoritative when stale metadata changes during provider start", () => Effect.gen(function* () { const startEntered = yield* Deferred.make(); @@ -1238,7 +1530,8 @@ describe("ProviderCommandReactor", () => { readModel = yield* Effect.promise(() => harness.readModel()); thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.runtimeMode).toBe("full-access"); + expect(thread?.runtimeMode).toBe("approval-required"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); expect(thread?.session?.status).toBe("running"); expect(thread?.session?.activeTurnRequestId).toBe(requestId); expect(harness.startSession).toHaveBeenCalledTimes(1); @@ -2849,6 +3142,15 @@ describe("ProviderCommandReactor", () => { }); yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + const beforeRejectedChange = (yield* Effect.promise(() => + harness.readModel(), + )).threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const beforeProjectedSettings = { + modelSelection: beforeRejectedChange?.modelSelection, + runtimeMode: beforeRejectedChange?.runtimeMode, + interactionMode: beforeRejectedChange?.interactionMode, + providerInstanceId: beforeRejectedChange?.session?.providerInstanceId, + }; yield* harness.engine.dispatch({ type: "thread.turn.start", @@ -2886,6 +3188,12 @@ describe("ProviderCommandReactor", () => { expect(harness.sendTurn).toHaveBeenCalledTimes(1); const readModel = yield* Effect.promise(() => harness.readModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect({ + modelSelection: thread?.modelSelection, + runtimeMode: thread?.runtimeMode, + interactionMode: thread?.interactionMode, + providerInstanceId: thread?.session?.providerInstanceId, + }).toEqual(beforeProjectedSettings); expect( thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), ).toMatchObject({ @@ -2992,7 +3300,85 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); - it("restarts an existing Codex thread on a compatible requested instance", async () => { + effectIt.effect("delivers an accepted steer before a later settings transition", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-admission-order-initial"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-admission-order-initial"), + role: "user", + text: "initial", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "running"; + }), + ); + + const steerEntered = yield* Deferred.make(); + const releaseSteer = yield* Deferred.make(); + harness.sendTurn.mockImplementationOnce((input: ProviderSendTurnInput) => + Deferred.succeed(steerEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseSteer)), + Effect.as({ threadId: input.threadId, turnId: asTurnId("turn-steer-a") }), + ), + ); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-admission-order-steer-a"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-admission-order-steer-a"), + role: "user", + text: "steer A", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Deferred.await(steerEntered); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-admission-order-settings-b"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-admission-order-settings-b"), + role: "user", + text: "settings B", + attachments: [], + }, + interactionMode: "plan", + runtimeMode: "full-access", + createdAt: now, + }); + yield* Effect.yieldNow; + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.sendTurn).toHaveBeenCalledTimes(2); + + yield* Deferred.succeed(releaseSteer, undefined); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 3)); + expect(harness.startSession).toHaveBeenCalledTimes(2); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ input: "steer A" }); + expect(harness.sendTurn.mock.calls[2]?.[0]).toMatchObject({ input: "settings B" }); + }), + ); + + it("accepts a compatible cross-account Codex switch and projects it atomically", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -3016,8 +3402,32 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "running"; + }); + const afterFirst = (await harness.readModel()).threads.find( + (thread) => thread.id === ThreadId.make("thread-1"), + ); + expect(afterFirst?.session?.status).toBe("running"); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-compatible-codex-ready"), + threadId: ThreadId.make("thread-1"), + session: { + ...afterFirst!.session!, + status: "ready", + activeTurnRequestId: undefined, + activeTurnId: null, + updatedAt: now, + }, + createdAt: now, + }), + ); await Effect.runPromise( harness.engine.dispatch({ @@ -3034,24 +3444,90 @@ describe("ProviderCommandReactor", () => { instanceId: ProviderInstanceId.make("codex_work"), model: "gpt-5-codex", }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: "2026-01-01T00:00:00.000Z", + interactionMode: "plan", + runtimeMode: "full-access", + createdAt: now, }), ); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - + const switched = (await harness.readModel()).threads.find( + (thread) => thread.id === ThreadId.make("thread-1"), + ); expect(harness.startSession).toHaveBeenCalledTimes(2); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ - provider: ProviderDriverKind.make("codex"), providerInstanceId: ProviderInstanceId.make("codex_work"), - resumeCursor: { opaque: "resume-1" }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + }); + expect(switched?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + expect(switched?.modelSelection).toEqual({ + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }); + expect(switched?.runtimeMode).toBe("full-access"); + expect(switched?.interactionMode).toBe("plan"); + }); + + it("passes the exact persisted cursor through a cold compatible Codex switch", async () => { + const persistedCursor = { threadId: "codex-thread-persisted", rollout: "rollout-42" }; + const harness = await createHarness({ + sessionContinuation: { + providerInstanceId: ProviderInstanceId.make("codex"), + resumeCursor: persistedCursor, + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-cold-compatible-codex-stopped"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "stopped", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + sessionIncarnationId: RuntimeSessionId.make("session-cold-codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-cold-compatible-codex"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-cold-compatible-codex"), + role: "user", + text: "continue the exact conversation", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + providerInstanceId: ProviderInstanceId.make("codex_work"), + resumeCursor: persistedCursor, }); - - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); }); it("restarts the provider session when the thread workspace changes", async () => { @@ -3229,22 +3705,6 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-1"), - threadId: ThreadId.make("thread-1"), - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(async () => { - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return thread?.runtimeMode === "approval-required"; - }); - await waitFor(() => harness.startSession.mock.calls.length === 2); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -3257,11 +3717,12 @@ describe("ProviderCommandReactor", () => { attachments: [], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", + runtimeMode: "approval-required", createdAt: now, }), ); + await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); expect(harness.stopSession.mock.calls.length).toBe(0); @@ -3328,7 +3789,7 @@ describe("ProviderCommandReactor", () => { }); }); - it("does not stop the active session when restart fails before rebind", async () => { + it("does not project changed settings when provider restart fails before rebind", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -3368,35 +3829,44 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-restart-failure-2"), threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-restart-failure-2"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), ); + await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(async () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return thread?.runtimeMode === "approval-required"; + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); }); - await waitFor(() => harness.startSession.mock.calls.length === 2); - await harness.drain(); expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.sendTurn.mock.calls.length).toBe(1); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.runtimeMode).toBe("full-access"); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("full-access"); }); - it("rejects provider changes after a thread is already bound to a session provider", async () => { + it("rejects provider changes atomically after a thread is bound", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -3413,8 +3883,6 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - - await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); await Effect.runPromise( @@ -3439,36 +3907,33 @@ describe("ProviderCommandReactor", () => { ); await waitFor(async () => { - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false + thread?.activities.some( + (activity) => + activity.kind === "provider.turn.start.failed" && + typeof activity.payload === "object" && + activity.payload !== null && + "detail" in activity.payload && + String(activity.payload.detail).includes("cannot switch to 'claudeAgent'"), + ) ?? false ); }); - - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.sendTurn.mock.calls.length).toBe(1); - expect(harness.stopSession.mock.calls.length).toBe(0); - - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.providerName).toBe("codex"); - expect(thread?.session?.runtimeMode).toBe("approval-required"); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.modelSelection.instanceId).toBe(ProviderInstanceId.make("codex")); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex")); + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.stopSession).not.toHaveBeenCalled(); }); - it("rejects cross-driver provider changes after the existing thread session has stopped", async () => { + it("rejects cross-driver changes after the existing session stops", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", @@ -3508,27 +3973,23 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await waitFor(async () => { - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false + thread?.activities.some( + (activity) => + activity.kind === "provider.turn.start.failed" && + typeof activity.payload === "object" && + activity.payload !== null && + "detail" in activity.payload && + String(activity.payload.detail).includes("cannot switch to 'claudeAgent'"), + ) ?? false ); }); - - expect(harness.startSession.mock.calls.length).toBe(0); - expect(harness.sendTurn.mock.calls.length).toBe(0); - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); }); it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { @@ -3836,6 +4297,68 @@ describe("ProviderCommandReactor", () => { }); }); + it("blocks a stale projected model instead of synthesizing it onto the Prime binding", async () => { + const harness = await createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-projected-prime"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "primeAgent", + providerInstanceId: ProviderInstanceId.make("primeAgent"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-projected-prime"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-projected-prime"), + role: "user", + text: "continue the Prime session", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.activities.some( + (activity) => + activity.kind === "provider.turn.start.failed" && + typeof activity.payload === "object" && + activity.payload !== null && + "detail" in activity.payload && + String(activity.payload.detail).includes("cannot switch to 'codex'"), + ) ?? false + ); + }); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }); + it("rejects active runtime sessions that are missing provider instance ids", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -4006,7 +4529,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval-error"), @@ -4024,7 +4547,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-approval-requested"), @@ -4045,7 +4568,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond-stale"), @@ -4197,6 +4720,278 @@ describe("ProviderCommandReactor", () => { expect(resolvedActivity).toBeUndefined(); }); + const makeRestartStopTarget = (): ProviderSession => ({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + model: "gpt-5-codex", + sessionIncarnationId: RuntimeSessionId.make("session-restart-stop"), + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + const seedPendingStopBeforeReactor = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-restart-stop-bind"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + startedAt: "2026-01-01T00:00:00.000Z", + sessionIncarnationId: RuntimeSessionId.make("session-restart-stop"), + activeTurnRequestId: CommandId.make("cmd-restart-stop-turn"), + activeTurnId: TurnId.make("turn-restart-stop"), + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-restart-stop"), + threadId: ThreadId.make("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + }); + }); + + it.each([true, false])( + "reconciles a durable pending stop on restart when the exact runtime is present=%s", + async (runtimePresent) => { + const harness = await createHarness({ + beforeReactorStart: seedPendingStopBeforeReactor, + initialRuntimeSessions: runtimePresent ? [makeRestartStopTarget()] : [], + }); + + expect(harness.stopSession).toHaveBeenCalledTimes(1); + expect(harness.stopSession).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + expectedProviderInstanceId: ProviderInstanceId.make("codex"), + expectedSessionIncarnationId: RuntimeSessionId.make("session-restart-stop"), + expectedAdmissionRequestId: CommandId.make("cmd-restart-stop-turn"), + invalidateStartReservation: true, + }); + expect(harness.runtimeSessions).toHaveLength(0); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.session?.status === "stopped" && thread.session.pendingStopRequestId === undefined + ); + }); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "stopped", + activeTurnId: null, + }); + expect(thread?.session?.activeTurnRequestId).toBeUndefined(); + }, + ); + + it("clears only the old pending stop when a later Start was persisted before restart", async () => { + const newRequestId = CommandId.make("cmd-restart-stop-later-start"); + const newMessageId = MessageId.make("message-restart-stop-later-start"); + const harness = await createHarness({ + initialRuntimeSessions: [makeRestartStopTarget()], + beforeReactorStart: seedPendingStopBeforeReactor.pipe( + Effect.andThen( + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: newRequestId, + threadId: ThreadId.make("thread-1"), + message: { + messageId: newMessageId, + role: "user", + text: "start after durable stop", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:02.000Z", + }); + }), + ), + ), + }); + + expect(harness.stopSession).toHaveBeenCalledWith( + expect.objectContaining({ + expectedSessionIncarnationId: RuntimeSessionId.make("session-restart-stop"), + invalidateStartReservation: false, + }), + ); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.session?.status === "starting" && + thread.session.pendingTurnRequestId === newRequestId && + thread.session.pendingStopRequestId === undefined + ); + }); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.messages.map((message) => message.id)).toContain(newMessageId); + expect(thread?.session?.pendingTurnRequestId).toBe(newRequestId); + expect(thread?.session?.pendingStopRequestId).toBeUndefined(); + }); + + effectIt.effect( + "keeps a post-receipt Stop → Start admission authoritative for plain and settings starts", + () => + Effect.gen(function* () { + const stopEntered = yield* Deferred.make(); + const releaseFirstStop = yield* Deferred.make(); + let stopCall = 0; + const harness = yield* Effect.promise(() => + createHarness({ + stopSessionEffect: () => { + stopCall += 1; + return stopCall === 1 + ? Deferred.succeed(stopEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstStop)), + ) + : Effect.void; + }, + }), + ); + const threadId = ThreadId.make("thread-1"); + const start = ( + suffix: string, + settings?: { + readonly modelSelection: ModelSelection; + readonly runtimeMode: "full-access"; + }, + ) => + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-stop-start-${suffix}`), + threadId, + message: { + messageId: asMessageId(`message-stop-start-${suffix}`), + role: "user", + text: suffix, + attachments: [], + }, + ...(settings?.modelSelection === undefined + ? {} + : { modelSelection: settings.modelSelection }), + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: settings?.runtimeMode ?? "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* start("initial"); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + return thread?.session?.status === "running"; + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-start-stop-first"), + threadId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* Deferred.await(stopEntered); + yield* Effect.promise(() => + waitFor(async () => { + const snapshot = await harness.readModel(); + return snapshot.threads.some( + (thread) => + thread.id === threadId && + thread.session?.pendingStopRequestId === + CommandId.make("cmd-stop-start-stop-first"), + ); + }), + ); + let thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("stopped"); + + const plainRequestId = CommandId.make("cmd-stop-start-plain"); + yield* start("plain"); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.pendingTurnRequestId).toBe(plainRequestId); + expect(thread?.session?.pendingStopRequestId).toBe( + CommandId.make("cmd-stop-start-stop-first"), + ); + + yield* Deferred.succeed(releaseFirstStop, undefined); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 2)); + yield* Effect.promise(() => + waitFor(async () => { + const current = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + return ( + current?.session?.status === "running" && + current.session.activeTurnRequestId === plainRequestId + ); + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-start-stop-second"), + threadId, + createdAt: "2026-01-01T00:00:02.000Z", + }); + yield* Effect.promise(() => + waitFor(async () => { + const current = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + return current?.session?.status === "stopped"; + }), + ); + + const settingsRequestId = CommandId.make("cmd-stop-start-settings"); + yield* start("settings", { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + }); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 3)); + yield* Effect.promise(() => + waitFor(async () => { + const current = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + return ( + current?.session?.status === "running" && + current.session.activeTurnRequestId === settingsRequestId && + current.runtimeMode === "full-access" + ); + }), + ); + }), + ); + it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index dd3bcd9ea..935437d94 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -11,7 +11,6 @@ import { type OrchestrationSession, ThreadId, type ProviderSession, - type RuntimeMode, type TurnId, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; @@ -31,6 +30,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -41,6 +41,10 @@ import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; +import { + findUnavailableProviderInstance, + providerUnavailableDetail, +} from "../../provider/providerUnavailable.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { @@ -58,6 +62,10 @@ import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); +type TurnAdmissionIntent = NonNullable< + Extract["payload"]["admissionIntent"] +>; + type ProviderIntentEvent = Extract< OrchestrationEvent, { @@ -117,7 +125,6 @@ export const PROVIDER_TURN_INVENTORY_ATTEMPT_TIMEOUT_MS = 2_000; export const PROVIDER_TURN_INVENTORY_RETRY_TIMEOUT_MS = 6_500; const PROVIDER_TURN_ADMISSION_TIMEOUT_DETAIL = "Provider did not start the requested turn within 60 seconds."; -const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const MAX_REGENERATION_ATTACHMENTS = 4; const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000; @@ -356,6 +363,32 @@ const make = Effect.gen(function* () { const threadModelSelections = new Map(); const admissionFibers = new Map>(); + const admissionFiberThreads = new Map(); + type AdmissionStopToken = object; + const admissionStopTokens = new Map(); + const admissionPermits = new Map(); + const makeAdmissionStopToken = (): AdmissionStopToken => Object.freeze({}); + const admissionStopTokenForRequest = (requestId: CommandId) => { + const current = admissionStopTokens.get(requestId); + if (current !== undefined) return current; + const created = makeAdmissionStopToken(); + admissionStopTokens.set(requestId, created); + return created; + }; + const admissionPermitForThread = (threadId: ThreadId) => { + const current = admissionPermits.get(threadId); + if (current !== undefined) return current; + const created = Semaphore.makeUnsafe(1); + admissionPermits.set(threadId, created); + return created; + }; + const releaseAdmissionLaneIfIdle = (threadId: ThreadId) => { + for (const fiberThreadId of admissionFiberThreads.values()) { + if (fiberThreadId === threadId) return; + } + admissionPermits.delete(threadId); + }; + yield* Effect.addFinalizer(() => Effect.gen(function* () { const fibers = Array.from(admissionFibers.entries()); @@ -372,7 +405,11 @@ const make = Effect.gen(function* () { if (Option.isSome(joined)) { for (const [requestId, fiber] of fibers) { if (admissionFibers.get(requestId) === fiber && fiber.pollUnsafe() !== undefined) { + const threadId = admissionFiberThreads.get(requestId); admissionFibers.delete(requestId); + admissionFiberThreads.delete(requestId); + admissionStopTokens.delete(requestId); + if (threadId !== undefined) releaseAdmissionLaneIfIdle(threadId); } } } else if (fibers.length > 0) { @@ -461,6 +498,9 @@ const make = Effect.gen(function* () { expectedActiveTurnRequestId: expected?.activeTurnRequestId ?? null, expectedActiveTurnId: expected?.activeTurnId ?? null, expectedFailedTurnRequestId: expected?.failedTurnRequestId ?? null, + expectedPendingStopRequestId: expected?.pendingStopRequestId ?? null, + expectedPendingStopSessionIncarnationId: + expected?.pendingStopSessionIncarnationId ?? null, ...(input.allowFailedTurnRequestClear === true ? { allowFailedTurnRequestClear: true as const } : {}), @@ -609,11 +649,15 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; + readonly runtimeMode?: OrchestrationSession["runtimeMode"]; + readonly interactionMode?: "default" | "plan"; readonly pendingTurnStart?: boolean; readonly pendingTurnRequestId?: CommandId; readonly pendingTurnMessageId?: MessageId; readonly pendingTurnRequestedAt?: string; readonly pendingTurnDeadlineAt?: string; + readonly expectedProviderInstanceId?: ProviderInstanceId | null; + readonly expectedSessionIncarnationId?: TurnAdmissionIntent["expectedSessionIncarnationId"]; }, ) { const thread = yield* resolveThread(threadId); @@ -621,7 +665,8 @@ const make = Effect.gen(function* () { return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); } - const desiredRuntimeMode = thread.runtimeMode; + const desiredRuntimeMode = options?.runtimeMode ?? thread.runtimeMode; + const desiredInteractionMode = options?.interactionMode ?? thread.interactionMode; const requestedModelSelection = options?.modelSelection; const resolveActiveSession = (threadId: ThreadId) => providerService @@ -629,9 +674,31 @@ const make = Effect.gen(function* () { .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); const activeSession = yield* resolveActiveSession(threadId); - const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; - const desiredInstanceId = desiredModelSelection.instanceId; - + const observedInstanceId = thread.session?.providerInstanceId; + const persistedInstanceId = + options?.expectedProviderInstanceId === undefined + ? observedInstanceId + : (options.expectedProviderInstanceId ?? undefined); + if ( + options?.expectedProviderInstanceId !== undefined && + (observedInstanceId ?? null) !== options.expectedProviderInstanceId + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(options.expectedProviderInstanceId ?? "unknown")), + method: "thread.turn.start", + detail: `Turn admission observed a different provider binding for thread '${threadId}'.`, + }); + } + if ( + options?.expectedSessionIncarnationId !== undefined && + (thread.session?.sessionIncarnationId ?? null) !== options.expectedSessionIncarnationId + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(persistedInstanceId ?? "unknown")), + method: "thread.turn.start", + detail: `Turn admission observed a different provider session incarnation for thread '${threadId}'.`, + }); + } const activeThreadSession = thread.session !== null && thread.session.status !== "stopped" && activeSession ? thread.session @@ -648,91 +715,128 @@ const make = Effect.gen(function* () { detail: `Thread '${threadId}' has an active provider session without a provider instance id.`, }); } - const currentInstanceId = - activeThreadSession !== null && + if ( + persistedInstanceId !== undefined && + activeSession?.providerInstanceId !== undefined && + activeSession.providerInstanceId !== persistedInstanceId + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(persistedInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' is bound to provider instance '${persistedInstanceId}', but the active runtime belongs to '${activeSession.providerInstanceId}'.`, + }); + } + + if ( + options?.expectedSessionIncarnationId !== undefined && activeSession !== undefined && - activeSession.providerInstanceId !== undefined - ? activeSession.providerInstanceId - : thread.modelSelection.instanceId; - const currentInfo = yield* providerService.getInstanceInfo(currentInstanceId).pipe( - Effect.mapError( - () => - new ProviderAdapterRequestError({ - provider: providerErrorLabelFromInstanceHint({ - instanceId: String(currentInstanceId), - modelSelectionInstanceId: String(thread.modelSelection.instanceId), - sessionProvider: thread.session?.providerName ?? undefined, - }), - method: "thread.turn.start", - detail: `Thread '${threadId}' references unknown provider instance '${currentInstanceId}'. The instance is not configured in this build.`, - }), - ), - ); - const desiredInfo = yield* providerService.getInstanceInfo(desiredInstanceId).pipe( - Effect.mapError( - () => - new ProviderAdapterRequestError({ - provider: providerErrorLabelFromInstanceHint({ - instanceId: String(desiredModelSelection.instanceId), - }), - method: "thread.turn.start", - detail: `Requested provider instance '${desiredInstanceId}' is not configured in this build.`, - }), - ), - ); - const desiredDriverKind = desiredInfo.driverKind; - if (!isProviderDriverKind(desiredDriverKind)) { + activeSession.sessionIncarnationId !== options.expectedSessionIncarnationId + ) { return yield* new ProviderAdapterRequestError({ - provider: providerErrorLabel(String(desiredDriverKind)), + provider: providerErrorLabel(String(persistedInstanceId ?? "unknown")), method: "thread.turn.start", - detail: `Requested provider instance '${desiredInstanceId}' uses unknown provider driver '${desiredDriverKind}'. The driver is not installed in this build.`, + detail: `The provider runtime for thread '${threadId}' no longer matches the accepted session incarnation.`, }); } - const preferredProvider: ProviderDriverKind = desiredDriverKind; + + // The projected session binding survives provider-runtime restarts. Keep + // resolving that exact instance when the in-memory session is gone. A model + // selection from another instance is not repairable by replacing only its + // routing key: its model and options belong to the other provider. + const currentInstanceId = + persistedInstanceId ?? activeSession?.providerInstanceId ?? thread.modelSelection.instanceId; + const cachedModelSelection = threadModelSelections.get(threadId); + const persistedModelSelection = + cachedModelSelection?.instanceId === currentInstanceId + ? cachedModelSelection + : activeSession?.providerInstanceId === currentInstanceId && activeSession.model + ? { instanceId: currentInstanceId, model: activeSession.model } + : thread.modelSelection.instanceId === currentInstanceId + ? thread.modelSelection + : undefined; const hasStartedSession = activeSession !== undefined || thread.latestTurn !== null || thread.session?.startedAt !== undefined || - (thread.session?.providerInstanceId !== undefined && - thread.session.providerInstanceId !== desiredInstanceId); - if (thread.session !== null && hasStartedSession) { - yield* rejectStartedThreadModelChangeIfRequired({ - threadId, - currentModelSelection: - activeSession?.model !== undefined - ? { - ...thread.modelSelection, - instanceId: currentInstanceId, - model: activeSession.model, - } - : thread.modelSelection, - requestedModelSelection, + thread.session?.providerInstanceId !== undefined; + const desiredModelSelection = requestedModelSelection ?? persistedModelSelection; + if (desiredModelSelection === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(currentInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' is bound to provider instance '${currentInstanceId}', but no model selection persisted for that exact instance. Start a new thread or explicitly select a model for the bound provider.`, }); } - if ( - thread.session !== null && - hasStartedSession && - requestedModelSelection !== undefined && - requestedModelSelection.instanceId !== currentInstanceId + const desiredInstanceId = desiredModelSelection.instanceId; + const resolveInstanceInfo = Effect.fnUntraced(function* ( + instanceId: ProviderInstanceId, + role: "current" | "requested", ) { + return yield* providerService.getInstanceInfo(instanceId).pipe( + Effect.catch(() => + providerRegistry.getProviders.pipe( + Effect.flatMap((providers) => { + const unavailable = findUnavailableProviderInstance(providers, instanceId); + return Effect.fail( + new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(instanceId), + modelSelectionInstanceId: String(thread.modelSelection.instanceId), + sessionProvider: thread.session?.providerName ?? undefined, + }), + method: "thread.turn.start", + detail: unavailable + ? providerUnavailableDetail(unavailable) + : role === "current" + ? `Thread '${threadId}' references unknown provider instance '${instanceId}'. The instance is not configured in this build.` + : `Requested provider instance '${instanceId}' is not configured in this build.`, + }), + ); + }), + ), + ), + ); + }); + const desiredInfo = yield* resolveInstanceInfo(desiredInstanceId, "requested"); + if (hasStartedSession && desiredInstanceId !== currentInstanceId) { + const currentInfo = yield* resolveInstanceInfo(currentInstanceId, "current"); + const currentContinuationKey = currentInfo.continuationIdentity.continuationKey.trim(); + const desiredContinuationKey = desiredInfo.continuationIdentity.continuationKey.trim(); if (currentInfo.driverKind !== desiredInfo.driverKind) { return yield* new ProviderAdapterRequestError({ - provider: preferredProvider, + provider: providerErrorLabel(String(desiredInfo.driverKind)), method: "thread.turn.start", - detail: `Thread '${threadId}' is bound to driver '${currentInfo.driverKind}' and cannot switch to '${desiredInfo.driverKind}'.`, + detail: `Thread '${threadId}' is bound to driver '${currentInfo.driverKind}' and cannot switch to '${desiredInfo.driverKind}'. Start a new thread to change providers.`, }); } if ( - currentInfo.continuationIdentity.continuationKey !== - desiredInfo.continuationIdentity.continuationKey + currentContinuationKey.length === 0 || + desiredContinuationKey.length === 0 || + currentContinuationKey !== desiredContinuationKey ) { return yield* new ProviderAdapterRequestError({ - provider: preferredProvider, + provider: providerErrorLabel(String(desiredInfo.driverKind)), method: "thread.turn.start", - detail: `Thread '${threadId}' cannot switch from instance '${currentInstanceId}' to '${desiredInstanceId}' because their provider resume state is incompatible.`, + detail: `Thread '${threadId}' cannot switch from instance '${currentInstanceId}' to '${desiredInstanceId}' because they do not share the same non-empty provider continuation identity. Start a new thread to use that account.`, }); } } + const desiredDriverKind = desiredInfo.driverKind; + if (!isProviderDriverKind(desiredDriverKind)) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredDriverKind)), + method: "thread.turn.start", + detail: `Requested provider instance '${desiredInstanceId}' uses unknown provider driver '${desiredDriverKind}'. The driver is not installed in this build.`, + }); + } + const preferredProvider: ProviderDriverKind = desiredDriverKind; + if (thread.session !== null && hasStartedSession && desiredInstanceId === currentInstanceId) { + yield* rejectStartedThreadModelChangeIfRequired({ + threadId, + currentModelSelection: persistedModelSelection ?? desiredModelSelection, + requestedModelSelection, + }); + } const project = yield* resolveProject(thread.projectId); const effectiveCwd = resolveThreadWorkspaceCwd({ thread, @@ -792,6 +896,16 @@ const make = Effect.gen(function* () { ...(options?.pendingTurnRequestId === undefined ? {} : { pendingTurnSessionId: session.sessionIncarnationId }), + ...(thread.session?.pendingStopRequestId === undefined + ? {} + : { + pendingStopRequestId: thread.session.pendingStopRequestId, + pendingStopProviderInstanceId: thread.session.pendingStopProviderInstanceId ?? null, + pendingStopSessionIncarnationId: + thread.session.pendingStopSessionIncarnationId ?? null, + pendingStopTurnRequestId: thread.session.pendingStopTurnRequestId ?? null, + pendingStopTurnId: thread.session.pendingStopTurnId ?? null, + }), activeTurnRequestId: undefined, startedAt: session.createdAt, // Provider turn ids are not orchestration turn ids. @@ -815,6 +929,13 @@ const make = Effect.gen(function* () { threadId, requestId: options.pendingTurnRequestId, messageId: options.pendingTurnMessageId!, + expectedProviderInstanceId: + options?.expectedProviderInstanceId === undefined + ? (persistedInstanceId ?? null) + : options.expectedProviderInstanceId, + modelSelection: desiredModelSelection, + runtimeMode: desiredRuntimeMode, + interactionMode: desiredInteractionMode, session: sessionBinding, createdAt, }, @@ -822,10 +943,54 @@ const make = Effect.gen(function* () { return (dispatched.eventCount ?? 1) > 0; }); + const quarantineStartedSession = (session: ProviderSession) => { + if (session.providerInstanceId === undefined || session.sessionIncarnationId === undefined) { + return Effect.void; + } + return providerService + .stopSession({ + threadId, + expectedProviderInstanceId: session.providerInstanceId, + expectedSessionIncarnationId: session.sessionIncarnationId, + expectedAdmissionRequestId: options?.pendingTurnRequestId ?? null, + removeBinding: true, + invalidateStartReservation: false, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to quarantine superseded session", { + threadId, + providerInstanceId: session.providerInstanceId, + sessionIncarnationId: session.sessionIncarnationId, + cause: Cause.pretty(cause), + }), + ), + ); + }; + const bindStartedSession = (session: ProviderSession) => + bindSessionToThread(session).pipe( + Effect.tap((bound) => (bound ? Effect.void : quarantineStartedSession(session))), + Effect.onError(() => quarantineStartedSession(session)), + // Once an adapter returned a concrete runtime, either its exact CAS + // binds or that exact runtime is quarantined before interruption lands. + Effect.uninterruptible, + ); + const startAndBindSession = (input?: { + readonly resumeCursor?: unknown; + readonly provider?: ProviderDriverKind; + }) => + Effect.uninterruptibleMask((restore) => + restore(startProviderSession(input)).pipe( + Effect.flatMap((session) => + bindStartedSession(session).pipe(Effect.map((bound) => ({ session, bound }))), + ), + ), + ); + const existingSessionThreadId = thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { - const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; + const runtimeModeChanged = desiredRuntimeMode !== thread.session?.runtimeMode; const cwdChanged = effectiveCwd !== activeSession?.cwd; const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)) .sessionModelSwitch; @@ -875,9 +1040,10 @@ const make = Effect.gen(function* () { shouldRestartForModelSelectionChange, hasResumeCursor: resumeCursor !== undefined, }); - const restartedSession = yield* startProviderSession( + const restarted = yield* startAndBindSession( resumeCursor !== undefined ? { resumeCursor } : undefined, ); + const restartedSession = restarted.session; yield* Effect.logInfo("provider command reactor restarted provider session", { threadId, previousSessionId: existingSessionThreadId, @@ -886,11 +1052,31 @@ const make = Effect.gen(function* () { runtimeMode: restartedSession.runtimeMode, cwd: restartedSession.cwd, }); - return (yield* bindSessionToThread(restartedSession)) ? restartedSession : undefined; + return restarted.bound ? restartedSession : undefined; } - const startedSession = yield* startProviderSession(undefined); - return (yield* bindSessionToThread(startedSession)) ? startedSession : undefined; + const compatibleColdTransition = + hasStartedSession && desiredInstanceId !== currentInstanceId + ? yield* providerService.getSessionContinuation?.(threadId) ?? Effect.succeed(null) + : null; + if (hasStartedSession && desiredInstanceId !== currentInstanceId) { + if ( + compatibleColdTransition === null || + compatibleColdTransition.providerInstanceId !== currentInstanceId + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' cannot continue on '${desiredInstanceId}' because its exact persisted provider continuation is unavailable.`, + }); + } + } + const started = yield* startAndBindSession( + compatibleColdTransition === null + ? undefined + : { resumeCursor: compatibleColdTransition.resumeCursor }, + ); + return started.bound ? started.session : undefined; }); const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { @@ -898,9 +1084,11 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; - readonly interactionMode?: "default" | "plan"; + readonly runtimeMode: OrchestrationSession["runtimeMode"]; + readonly interactionMode: "default" | "plan"; readonly requestId: CommandId; readonly messageId: MessageId; + readonly admissionIntent?: TurnAdmissionIntent; readonly admissionRequestedAt: string; readonly admissionDeadlineAt: string; readonly createdAt: string; @@ -911,10 +1099,22 @@ const make = Effect.gen(function* () { new Error(`Thread '${input.threadId}' was not found in read model.`), ); } - const requiresExactAdmission = thread.session?.status !== "running"; + const admissionIntent = input.admissionIntent; + const requiresExactAdmission = + admissionIntent === undefined + ? thread.session?.status !== "running" + : admissionIntent.kind !== "steer"; const admittedSession = yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, pendingTurnStart: requiresExactAdmission, + ...(admissionIntent === undefined + ? {} + : { + expectedProviderInstanceId: admissionIntent.expectedProviderInstanceId, + expectedSessionIncarnationId: admissionIntent.expectedSessionIncarnationId, + }), ...(requiresExactAdmission ? { pendingTurnRequestId: input.requestId, @@ -931,9 +1131,6 @@ const make = Effect.gen(function* () { detail: `Turn admission '${input.requestId}' was superseded before provider binding completed.`, }); } - if (input.modelSelection !== undefined) { - threadModelSelections.set(input.threadId, input.modelSelection); - } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService @@ -941,27 +1138,38 @@ const make = Effect.gen(function* () { .pipe( Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)), ); - const sessionModelSwitch = - activeSession === undefined - ? "in-session" - : activeSession.providerInstanceId === undefined - ? yield* new ProviderAdapterRequestError({ - provider: providerErrorLabel(activeSession.provider), - method: "thread.turn.start", - detail: `Active provider session '${activeSession.threadId}' is missing a provider instance id.`, - }) - : (yield* providerService.getCapabilities(activeSession.providerInstanceId)) - .sessionModelSwitch; + const admittedInstanceId = admittedSession.providerInstanceId; + if (admittedInstanceId === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(admittedSession.provider), + method: "thread.turn.start", + detail: `Admitted provider session '${admittedSession.threadId}' is missing a provider instance id.`, + }); + } const requestedModelSelection = - input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; + input.admissionIntent?.targetModelSelection ?? + input.modelSelection ?? + threadModelSelections.get(input.threadId) ?? + thread.modelSelection; + if (requestedModelSelection.instanceId !== admittedInstanceId) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(admittedInstanceId)), + method: "thread.turn.start", + detail: `Thread '${input.threadId}' is bound to provider instance '${admittedInstanceId}', but its requested model selection belongs to '${requestedModelSelection.instanceId}'. Start a new thread or explicitly select a model for the bound provider.`, + }); + } + threadModelSelections.set(input.threadId, requestedModelSelection); + const sessionModelSwitch = (yield* providerService.getCapabilities(admittedInstanceId)) + .sessionModelSwitch; const modelForTurn = - sessionModelSwitch === "unsupported" && input.modelSelection === undefined - ? activeSession?.model !== undefined - ? { - ...requestedModelSelection, - model: activeSession.model, - } - : requestedModelSelection + sessionModelSwitch === "unsupported" && + input.modelSelection === undefined && + activeSession?.providerInstanceId === admittedInstanceId && + activeSession.model !== undefined + ? { + ...requestedModelSelection, + model: activeSession.model, + } : input.modelSelection; return { @@ -971,9 +1179,11 @@ const make = Effect.gen(function* () { ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), admissionRequestId: - requiresExactAdmission || thread.session?.activeTurnRequestId === undefined - ? input.requestId - : thread.session.activeTurnRequestId, + input.admissionIntent?.kind === "steer" + ? (input.admissionIntent.expectedActiveTurnRequestId ?? input.requestId) + : requiresExactAdmission || thread.session?.activeTurnRequestId === undefined + ? input.requestId + : thread.session.activeTurnRequestId, sessionIncarnationId: admittedSession.sessionIncarnationId!, }; }); @@ -1365,6 +1575,7 @@ const make = Effect.gen(function* () { Effect.forkScoped, ); + const admissionStopToken = admissionStopTokenForRequest(requestId); const admissionEffect = Effect.gen(function* () { yield* ensureThreadWorktree(thread); const isFirstUserMessageTurn = @@ -1402,22 +1613,40 @@ const make = Effect.gen(function* () { ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), + runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, requestId, messageId: event.payload.messageId, + ...(event.payload.admissionIntent === undefined + ? {} + : { admissionIntent: event.payload.admissionIntent }), admissionRequestedAt, admissionDeadlineAt, createdAt: event.payload.createdAt, }); + if (admissionStopTokens.get(requestId) !== admissionStopToken) { + return yield* Effect.interrupt; + } return yield* providerService.sendTurn(sendTurnRequest); }); - const admissionFiber = yield* admissionEffect.pipe(Effect.forkDetach); + // Event handlers detach provider work so Stop can invalidate a slow start + // immediately. The keyed permit still preserves accepted event order for + // the same thread: an earlier steer reaches the provider before a later + // settings transition starts. + const orderedAdmissionEffect = admissionPermitForThread(event.payload.threadId).withPermit( + admissionEffect, + ); + const admissionFiber = yield* orderedAdmissionEffect.pipe(Effect.forkDetach); admissionFibers.set(requestId, admissionFiber); + admissionFiberThreads.set(requestId, event.payload.threadId); yield* Fiber.await(admissionFiber).pipe( Effect.ensuring( Effect.sync(() => { if (admissionFibers.get(requestId) === admissionFiber) { admissionFibers.delete(requestId); + admissionFiberThreads.delete(requestId); + admissionStopTokens.delete(requestId); + releaseAdmissionLaneIfIdle(event.payload.threadId); } }), ), @@ -1428,7 +1657,10 @@ const make = Effect.gen(function* () { ); const superviseAdmission = Effect.gen(function* () { - if (thread.session?.status === "running") { + if ( + event.payload.admissionIntent?.kind === "steer" || + (event.payload.admissionIntent === undefined && thread.session?.status === "running") + ) { const steeringExit = yield* Fiber.await(admissionFiber); yield* Fiber.interrupt(watchdog); if (Exit.isFailure(steeringExit) && !Cause.hasInterruptsOnly(steeringExit.cause)) { @@ -1437,7 +1669,7 @@ const make = Effect.gen(function* () { kind: "provider.turn.start.failed", summary: "Provider turn start failed", detail: formatFailureDetail(steeringExit.cause), - turnId: thread.session.activeTurnId, + turnId: thread.session?.activeTurnId ?? null, createdAt: yield* nowIso, requestId, }); @@ -1715,44 +1947,147 @@ const make = Effect.gen(function* () { }, ); - const processSessionStopRequested = Effect.fn("processSessionStopRequested")(function* ( - event: Extract, + type PendingSessionStopTarget = { + readonly threadId: ThreadId; + readonly stopRequestId: CommandId; + readonly providerInstanceId: ProviderInstanceId | null; + readonly sessionIncarnationId: NonNullable | null; + readonly turnRequestId: CommandId | null; + readonly turnId: TurnId | null; + readonly createdAt: string; + }; + + const sessionRequestId = (session: OrchestrationSession | null | undefined) => + session?.pendingTurnRequestId ?? + session?.activeTurnRequestId ?? + session?.failedTurnRequestId ?? + null; + + const pendingStopTargetIsCurrent = ( + session: OrchestrationSession | null | undefined, + target: PendingSessionStopTarget, + ) => + session?.pendingStopRequestId === target.stopRequestId && + (session.pendingStopProviderInstanceId ?? null) === target.providerInstanceId && + (session.pendingStopSessionIncarnationId ?? null) === target.sessionIncarnationId && + (session.pendingStopTurnRequestId ?? null) === target.turnRequestId && + (session.pendingStopTurnId ?? null) === target.turnId; + + const clearPendingSessionStop = Effect.fn("clearPendingSessionStop")(function* ( + target: PendingSessionStopTarget, ) { - const thread = yield* resolveThread(event.payload.threadId); - if (!thread) { + const latestThread = yield* resolveThread(target.threadId); + const latestSession = latestThread?.session; + if (!latestThread || !latestSession || !pendingStopTargetIsCurrent(latestSession, target)) { return; } - - const now = event.payload.createdAt; - if (thread.session && thread.session.status !== "stopped") { - yield* providerService.stopSession({ threadId: thread.id }); - } - + const targetStillOwnsProjectedSession = + latestSession.status === "stopped" && + (latestSession.providerInstanceId ?? null) === target.providerInstanceId && + (latestSession.sessionIncarnationId ?? null) === target.sessionIncarnationId && + sessionRequestId(latestSession) === target.turnRequestId; + const clearedAt = + latestSession.updatedAt > target.createdAt ? latestSession.updatedAt : target.createdAt; yield* applyThreadSessionLifecycle({ - threadId: thread.id, - expectedSession: thread.session, + threadId: target.threadId, + expectedSession: latestSession, allowFailedTurnRequestClear: true, session: { - threadId: thread.id, - status: "stopped", - providerName: thread.session?.providerName ?? null, - ...(thread.session?.providerInstanceId !== undefined - ? { providerInstanceId: thread.session.providerInstanceId } - : {}), - runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, - ...(thread.session?.restored === true ? { restored: true } : {}), - ...(thread.session?.startedAt !== undefined ? { startedAt: thread.session.startedAt } : {}), - ...(thread.session?.harnessRefinementStatus !== undefined - ? { harnessRefinementStatus: thread.session.harnessRefinementStatus } + ...latestSession, + ...(targetStillOwnsProjectedSession + ? { + pendingTurnRequestId: undefined, + pendingTurnMessageId: undefined, + pendingTurnRequestedAt: undefined, + pendingTurnDeadlineAt: undefined, + pendingTurnSessionId: undefined, + activeTurnRequestId: undefined, + failedTurnRequestId: undefined, + activeTurnId: null, + } : {}), - activeTurnId: null, - lastError: thread.session?.lastError ?? null, - updatedAt: now, + pendingStopRequestId: undefined, + pendingStopProviderInstanceId: undefined, + pendingStopSessionIncarnationId: undefined, + pendingStopTurnRequestId: undefined, + pendingStopTurnId: undefined, + updatedAt: clearedAt, }, - createdAt: now, + createdAt: clearedAt, + }); + }); + + const stopPendingSessionTarget = Effect.fn("stopPendingSessionTarget")(function* ( + target: PendingSessionStopTarget, + ) { + const currentThread = yield* resolveThread(target.threadId); + const currentSession = currentThread?.session; + // A later Start can coexist with the old target. Never invalidate that new + // reservation; the exact provider identity below still cleans only the old + // runtime. A still-projected stopped target owns the original reservation. + const invalidateStartReservation = + currentSession?.status === "stopped" && pendingStopTargetIsCurrent(currentSession, target); + yield* providerService.stopSession({ + threadId: target.threadId, + expectedProviderInstanceId: target.providerInstanceId, + expectedSessionIncarnationId: target.sessionIncarnationId, + expectedAdmissionRequestId: target.turnRequestId, + invalidateStartReservation, }); + yield* clearPendingSessionStop(target); + releaseAdmissionLaneIfIdle(target.threadId); }); + const processSessionStopRequested = Effect.fn("processSessionStopRequested")( + function* (event: Extract) { + const targetRequestId = event.payload.targetTurnRequestId ?? null; + // Invalidate only the admission captured by the persisted stop intent. + // A later Start has a different globally unique request id and token. + if (targetRequestId !== null && admissionStopTokens.has(targetRequestId)) { + admissionStopTokens.set(targetRequestId, makeAdmissionStopToken()); + } + if (targetRequestId !== null) { + const targetFiber = admissionFibers.get(targetRequestId); + if ( + targetFiber !== undefined && + admissionFiberThreads.get(targetRequestId) === event.payload.threadId + ) { + targetFiber.interruptUnsafe(); + } + } + if (event.commandId === null) { + // Legacy stop events have no durable pending-stop marker. Keep their + // prior exact-stop behavior, but never manufacture a cleanup target. + yield* providerService.stopSession({ + threadId: event.payload.threadId, + ...(event.payload.targetProviderInstanceId !== undefined || + event.payload.targetSessionIncarnationId !== undefined || + event.payload.targetTurnRequestId !== undefined + ? { + expectedProviderInstanceId: event.payload.targetProviderInstanceId ?? null, + expectedSessionIncarnationId: event.payload.targetSessionIncarnationId ?? null, + expectedAdmissionRequestId: targetRequestId, + } + : {}), + }); + return; + } + yield* stopPendingSessionTarget({ + threadId: event.payload.threadId, + stopRequestId: event.commandId, + providerInstanceId: event.payload.targetProviderInstanceId ?? null, + sessionIncarnationId: event.payload.targetSessionIncarnationId ?? null, + turnRequestId: targetRequestId, + turnId: event.payload.targetTurnId ?? null, + createdAt: event.payload.createdAt, + }); + }, + (effect, event) => + effect.pipe( + Effect.ensuring(Effect.sync(() => releaseAdmissionLaneIfIdle(event.payload.threadId))), + ), + ); + const processDomainEvent = Effect.fn("processDomainEvent")(function* ( event: ProviderIntentEvent, ) { @@ -1807,6 +2142,29 @@ const make = Effect.gen(function* () { } }); + const reconcilePendingSessionStops = Effect.fn("reconcilePendingSessionStops")(function* () { + const readModel = yield* projectionSnapshotQuery.getCommandReadModel(); + const pendingTargets = readModel.threads.flatMap((thread) => { + const session = thread.session; + if (session?.pendingStopRequestId === undefined) return []; + return [ + { + threadId: thread.id, + stopRequestId: session.pendingStopRequestId, + providerInstanceId: session.pendingStopProviderInstanceId ?? null, + sessionIncarnationId: session.pendingStopSessionIncarnationId ?? null, + turnRequestId: session.pendingStopTurnRequestId ?? null, + turnId: session.pendingStopTurnId ?? null, + createdAt: session.updatedAt, + } satisfies PendingSessionStopTarget, + ]; + }); + yield* Effect.forEach(pendingTargets, stopPendingSessionTarget, { + concurrency: PROVIDER_TURN_RECONCILIATION_CONCURRENCY, + discard: true, + }); + }); + const reconcileOverdueTurnAdmissions = Effect.fn("reconcileOverdueTurnAdmissions")(function* () { const pendingAdmissions = yield* ( projectionSnapshotQuery.listPendingTurnAdmissions?.() ?? Effect.succeed([]) @@ -1998,22 +2356,33 @@ const make = Effect.gen(function* () { ); }), ); - const reconcileAdmissions = reconcileOverdueTurnAdmissions().pipe( + const reconcileStopsThenAdmissions = reconcilePendingSessionStops().pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt; return Effect.logWarning( - "provider command reactor failed to reconcile overdue turn admissions", + "provider command reactor failed to reconcile pending session stops", { cause: Cause.pretty(cause) }, ); }), + Effect.andThen( + reconcileOverdueTurnAdmissions().pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt; + return Effect.logWarning( + "provider command reactor failed to reconcile overdue turn admissions", + { cause: Cause.pretty(cause) }, + ); + }), + ), + ), ); const activation = yield* ServerActivation; if (activation === undefined) { yield* clearInterrupted; - yield* reconcileAdmissions; + yield* reconcileStopsThenAdmissions; } else { yield* forkParked(clearInterrupted); - yield* forkParked(reconcileAdmissions); + yield* forkParked(reconcileStopsThenAdmissions); } }); @@ -2023,6 +2392,12 @@ const make = Effect.gen(function* () { yield* worker.drain; yield* threadTitleRegenerationWorker.drain; }), + getAdmissionTrackingCounts: () => ({ + fibers: admissionFibers.size, + fiberThreads: admissionFiberThreads.size, + stopTokens: admissionStopTokens.size, + permits: admissionPermits.size, + }), } satisfies ProviderCommandReactorShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index e94b39a85..f80fc543d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -895,6 +895,13 @@ describe("ProviderRuntimeIngestion", () => { threadId, requestId, messageId, + expectedProviderInstanceId: null, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, session: { threadId, status: "starting", @@ -1183,6 +1190,13 @@ describe("ProviderRuntimeIngestion", () => { threadId, requestId, messageId, + expectedProviderInstanceId: null, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, session: { threadId, status: "starting", @@ -1559,15 +1573,180 @@ describe("ProviderRuntimeIngestion", () => { }, }); - thread = await waitForThread( - harness.readModel, - (entry) => - entry.session?.status === "ready" && - entry.session?.activeTurnId === null && - entry.session?.lastError === null, - ); - expect(thread.session?.status).toBe("ready"); - expect(thread.session?.lastError).toBeNull(); + await harness.drain(); + thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + )!; + expect(thread.session?.status).toBe("stopped"); + expect(thread.session?.lastError).toBe("provider crashed"); + }); + + it("rejects same-incarnation lifecycle and targeted turn events behind a durable stop", async () => { + const harness = await createHarness(); + const threadId = ThreadId.make("thread-1"); + const instanceId = ProviderInstanceId.make("codex"); + const incarnationId = RuntimeSessionId.make("session-stopped-barrier"); + const turnRequestId = CommandId.make("cmd-stopped-barrier-turn"); + const turnId = TurnId.make("turn-stopped-barrier"); + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-stopped-barrier-seed"), + threadId, + session: { + threadId, + status: "stopped", + providerName: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + runtimeMode: "approval-required", + sessionIncarnationId: incarnationId, + activeTurnRequestId: turnRequestId, + pendingStopRequestId: CommandId.make("cmd-stopped-barrier-stop"), + pendingStopProviderInstanceId: instanceId, + pendingStopSessionIncarnationId: incarnationId, + pendingStopTurnRequestId: turnRequestId, + pendingStopTurnId: turnId, + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + + const eventBase = { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + sessionIncarnationId: incarnationId, + threadId, + admissionRequestId: turnRequestId, + turnId, + createdAt: "2026-01-01T00:00:02.000Z", + } as const; + harness.emit({ + ...eventBase, + type: "session.started", + eventId: EventId.make("evt-stopped-barrier-session-started"), + payload: {}, + }); + harness.emit({ + ...eventBase, + type: "thread.started", + eventId: EventId.make("evt-stopped-barrier-thread-started"), + payload: {}, + }); + harness.emit({ + ...eventBase, + type: "session.state.changed", + eventId: EventId.make("evt-stopped-barrier-state-ready"), + payload: { state: "ready" }, + }); + harness.emit({ + ...eventBase, + type: "turn.completed", + eventId: EventId.make("evt-stopped-barrier-turn-completed"), + status: "completed", + }); + harness.emit({ + ...eventBase, + type: "runtime.error", + eventId: EventId.make("evt-stopped-barrier-runtime-error"), + payload: { message: "late stopped runtime error" }, + }); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ + status: "stopped", + sessionIncarnationId: incarnationId, + pendingStopRequestId: CommandId.make("cmd-stopped-barrier-stop"), + activeTurnId: null, + lastError: null, + }); + expect( + thread?.activities.some((activity) => String(activity.id).startsWith("evt-stopped-barrier")), + ).toBe(false); + }); + + it("lets exact exit clear an old pending target without exposing a later incarnation", async () => { + const harness = await createHarness(); + const threadId = ThreadId.make("thread-1"); + const instanceId = ProviderInstanceId.make("codex"); + const oldIncarnationId = RuntimeSessionId.make("session-old-stop-target"); + const newIncarnationId = RuntimeSessionId.make("session-new-after-stop"); + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-new-session-with-old-stop-target"), + threadId, + session: { + threadId, + status: "ready", + providerName: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + runtimeMode: "approval-required", + sessionIncarnationId: newIncarnationId, + pendingStopRequestId: CommandId.make("cmd-old-stop-target"), + pendingStopProviderInstanceId: instanceId, + pendingStopSessionIncarnationId: oldIncarnationId, + pendingStopTurnRequestId: CommandId.make("cmd-old-stop-turn"), + pendingStopTurnId: TurnId.make("turn-old-stop"), + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + + const oldEventBase = { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + sessionIncarnationId: oldIncarnationId, + threadId, + createdAt: "2026-01-01T00:00:02.000Z", + } as const; + harness.emit({ + ...oldEventBase, + type: "session.state.changed", + eventId: EventId.make("evt-old-stop-state-before-exit"), + payload: { state: "error", reason: "must not replace new session" }, + }); + harness.emit({ + ...oldEventBase, + type: "session.exited", + eventId: EventId.make("evt-old-stop-exit"), + payload: {}, + }); + await harness.drain(); + + let thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ + status: "ready", + sessionIncarnationId: newIncarnationId, + activeTurnId: null, + lastError: null, + }); + expect(thread?.session?.pendingStopRequestId).toBeUndefined(); + + harness.emit({ + ...oldEventBase, + type: "runtime.error", + eventId: EventId.make("evt-old-stop-error-after-exit"), + payload: { message: "late old error" }, + }); + await harness.drain(); + thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("ready"); + expect(thread?.session?.lastError).toBeNull(); + + harness.emit({ + ...oldEventBase, + sessionIncarnationId: newIncarnationId, + type: "session.state.changed", + eventId: EventId.make("evt-new-session-waiting"), + payload: { state: "waiting", reason: "waiting for approval" }, + }); + await harness.drain(); + thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("running"); + expect(thread?.session?.sessionIncarnationId).toBe(newIncarnationId); }); it("projects harness refinement lifecycle on the session incarnation", async () => { @@ -4981,7 +5160,7 @@ describe("ProviderRuntimeIngestion", () => { expect(completedPayload?.title).toBe("wait for codex review to finish"); }); - it("titles task completion from persisted activities after the description cache is swept", async () => { + it("rejects task completion after the exact session has exited", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -5031,21 +5210,16 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.readModel, (entry) => - entry.activities.some( + await harness.drain(); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect( + thread?.activities.some( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", ), - ); - - const completed = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", - ); - const completedPayload = - completed?.payload && typeof completed.payload === "object" - ? (completed.payload as Record) - : undefined; - - expect(completedPayload?.title).toBe("Watch round-3 CI and bots"); + ).toBe(false); + expect(thread?.session?.status).toBe("stopped"); }); it("projects structured user input request and resolution as thread activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index de6d528dd..c35103b55 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2159,6 +2159,75 @@ const make = Effect.gen(function* () { let thread = yield* resolveThreadShell(event.threadId); if (!thread) return; + const pendingStopSession = thread.session; + const eventMatchesPendingStop = + pendingStopSession?.pendingStopRequestId !== undefined && + (pendingStopSession.pendingStopProviderInstanceId ?? null) === + (event.providerInstanceId ?? null) && + (pendingStopSession.pendingStopSessionIncarnationId ?? null) === + (event.sessionIncarnationId ?? null); + if (eventMatchesPendingStop) { + // A Stop receipt is a durable lifecycle barrier. The exact old runtime + // may still flush events while its adapter shuts down, but only its + // terminal exit may finish cleanup. A later incarnation can coexist + // with this target and must remain otherwise untouched. + if (event.type !== "session.exited") return; + const stoppedLineageStillProjected = + pendingStopSession.status === "stopped" && + (pendingStopSession.providerInstanceId ?? null) === + (pendingStopSession.pendingStopProviderInstanceId ?? null) && + (pendingStopSession.sessionIncarnationId ?? null) === + (pendingStopSession.pendingStopSessionIncarnationId ?? null); + const cleared = yield* orchestrationEngine.dispatch({ + type: "thread.session.apply-lifecycle", + commandId: yield* providerCommandId(event, "pending-stop-exit-cleanup"), + threadId: thread.id, + expectedStatus: pendingStopSession.status, + expectedProviderInstanceId: pendingStopSession.providerInstanceId ?? null, + expectedSessionIncarnationId: pendingStopSession.sessionIncarnationId ?? null, + expectedPendingTurnRequestId: pendingStopSession.pendingTurnRequestId ?? null, + expectedPendingTurnSessionId: pendingStopSession.pendingTurnSessionId ?? null, + expectedActiveTurnRequestId: pendingStopSession.activeTurnRequestId ?? null, + expectedActiveTurnId: pendingStopSession.activeTurnId ?? null, + expectedFailedTurnRequestId: pendingStopSession.failedTurnRequestId ?? null, + expectedPendingStopRequestId: pendingStopSession.pendingStopRequestId, + expectedPendingStopSessionIncarnationId: + pendingStopSession.pendingStopSessionIncarnationId ?? null, + allowFailedTurnRequestClear: true, + session: { + ...pendingStopSession, + ...(stoppedLineageStillProjected + ? { + pendingTurnRequestId: undefined, + pendingTurnMessageId: undefined, + pendingTurnRequestedAt: undefined, + pendingTurnDeadlineAt: undefined, + pendingTurnSessionId: undefined, + activeTurnRequestId: undefined, + failedTurnRequestId: undefined, + activeTurnId: null, + } + : {}), + pendingStopRequestId: undefined, + pendingStopProviderInstanceId: undefined, + pendingStopSessionIncarnationId: undefined, + pendingStopTurnRequestId: undefined, + pendingStopTurnId: undefined, + updatedAt: event.createdAt, + }, + createdAt: event.createdAt, + }); + if ((cleared.eventCount ?? 0) > 0 && stoppedLineageStillProjected) { + yield* clearTurnStateForSession(thread.id); + } + return; + } + const eventMatchesStoppedSession = + pendingStopSession?.status === "stopped" && + (pendingStopSession.providerInstanceId ?? null) === (event.providerInstanceId ?? null) && + (pendingStopSession.sessionIncarnationId ?? null) === (event.sessionIncarnationId ?? null); + if (eventMatchesStoppedSession && event.type !== "session.exited") return; + if (thread.session?.failedTurnRequestId !== undefined) { // A failed admission quarantines the lineage even for legacy sessions // that do not have an incarnation id. Runtime start/state/output/item/ @@ -2282,6 +2351,9 @@ const make = Effect.gen(function* () { expectedActiveTurnRequestId: observed?.activeTurnRequestId ?? null, expectedActiveTurnId: observed?.activeTurnId ?? null, expectedFailedTurnRequestId: observed?.failedTurnRequestId ?? null, + expectedPendingStopRequestId: observed?.pendingStopRequestId ?? null, + expectedPendingStopSessionIncarnationId: + observed?.pendingStopSessionIncarnationId ?? null, session, createdAt: now, }); diff --git a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts index 65aa9949f..38cac04b2 100644 --- a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts @@ -30,6 +30,14 @@ export interface ProviderCommandReactorShape { * Intended for test use to replace timing-sensitive sleeps. */ readonly drain: Effect.Effect; + + /** Internal counts used by deterministic lifecycle tests. */ + readonly getAdmissionTrackingCounts?: () => { + readonly fibers: number; + readonly fiberThreads: number; + readonly stopTokens: number; + readonly permits: number; + }; } /** diff --git a/apps/server/src/orchestration/decider.sessionLifecycle.test.ts b/apps/server/src/orchestration/decider.sessionLifecycle.test.ts index 7f6eb3d4d..f8c0ffd6d 100644 --- a/apps/server/src/orchestration/decider.sessionLifecycle.test.ts +++ b/apps/server/src/orchestration/decider.sessionLifecycle.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, RuntimeSessionId, ThreadId, + TurnId, type OrchestrationReadModel, type OrchestrationSession, } from "@t3tools/contracts"; @@ -149,6 +150,56 @@ it.layer(NodeServices.layer)("session lifecycle CAS decider", (it) => { }), ); + it.effect( + "binds accepted provider settings and the compatible target session in one decision", + () => + Effect.gen(function* () { + const current = makeSession(); + const targetInstanceId = ProviderInstanceId.make("codex_personal"); + const acceptedSession = makeSession({ + providerInstanceId: targetInstanceId, + runtimeMode: "approval-required", + sessionIncarnationId: RuntimeSessionId.make("session-target"), + pendingTurnSessionId: RuntimeSessionId.make("session-target"), + }); + const command = { + type: "thread.session.bind-pending" as const, + commandId: CommandId.make("cmd-bind-compatible-target"), + threadId: THREAD_ID, + requestId: REQUEST_ID, + messageId: MESSAGE_ID, + expectedProviderInstanceId: INSTANCE_ID, + modelSelection: { + instanceId: targetInstanceId, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required" as const, + interactionMode: "plan" as const, + session: acceptedSession, + createdAt: NOW, + }; + + const accepted = yield* decideOrchestrationCommand({ + command, + readModel: makeReadModel(current), + }); + const events = Array.isArray(accepted) ? accepted : [accepted]; + expect(events.map((event) => event.type)).toEqual([ + "thread.meta-updated", + "thread.runtime-mode-set", + "thread.interaction-mode-set", + "thread.session-set", + ]); + + const stale = yield* decideOrchestrationCommand({ + command: { ...command, expectedProviderInstanceId: targetInstanceId }, + readModel: makeReadModel(current), + }); + expect(stale).toEqual([]); + }), + ); + it.effect("rejects a pending bind after that admission was quarantined", () => Effect.gen(function* () { const quarantined = makeSession({ @@ -162,6 +213,10 @@ it.layer(NodeServices.layer)("session lifecycle CAS decider", (it) => { threadId: THREAD_ID, requestId: REQUEST_ID, messageId: MESSAGE_ID, + expectedProviderInstanceId: INSTANCE_ID, + modelSelection: { instanceId: INSTANCE_ID, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", session: quarantined, createdAt: NOW, }, @@ -170,4 +225,252 @@ it.layer(NodeServices.layer)("session lifecycle CAS decider", (it) => { expect(result).toEqual([]); }), ); + + it.effect("keeps branch metadata while rejecting stale provider-shaped metadata", () => + Effect.gen(function* () { + const session = makeSession({ status: "ready" }); + const readModel = makeReadModel(session); + const branchUpdate = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-bound-branch-update"), + threadId: THREAD_ID, + modelSelection: { instanceId: INSTANCE_ID, model: "gpt-5.4" }, + branch: "fix/accepted-branch", + }, + readModel, + }); + const events = Array.isArray(branchUpdate) ? branchUpdate : [branchUpdate]; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "thread.meta-updated", + payload: { branch: "fix/accepted-branch" }, + }); + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload.modelSelection).toBeUndefined(); + } + + const staleModelFailure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-stale-model-update"), + threadId: THREAD_ID, + modelSelection: { + instanceId: INSTANCE_ID, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }, + readModel, + }), + ); + expect(staleModelFailure.message).toContain("validated turn transition"); + + const staleRuntimeFailure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-stale-runtime-update"), + threadId: THREAD_ID, + runtimeMode: "approval-required", + createdAt: NOW, + }, + readModel, + }), + ); + expect(staleRuntimeFailure.message).toContain("validated turn transition"); + + const staleInteractionFailure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-stale-interaction-update"), + threadId: THREAD_ID, + interactionMode: "plan", + createdAt: NOW, + }, + readModel, + }), + ); + expect(staleInteractionFailure.message).toContain("validated turn transition"); + }), + ); + + it.effect("accepts exact older provider-setting writes as no-event successes", () => + Effect.gen(function* () { + const readModel = makeReadModel(makeSession({ status: "ready" })); + expect( + yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-bound-model-idempotent"), + threadId: THREAD_ID, + modelSelection: { instanceId: INSTANCE_ID, model: "gpt-5.4" }, + }, + readModel, + }), + ).toEqual([]); + expect( + yield* decideOrchestrationCommand({ + command: { + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-bound-runtime-idempotent"), + threadId: THREAD_ID, + runtimeMode: "full-access", + createdAt: NOW, + }, + readModel, + }), + ).toEqual([]); + expect( + yield* decideOrchestrationCommand({ + command: { + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-bound-interaction-idempotent"), + threadId: THREAD_ID, + interactionMode: "default", + createdAt: NOW, + }, + readModel, + }), + ).toEqual([]); + }), + ); + + it.effect("requires exact admission for running settings changes but keeps plain steering", () => + Effect.gen(function* () { + const running = makeSession({ + status: "running", + pendingTurnRequestId: undefined, + pendingTurnMessageId: undefined, + pendingTurnRequestedAt: undefined, + pendingTurnDeadlineAt: undefined, + pendingTurnSessionId: undefined, + activeTurnRequestId: CommandId.make("request-active"), + activeTurnId: TurnId.make("turn-active"), + }); + const readModel = makeReadModel(running); + const settingsChange = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-running-settings-change"), + threadId: THREAD_ID, + message: { + messageId: MessageId.make("message-running-settings-change"), + role: "user", + text: "restart through exact admission", + attachments: [], + }, + modelSelection: { + instanceId: INSTANCE_ID, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel, + }); + const settingsEvents = Array.isArray(settingsChange) ? settingsChange : [settingsChange]; + expect(settingsEvents.map((event) => event.type)).toEqual([ + "thread.message-sent", + "thread.session-set", + "thread.turn-start-requested", + ]); + expect(settingsEvents[1]).toMatchObject({ + type: "thread.session-set", + payload: { + session: { + status: "starting", + providerInstanceId: INSTANCE_ID, + pendingTurnRequestId: CommandId.make("cmd-running-settings-change"), + }, + }, + }); + + const accepted = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-running-steer"), + threadId: THREAD_ID, + message: { + messageId: MessageId.make("message-running-steer"), + role: "user", + text: "steer with accepted settings", + attachments: [], + }, + modelSelection: { instanceId: INSTANCE_ID, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel, + }); + const events = Array.isArray(accepted) ? accepted : [accepted]; + expect(events.map((event) => event.type)).toEqual([ + "thread.message-sent", + "thread.turn-start-requested", + ]); + }), + ); + + it.effect("captures the exact stop target and projects stopped atomically", () => + Effect.gen(function* () { + const turnId = TurnId.make("turn-stop-target"); + const current = makeSession({ + status: "running", + pendingTurnRequestId: undefined, + pendingTurnMessageId: undefined, + pendingTurnRequestedAt: undefined, + pendingTurnDeadlineAt: undefined, + pendingTurnSessionId: undefined, + activeTurnRequestId: REQUEST_ID, + activeTurnId: turnId, + }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.stop", + commandId: CommandId.make("cmd-exact-session-stop"), + threadId: THREAD_ID, + createdAt: NOW, + }, + readModel: makeReadModel(current), + }); + const events = Array.isArray(decided) ? decided : [decided]; + + expect(events.map((event) => event.type)).toEqual([ + "thread.session-stop-requested", + "thread.session-set", + ]); + expect(events[0]).toMatchObject({ + type: "thread.session-stop-requested", + payload: { + targetProviderInstanceId: INSTANCE_ID, + targetSessionIncarnationId: INCARNATION_ID, + targetPendingTurnSessionId: null, + targetTurnRequestId: REQUEST_ID, + targetTurnId: turnId, + }, + }); + expect(events[1]).toMatchObject({ + type: "thread.session-set", + payload: { + session: { + status: "stopped", + providerInstanceId: INSTANCE_ID, + sessionIncarnationId: INCARNATION_ID, + activeTurnRequestId: REQUEST_ID, + pendingStopRequestId: CommandId.make("cmd-exact-session-stop"), + pendingStopProviderInstanceId: INSTANCE_ID, + pendingStopSessionIncarnationId: INCARNATION_ID, + pendingStopTurnRequestId: REQUEST_ID, + pendingStopTurnId: turnId, + activeTurnId: null, + }, + }, + }); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c13a26937..1a2e0c1fa 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -7,6 +7,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import type * as PlatformError from "effect/PlatformError"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -825,6 +826,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const boundInstanceId = thread.session?.providerInstanceId; + const providerSelectionIsBoundNoop = + command.modelSelection !== undefined && + boundInstanceId !== undefined && + Equal.equals(command.modelSelection, thread.modelSelection); + if ( + command.modelSelection !== undefined && + boundInstanceId !== undefined && + !providerSelectionIsBoundNoop + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' is bound to provider instance '${boundInstanceId}'. Provider model and options may change only through a validated turn transition.`, + }); + } + const hasNonProviderMetaUpdate = + command.title !== undefined || + command.regenerateTitle === true || + command.branch !== undefined || + command.worktreePath !== undefined || + command.linkedPullRequest !== undefined; + if (providerSelectionIsBoundNoop && !hasNonProviderMetaUpdate) { + return []; + } const branch = command.branch !== undefined && command.expectedBranch !== undefined && @@ -856,7 +881,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.title !== undefined && thread.titleRegeneration != null ? { titleRegeneration: null } : {}), - ...(command.modelSelection !== undefined + ...(command.modelSelection !== undefined && !providerSelectionIsBoundNoop ? { modelSelection: command.modelSelection } : {}), ...(branch !== undefined ? { branch } : {}), @@ -895,11 +920,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.runtime-mode.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + if (thread.session?.providerInstanceId !== undefined) { + if (thread.runtimeMode === command.runtimeMode) return []; + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' is bound to provider instance '${thread.session.providerInstanceId}'. Runtime mode may change only through a validated turn transition.`, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -918,11 +950,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.interaction-mode.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + if (thread.session?.providerInstanceId !== undefined) { + if (thread.interactionMode === command.interactionMode) return []; + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' is bound to provider instance '${thread.session.providerInstanceId}'. Interaction mode may change only through a validated turn transition.`, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -946,6 +985,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const effectiveModelSelection = command.modelSelection ?? targetThread.modelSelection; + const providerSettingsChanged = + !Equal.equals(effectiveModelSelection, targetThread.modelSelection) || + command.runtimeMode !== targetThread.runtimeMode || + command.interactionMode !== targetThread.interactionMode; const sourceProposedPlan = command.sourceProposedPlan; const sourceThread = sourceProposedPlan ? yield* requireThread({ @@ -1005,6 +1049,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: command.createdAt, }, }; + const admissionIntent = { + kind: + targetThread.session?.providerInstanceId !== undefined && + targetThread.session.providerInstanceId !== effectiveModelSelection.instanceId + ? ("compatible-transition" as const) + : targetThread.session?.status === "running" && !providerSettingsChanged + ? ("steer" as const) + : ("start" as const), + expectedProviderInstanceId: targetThread.session?.providerInstanceId ?? null, + expectedSessionIncarnationId: targetThread.session?.sessionIncarnationId ?? null, + expectedActiveTurnRequestId: targetThread.session?.activeTurnRequestId ?? null, + targetModelSelection: effectiveModelSelection, + }; const turnStartRequestedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", @@ -1017,12 +1074,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, messageId: command.message.messageId, - ...(command.modelSelection !== undefined - ? { modelSelection: command.modelSelection } - : {}), + modelSelection: effectiveModelSelection, + admissionIntent, ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), - runtimeMode: targetThread.runtimeMode, - interactionMode: targetThread.interactionMode, + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), admissionRequestedAt, admissionDeadlineAt, @@ -1030,9 +1086,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, }; const admissionPendingEvents: Array> = []; - if (targetThread.session?.status !== "running") { - const desiredInstanceId = - command.modelSelection?.instanceId ?? targetThread.modelSelection.instanceId; + if (targetThread.session?.status !== "running" || providerSettingsChanged) { admissionPendingEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", @@ -1049,8 +1103,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" providerName: null, }), status: "starting", - providerInstanceId: targetThread.session?.providerInstanceId ?? desiredInstanceId, - runtimeMode: command.runtimeMode, + ...(targetThread.session?.providerInstanceId !== undefined + ? { providerInstanceId: targetThread.session.providerInstanceId } + : {}), + runtimeMode: targetThread.session?.runtimeMode ?? targetThread.runtimeMode, pendingTurnRequestId: command.commandId, pendingTurnMessageId: command.message.messageId, pendingTurnRequestedAt: admissionRequestedAt, @@ -1443,7 +1499,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ); } } - return { + const targetSession = thread.session; + const stopRequestedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -1453,9 +1510,56 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.session-stop-requested", payload: { threadId: command.threadId, + targetProviderInstanceId: targetSession?.providerInstanceId ?? null, + targetSessionIncarnationId: targetSession?.sessionIncarnationId ?? null, + targetPendingTurnSessionId: targetSession?.pendingTurnSessionId ?? null, + targetTurnRequestId: + targetSession?.pendingTurnRequestId ?? + targetSession?.activeTurnRequestId ?? + targetSession?.failedTurnRequestId ?? + null, + targetTurnId: targetSession?.activeTurnId ?? null, createdAt: command.createdAt, }, }; + // Persist the reverse transition and exact cleanup target in the same + // transaction as the stop intent. `stopped` is understood by frozen + // clients; the additive pending-stop fields are owned by the server and + // survive a restart until exact provider cleanup finishes. + const stoppedEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + causationEventId: stopRequestedEvent.eventId, + type: "thread.session-set", + payload: { + threadId: command.threadId, + session: { + ...(targetSession ?? { + threadId: command.threadId, + providerName: null, + runtimeMode: thread.runtimeMode, + lastError: null, + }), + status: "stopped", + pendingStopRequestId: command.commandId, + pendingStopProviderInstanceId: targetSession?.providerInstanceId ?? null, + pendingStopSessionIncarnationId: targetSession?.sessionIncarnationId ?? null, + pendingStopTurnRequestId: + targetSession?.pendingTurnRequestId ?? + targetSession?.activeTurnRequestId ?? + targetSession?.failedTurnRequestId ?? + null, + pendingStopTurnId: targetSession?.activeTurnId ?? null, + activeTurnId: null, + updatedAt: command.createdAt, + }, + }, + }; + return [stopRequestedEvent, stoppedEvent]; } case "thread.session.apply-lifecycle": { @@ -1473,7 +1577,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" (current?.pendingTurnSessionId ?? null) === command.expectedPendingTurnSessionId && (current?.activeTurnRequestId ?? null) === command.expectedActiveTurnRequestId && (current?.activeTurnId ?? null) === command.expectedActiveTurnId && - (current?.failedTurnRequestId ?? null) === command.expectedFailedTurnRequestId; + (current?.failedTurnRequestId ?? null) === command.expectedFailedTurnRequestId && + (command.expectedPendingStopRequestId === undefined || + (current?.pendingStopRequestId ?? null) === command.expectedPendingStopRequestId) && + (command.expectedPendingStopSessionIncarnationId === undefined || + (current?.pendingStopSessionIncarnationId ?? null) === + command.expectedPendingStopSessionIncarnationId); if (!lineageIsCurrent) return []; if ( current?.failedTurnRequestId !== undefined && @@ -1527,16 +1636,69 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.session?.status !== "starting" || thread.session.pendingTurnRequestId !== command.requestId || thread.session.pendingTurnMessageId !== command.messageId || + (thread.session.providerInstanceId ?? null) !== command.expectedProviderInstanceId || thread.session.failedTurnRequestId !== undefined || thread.session.activeTurnId !== null || command.session.pendingTurnRequestId !== command.requestId || command.session.pendingTurnMessageId !== command.messageId || + command.session.providerInstanceId !== command.modelSelection.instanceId || + command.session.runtimeMode !== command.runtimeMode || command.session.sessionIncarnationId === undefined || command.session.pendingTurnSessionId !== command.session.sessionIncarnationId ) { return []; } - return { + + const acceptedEvents: Array> = []; + if (!Equal.equals(thread.modelSelection, command.modelSelection)) { + acceptedEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + modelSelection: command.modelSelection, + updatedAt: command.createdAt, + }, + }); + } + if (thread.runtimeMode !== command.runtimeMode) { + acceptedEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.runtime-mode-set", + payload: { + threadId: command.threadId, + runtimeMode: command.runtimeMode, + updatedAt: command.createdAt, + }, + }); + } + if (thread.interactionMode !== command.interactionMode) { + acceptedEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.interaction-mode-set", + payload: { + threadId: command.threadId, + interactionMode: command.interactionMode, + updatedAt: command.createdAt, + }, + }); + } + acceptedEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -1548,7 +1710,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, session: command.session, }, - }; + }); + return acceptedEvents; } case "thread.session.set": { diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index 75bbd04e8..0a84b05f6 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -54,6 +54,11 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { pending_turn_session_id, active_turn_request_id, failed_turn_request_id, + pending_stop_request_id, + pending_stop_provider_instance_id, + pending_stop_session_incarnation_id, + pending_stop_turn_request_id, + pending_stop_turn_id, active_turn_id, last_error, updated_at @@ -76,6 +81,11 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.pendingTurnSessionId}, ${row.activeTurnRequestId}, ${row.failedTurnRequestId}, + ${row.pendingStopRequestId}, + ${row.pendingStopProviderInstanceId}, + ${row.pendingStopSessionIncarnationId}, + ${row.pendingStopTurnRequestId}, + ${row.pendingStopTurnId}, ${row.activeTurnId}, ${row.lastError}, ${row.updatedAt} @@ -98,6 +108,11 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { pending_turn_session_id = excluded.pending_turn_session_id, active_turn_request_id = excluded.active_turn_request_id, failed_turn_request_id = excluded.failed_turn_request_id, + pending_stop_request_id = excluded.pending_stop_request_id, + pending_stop_provider_instance_id = excluded.pending_stop_provider_instance_id, + pending_stop_session_incarnation_id = excluded.pending_stop_session_incarnation_id, + pending_stop_turn_request_id = excluded.pending_stop_turn_request_id, + pending_stop_turn_id = excluded.pending_stop_turn_id, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, updated_at = excluded.updated_at @@ -127,6 +142,11 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { pending_turn_session_id AS "pendingTurnSessionId", active_turn_request_id AS "activeTurnRequestId", failed_turn_request_id AS "failedTurnRequestId", + pending_stop_request_id AS "pendingStopRequestId", + pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + pending_stop_turn_request_id AS "pendingStopTurnRequestId", + pending_stop_turn_id AS "pendingStopTurnId", active_turn_id AS "activeTurnId", last_error AS "lastError", updated_at AS "updatedAt" diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index e7bdac271..54cecd0fe 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -60,6 +60,7 @@ import Migration0045 from "./Migrations/045_AuthSessionClientConnection.ts"; import Migration0046 from "./Migrations/046_ProjectionThreadLinkedPullRequest.ts"; import Migration0047 from "./Migrations/047_ProjectionThreadsUnsettledAt.ts"; import Migration0048 from "./Migrations/048_ProjectionThreadSessionPendingTurnRequest.ts"; +import Migration0049 from "./Migrations/049_ProjectionThreadSessionPendingStop.ts"; /** * Migration loader with all migrations defined inline. * @@ -142,6 +143,7 @@ export const migrationEntries = [ // `unsettled_at` column. [47, "ProjectionThreadsUnsettledAt", Migration0047], [48, "ProjectionThreadSessionPendingTurnRequest", Migration0048], + [49, "ProjectionThreadSessionPendingStop", Migration0049], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts b/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts index 88a975c13..821a71c42 100644 --- a/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts +++ b/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts @@ -229,6 +229,9 @@ layer("048_ProjectionThreadSessionPendingTurnRequest", (it) => { DELETE FROM projection_state WHERE projector IN ('projection.thread-sessions', 'projection.thread-turns') `; + // The current projector repository reads the additive pending-stop + // columns introduced immediately after this historical migration. + yield* runMigrations({ toMigrationInclusive: 49 }); yield* projectionPipeline.bootstrap; assert.deepStrictEqual(yield* readPendingSessionRows, migrated); }), diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts new file mode 100644 index 000000000..7c6fcf62c --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts @@ -0,0 +1,114 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +it.layer(NodeServices.layer)("049_ProjectionThreadSessionPendingStop", (it) => { + it.effect("adds the exact pending-stop target columns and remains idempotent", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "pylon-migration-049-", + }); + const databaseLayer = NodeSqliteClient.layer({ filename: path.join(root, "state.sqlite") }); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 48 }); + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_instance_id, + runtime_mode, + restored, + session_incarnation_id, + active_turn_id, + last_error, + updated_at + ) VALUES ( + 'thread-pending-stop', + 'stopped', + 'codex', + 'codex', + 'approval-required', + 0, + 'session-pending-stop', + NULL, + NULL, + '2026-01-01T00:00:00.000Z' + ) + `; + + const executed = yield* runMigrations({ toMigrationInclusive: 49 }); + assert.deepStrictEqual(executed, [[49, "ProjectionThreadSessionPendingStop"]]); + const columns = yield* sql<{ readonly name: string; readonly type: string }>` + SELECT name, type + FROM pragma_table_info('projection_thread_sessions') + WHERE name LIKE 'pending_stop_%' + ORDER BY cid ASC + `; + assert.deepStrictEqual(columns, [ + { name: "pending_stop_request_id", type: "TEXT" }, + { name: "pending_stop_provider_instance_id", type: "TEXT" }, + { name: "pending_stop_session_incarnation_id", type: "TEXT" }, + { name: "pending_stop_turn_request_id", type: "TEXT" }, + { name: "pending_stop_turn_id", type: "TEXT" }, + ]); + const indexes = yield* sql<{ readonly name: string }>` + SELECT name + FROM pragma_index_list('projection_thread_sessions') + WHERE name = 'idx_projection_thread_sessions_pending_stop' + `; + assert.deepStrictEqual(indexes, [{ name: "idx_projection_thread_sessions_pending_stop" }]); + yield* sql` + UPDATE projection_thread_sessions + SET + pending_stop_request_id = 'cmd-stop', + pending_stop_provider_instance_id = 'codex', + pending_stop_session_incarnation_id = 'session-pending-stop', + pending_stop_turn_request_id = 'cmd-turn', + pending_stop_turn_id = 'turn-1' + WHERE thread_id = 'thread-pending-stop' + `; + }).pipe(Effect.provide(databaseLayer), Effect.scoped); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + assert.deepStrictEqual(yield* runMigrations({ toMigrationInclusive: 49 }), []); + const rows = yield* sql<{ + readonly requestId: string; + readonly providerInstanceId: string; + readonly sessionIncarnationId: string; + readonly turnRequestId: string; + readonly turnId: string; + }>` + SELECT + pending_stop_request_id AS "requestId", + pending_stop_provider_instance_id AS "providerInstanceId", + pending_stop_session_incarnation_id AS "sessionIncarnationId", + pending_stop_turn_request_id AS "turnRequestId", + pending_stop_turn_id AS "turnId" + FROM projection_thread_sessions + WHERE thread_id = 'thread-pending-stop' + `; + assert.deepStrictEqual(rows, [ + { + requestId: "cmd-stop", + providerInstanceId: "codex", + sessionIncarnationId: "session-pending-stop", + turnRequestId: "cmd-turn", + turnId: "turn-1", + }, + ]); + }).pipe(Effect.provide(databaseLayer), Effect.scoped); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.ts new file mode 100644 index 000000000..9befb5838 --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +const pendingStopColumns = [ + ["pending_stop_request_id", "TEXT"], + ["pending_stop_provider_instance_id", "TEXT"], + ["pending_stop_session_incarnation_id", "TEXT"], + ["pending_stop_turn_request_id", "TEXT"], + ["pending_stop_turn_id", "TEXT"], +] as const; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_sessions) + `; + const existing = new Set(columns.map((column) => column.name)); + for (const [name, type] of pendingStopColumns) { + if (!existing.has(name)) { + yield* sql.unsafe(`ALTER TABLE projection_thread_sessions ADD COLUMN ${name} ${type}`); + } + } + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_sessions_pending_stop + ON projection_thread_sessions(pending_stop_request_id, thread_id) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 801e96bab..ac2e7c940 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -44,6 +44,11 @@ export const ProjectionThreadSession = Schema.Struct({ pendingTurnSessionId: Schema.NullOr(RuntimeSessionId), activeTurnRequestId: Schema.NullOr(CommandId), failedTurnRequestId: Schema.NullOr(CommandId), + pendingStopRequestId: Schema.NullOr(CommandId), + pendingStopProviderInstanceId: Schema.NullOr(ProviderInstanceId), + pendingStopSessionIncarnationId: Schema.NullOr(RuntimeSessionId), + pendingStopTurnRequestId: Schema.NullOr(CommandId), + pendingStopTurnId: Schema.NullOr(TurnId), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), updatedAt: IsoDateTime, diff --git a/apps/server/src/provider/Drivers/PrimeAgentDriver.test.ts b/apps/server/src/provider/Drivers/PrimeAgentDriver.test.ts index 545b918fa..58223aa3c 100644 --- a/apps/server/src/provider/Drivers/PrimeAgentDriver.test.ts +++ b/apps/server/src/provider/Drivers/PrimeAgentDriver.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; -import { PrimeAgentDriver } from "./PrimeAgentDriver.ts"; +import { isPrimeAgentProviderPlatformSupported, PrimeAgentDriver } from "./PrimeAgentDriver.ts"; describe("PrimeAgentDriver", () => { it("registers one global Prime Agent driver with contract defaults", () => { @@ -21,4 +21,11 @@ describe("PrimeAgentDriver", () => { PrimeAgentDriver, ]); }); + + it("supports macOS, Linux, and WSL2's Linux runtime only", () => { + expect(isPrimeAgentProviderPlatformSupported("darwin")).toBe(true); + expect(isPrimeAgentProviderPlatformSupported("linux")).toBe(true); + expect(isPrimeAgentProviderPlatformSupported("win32")).toBe(false); + expect(isPrimeAgentProviderPlatformSupported("freebsd")).toBe(false); + }); }); diff --git a/apps/server/src/provider/Drivers/PrimeAgentDriver.ts b/apps/server/src/provider/Drivers/PrimeAgentDriver.ts index 8cfa2f8a7..d7a0ec10b 100644 --- a/apps/server/src/provider/Drivers/PrimeAgentDriver.ts +++ b/apps/server/src/provider/Drivers/PrimeAgentDriver.ts @@ -53,6 +53,19 @@ import { const decodePrimeAgentSettings = Schema.decodeSync(PrimeAgentSettings); const DRIVER_KIND = ProviderDriverKind.make("primeAgent"); +export const PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE = + "Prime Agent is unavailable because this Pylon server is running on native Windows. Run the Pylon server and Prime Agent in WSL2, or connect this client to a Pylon server running in WSL2 or another remote environment."; + +export function isPrimeAgentProviderPlatformSupported(platform: NodeJS.Platform): boolean { + return platform === "darwin" || platform === "linux"; +} + +function unsupportedPlatformMessage(platform: NodeJS.Platform): string { + return platform === "win32" + ? PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE + : `Prime Agent is unavailable on '${platform}'. Run the Pylon server and Prime Agent on macOS, Linux, or WSL2.`; +} + const UPDATE = makeStaticProviderMaintenanceResolver( makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, @@ -97,8 +110,15 @@ export const PrimeAgentDriver: ProviderDriver decodePrimeAgentSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; + if (!isPrimeAgentProviderPlatformSupported(hostPlatform)) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: unsupportedPlatformMessage(hostPlatform), + }); + } + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -221,7 +241,7 @@ export const PrimeAgentDriver: ProviderDriver Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), + removeExact: () => Effect.succeed(false), listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 5bee84b61..92036c34c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -407,6 +407,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), + removeExact: () => Effect.succeed(false), listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 524b35d5d..70584723d 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -30,15 +30,20 @@ import { type CursorSettings, type GrokSettings, type OpenCodeSettings, + type PrimeAgentSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, + TextGenerationError, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import { createModelSelection } from "@t3tools/shared/model"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; @@ -49,10 +54,18 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import { + PrimeAgentDriver, + PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE, +} from "../Drivers/PrimeAgentDriver.ts"; +import { ProviderDriverError, ProviderUnsupportedError } from "../Errors.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import { makeProviderAdapterRegistry } from "./ProviderAdapterRegistry.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { makeTextGenerationFromRegistry } from "../../textGeneration/TextGeneration.ts"; const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, @@ -136,6 +149,15 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makePrimeAgentConfig = (overrides: Partial): PrimeAgentSettings => ({ + enabled: true, + binaryPath: "prime-agent", + agentHomePath: "", + launchArgs: "", + customModels: [], + ...overrides, +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -292,6 +314,122 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { expect(ghost.unavailableReason).toMatch(/ghostDriver/); }).pipe(Effect.provide(testLayer)), ); + + it.live("fails native Windows Prime closed across every routed entry point", () => { + let processCalls = 0; + let networkProbeCalls = 0; + const trackingSpawner = ChildProcessSpawner.make(() => + Effect.sync(() => { + processCalls += 1; + throw new Error("native Windows Prime must not spawn a process"); + }), + ); + const trackingHttpClient = HttpClient.make((request) => + Effect.sync(() => { + networkProbeCalls += 1; + return HttpClientResponse.fromWeb(request, Response.json({})); + }), + ); + + return Effect.gen(function* () { + expect(yield* HostProcessPlatform).toBe("win32"); + expect(yield* ChildProcessSpawner.ChildProcessSpawner).toBe(trackingSpawner); + expect(yield* HttpClient.HttpClient).toBe(trackingHttpClient); + + const primeId = ProviderInstanceId.make("primeAgent"); + const codexId = ProviderInstanceId.make("codex"); + const primeConfig = makePrimeAgentConfig({}); + + const materialization = yield* PrimeAgentDriver.create({ + instanceId: primeId, + displayName: "Prime Agent", + accentColor: undefined, + environment: [], + enabled: true, + config: primeConfig, + }).pipe(Effect.result); + expect(materialization._tag).toBe("Failure"); + if (materialization._tag === "Failure") { + expect(materialization.failure).toBeInstanceOf(ProviderDriverError); + expect(materialization.failure.detail).toBe(PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE); + } + + const configMap: ProviderInstanceConfigMap = { + [primeId]: { + driver: ProviderDriverKind.make("primeAgent"), + displayName: "Prime Agent", + enabled: true, + config: primeConfig, + }, + [codexId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex", + enabled: false, + config: makeCodexConfig({}), + }, + }; + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [PrimeAgentDriver, CodexDriver], + configMap, + }); + + expect(yield* registry.getInstance(primeId)).toBeUndefined(); + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toHaveLength(1); + expect(unavailable[0]).toMatchObject({ + instanceId: primeId, + driver: "primeAgent", + availability: "unavailable", + enabled: false, + installed: false, + status: "disabled", + models: [], + }); + expect(unavailable[0]!.message).toContain(PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE); + expect(unavailable[0]!.unavailableReason).toContain( + PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE, + ); + expect(unavailable[0]!.updateState).toBeUndefined(); + expect(unavailable[0]!.versionAdvisory).toBeUndefined(); + + const codex = yield* registry.getInstance(codexId); + expect(codex).toBeDefined(); + expect((yield* codex!.snapshot.getSnapshot).enabled).toBe(false); + + const adapterRegistry = yield* makeProviderAdapterRegistry().pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + ); + const interactiveStart = yield* adapterRegistry.getByInstance(primeId).pipe(Effect.result); + expect(interactiveStart._tag).toBe("Failure"); + if (interactiveStart._tag === "Failure") { + expect(interactiveStart.failure).toBeInstanceOf(ProviderUnsupportedError); + } + + const textGeneration = makeTextGenerationFromRegistry(registry); + const backgroundGeneration = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Native Windows must fail closed", + modelSelection: createModelSelection(primeId, "default"), + }) + .pipe(Effect.result); + expect(backgroundGeneration._tag).toBe("Failure"); + if (backgroundGeneration._tag === "Failure") { + expect(backgroundGeneration.failure).toBeInstanceOf(TextGenerationError); + expect(backgroundGeneration.failure.detail).toBe( + PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE, + ); + } + + expect(processCalls).toBe(0); + expect(networkProbeCalls).toBe(0); + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, trackingSpawner), + Effect.provideService(HttpClient.HttpClient, trackingHttpClient), + Effect.provide(testLayer), + ); + }); }); describe("ProviderInstanceRegistryLive — all drivers slice", () => { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts index fb75652e3..cbd7f9a68 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -198,7 +198,7 @@ const buildEntry = (input: { instanceId, displayName: entry.displayName, accentColor: entry.accentColor, - reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, + reason: createResult.failure.detail, }), }; } diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 9b0d4a178..4308d104f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -504,6 +504,7 @@ const hasMetricSnapshot = ( ); function makeProviderServiceLayer() { + const startReservationCounts: number[] = []; const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); const cursor = makeFakeCodexAdapter(CURSOR_DRIVER, { supportsSideQuestions: false }); @@ -524,7 +525,9 @@ function makeProviderServiceLayer() { const layer = it.layer( Layer.mergeAll( - makeProviderServiceLive().pipe( + makeProviderServiceLive({ + onStartReservationCountChange: (count) => startReservationCounts.push(count), + }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -549,6 +552,7 @@ function makeProviderServiceLayer() { claude, cursor, layer, + startReservationCounts, }; } @@ -1157,6 +1161,27 @@ it.effect( ); routing.layer("ProviderServiceLive routing", (it) => { + it.effect("reclaims start reservations after long historical-thread churn", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + routing.startReservationCounts.length = 0; + + for (let index = 0; index < 200; index += 1) { + const threadId = asThreadId(`thread-reservation-churn-${index}`); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* provider.stopSession({ threadId }); + } + + assert.equal(Math.max(...routing.startReservationCounts), 1); + assert.equal(routing.startReservationCounts.at(-1), 0); + }), + ); + it.effect("routes side questions once without recovering inactive sessions", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1284,6 +1309,41 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("rejects a model selection from another instance before adapter dispatch", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-model-instance-mismatch"); + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + routing.codex.sendTurn.mockClear(); + routing.codex.startSession.mockClear(); + routing.codex.removeSession(threadId); + + const error = yield* provider + .sendTurn({ + threadId, + input: "must not dispatch", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.match(error.issue, /model selection belongs to 'claudeAgent'/); + assert.equal(routing.codex.sendTurn.mock.calls.length, 0); + assert.equal(routing.codex.startSession.mock.calls.length, 0); + yield* provider.stopSession({ threadId }); + }), + ); + it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -2375,6 +2435,113 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }), ); + it.effect("cancels a first slow start before it can persist or send", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = asThreadId("thread-stop-slow-first-start"); + const stopCallsBefore = fanout.codex.stopSession.mock.calls.length; + const startEntered = yield* Deferred.make(); + const releaseStart = yield* Deferred.make(); + fanout.codex.startSession.mockImplementationOnce((input) => + Deferred.succeed(startEntered, input).pipe(Effect.andThen(Deferred.await(releaseStart))), + ); + + const startFiber = yield* provider + .startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + const startInput = yield* Deferred.await(startEntered); + + // No binding exists yet. Stop must still invalidate this exact start and + // return without waiting for adapter creation to finish. + yield* provider.stopSession({ threadId }); + const now = "2026-01-01T00:00:00.000Z"; + yield* Deferred.succeed(releaseStart, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + sessionIncarnationId: startInput.sessionIncarnationId, + status: "ready", + runtimeMode: "full-access", + threadId, + cwd: process.cwd(), + createdAt: now, + updatedAt: now, + }); + + const startExit = yield* Fiber.await(startFiber); + assert.equal(Exit.isFailure(startExit), true); + assert.equal(fanout.codex.stopSession.mock.calls.length, stopCallsBefore + 1); + assert.equal(Option.isNone(yield* directory.getBinding(threadId)), true); + assert.deepEqual( + (yield* provider.listSessions()).filter((session) => session.threadId === threadId), + [], + ); + }), + ); + + it.effect("keeps Stop authoritative while an account transition is starting", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = asThreadId("thread-stop-slow-transition"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const codexStopCallsBefore = fanout.codex.stopSession.mock.calls.length; + const claudeStopCallsBefore = fanout.claude.stopSession.mock.calls.length; + + const transitionEntered = yield* Deferred.make(); + const releaseTransition = yield* Deferred.make(); + fanout.claude.startSession.mockImplementationOnce((input) => + Deferred.succeed(transitionEntered, input).pipe( + Effect.andThen(Deferred.await(releaseTransition)), + ), + ); + const transitionFiber = yield* provider + .startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + const transitionInput = yield* Deferred.await(transitionEntered); + + yield* provider.stopSession({ threadId }); + const now = "2026-01-01T00:00:00.000Z"; + yield* Deferred.succeed(releaseTransition, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + sessionIncarnationId: transitionInput.sessionIncarnationId, + status: "ready", + runtimeMode: "full-access", + threadId, + cwd: process.cwd(), + createdAt: now, + updatedAt: now, + }); + + assert.equal(Exit.isFailure(yield* Fiber.await(transitionFiber)), true); + assert.equal(fanout.codex.stopSession.mock.calls.length, codexStopCallsBefore + 1); + assert.equal(fanout.claude.stopSession.mock.calls.length, claudeStopCallsBefore + 1); + const binding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + assert.equal(binding?.providerInstanceId, codexInstanceId); + assert.equal(binding?.status, "stopped"); + assert.deepEqual( + (yield* provider.listSessions()).filter((session) => session.threadId === threadId), + [], + ); + }), + ); + it.effect("retains a stopping incarnation until its delayed exit is ingested", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f26a7ecf3..29e2b44c8 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -109,6 +109,8 @@ export interface ProviderServiceLiveOptions { readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; /** Same seam as `issueMcpCredential`, for observing session credential revocation. */ readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread; + /** Test-only observation seam for proving idle reservation lanes are reclaimed. */ + readonly onStartReservationCountChange?: (count: number) => void; } type ProviderServiceMethod = @@ -330,31 +332,78 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly turnId: ProviderRuntimeEvent["turnId"]; } >(); - const startReservations = yield* SynchronizedRef.make( - new Map(), - ); + type StartReservationToken = string; + type StartReservationEntry = { + readonly currentToken: StartReservationToken; + readonly activeTokens: ReadonlySet; + readonly semaphore: Semaphore.Semaphore; + }; + const startReservations = yield* SynchronizedRef.make(new Map()); + const makeStartReservationToken = (): StartReservationToken => NodeCrypto.randomUUID(); + const reportStartReservationCount = options?.onStartReservationCountChange + ? SynchronizedRef.get(startReservations).pipe( + Effect.tap((current) => + Effect.sync(() => options.onStartReservationCountChange?.(current.size)), + ), + Effect.asVoid, + ) + : Effect.void; const reserveStartSession = (threadId: ThreadId) => SynchronizedRef.modify(startReservations, (current) => { const previous = current.get(threadId); + const token = makeStartReservationToken(); + const activeTokens = new Set(previous?.activeTokens ?? []); + activeTokens.add(token); const reservation = { - generation: (previous?.generation ?? 0) + 1, + token, semaphore: previous?.semaphore ?? Semaphore.makeUnsafe(1), }; const next = new Map(current); - next.set(threadId, reservation); + next.set(threadId, { + currentToken: token, + activeTokens, + semaphore: reservation.semaphore, + }); return [reservation, next] as const; - }); - const isStartReservationCurrent = (threadId: ThreadId, generation: number) => + }).pipe(Effect.tap(() => reportStartReservationCount)); + const isStartReservationCurrent = (threadId: ThreadId, token: StartReservationToken) => SynchronizedRef.get(startReservations).pipe( - Effect.map((reservations) => reservations.get(threadId)?.generation === generation), + Effect.map((reservations) => reservations.get(threadId)?.currentToken === token), ); - const releaseStartReservation = (threadId: ThreadId, generation: number) => + const releaseStartReservation = (threadId: ThreadId, token: StartReservationToken) => SynchronizedRef.update(startReservations, (current) => { - if (current.get(threadId)?.generation !== generation) return current; + const previous = current.get(threadId); + if (previous === undefined || !previous.activeTokens.has(token)) return current; + const activeTokens = new Set(previous.activeTokens); + activeTokens.delete(token); const next = new Map(current); - next.delete(threadId); + if (activeTokens.size === 0) { + // Tokens are process-globally unique, so deleting an idle tombstone + // cannot make any older reservation current again through an ABA reset. + next.delete(threadId); + } else { + next.set(threadId, { ...previous, activeTokens }); + } return next; - }); + }).pipe(Effect.tap(() => reportStartReservationCount)); + // Stop is a cancellation boundary, not another participant in the start + // semaphore. Replace the current token before any directory read so a first + // start with no persisted binding still quarantines itself when it returns. + const invalidateStartSession = (threadId: ThreadId) => + SynchronizedRef.update(startReservations, (current) => { + const previous = current.get(threadId); + if (previous === undefined || previous.activeTokens.size === 0) { + // There is no work that can observe this token. Do not retain a + // historical-thread tombstone indefinitely. + return current; + } + const next = new Map(current); + next.set(threadId, { + ...previous, + currentToken: makeStartReservationToken(), + }); + return next; + }).pipe(Effect.tap(() => reportStartReservationCount)); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); /** * Attach the `t3-code` MCP server to the session that is about to start. @@ -907,7 +956,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return yield* reservation.semaphore .withPermit( Effect.gen(function* () { - if (!(yield* isStartReservationCurrent(threadId, reservation.generation))) { + if (!(yield* isStartReservationCurrent(threadId, reservation.token))) { return yield* toValidationError( "ProviderService.startSession", `Provider session start for thread '${threadId}' was superseded by a newer request.`, @@ -1007,7 +1056,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( sessionIncarnationId, }; const requireCurrentStartReservation = Effect.fnUntraced(function* () { - if (yield* isStartReservationCurrent(threadId, reservation.generation)) return; + if (yield* isStartReservationCurrent(threadId, reservation.token)) return; yield* adapter.stopSession(threadId).pipe( Effect.catchCause((cause) => Effect.logWarning("provider.session.stop-superseded-start-failed", { @@ -1022,6 +1071,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( currentSessionIncarnations.delete(threadId); } activeTurnAdmissions.delete(threadId); + // The invalidation can land between the pre-upsert check and the + // directory write. Remove only the exact late incarnation; a + // newer start may already own the thread and must remain intact. + yield* directory.removeExact({ + threadId, + providerInstanceId: resolvedInstanceId, + sessionIncarnationId, + }); return yield* toValidationError( "ProviderService.startSession", `Provider session start for thread '${threadId}' was superseded by a newer request.`, @@ -1073,7 +1130,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), ), ) - .pipe(Effect.ensuring(releaseStartReservation(threadId, reservation.generation))); + .pipe(Effect.ensuring(releaseStartReservation(threadId, reservation.token))); }, ); @@ -1113,6 +1170,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( let metricProvider = "unknown"; let metricModel = input.modelSelection?.model; return yield* Effect.gen(function* () { + if (input.modelSelection !== undefined) { + const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(input.threadId)); + if (persistedBinding !== undefined) { + const boundInstanceId = yield* requireBindingInstanceId( + "ProviderService.sendTurn", + persistedBinding, + ); + if (input.modelSelection.instanceId !== boundInstanceId) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Provider session for thread '${input.threadId}' is bound to instance '${boundInstanceId}', but the model selection belongs to '${input.modelSelection.instanceId}'.`, + ); + } + } + } const routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.sendTurn", @@ -1120,6 +1192,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); metricProvider = routed.adapter.provider; metricModel = input.modelSelection?.model; + if ( + input.modelSelection !== undefined && + input.modelSelection.instanceId !== routed.instanceId + ) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Provider session for thread '${input.threadId}' is bound to instance '${routed.instanceId}', but the model selection belongs to '${input.modelSelection.instanceId}'.`, + ); + } yield* Effect.annotateCurrentSpan({ "provider.kind": routed.adapter.provider, ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), @@ -1985,6 +2066,77 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); let metricProvider = "unknown"; + // Invalidate before the first directory read. A first-turn start can be + // inside adapter creation without having a persisted binding yet; stop + // must still win that race and let the late start quarantine itself. + if (input.invalidateStartReservation !== false) { + yield* invalidateStartSession(input.threadId); + } + const activeAdmission = activeTurnAdmissions.get(input.threadId); + if ( + input.expectedAdmissionRequestId === undefined || + (input.expectedAdmissionRequestId !== null && + activeAdmission?.requestId === input.expectedAdmissionRequestId) + ) { + activeTurnAdmissions.delete(input.threadId); + } + + const targetIsExact = + input.expectedProviderInstanceId !== undefined || + input.expectedSessionIncarnationId !== undefined; + const binding = Option.getOrUndefined(yield* directory.getBinding(input.threadId)); + const currentIncarnation = currentSessionIncarnations.get(input.threadId); + const persistedIncarnationId = + binding === undefined + ? undefined + : readRuntimePayloadString(binding.runtimePayload, "sessionIncarnationId"); + const instanceMatches = + input.expectedProviderInstanceId === undefined || + (input.expectedProviderInstanceId === null + ? binding === undefined && currentIncarnation === undefined + : (binding?.providerInstanceId ?? currentIncarnation?.instanceId) === + input.expectedProviderInstanceId); + const incarnationMatches = + input.expectedSessionIncarnationId === undefined || + (input.expectedSessionIncarnationId === null + ? persistedIncarnationId === undefined && currentIncarnation === undefined + : (persistedIncarnationId ?? currentIncarnation?.id) === + input.expectedSessionIncarnationId); + if (!instanceMatches || !incarnationMatches) { + return; + } + + if (binding === undefined) { + // A target with no directory row can only be an in-flight start. Its + // reservation was invalidated above; if the exact adapter incarnation + // is already known, stop it now as well. + if ( + targetIsExact && + input.expectedProviderInstanceId != null && + input.expectedSessionIncarnationId != null && + currentIncarnation !== undefined + ) { + metricProvider = currentIncarnation.adapter.provider; + yield* currentIncarnation.adapter.stopSession(input.threadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider.session.stop-exact-unbound-failed", { + threadId: input.threadId, + provider: currentIncarnation.adapter.provider, + cause, + }), + ), + ); + } + if ( + !targetIsExact || + currentSessionIncarnations.get(input.threadId) === currentIncarnation + ) { + yield* clearMcpSession(input.threadId); + currentSessionIncarnations.delete(input.threadId); + } + return; + } + return yield* Effect.gen(function* () { const routed = yield* resolveRoutableSession({ threadId: input.threadId, @@ -1996,6 +2148,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.operation": "stop-session", "provider.kind": routed.adapter.provider, "provider.thread_id": input.threadId, + ...(input.expectedSessionIncarnationId + ? { "provider.session_incarnation_id": input.expectedSessionIncarnationId } + : {}), }); if (routed.isActive) { yield* routed.adapter @@ -2005,20 +2160,49 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // an asynchronous relay after stopSession returns. Keep this exact // incarnation routable until processRuntimeEvent ingests that exit. A // later start safely replaces the map entry before its adapter starts. - } else { - yield* clearMcpSession(input.threadId); + } else if ( + !targetIsExact || + (currentIncarnation !== undefined && + currentSessionIncarnations.get(input.threadId) === currentIncarnation) + ) { currentSessionIncarnations.delete(input.threadId); } - activeTurnAdmissions.delete(input.threadId); - yield* directory.upsert({ - threadId: input.threadId, - provider: routed.adapter.provider, - providerInstanceId: routed.instanceId, - status: "stopped", - runtimePayload: { - activeTurnId: null, - }, - }); + yield* clearMcpSession(input.threadId); + + const latestBinding = Option.getOrUndefined(yield* directory.getBinding(input.threadId)); + const latestIncarnationId = + latestBinding === undefined + ? undefined + : readRuntimePayloadString(latestBinding.runtimePayload, "sessionIncarnationId"); + const latestIsTarget = + latestBinding !== undefined && + (input.expectedProviderInstanceId === undefined || + latestBinding.providerInstanceId === input.expectedProviderInstanceId) && + (input.expectedSessionIncarnationId === undefined || + latestIncarnationId === input.expectedSessionIncarnationId); + if (input.removeBinding === true) { + if ( + latestIsTarget && + input.expectedProviderInstanceId != null && + input.expectedSessionIncarnationId != null + ) { + yield* directory.removeExact({ + threadId: input.threadId, + providerInstanceId: input.expectedProviderInstanceId, + sessionIncarnationId: input.expectedSessionIncarnationId, + }); + } + } else if (!targetIsExact || latestIsTarget) { + yield* directory.upsert({ + threadId: input.threadId, + provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, + status: "stopped", + runtimePayload: { + activeTurnId: null, + }, + }); + } yield* analytics.record("provider.session.stopped", { provider: routed.adapter.provider, }); @@ -2080,6 +2264,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); + const getSessionContinuation: NonNullable< + ProviderService.ProviderServiceShape["getSessionContinuation"] + > = Effect.fn("getSessionContinuation")(function* (threadId) { + const binding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + if (binding?.resumeCursor === undefined || binding.resumeCursor === null) return null; + return { + providerInstanceId: yield* requireBindingInstanceId( + "ProviderService.getSessionContinuation", + binding, + ), + resumeCursor: binding.resumeCursor, + }; + }); + const listSessions: ProviderServiceMethod<"listSessions"> = Effect.fn("listSessions")( function* () { const currentAdapters = yield* getAdapterEntries; @@ -2360,6 +2558,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( refineSessionHarness, stopSession, listSessions, + getSessionContinuation, listSessionsForInstance, getCapabilities, getInstanceInfo, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 23075bd9a..70b004702 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; @@ -100,52 +101,97 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); + const mutationPermit = Semaphore.makeUnsafe(1); const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { - const existing = yield* repository - .getByThreadId({ threadId: binding.threadId }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); - - const existingRuntime = Option.getOrUndefined(existing); - const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; - if (!resolvedThreadId) { - return yield* new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "threadId must be a non-empty string.", - }); - } - - const now = DateTime.formatIso(yield* DateTime.now); - const providerChanged = - existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; - const providerInstanceId = - binding.providerInstanceId ?? (!providerChanged ? existingRuntime?.providerInstanceId : null); - if (providerInstanceId === null || providerInstanceId === undefined) { - return yield* new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "providerInstanceId is required for provider session runtime bindings.", - }); - } - yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - providerInstanceId, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + return yield* mutationPermit.withPermit( + Effect.gen(function* () { + const existing = yield* repository + .getByThreadId({ threadId: binding.threadId }) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId")), + ); + + const existingRuntime = Option.getOrUndefined(existing); + const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; + if (!resolvedThreadId) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "threadId must be a non-empty string.", + }); + } + + const now = DateTime.formatIso(yield* DateTime.now); + const providerChanged = + existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; + const providerInstanceId = + binding.providerInstanceId ?? + (!providerChanged ? existingRuntime?.providerInstanceId : null); + if (providerInstanceId === null || providerInstanceId === undefined) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "providerInstanceId is required for provider session runtime bindings.", + }); + } + yield* repository + .upsert({ + threadId: resolvedThreadId, + providerName: binding.provider, + providerInstanceId, + adapterKey: + binding.adapterKey ?? + (providerChanged + ? binding.provider + : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + }), + ); + }); + + const removeExact: ProviderSessionDirectoryShape["removeExact"] = Effect.fn(function* (input) { + return yield* mutationPermit.withPermit( + Effect.gen(function* () { + const existing = Option.getOrUndefined( + yield* repository + .getByThreadId({ threadId: input.threadId }) + .pipe( + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.removeExact:getByThreadId"), + ), + ), + ); + const payload = existing?.runtimePayload; + const persistedIncarnationId = + isRecord(payload) && typeof payload.sessionIncarnationId === "string" + ? payload.sessionIncarnationId + : undefined; + if ( + existing?.providerInstanceId !== input.providerInstanceId || + persistedIncarnationId !== input.sessionIncarnationId + ) { + return false; + } + yield* repository + .deleteByThreadId({ threadId: input.threadId }) + .pipe( + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.removeExact:deleteByThreadId"), + ), + ); + return true; + }), + ); }); const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => @@ -186,6 +232,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { upsert, getProvider, getBinding, + removeExact, listThreadIds, listBindings, } satisfies ProviderSessionDirectoryShape; diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 68becc409..93b6318b3 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -199,6 +199,14 @@ export interface ProviderServiceShape { */ readonly listSessions: () => Effect.Effect>; + /** Read the exact durable continuation even when no adapter runtime is live. */ + readonly getSessionContinuation?: ( + threadId: ThreadId, + ) => Effect.Effect< + { readonly providerInstanceId: ProviderInstanceId; readonly resumeCursor: unknown } | null, + ProviderServiceError + >; + /** Inventory exactly one configured provider instance without coupling failures. */ readonly listSessionsForInstance?: ( instanceId: ProviderInstanceId, diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f2dd4323f..766633634 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -2,6 +2,7 @@ import type { ProviderInstanceId, ProviderDriverKind, ProviderSessionRuntimeStatus, + RuntimeSessionId, RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -53,6 +54,13 @@ export interface ProviderSessionDirectoryShape { threadId: ThreadId, ) => Effect.Effect, ProviderSessionDirectoryReadError>; + /** Atomically removes only the exact runtime incarnation supplied by its owner. */ + readonly removeExact: (input: { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + readonly sessionIncarnationId: RuntimeSessionId; + }) => Effect.Effect; + readonly listThreadIds: () => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts index 0a2bf7c90..35af06fbe 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts @@ -9,7 +9,6 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { isPathInside, loadPrimeAgentDaemonBridge, - locatePrimeAgentPublicPackage, PRIME_AGENT_DAEMON_PROTOCOL_NAME, PRIME_AGENT_MIN_DAEMON_PROTOCOL_VERSION, PRIME_AGENT_NEGOTIATED_DAEMON_SESSION_CAPABILITIES_FEATURE, @@ -156,41 +155,6 @@ describe("PrimeAgentDaemonBridge", () => { }), ); - it.effect("locates a Windows npm wrapper through its sibling package bin", () => - Effect.gen(function* () { - const npmRoot = makeTemporaryDirectory(); - const packageRoot = NodePath.join(npmRoot, "node_modules", "prime-agent"); - const dist = NodePath.join(packageRoot, "dist"); - NodeFS.mkdirSync(dist, { recursive: true }); - const cliPath = NodePath.join(dist, "cli.js"); - const entryPath = NodePath.join(dist, "index.js"); - const wrapperPath = NodePath.join(npmRoot, "prime-agent.cmd"); - NodeFS.writeFileSync(cliPath, "#!/usr/bin/env node\n"); - NodeFS.writeFileSync(entryPath, "export const VERSION = '0.8.1';\n"); - NodeFS.writeFileSync( - NodePath.join(packageRoot, "package.json"), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - name: "prime-agent", - version: "0.8.1", - type: "module", - bin: { "prime-agent": "dist/cli.js" }, - exports: { ".": { import: "./dist/index.js" } }, - }), - ); - NodeFS.writeFileSync( - wrapperPath, - '@ECHO off\r\n"%~dp0\\node.exe" "%~dp0\\node_modules\\prime-agent\\dist\\cli.js" %*\r\n', - ); - - const located = yield* locatePrimeAgentPublicPackage(wrapperPath); - - expect(located.packageRoot).toBe(NodeFS.realpathSync(packageRoot)); - expect(located.moduleEntryPath).toBe(NodeFS.realpathSync(entryPath)); - expect(located.version).toBe("0.8.1"); - }), - ); - it.effect("follows an executable symlink into an npm-style package", () => Effect.gen(function* () { const pkg = makePackage(); diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.ts b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.ts index e2ba72e5f..936056de8 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.ts @@ -249,7 +249,6 @@ const packageIdentitySchema = Schema.Struct({ const primeAgentPackageSchema = Schema.Struct({ name: Schema.Literal("prime-agent"), version: Schema.String, - bin: Schema.optional(Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)])), exports: Schema.Union([ Schema.String, Schema.Struct({ ".": Schema.String }), @@ -292,93 +291,6 @@ async function readJson(filePath: string): Promise { return JSON.parse(source) as unknown; } -function packageBinEntry( - manifest: LocatedPackage["manifest"], - commandName: string, -): string | undefined { - if (Predicate.isString(manifest.bin)) { - return commandName === "prime-agent" ? manifest.bin : undefined; - } - return manifest.bin?.[commandName]; -} - -async function locatePrimeAgentWrapperPackage( - binaryPath: string, - canonicalWrapperPath: string, -): Promise { - const wrapperName = NodePath.basename(binaryPath).replace(/\.(?:cmd|ps1)$/iu, ""); - if (wrapperName.toLowerCase() !== "prime-agent") return undefined; - - const siblingRoot = NodePath.join( - NodePath.dirname(NodePath.resolve(binaryPath)), - "node_modules", - "prime-agent", - ); - let rawManifest: unknown; - try { - rawManifest = await readJson(NodePath.join(siblingRoot, "package.json")); - } catch (cause) { - if ( - Predicate.isObject(cause) && - "code" in cause && - (cause.code === "ENOENT" || cause.code === "ENOTDIR") - ) { - return undefined; - } - throw bridgeError( - binaryPath, - "invalid-package-manifest", - `Could not read the wrapper-owned prime-agent package at '${siblingRoot}'.`, - cause, - ); - } - - const manifest = decodePrimeAgentPackage(rawManifest); - if (Option.isNone(manifest)) { - throw bridgeError( - binaryPath, - "invalid-package-manifest", - `The wrapper-owned package at '${siblingRoot}' is not a valid prime-agent public package.`, - ); - } - const binEntry = packageBinEntry(manifest.value, wrapperName.toLowerCase()); - if (!binEntry) { - throw bridgeError( - binaryPath, - "wrong-package", - "The Prime Agent wrapper is not bound to the package's prime-agent bin entry.", - ); - } - - try { - const [canonicalRoot, canonicalBin, wrapperSource] = await Promise.all([ - NodeFSP.realpath(siblingRoot), - NodeFSP.realpath(NodePath.resolve(siblingRoot, binEntry)), - NodeFSP.readFile(canonicalWrapperPath, "utf8"), - ]); - const binStat = await NodeFSP.stat(canonicalBin); - const expectedReference = `node_modules/prime-agent/${binEntry.replace(/^\.\//u, "")}` - .replaceAll("\\", "/") - .toLowerCase(); - const normalizedWrapperSource = wrapperSource.replaceAll("\\", "/").toLowerCase(); - if ( - !binStat.isFile() || - !isPathInside(canonicalRoot, canonicalBin) || - !normalizedWrapperSource.includes(expectedReference) - ) { - throw new Error("wrapper does not reference its package-owned bin file"); - } - return { root: canonicalRoot, manifest: manifest.value }; - } catch (cause) { - throw bridgeError( - binaryPath, - "wrong-package", - "The Prime Agent wrapper is not safely bound to its sibling package.", - cause, - ); - } -} - async function locatePrimeAgentPackage(binaryPath: string): Promise { let canonicalPath: string; try { @@ -393,10 +305,6 @@ async function locatePrimeAgentPackage(binaryPath: string): Promise= 0; index -= 1) { - const [candidate, value] = entries[index]!; - if (candidate.toUpperCase() === name.toUpperCase()) return value; - } - return undefined; -} - -function environmentHome( - environment: NodeJS.ProcessEnv, - platform: NodeJS.Platform, -): string | undefined { - const pathApi = platform === "win32" ? NodePath.win32 : NodePath.posix; - const candidates = - platform === "win32" - ? [ - environmentValue(environment, "USERPROFILE", platform)?.trim(), - `${environmentValue(environment, "HOMEDRIVE", platform)?.trim() ?? ""}${environmentValue(environment, "HOMEPATH", platform)?.trim() ?? ""}`, - ] - : [environment.HOME?.trim()]; - return candidates.find((candidate): candidate is string => - Boolean(candidate && pathApi.isAbsolute(candidate)), - ); } /** Prime's effective agent home for the selected instance environment. */ @@ -209,23 +174,17 @@ export function resolvePrimeAgentHomePath( if (configured) return resolveProviderHomePath(configured); if (!options.processEnv) return path.join(NodeOS.homedir(), ".prime", "agent"); - const platform = options.platform; - if (!platform) return undefined; - const pathApi = platform === "win32" ? NodePath.win32 : NodePath.posix; - const home = environmentHome(options.processEnv, platform); - const environmentAgentDir = environmentValue( - options.processEnv, - PRIME_AGENT_HOME_ENV, - platform, - )?.trim(); - if (!environmentAgentDir) return home ? pathApi.join(home, ".prime", "agent") : undefined; - if (environmentAgentDir === "~") return home; - if (environmentAgentDir.startsWith("~/") || environmentAgentDir.startsWith("~\\")) { - return home ? pathApi.join(home, environmentAgentDir.slice(2)) : undefined; + const home = options.processEnv.HOME?.trim(); + const absoluteHome = home && path.isAbsolute(home) ? path.normalize(home) : undefined; + const environmentAgentDir = options.processEnv[PRIME_AGENT_HOME_ENV]?.trim(); + if (!environmentAgentDir) { + return absoluteHome ? path.join(absoluteHome, ".prime", "agent") : undefined; + } + if (environmentAgentDir === "~") return absoluteHome; + if (environmentAgentDir.startsWith("~/")) { + return absoluteHome ? path.join(absoluteHome, environmentAgentDir.slice(2)) : undefined; } - return pathApi.isAbsolute(environmentAgentDir) - ? pathApi.normalize(environmentAgentDir) - : undefined; + return path.isAbsolute(environmentAgentDir) ? path.normalize(environmentAgentDir) : undefined; } /** diff --git a/apps/server/src/provider/providerUnavailable.ts b/apps/server/src/provider/providerUnavailable.ts new file mode 100644 index 000000000..5a33991d4 --- /dev/null +++ b/apps/server/src/provider/providerUnavailable.ts @@ -0,0 +1,20 @@ +import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; + +/** Exact configured shadow lookup shared by interactive and background routing. */ +export function findUnavailableProviderInstance( + providers: ReadonlyArray, + instanceId: ProviderInstanceId, +): ServerProvider | undefined { + return providers.find( + (provider) => provider.instanceId === instanceId && provider.availability === "unavailable", + ); +} + +/** Client-neutral remediation carried by an unavailable provider shadow. */ +export function providerUnavailableDetail(provider: ServerProvider): string { + return ( + provider.unavailableReason?.trim() || + provider.message?.trim() || + `Provider instance '${provider.instanceId}' is unavailable in this environment.` + ); +} diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index ad2a0e40e..0b5974384 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -146,6 +146,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + removeExact: () => Effect.succeed(false), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -215,6 +216,7 @@ it.effect( }), ), upsert: () => Effect.fail(writeFailure), + removeExact: () => Effect.succeed(false), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -252,6 +254,7 @@ it.effect("retries failed projections and continues after a persistent failure", directory: { getBinding: () => Effect.succeed(Option.none()), upsert: () => Effect.void, + removeExact: () => Effect.succeed(false), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -300,6 +303,7 @@ it.effect("does not fail startup when the live provider session inventory cannot Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { getBinding: () => Effect.die("unused"), upsert: () => Effect.die("unused"), + removeExact: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), diff --git a/apps/server/src/textGeneration/PrimeAgentTextGeneration.platform.test.ts b/apps/server/src/textGeneration/PrimeAgentTextGeneration.platform.test.ts index af0bcf7fc..e152b9539 100644 --- a/apps/server/src/textGeneration/PrimeAgentTextGeneration.platform.test.ts +++ b/apps/server/src/textGeneration/PrimeAgentTextGeneration.platform.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { PrimeAgentSettings, ProviderInstanceId } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -21,17 +20,16 @@ const TestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "pylon-prime-platform-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); -it.layer(TestLayer)("PrimeAgentTextGeneration platform subprocess", (it) => { +it.layer(TestLayer)("PrimeAgentTextGeneration POSIX subprocess", (it) => { it.effect("spawns the selected public ESM SDK with exact instance affinity and cleans up", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const hostPlatform = yield* HostProcessPlatform; const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "pylon prime platform with spaces ", }); const packageRoot = path.join(root, "Selected Prime Package With Spaces"); - const binaryName = hostPlatform === "win32" ? "prime-agent.cmd" : "prime-agent"; + const binaryName = "prime-agent"; const binaryPath = path.join(packageRoot, "bin", binaryName); const sdkEntryPath = path.join(packageRoot, "public sdk entry.js"); const cwd = path.join(root, "Project Working Directory With Spaces"); @@ -52,13 +50,8 @@ it.layer(TestLayer)("PrimeAgentTextGeneration platform subprocess", (it) => { exports: { ".": { import: "./public sdk entry.js" } }, }), ); - yield* fileSystem.writeFileString( - binaryPath, - hostPlatform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/usr/bin/env node\n", - ); - if (hostPlatform !== "win32") { - yield* fileSystem.chmod(binaryPath, 0o755); - } + yield* fileSystem.writeFileString(binaryPath, "#!/usr/bin/env node\n"); + yield* fileSystem.chmod(binaryPath, 0o755); yield* fileSystem.writeFileString(sdkEntryPath, FAKE_PUBLIC_SDK); const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/textGeneration/PrimeAgentTextGeneration.test.ts b/apps/server/src/textGeneration/PrimeAgentTextGeneration.test.ts index ce25577b2..86b8a0c0a 100644 --- a/apps/server/src/textGeneration/PrimeAgentTextGeneration.test.ts +++ b/apps/server/src/textGeneration/PrimeAgentTextGeneration.test.ts @@ -23,7 +23,6 @@ import { FAKE_PUBLIC_SDK } from "./PrimeAgentTextGeneration.test-fixture.ts"; import { hasStablePrimeAgentImageFileIdentity, makePrimeAgentTextGeneration, - normalizePrimeAgentTextGenerationEnvironment, resolvePrimeAgentTextGenerationHomePath, } from "./PrimeAgentTextGeneration.ts"; @@ -179,7 +178,6 @@ it.layer(TestLayer)("PrimeAgentTextGeneration", (it) => { PRIME_AGENT_CODING_AGENT_DIR: "/explicit/prime-home", }, cwd: "/srv/project", - platform: "linux", }), ).toBe("/explicit/prime-home"); expect( @@ -189,7 +187,6 @@ it.layer(TestLayer)("PrimeAgentTextGeneration", (it) => { PRIME_AGENT_CODING_AGENT_DIR: "~/.prime-instance", }, cwd: "/srv/project", - platform: "linux", }), ).toBe("/srv/instance-home/.prime-instance"); expect( @@ -199,74 +196,17 @@ it.layer(TestLayer)("PrimeAgentTextGeneration", (it) => { PRIME_AGENT_CODING_AGENT_DIR: "relative-prime-home", }, cwd: "/srv/project", - platform: "darwin", }), ).toBe("/srv/project/relative-prime-home"); expect( resolvePrimeAgentTextGenerationHomePath({ environment: { HOME: "/srv/instance-home" }, cwd: "/srv/project", - platform: "linux", }), ).toBe("/srv/instance-home/.prime/agent"); }), ); - it.effect("uses Windows USERPROFILE then HOMEDRIVE and HOMEPATH", () => - Effect.sync(() => { - expect( - resolvePrimeAgentTextGenerationHomePath({ - environment: { - USERPROFILE: "C:\\Users\\Instance", - HOMEDRIVE: "D:", - HOMEPATH: "\\Ignored", - PRIME_AGENT_CODING_AGENT_DIR: "~\\prime-instance", - }, - cwd: "C:\\project", - platform: "win32", - }), - ).toBe("C:\\Users\\Instance\\prime-instance"); - expect( - resolvePrimeAgentTextGenerationHomePath({ - environment: { HOMEDRIVE: "D:", HOMEPATH: "\\Profiles\\DriveUser" }, - cwd: "D:\\project", - platform: "win32", - }), - ).toBe("D:\\Profiles\\DriveUser\\.prime\\agent"); - const normalized = normalizePrimeAgentTextGenerationEnvironment( - { - PATH: "C:\\base-bin", - USERPROFILE: "C:\\Users\\Base", - HOME: "C:\\home-base", - CUSTOM_VALUE: "base", - Path: "D:\\instance-bin", - UserProfile: "D:\\Users\\Instance", - Home: "D:\\home-instance", - custom_value: "instance", - }, - "win32", - ); - expect(normalized).toMatchObject({ - PATH: "D:\\instance-bin", - USERPROFILE: "D:\\Users\\Instance", - HOME: "D:\\home-instance", - CUSTOM_VALUE: "instance", - }); - expect( - resolvePrimeAgentTextGenerationHomePath({ - environment: { - USERPROFILE: "C:\\Users\\Base", - PRIME_AGENT_CODING_AGENT_DIR: "C:\\prime-base", - UserProfile: "D:\\Users\\Instance", - prime_agent_coding_agent_dir: "~\\prime-instance", - }, - cwd: "D:\\project", - platform: "win32", - }), - ).toBe("D:\\Users\\Instance\\prime-instance"); - }), - ); - it.effect("supports all four structured operations and shared sanitizers", () => withFakePrime({}, ({ textGeneration }) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/PrimeAgentTextGeneration.ts b/apps/server/src/textGeneration/PrimeAgentTextGeneration.ts index 0f5a2711c..9fe097790 100644 --- a/apps/server/src/textGeneration/PrimeAgentTextGeneration.ts +++ b/apps/server/src/textGeneration/PrimeAgentTextGeneration.ts @@ -10,7 +10,6 @@ import { TextGenerationError, } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { PRIME_AGENT_TEXT_GENERATION_HELPER_SOURCE } from "@t3tools/shared/primeAgentTextGenerationHelper"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { resolveCommandPath } from "@t3tools/shared/shell"; @@ -78,82 +77,30 @@ export interface PrimeAgentTextGenerationOptions { readonly beforeImageOpen?: ((filePath: string) => Effect.Effect) | undefined; } -function pathApiForPlatform(platform: NodeJS.Platform) { - return platform === "win32" ? NodePath.win32 : NodePath.posix; -} - -function environmentValueLast( - environment: NodeJS.ProcessEnv, - name: string, - platform: NodeJS.Platform, -): string | undefined { - if (platform !== "win32") return environment[name]; - const entries = Object.entries(environment); - for (let index = entries.length - 1; index >= 0; index -= 1) { - const [candidate, value] = entries[index]!; - if (candidate.toUpperCase() === name.toUpperCase()) return value; - } - return undefined; -} - -/** Apply Windows' case-insensitive, last-assignment-wins environment semantics. */ -export function normalizePrimeAgentTextGenerationEnvironment( - environment: NodeJS.ProcessEnv, - platform: NodeJS.Platform, -): NodeJS.ProcessEnv { - if (platform !== "win32") return { ...environment }; - const normalized = new Map(); - for (const [name, value] of Object.entries(environment)) { - normalized.set(name.toUpperCase(), value); - } - return Object.fromEntries(normalized); -} - -function effectiveEnvironmentHome( - environment: NodeJS.ProcessEnv, - platform: NodeJS.Platform, -): string { - const pathApi = pathApiForPlatform(platform); - const candidates = - platform === "win32" - ? [ - environmentValueLast(environment, "USERPROFILE", platform)?.trim(), - `${environmentValueLast(environment, "HOMEDRIVE", platform)?.trim() ?? ""}${environmentValueLast(environment, "HOMEPATH", platform)?.trim() ?? ""}`, - ] - : [environment.HOME?.trim()]; - const configuredHome = candidates.find((candidate): candidate is string => - Boolean(candidate && pathApi.isAbsolute(candidate)), - ); - if (configuredHome) { - return pathApi.normalize(configuredHome); - } - return NodeOS.homedir(); +function effectiveEnvironmentHome(environment: NodeJS.ProcessEnv): string { + const configuredHome = environment.HOME?.trim(); + return configuredHome && NodePath.isAbsolute(configuredHome) + ? NodePath.normalize(configuredHome) + : NodeOS.homedir(); } /** Resolve Prime's final merged agent-dir environment for one helper cwd. */ export function resolvePrimeAgentTextGenerationHomePath(input: { readonly environment: NodeJS.ProcessEnv; readonly cwd: string; - readonly platform: NodeJS.Platform; }): string { - const platform = input.platform; - const pathApi = pathApiForPlatform(platform); - const effectiveHome = effectiveEnvironmentHome(input.environment, platform); - const configured = environmentValueLast( - input.environment, - PRIME_AGENT_HOME_ENV, - platform, - )?.trim(); - if (!configured) return pathApi.join(effectiveHome, ".prime", "agent"); + const effectiveHome = effectiveEnvironmentHome(input.environment); + const configured = input.environment[PRIME_AGENT_HOME_ENV]?.trim(); + if (!configured) return NodePath.join(effectiveHome, ".prime", "agent"); const expanded = configured === "~" ? effectiveHome - : configured.startsWith("~/") || configured.startsWith("~\\") - ? pathApi.join(effectiveHome, configured.slice(2)) + : configured.startsWith("~/") + ? NodePath.join(effectiveHome, configured.slice(2)) : configured; - return pathApi.isAbsolute(expanded) - ? pathApi.resolve(expanded) - : pathApi.resolve(input.cwd, expanded); + return NodePath.isAbsolute(expanded) + ? NodePath.resolve(expanded) + : NodePath.resolve(input.cwd, expanded); } function textGenerationError( @@ -243,14 +190,9 @@ export const makePrimeAgentTextGeneration = Effect.fn("makePrimeAgentTextGenerat const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* ServerConfig.ServerConfig; - const hostPlatform = yield* HostProcessPlatform; - const normalizedInputEnvironment = normalizePrimeAgentTextGenerationEnvironment( + const resolvedEnvironment = makePrimeAgentEnvironment( + primeAgentSettings, environment ?? process.env, - hostPlatform, - ); - const resolvedEnvironment = normalizePrimeAgentTextGenerationEnvironment( - makePrimeAgentEnvironment(primeAgentSettings, normalizedInputEnvironment), - hostPlatform, ); const helperEnvironment = Object.fromEntries( Object.entries(resolvedEnvironment).filter(([name]) => { @@ -387,7 +329,6 @@ export const makePrimeAgentTextGeneration = Effect.fn("makePrimeAgentTextGenerat const agentDir = resolvePrimeAgentTextGenerationHomePath({ environment: resolvedEnvironment, cwd: input.cwd, - platform: hostPlatform, }); const requestEnvironment = { ...helperEnvironment, diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index b79947839..4af30749c 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -7,6 +7,10 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import { + findUnavailableProviderInstance, + providerUnavailableDetail, +} from "../provider/providerUnavailable.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; export type TextGenerationProvider = @@ -138,16 +142,22 @@ const resolveInstance = ( instanceId: ProviderInstanceId, ): Effect.Effect => registry.getInstance(instanceId).pipe( - Effect.flatMap((instance) => - instance - ? Effect.succeed(instance) - : Effect.fail( + Effect.flatMap((instance) => { + if (instance) return Effect.succeed(instance); + return registry.listUnavailable.pipe( + Effect.flatMap((unavailable) => { + const shadow = findUnavailableProviderInstance(unavailable, instanceId); + return Effect.fail( new TextGenerationError({ operation, - detail: `No provider instance registered for id '${instanceId}'.`, + detail: shadow + ? providerUnavailableDetail(shadow) + : `No provider instance registered for id '${instanceId}'.`, }), - ), - ), + ); + }), + ); + }), ); export const makeTextGenerationFromRegistry = ( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 163dc8833..a0ea6a9ca 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -24,6 +24,7 @@ import { ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, loadVideoPreviewUrl, + mergeFailedComposerSend, isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, @@ -407,10 +408,10 @@ describe("buildThreadTurnInterruptInput", () => { ).toEqual({ threadId, turnId: activeTurnId }); }); - it("omits a turn id when the session is not running", () => { - expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({ - threadId, - }); + it.each(["ready", "starting"] as const)("omits a turn id when the session is %s", (status) => { + expect( + buildThreadTurnInterruptInput(makeThread({ session: { ...readySession, status } })), + ).toEqual({ threadId }); }); }); @@ -500,6 +501,22 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("mergeFailedComposerSend", () => { + it("puts a failed immediate send before a newer draft and deduplicates attachments", () => { + expect( + mergeFailedComposerSend({ + failedText: "failed first", + currentText: "typed while sending", + failedAttachments: [{ id: "failed" }, { id: "shared", source: "failed" }], + currentAttachments: [{ id: "shared", source: "current" }, { id: "new" }], + }), + ).toEqual({ + text: "failed first\n\ntyped while sending", + attachments: [{ id: "failed" }, { id: "shared", source: "current" }, { id: "new" }], + }); + }); +}); + describe("getStartedThreadModelChangeBlockReason", () => { const providers = [ { @@ -589,7 +606,7 @@ describe("getStartedThreadModelChangeBlockReason", () => { ).toBeNull(); }); - it("blocks started-session model changes when either provider requires a new thread", () => { + it("blocks every cross-instance change after a session starts", () => { expect( getStartedThreadModelChangeBlockReason({ providers, @@ -604,11 +621,91 @@ describe("getStartedThreadModelChangeBlockReason", () => { }, }), ).toEqual({ - title: "Start a new chat to change models", + title: "Start a new chat to change providers", description: - "This provider does not allow switching models after a conversation has started.", + "A started thread stays bound to the exact provider account that created its session.", + }); + }); + + it("allows only available peers with the same exact continuation identity", () => { + const compatibleProviders = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + continuation: { groupKey: "codex:home:shared" }, + enabled: true, + installed: true, + status: "ready" as const, + auth: { status: "authenticated" as const }, + }, + { + instanceId: ProviderInstanceId.make("codex_personal"), + driver: ProviderDriverKind.make("codex"), + continuation: { groupKey: "codex:home:shared" }, + enabled: true, + installed: true, + status: "ready" as const, + auth: { status: "authenticated" as const }, + }, + ]; + const input = { + providers: compatibleProviders, + hasStartedSession: true, + currentProviderInstanceId: ProviderInstanceId.make("codex"), + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + } as const; + + expect(getStartedThreadModelChangeBlockReason(input)).toBeNull(); + expect( + getStartedThreadModelChangeBlockReason({ + ...input, + providers: compatibleProviders.map((provider) => + provider.instanceId === ProviderInstanceId.make("codex_personal") + ? { ...provider, availability: "unavailable" as const } + : provider, + ), + }), + ).toMatchObject({ + description: expect.stringContaining("unavailable"), }); }); + + it("lets a warning bound provider reconcile a stale picker selection", () => { + const providersWithWarningBinding = providers.map((provider) => + provider.instanceId === ProviderInstanceId.make("prime") + ? { + ...provider, + enabled: true, + installed: true, + status: "warning" as const, + auth: { status: "authenticated" as const }, + } + : provider, + ); + expect( + getStartedThreadModelChangeBlockReason({ + providers: providersWithWarningBinding, + hasStartedSession: true, + currentProviderInstanceId: ProviderInstanceId.make("prime"), + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("prime"), + model: "anthropic/claude-sonnet-4.5", + }, + }), + ).toBeNull(); + }); }); describe("resolveSendEnvMode", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index feda7e46a..dd0ce2b5a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -13,6 +13,7 @@ import { type TurnId, } from "@t3tools/contracts"; import { isPrimeAgentDefaultModelUnavailable } from "@t3tools/shared/model"; +import { getProviderAdmissionAvailability } from "@t3tools/client-runtime/providerAvailability"; import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, @@ -397,6 +398,29 @@ export function cloneComposerImageForRetry( } } +export function mergeFailedComposerSend(input: { + readonly failedText: string; + readonly currentText: string; + readonly failedAttachments: ReadonlyArray; + readonly currentAttachments: ReadonlyArray; +}): { readonly text: string; readonly attachments: T[] } { + const currentIds = new Set(input.currentAttachments.map((attachment) => attachment.id)); + return { + text: + input.failedText.length === 0 + ? input.currentText + : input.currentText.length === 0 + ? input.failedText + : `${input.failedText} + +${input.currentText}`, + attachments: [ + ...input.failedAttachments.filter((attachment) => !currentIds.has(attachment.id)), + ...input.currentAttachments, + ], + }; +} + export function deriveComposerSendState(options: { prompt: string; imageCount: number; @@ -547,7 +571,13 @@ export function deriveLockedProvider(input: { export function getStartedThreadModelChangeBlockReason(input: { providers: ReadonlyArray< - Pick + Pick & + Partial< + Pick< + ServerProvider, + "continuation" | "enabled" | "installed" | "auth" | "availability" | "status" + > + > >; hasStartedSession: boolean; currentModelSelection: ModelSelection; @@ -557,18 +587,51 @@ export function getStartedThreadModelChangeBlockReason(input: { if (!input.hasStartedSession) { return null; } - const currentModelSelection = { - ...input.currentModelSelection, - instanceId: input.currentProviderInstanceId ?? input.currentModelSelection.instanceId, - }; - if ( - currentModelSelection.instanceId === input.nextModelSelection.instanceId && - currentModelSelection.model === input.nextModelSelection.model - ) { + const currentInstanceId = + input.currentProviderInstanceId ?? input.currentModelSelection.instanceId; + if (input.nextModelSelection.instanceId !== currentInstanceId) { + const currentProvider = input.providers.find( + (provider) => provider.instanceId === currentInstanceId, + ); + const targetProvider = input.providers.find( + (provider) => provider.instanceId === input.nextModelSelection.instanceId, + ); + const currentContinuationKey = currentProvider?.continuation?.groupKey?.trim() ?? ""; + const targetContinuationKey = targetProvider?.continuation?.groupKey?.trim() ?? ""; + const targetAvailable = + targetProvider !== undefined && + getProviderAdmissionAvailability({ + provider: targetProvider, + instanceId: String(input.nextModelSelection.instanceId), + providerSnapshotKnown: true, + }).status === "available"; + if ( + currentProvider?.driver === targetProvider?.driver && + currentContinuationKey.length > 0 && + currentContinuationKey === targetContinuationKey && + targetAvailable + ) { + return null; + } + return { + title: "Start a new chat to change providers", + description: targetAvailable + ? "A started thread stays bound to the exact provider account that created its session." + : "The selected compatible provider account is unavailable on this environment.", + }; + } + // A stale device can observe the session binding before the exact persisted + // model arrives. Selecting an explicit model for that binding is remediation; + // never synthesize it by copying the old provider's model or options. + if (input.currentModelSelection.instanceId !== currentInstanceId) { + return null; + } + const currentModelSelection = input.currentModelSelection; + if (currentModelSelection.model === input.nextModelSelection.model) { return null; } const currentProvider = input.providers.find( - (snapshot) => snapshot.instanceId === currentModelSelection.instanceId, + (snapshot) => snapshot.instanceId === currentInstanceId, ); const nextProvider = input.providers.find( (snapshot) => snapshot.instanceId === input.nextModelSelection.instanceId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a358df41e..a0e4f1963 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -86,6 +86,7 @@ import { import { flushSync } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; +import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; import { isAtomCommandInterrupted, mapAtomCommandResult, @@ -224,14 +225,17 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; +import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, sortProviderInstanceEntries, } from "../providerInstances"; -import { resolveComposerInstanceSelection } from "../composerInstanceSelection"; +import { + canStartComposerTurn, + resolveComposerInstanceSelection, +} from "../composerInstanceSelection"; import { buildThreadHandoffSeed, getThreadContinuationLinks, @@ -242,6 +246,7 @@ import { ThreadContinuationBanner } from "./chat/ThreadContinuationBanner"; import { deriveComposerUsage } from "../providerUsageAccounts"; import { usageStaleAfterMs } from "./providerUsage/ProviderUsageMatrix.logic"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { useCheckpointDiff } from "../lib/checkpointDiffState"; import { useClientSettings, @@ -404,6 +409,7 @@ import { deriveLockedProvider, readFileAsDataUrl, loadVideoPreviewUrl, + mergeFailedComposerSend, isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, @@ -1365,12 +1371,6 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); - const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { - reportFailure: false, - }); - const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { - reportFailure: false, - }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const followUpThreadInputQueue = useAtomCommand(threadEnvironment.followUpInputQueue, { reportFailure: false, @@ -2480,11 +2480,35 @@ function ChatViewContent(props: ChatViewProps) { versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider, + const threadHandoffEntries = useMemo( + () => applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + [providerStatuses, settings], + ); + const composerInstanceEntries = useMemo( + () => sortProviderInstanceEntries(threadHandoffEntries), + [threadHandoffEntries], ); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const composerInstanceSelection = useMemo( + () => + resolveComposerInstanceSelection({ + entries: composerInstanceEntries, + draftActiveProvider: composerActiveProvider, + sessionInstanceId: activeThread?.session?.providerInstanceId, + threadInstanceId: activeThread?.modelSelection?.instanceId, + projectInstanceId: activeProject?.defaultModelSelection?.instanceId, + lockedProvider, + nowMs: Date.now(), + }), + [ + activeProject?.defaultModelSelection?.instanceId, + activeThread?.modelSelection?.instanceId, + activeThread?.session?.providerInstanceId, + composerActiveProvider, + composerInstanceEntries, + lockedProvider, + ], + ); + const selectedProvider: ProviderDriverKind = composerInstanceSelection.driverKind; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null; @@ -2571,6 +2595,9 @@ function ChatViewContent(props: ChatViewProps) { if (instanceId === undefined) return null; return serverConfig?.providers.find((provider) => provider.instanceId === instanceId) ?? null; }, [activeThread?.session?.providerInstanceId, serverConfig?.providers]); + const sessionProviderAdmissionAvailable = + activeThread?.session?.providerInstanceId !== undefined && + canStartComposerTurn(composerInstanceSelection); const quickQuestionAvailable = canAskSessionSideQuestion( activeSessionProviderStatus, activeEnvironmentConnectionPhase === "connected" ? "connected" : "available", @@ -3178,15 +3205,12 @@ function ChatViewContent(props: ChatViewProps) { }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - // Prefer an instance-id match so a custom Codex instance (e.g. - // `codex_personal`) surfaces its own status/message in the banner rather - // than the default Codex's. Falls back to first-match-by-kind when no - // saved instance id is available or the instance no longer exists. - const selectedProviderInstanceId = - providerStatuses.find((status) => status.instanceId === selectedProviderByThreadId) - ?.instanceId ?? null; + // The banner and adjacent provider controls must describe the exact entry + // the composer resolved. In particular, an unavailable stored preference + // remains the active entry while turn admission is blocked; it must not be + // replaced by a ready account behind the banner. const activeProviderInstanceId = - selectedProviderInstanceId ?? + composerInstanceSelection.entry?.instanceId ?? activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? @@ -3223,6 +3247,46 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const providerAdmissionDisabledReason = useMemo(() => { + if (activeEnvironmentUnavailable) return "Environment disconnected"; + if (isLocalDraftThread && activeProject === null) return "Choose a project before sending"; + const providerUnavailableReason = getProviderAdmissionUnavailableReason({ + provider: activeProviderStatus, + instanceId: activeProviderInstanceId ?? undefined, + providerSnapshotKnown: serverConfig !== null, + }); + if (providerUnavailableReason) return providerUnavailableReason; + if (composerInstanceSelection.entry === undefined) { + const boundInstanceId = activeThread?.session?.providerInstanceId; + return boundInstanceId + ? `The thread's provider binding '${boundInstanceId}' cannot be resolved on this environment.` + : "No provider is available on this environment."; + } + if (!composerInstanceSelection.entry.enabled) { + return `${composerInstanceSelection.entry.displayName} is disabled in provider settings.`; + } + if (!sessionProviderAdmissionAvailable && activeThread?.session?.providerInstanceId) { + const transition = resolveProviderContinuationTransition({ + providers: providerStatuses, + currentInstanceId: activeThread.session.providerInstanceId, + targetInstanceId: composerInstanceSelection.instanceId, + }); + if (!transition.compatible) return transition.reason; + return "The selected model does not belong to an available compatible provider instance."; + } + return null; + }, [ + activeEnvironmentUnavailable, + activeProject, + activeProviderInstanceId, + activeProviderStatus, + activeThread?.session?.providerInstanceId, + composerInstanceSelection, + isLocalDraftThread, + providerStatuses, + serverConfig, + sessionProviderAdmissionAvailable, + ]); const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = useLocalStorage( `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, @@ -4261,78 +4325,32 @@ function ChatViewContent(props: ChatViewProps) { const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; - createdAt: string; - modelSelection?: ModelSelection; branch?: string; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; }): Promise> => { - if (!serverThread) { + if (!serverThread || input.branch === undefined) { return AsyncResult.success(undefined); } - let result: AtomCommandResult = AsyncResult.success(undefined); const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: serverThread.modelSelection, - ...(input.modelSelection ? { nextModelSelection: input.modelSelection } : {}), currentBranch: serverThread.branch, - ...(input.branch ? { nextBranch: input.branch } : {}), + nextBranch: input.branch, }); - if (metadataUpdate) { - result = mapAtomCommandResult( - await updateThreadMetadata({ - environmentId, - input: { - threadId: input.threadId, - ...metadataUpdate, - }, - }), - () => undefined, - ); - if (result._tag === "Failure") { - return result; - } - } - - if (input.runtimeMode !== serverThread.runtimeMode) { - result = mapAtomCommandResult( - await setThreadRuntimeMode({ - environmentId, - input: { - threadId: input.threadId, - runtimeMode: input.runtimeMode, - createdAt: input.createdAt, - }, - }), - () => undefined, - ); - if (result._tag === "Failure") { - return result; - } - } - - if (input.interactionMode !== serverThread.interactionMode) { - result = mapAtomCommandResult( - await setThreadInteractionMode({ - environmentId, - input: { - threadId: input.threadId, - interactionMode: input.interactionMode, - createdAt: input.createdAt, - }, - }), - () => undefined, - ); + if (!metadataUpdate) { + return AsyncResult.success(undefined); } - return result; + return mapAtomCommandResult( + await updateThreadMetadata({ + environmentId, + input: { + threadId: input.threadId, + ...metadataUpdate, + }, + }), + () => undefined, + ); }, - [ - environmentId, - serverThread, - setThreadInteractionMode, - setThreadRuntimeMode, - updateThreadMetadata, - ], + [environmentId, serverThread, updateThreadMetadata], ); // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during @@ -4996,10 +5014,6 @@ function ChatViewContent(props: ChatViewProps) { // drained one can only continue as a fresh thread elsewhere. The offer is // resolved here, next to the thread-creation path that acts on it, and the // composer only renders it. - const threadHandoffEntries = useMemo( - () => applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), - [providerStatuses, settings], - ); // Same snapshot the resume-compaction banner reads; derived once. const threadHandoffContextWindow = activeContextWindow; const threadHandoffOffer = useMemo(() => { @@ -5046,35 +5060,9 @@ function ChatViewContent(props: ChatViewProps) { [threadHandoffDiff.data?.diff], ); const [isContinuingThreadOnAccount, setIsContinuingThreadOnAccount] = useState(false); - // Capacity for the account the composer will actually send to, resolved the - // same way the composer resolves it — picker choice, session binding, drain - // routing and all — so the strip never names one account while the send - // goes to another. For a Prime Agent thread the selected model decides - // whose capacity applies. - const composerInstanceEntries = useMemo( - () => sortProviderInstanceEntries(threadHandoffEntries), - [threadHandoffEntries], - ); - const composerInstanceSelection = useMemo( - () => - resolveComposerInstanceSelection({ - entries: composerInstanceEntries, - draftActiveProvider: composerActiveProvider, - sessionInstanceId: activeThread?.session?.providerInstanceId, - threadInstanceId: activeThread?.modelSelection?.instanceId, - projectInstanceId: activeProject?.defaultModelSelection?.instanceId, - lockedProvider, - nowMs: Date.now(), - }), - [ - activeProject?.defaultModelSelection?.instanceId, - activeThread?.modelSelection?.instanceId, - activeThread?.session?.providerInstanceId, - composerActiveProvider, - composerInstanceEntries, - lockedProvider, - ], - ); + // Capacity for the account the composer will actually send to uses the + // shared selection above, so the strip never names one account while the + // composer is blocked on or sends to another. const { selectedModel: composerSelectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, providers: providerStatuses, @@ -6011,7 +5999,7 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx?.providerAvailable || sendCtx.selectedModelSelection === null) { notifyDirectAnnotationAttached(); return; } @@ -6516,13 +6504,9 @@ function ChatViewContent(props: ChatViewProps) { if (failure === null && delivery === "immediate" && isServerThread) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode, }); if (settingsResult._tag === "Failure") { failure = settingsResult; @@ -6685,87 +6669,65 @@ function ChatViewContent(props: ChatViewProps) { const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); - if (delivery === "follow-up") { - removeOptimisticMessage(); - const currentPrompt = promptRef.current; - const mergedPrompt = - promptForSend.length === 0 - ? currentPrompt - : currentPrompt.length === 0 - ? promptForSend - : `${promptForSend}\n\n${currentPrompt}`; - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - const mergeById = ( - sent: ReadonlyArray, - current: ReadonlyArray, - ): T[] => { - const currentIds = new Set(current.map((item) => item.id)); - return [...sent.filter((item) => !currentIds.has(item.id)), ...current]; - }; - const currentDraft = - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) ?? null; - const mergedTerminalContexts = mergeById( - composerTerminalContextsSnapshot, - composerTerminalContextsRef.current, - ); - const mergedElementContexts = mergeById( - composerElementContextsSnapshot, - composerElementContextsRef.current, - ); - const mergedPreviewAnnotations = mergeById( - composerPreviewAnnotationsSnapshot, - currentDraft?.previewAnnotations ?? [], - ); - const mergedReviewComments = mergeById( - composerReviewCommentsSnapshot, - currentDraft?.reviewComments ?? [], - ); - promptRef.current = mergedPrompt; - composerImagesRef.current = [...composerImagesRef.current, ...retryComposerImages]; - composerTerminalContextsRef.current = mergedTerminalContexts; - composerElementContextsRef.current = mergedElementContexts; - setComposerDraftPrompt(composerDraftTarget, mergedPrompt); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - setComposerDraftTerminalContexts(composerDraftTarget, mergedTerminalContexts); - setComposerDraftElementContexts(composerDraftTarget, mergedElementContexts); - setComposerDraftPreviewAnnotations(composerDraftTarget, mergedPreviewAnnotations); - setComposerDraftReviewComments(composerDraftTarget, mergedReviewComments); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(mergedPrompt, mergedPrompt.length), - prompt: mergedPrompt, - detectTrigger: true, - }); - } else if ( - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerFilesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && - composerElementContextsRef.current.length === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations - .length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 - ) { - removeOptimisticMessage(); - promptRef.current = promptForSend; - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - composerImagesRef.current = retryComposerImages; - composerFilesRef.current = composerFilesSnapshot; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; - composerElementContextsRef.current = composerElementContextsSnapshot; - setComposerDraftPrompt(composerDraftTarget, promptForSend); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); - setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), - prompt: promptForSend, - detectTrigger: true, - }); - } + removeOptimisticMessage(); + const currentDraft = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) ?? null; + const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); + const mergedPromptAndImages = mergeFailedComposerSend({ + failedText: promptForSend, + currentText: promptRef.current, + failedAttachments: retryComposerImages, + currentAttachments: composerImagesRef.current, + }); + const mergedFiles = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerFilesSnapshot, + currentAttachments: composerFilesRef.current, + }).attachments; + const mergedTerminalContexts = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerTerminalContextsSnapshot, + currentAttachments: composerTerminalContextsRef.current, + }).attachments; + const mergedElementContexts = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerElementContextsSnapshot, + currentAttachments: composerElementContextsRef.current, + }).attachments; + const mergedPreviewAnnotations = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerPreviewAnnotationsSnapshot, + currentAttachments: currentDraft?.previewAnnotations ?? [], + }).attachments; + const mergedReviewComments = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerReviewCommentsSnapshot, + currentAttachments: currentDraft?.reviewComments ?? [], + }).attachments; + const mergedPrompt = mergedPromptAndImages.text; + promptRef.current = mergedPrompt; + composerImagesRef.current = mergedPromptAndImages.attachments; + composerFilesRef.current = mergedFiles; + composerTerminalContextsRef.current = mergedTerminalContexts; + composerElementContextsRef.current = mergedElementContexts; + clearComposerDraftContent(composerDraftTarget); + setComposerDraftPrompt(composerDraftTarget, mergedPrompt); + addComposerDraftImages(composerDraftTarget, mergedPromptAndImages.attachments); + addComposerDraftFiles(composerDraftTarget, mergedFiles); + setComposerDraftTerminalContexts(composerDraftTarget, mergedTerminalContexts); + setComposerDraftElementContexts(composerDraftTarget, mergedElementContexts); + setComposerDraftPreviewAnnotations(composerDraftTarget, mergedPreviewAnnotations); + setComposerDraftReviewComments(composerDraftTarget, mergedReviewComments); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(mergedPrompt, mergedPrompt.length), + prompt: mergedPrompt, + detectTrigger: true, + }); if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { @@ -6898,12 +6860,18 @@ function ChatViewContent(props: ChatViewProps) { ); const onCompactSession = useCallback( () => - activeThreadId + activeThreadId && sessionProviderAdmissionAvailable ? runSessionCompactionCommand(() => compactThreadSession({ environmentId, input: { threadId: activeThreadId } }), ) : Promise.resolve(null), - [activeThreadId, compactThreadSession, environmentId, runSessionCompactionCommand], + [ + activeThreadId, + compactThreadSession, + environmentId, + runSessionCompactionCommand, + sessionProviderAdmissionAvailable, + ], ); const onAbortSessionCompaction = useCallback( () => @@ -6919,7 +6887,7 @@ function ChatViewContent(props: ChatViewProps) { ); const onSetSessionAutoCompaction = useCallback( (enabled: boolean) => - activeThreadId + activeThreadId && sessionProviderAdmissionAvailable ? runSessionCompactionCommand(() => setThreadSessionAutoCompaction({ environmentId, @@ -6927,7 +6895,13 @@ function ChatViewContent(props: ChatViewProps) { }), ) : Promise.resolve(null), - [activeThreadId, environmentId, runSessionCompactionCommand, setThreadSessionAutoCompaction], + [ + activeThreadId, + environmentId, + runSessionCompactionCommand, + sessionProviderAdmissionAvailable, + setThreadSessionAutoCompaction, + ], ); const onRefineSessionHarness = @@ -7115,7 +7089,7 @@ function ChatViewContent(props: ChatViewProps) { const onSetSessionInputQueueMode = useCallback( async (queue: "steering" | "follow-up", mode: "all-at-once" | "one-at-a-time") => { - if (!activeThreadId) return; + if (!activeThreadId || !sessionProviderAdmissionAvailable) return; const result = await setThreadSessionInputQueueMode({ environmentId, input: { threadId: activeThreadId, queue, mode }, @@ -7132,7 +7106,13 @@ function ChatViewContent(props: ChatViewProps) { title: `${queue === "steering" ? "Steering" : "Follow-up"} delivery updated`, }); }, - [activeThreadId, environmentId, setThreadError, setThreadSessionInputQueueMode], + [ + activeThreadId, + environmentId, + sessionProviderAdmissionAvailable, + setThreadError, + setThreadSessionInputQueueMode, + ], ); const onRespondToApproval = useCallback( @@ -7380,7 +7360,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx?.providerAvailable || sendCtx.selectedModelSelection === null) { return; } const { @@ -7423,13 +7403,9 @@ function ChatViewContent(props: ChatViewProps) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, - createdAt: messageCreatedAt, - modelSelection: ctxSelectedModelSelection, ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode: nextInteractionMode, }); let failure: AtomCommandResult | null = settingsResult._tag === "Failure" ? settingsResult : null; @@ -7525,7 +7501,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx?.providerAvailable || sendCtx.selectedModelSelection === null) { return; } const { @@ -7716,11 +7692,7 @@ function ChatViewContent(props: ChatViewProps) { sendCtx.selectedModel, ); if (targetModel === null) return; - const nextThreadModelSelection: ModelSelection = { - ...sendCtx.selectedModelSelection, - instanceId: offer.targetInstanceId, - model: targetModel, - }; + const nextThreadModelSelection = createModelSelection(offer.targetInstanceId, targetModel); const targetProviderModels = providerStatuses.find((provider) => provider.instanceId === offer.targetInstanceId)?.models ?? sendCtx.selectedProviderModels; @@ -7894,14 +7866,17 @@ function ChatViewContent(props: ChatViewProps) { return; } if (lockedProvider !== null && activeThread.session?.providerInstanceId) { - const currentEntry = providerStatuses.find( - (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, - ); - if ( - currentEntry?.continuation?.groupKey && - entry?.continuation?.groupKey && - currentEntry.continuation.groupKey !== entry.continuation.groupKey - ) { + const transition = resolveProviderContinuationTransition({ + providers: providerStatuses, + currentInstanceId: activeThread.session.providerInstanceId, + targetInstanceId: instanceId, + }); + if (!transition.compatible) { + toastManager.add({ + type: "warning", + title: "This account cannot continue the thread", + description: transition.reason, + }); scheduleComposerFocus(); return; } @@ -8463,7 +8438,7 @@ function ChatViewContent(props: ChatViewProps) { ? "Messages loading" : activeSessionInteraction ? "Resolve the session request to continue" - : null + : providerAdmissionDisabledReason } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7d5416f2f..6698d57e1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -25,6 +25,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { canAbortSessionCompaction, canConfigureSessionAutoCompaction, @@ -416,7 +417,10 @@ import { SESSION_HARNESS_REFINEMENT_CONFIRMATION, } from "../../sessionHarnessRefinement"; import { getProviderInteractionModeToggle } from "../../providerModels"; -import { resolveComposerInstanceSelection } from "../../composerInstanceSelection"; +import { + canStartComposerTurn, + resolveComposerInstanceSelection, +} from "../../composerInstanceSelection"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -626,6 +630,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isComplete: boolean; } | null; isRunning: boolean; + isStopCapable: boolean; canQueueFollowUp: boolean; onQueueFollowUp: () => void; showPlanFollowUpPrompt: boolean; @@ -660,6 +665,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compact={props.compact} pendingAction={props.pendingAction} isRunning={props.isRunning} + isStopCapable={props.isStopCapable} canQueueFollowUp={props.canQueueFollowUp} onQueueFollowUp={props.onQueueFollowUp} showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} @@ -722,7 +728,7 @@ export interface ChatComposerHandle { reviewComments: ReviewCommentContext[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; - selectedModelSelection: ModelSelection; + selectedModelSelection: ModelSelection | null; providerAvailable: boolean; selectedProvider: ProviderDriverKind; selectedModel: string; @@ -993,6 +999,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ const composerDraft = useComposerThreadDraft(composerDraftTarget); + const reconcileComposerProviderBinding = useComposerDraftStore( + (state) => state.reconcileProviderBinding, + ); // Live target key, for async flows that must notice a thread switch that // happened while they awaited. const composerDraftTargetKeyRef = useRef(""); @@ -1199,6 +1208,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // same driver kind. const selectedProviderEntry = composerSelection.entry; const noProviderAvailable = selectedProviderEntry === undefined; + const providerSelectionBlocked = composerSelection.blockedByUnavailablePreference; + const activeSessionInstanceId = activeThread?.session?.providerInstanceId; + const draftSelectionForSelectedInstance = + composerDraft.modelSelectionByProvider[selectedInstanceId]; + const selectedInstanceTransition = activeSessionInstanceId + ? resolveProviderContinuationTransition({ + providers: providerStatuses, + currentInstanceId: activeSessionInstanceId, + targetInstanceId: selectedInstanceId, + }) + : ({ compatible: true } as const); + const hasSelectionOwnedBySelectedInstance = + activeSessionInstanceId === undefined || + activeThreadModelSelection?.instanceId === selectedInstanceId || + (composerDraft.activeProvider === selectedInstanceId && + draftSelectionForSelectedInstance?.instanceId === selectedInstanceId); + const boundProviderSelectionReason = + activeSessionInstanceId !== undefined && !hasSelectionOwnedBySelectedInstance + ? "Select a model for the thread’s bound provider" + : null; + const providerTurnUnavailable = + !canStartComposerTurn(composerSelection) || + !selectedInstanceTransition.compatible || + !hasSelectionOwnedBySelectedInstance; + useEffect(() => { + if ( + activeSessionInstanceId === undefined || + !composerSelection.draftConflictsWithSessionBinding + ) { + return; + } + reconcileComposerProviderBinding(composerDraftTarget, activeSessionInstanceId); + }, [ + activeSessionInstanceId, + composerDraftTarget, + composerSelection.draftConflictsWithSessionBinding, + reconcileComposerProviderBinding, + ]); // The driver kind follows the instance that will actually run the turn, // which can differ from the persisted selection when that selection is // disabled. @@ -1254,7 +1301,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsReloadingSessionResources(false); } }, [isReloadingSessionResources, onReloadSessionResources, sessionResourceReloadDisabled]); - const activeSessionInstanceId = activeThread?.session?.providerInstanceId; const sessionInputQueue = useMemo( () => activeSessionInstanceId === undefined @@ -1272,6 +1318,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasSessionInputQueueModes(sessionInputQueue) && supportsSessionInputQueueSetModes(activeSessionProviderStatus); const canQueueSessionFollowUp = + !providerTurnUnavailable && activeThread?.session?.status === "running" && phase === "running" && supportsSessionInputQueueFollowUp(activeSessionProviderStatus); @@ -1350,6 +1397,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) settingSessionInputQueueMode?.scopeKey === sessionInputQueueScopeKey; const canSetSessionInputQueueModes = showSessionInputQueueModes && + !providerTurnUnavailable && (activeThread?.session?.status === "ready" || activeThread?.session?.status === "running") && !isConnecting && environmentUnavailable === null && @@ -1471,6 +1519,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sessionCompactionMutation.action === "compact", }); const sendDisabledReason = + boundProviderSelectionReason ?? baseSendDisabledReason ?? (sessionCompactionBlocksSubmission ? "Context compaction in progress" : null); const isSendDisabled = sendDisabledReason !== null; @@ -1568,6 +1617,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : null, canCompact: contextCompactionConnected && + !providerTurnUnavailable && sessionCompactionMutation === null && canStartSessionCompaction(activeSessionProviderStatus, sessionCompaction), canAbort: @@ -1576,11 +1626,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) canAbortSessionCompaction(activeSessionProviderStatus, sessionCompaction), canSetAuto: contextCompactionConnected && + !providerTurnUnavailable && sessionCompactionMutation === null && canConfigureSessionAutoCompaction(activeSessionProviderStatus, sessionCompaction), onCompact: () => { if ( !contextCompactionConnected || + providerTurnUnavailable || !canStartSessionCompaction(activeSessionProviderStatus, sessionCompaction) ) { return; @@ -1599,6 +1651,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onSetAuto: (enabled: boolean) => { if ( !contextCompactionConnected || + providerTurnUnavailable || !canConfigureSessionAutoCompaction(activeSessionProviderStatus, sessionCompaction) ) { return; @@ -1815,9 +1868,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) handleRuntimeModeChange(resolvedRuntimeMode); } }, [handleRuntimeModeChange, resolvedRuntimeMode, runtimeMode]); - const selectedModelSelection = useMemo( - () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), - [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], + const selectedModelSelection = useMemo( + () => + hasSelectionOwnedBySelectedInstance + ? createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch) + : null, + [ + hasSelectionOwnedBySelectedInstance, + selectedInstanceId, + selectedModel, + selectedModelOptionsForDispatch, + ], ); const selectedModelForPicker = selectedModel; // Instance-keyed option list so the picker can show each configured @@ -2199,11 +2260,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy || isSendDisabled || isConnecting || - noProviderAvailable || + providerTurnUnavailable || projectSelectionRequired || environmentUnavailable !== null || !composerSendState.hasSendableContent; - const collapsedComposerPrimaryActionLabel = "Send message"; + const collapsedComposerPrimaryActionLabel = + sendDisabledReason ?? + (environmentUnavailable !== null + ? "Environment disconnected" + : projectSelectionRequired + ? "Choose a project before sending" + : providerTurnUnavailable + ? "The selected provider is unavailable for this thread" + : "Send message"); const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -2793,7 +2862,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy || isSendDisabled || isConnecting || - noProviderAvailable || + providerTurnUnavailable || environmentUnavailable !== null || phase === "running" ) { @@ -2812,14 +2881,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isMobileViewport, isSendBusy, isSendDisabled, - noProviderAvailable, + providerTurnUnavailable, phase, showPlanFollowUpPrompt, ]); const submitComposer = useCallback( (event?: { preventDefault: () => void }, intent: ComposerSubmissionIntent = "foreground") => { - if (noProviderAvailable || isSendDisabled) { + if (providerTurnUnavailable || isSendDisabled) { event?.preventDefault(); return; } @@ -2859,7 +2928,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress, blurMobileComposerAfterSend, isSendDisabled, - noProviderAvailable, + providerTurnUnavailable, onSend, promptRef, shouldBlurMobileComposerOnSubmit, @@ -2868,7 +2937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const compactThreadContext = useCallback(() => { if ( compactDisabled || - noProviderAvailable || + providerTurnUnavailable || composerSendState.hasSendableContent || activePendingApproval !== null || pendingUserInputs.length > 0 || @@ -2910,7 +2979,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerSendState.hasSendableContent, isConnecting, isSendBusy, - noProviderAvailable, + providerTurnUnavailable, pendingUserInputs.length, phase, promptRef, @@ -4095,7 +4164,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, - providerAvailable: !noProviderAvailable, + providerAvailable: !providerTurnUnavailable, selectedProvider, selectedModel, selectedProviderModels, @@ -4138,7 +4207,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, - noProviderAvailable, + providerTurnUnavailable, selectedPromptEffort, selectedProvider, selectedProviderModels, @@ -4294,6 +4363,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compact pendingAction={pendingPrimaryAction} isRunning={false} + isStopCapable={activeThread?.session?.status === "starting"} canQueueFollowUp={false} onQueueFollowUp={() => undefined} showPlanFollowUpPrompt={false} @@ -4303,7 +4373,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || - noProviderAvailable || + providerTurnUnavailable || projectSelectionRequired } isPreparingWorktree={false} @@ -4390,7 +4460,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? activePendingProgress.customAnswer || "Type your own answer, or leave this blank to use the selected option" : prompt.trim() || - (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} + (providerSelectionBlocked + ? "Select another provider to send" + : noProviderAvailable + ? "Enable a provider in Settings" + : "Ask anything...")} {inlineTasksBadge} {inlineStashBadge} @@ -4837,11 +4911,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Add feedback to refine the plan, or leave this blank to implement it" : projectSelectionRequired ? "Choose a project above to start a thread" - : noProviderAvailable - ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? DISCONNECTED_COMPOSER_PLACEHOLDER - : "Ask anything, @tag files/folders, $use skills, or / for commands" + : providerSelectionBlocked + ? "Select another provider to send a message" + : noProviderAvailable + ? "Enable a provider in Settings to send a message" + : phase === "disconnected" + ? DISCONNECTED_COMPOSER_PLACEHOLDER + : "Ask anything, @tag files/folders, $use skills, or / for commands" } disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> @@ -4856,6 +4932,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compact pendingAction={pendingPrimaryAction} isRunning={false} + isStopCapable={activeThread?.session?.status === "starting"} canQueueFollowUp={false} onQueueFollowUp={() => undefined} showPlanFollowUpPrompt={false} @@ -4865,7 +4942,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || - noProviderAvailable || + providerTurnUnavailable || projectSelectionRequired } isPreparingWorktree={false} @@ -4883,6 +4960,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) + {boundProviderSelectionReason ? ( +
+ {boundProviderSelectionReason} +
+ ) : null} {/* Bottom toolbar */} {isComposerCollapsedMobile || isComposerApprovalState ? null : ( @@ -4916,6 +5002,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} lockedContinuationGroupKey={lockedContinuationGroupKey} + lockedInstanceId={activeSessionInstanceId ?? null} instanceEntries={providerInstanceEntries} keybindings={keybindings} modelOptionsByInstance={modelOptionsByInstance} @@ -5101,6 +5188,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sessionGoal={sessionGoal} pendingAction={pendingPrimaryAction} isRunning={phase === "running"} + isStopCapable={ + phase === "running" || activeThread?.session?.status === "starting" + } canQueueFollowUp={canQueueSessionFollowUp} onQueueFollowUp={onQueueFollowUp} showPlanFollowUpPrompt={ @@ -5112,7 +5202,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || - noProviderAvailable || + providerTurnUnavailable || projectSelectionRequired } isPreparingWorktree={isPreparingWorktree} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index c60099076..b3d52a86c 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -46,19 +46,28 @@ function renderPendingActions(isRunning: boolean) { ); } -function renderStandaloneStop() { +function renderStandaloneStop({ + isRunning = true, + isStopCapable, + isConnecting = false, +}: { + isRunning?: boolean; + isStopCapable?: boolean; + isConnecting?: boolean; +} = {}) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { compact: true, pendingAction: null, - isRunning: true, + isRunning, + ...(isStopCapable === undefined ? {} : { isStopCapable }), canQueueFollowUp: false, onQueueFollowUp: () => {}, showPlanFollowUpPrompt: false, promptHasText: false, isSendBusy: false, sendDisabledReason: null, - isConnecting: false, + isConnecting, isEnvironmentUnavailable: false, isPreparingWorktree: false, hasSendableContent: false, @@ -98,6 +107,8 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: function renderQueueCapableRunningActions( showSendWhileRunning: boolean, hasSendableContent: boolean, + sendDisabledReason: string | null = null, + isEnvironmentUnavailable = false, ) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { @@ -109,9 +120,9 @@ function renderQueueCapableRunningActions( showPlanFollowUpPrompt: false, promptHasText: hasSendableContent, isSendBusy: false, - sendDisabledReason: null, + sendDisabledReason, isConnecting: false, - isEnvironmentUnavailable: false, + isEnvironmentUnavailable, isPreparingWorktree: false, hasSendableContent, showSendWhileRunning, @@ -122,7 +133,10 @@ function renderQueueCapableRunningActions( ); } -function renderSendButton(sendDisabledReason: string | null = null) { +function renderSendButton( + sendDisabledReason: string | null = null, + isEnvironmentUnavailable = false, +) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { compact: true, @@ -135,7 +149,7 @@ function renderSendButton(sendDisabledReason: string | null = null) { isSendBusy: false, sendDisabledReason, isConnecting: false, - isEnvironmentUnavailable: false, + isEnvironmentUnavailable, isPreparingWorktree: false, hasSendableContent: true, onPreviousPendingQuestion: () => {}, @@ -248,6 +262,28 @@ describe("ComposerPrimaryActions", () => { expect(markup).toContain('aria-label="Sending feedback"'); }); + it("keeps a concrete provider reason when the aggregate unavailable flag is also set", () => { + const reason = "Select a model for the thread’s bound provider"; + const sendMarkup = renderSendButton(reason, true); + const queueMarkup = renderQueueCapableRunningActions(true, true, reason, true); + + expect(sendMarkup).toContain(`aria-label="${reason}"`); + expect(sendMarkup).not.toContain('aria-label="Environment disconnected"'); + expect(queueMarkup).toContain(`aria-label="${reason}"`); + expect(queueMarkup).not.toContain('aria-label="Environment disconnected"'); + }); + + it("offers Stop generation while provider admission is still starting", () => { + const markup = renderStandaloneStop({ + isRunning: false, + isStopCapable: true, + isConnecting: true, + }); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Connecting…"'); + }); + it("offers Stop generation while a running turn is waiting for user input", () => { expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index e52eb287a..0d0b28df3 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -19,6 +19,7 @@ interface ComposerPrimaryActionsProps { compact: boolean; pendingAction: PendingActionState | null; isRunning: boolean; + isStopCapable?: boolean; canQueueFollowUp: boolean; onQueueFollowUp: () => void; showPlanFollowUpPrompt: boolean; @@ -64,6 +65,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ compact, pendingAction, isRunning, + isStopCapable = isRunning, canQueueFollowUp, onQueueFollowUp, showPlanFollowUpPrompt, @@ -113,7 +115,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ if (pendingAction) { return (
- {isRunning ? renderStopGenerationButton(true) : null} + {isStopCapable ? renderStopGenerationButton(true) : null} {pendingAction.questionIndex > 0 ? ( compact ? ( + +
+ {!props.canContinueOnBoundProvider ? ( + + The bound account settings are still syncing. Starting a new thread keeps the original + selection now. + + ) : null} + + ); +}); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d2be0f69d..74f4a17b3 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1612,21 +1612,270 @@ describe("composerDraftStore modelSelection", () => { resetComposerDraftStore(); }); - it("clears only provider-shaped draft state when a live session binds elsewhere", () => { + it("durably blocks a cross-client binding without changing content or explicit routing", () => { const store = useComposerDraftStore.getState(); + const image = makeImage({ id: "binding-image", previewUrl: "data:image/png;base64,AQ==" }); + const file = makeFile("binding-file"); + const originalSelection = modelSelection(CODEX_DRIVER, "gpt-5.4", { + reasoningEffort: "high", + }); store.setPrompt(threadRef, "keep this prompt"); - store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); + store.addImage(threadRef, image); + store.addFiles(threadRef, [file]); + store.setModelSelection(threadRef, originalSelection, { explicit: true }); store.setRuntimeMode(threadRef, "approval-required"); store.setInteractionMode(threadRef, "plan"); + store.syncPersistedAttachments(threadRef, [ + { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: image.previewUrl, + }, + ]); - store.reconcileProviderBinding(threadRef, CLAUDE_AGENT_INSTANCE); + store.setProviderBindingConflict(threadRef, CLAUDE_AGENT_INSTANCE); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); - expect(draft?.prompt).toBe("keep this prompt"); - expect(draft?.activeProvider).toBe(CLAUDE_AGENT_INSTANCE); - expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]).toBeUndefined(); - expect(draft?.runtimeMode).toBeNull(); - expect(draft?.interactionMode).toBeNull(); + expect(draft).toMatchObject({ + prompt: "keep this prompt", + activeProvider: CODEX_INSTANCE, + modelSelectionExplicit: true, + runtimeMode: "approval-required", + interactionMode: "plan", + providerBindingConflict: { + boundInstanceId: CLAUDE_AGENT_INSTANCE, + originalSelection, + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }); + expect(draft?.images.map((entry) => entry.id)).toEqual([image.id]); + expect(draft?.files.map((entry) => entry.id)).toEqual([file.id]); + expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]).toEqual(originalSelection); + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const options = persistApi.getOptions(); + const persisted = options.partialize(useComposerDraftStore.getState()); + const hydrated = options.merge(persisted, useComposerDraftStore.getState()); + const hydratedDraft = hydrated.getComposerDraft(threadRef); + expect(hydratedDraft?.providerBindingConflict).toEqual(draft?.providerBindingConflict); + expect(hydratedDraft?.prompt).toBe("keep this prompt"); + expect(hydratedDraft?.images.map((entry) => entry.id)).toEqual([image.id]); + expect(hydratedDraft?.files.map((entry) => entry.id)).toEqual([file.id]); + expect(hydratedDraft?.modelSelectionByProvider[CODEX_INSTANCE]).toEqual(originalSelection); + }); + + it("changes routing only after the user chooses the bound provider", () => { + const store = useComposerDraftStore.getState(); + const originalSelection = modelSelection(CODEX_DRIVER, "gpt-5.4"); + const boundSelection = modelSelection(CLAUDE_AGENT_DRIVER, "claude-sonnet-4-5"); + store.setPrompt(threadRef, "keep this prompt"); + store.setModelSelection(threadRef, originalSelection, { explicit: true }); + store.setProviderBindingConflict(threadRef, CLAUDE_AGENT_INSTANCE); + + store.continueOnBoundProvider(threadRef, boundSelection); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({ + prompt: "keep this prompt", + activeProvider: CLAUDE_AGENT_INSTANCE, + modelSelectionExplicit: true, + runtimeMode: null, + interactionMode: null, + }); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.providerBindingConflict).toBeUndefined(); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE], + ).toEqual(boundSelection); + }); + + it("atomically transfers a complete blocked composer snapshot with its exact selection", () => { + const store = useComposerDraftStore.getState(); + const destinationDraftId = DraftId.make("draft-provider-conflict-destination"); + const destinationThreadId = ThreadId.make("thread-provider-conflict-destination"); + const projectRef = scopeProjectRef( + TEST_ENVIRONMENT_ID, + ProjectId.make("project-provider-conflict"), + ); + const originalSelection = modelSelection(CODEX_DRIVER, "gpt-5.4", { + reasoningEffort: "xhigh", + fastMode: true, + }); + const boundSelection = modelSelection(CLAUDE_AGENT_DRIVER, "claude-sonnet-4-5"); + const image = makeImage({ + id: "move-conflict-image", + previewUrl: "data:image/png;base64,AQ==", + }); + const file = makeFile("move-conflict-file"); + const reviewComment = { + id: "move-conflict-comment", + sectionId: "file:src/app.ts", + sectionTitle: "File comment", + filePath: "src/app.ts", + startIndex: 1, + endIndex: 2, + rangeLabel: "L2 to L3", + text: "Keep this configurable.", + diff: "@@ -2,2 +2,2 @@\n two\n three", + } as const; + const previewAnnotation = { + id: "move-conflict-annotation", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Keep the primary action visible.", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-09-02T10:00:00.000Z", + } as const; + + store.setModelSelection(threadRef, boundSelection); + store.setModelSelection(threadRef, originalSelection, { explicit: true }); + store.setRuntimeMode(threadRef, "approval-required"); + store.setInteractionMode(threadRef, "plan"); + store.addTerminalContext(threadRef, makeTerminalContext({ id: "move-conflict-terminal" })); + store.setPrompt(threadRef, `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} move this exact prompt`); + store.addElementContext(threadRef, { + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + tagName: "button", + selector: "button.submit", + htmlPreview: "", + componentName: "SubmitButton", + source: null, + styles: ".submit { color: white; }", + }); + store.addPreviewAnnotation(threadRef, previewAnnotation); + store.addReviewComment(threadRef, reviewComment); + store.addImage(threadRef, image); + store.syncPersistedAttachments(threadRef, [ + { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: image.previewUrl, + }, + ]); + store.addFiles(threadRef, [file]); + store.setFileUpload(threadRef, file.id, TEST_ENVIRONMENT_ID, "pending-move-conflict-file"); + store.setProviderBindingConflict(threadRef, CLAUDE_AGENT_INSTANCE); + store.setProjectDraftThreadId(projectRef, destinationDraftId, { + threadId: destinationThreadId, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + + store.transferComposerContentSnapshotForProviderConflict(threadRef, destinationDraftId); + + const destination = store.getComposerDraft(destinationDraftId); + expect(destination).toMatchObject({ + prompt: `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} move this exact prompt`, + activeProvider: CODEX_INSTANCE, + modelSelectionExplicit: true, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + expect(destination?.images.map((entry) => entry.id)).toEqual([image.id]); + expect(destination?.files).toMatchObject([ + { + id: file.id, + uploadedAttachmentId: "pending-move-conflict-file", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + ]); + expect(destination?.terminalContexts).toMatchObject([ + { id: "move-conflict-terminal", threadId: destinationThreadId }, + ]); + expect(destination?.elementContexts).toMatchObject([{ threadId: destinationThreadId }]); + expect(destination?.previewAnnotations).toEqual([previewAnnotation]); + expect(destination?.reviewComments).toEqual([reviewComment]); + expect(destination?.modelSelectionByProvider).toEqual({ + [CODEX_INSTANCE]: originalSelection, + }); + + const source = store.getComposerDraft(threadRef); + expect(source).toMatchObject({ + prompt: "", + images: [], + files: [], + terminalContexts: [], + elementContexts: [], + previewAnnotations: [], + reviewComments: [], + activeProvider: CLAUDE_AGENT_INSTANCE, + runtimeMode: null, + interactionMode: null, + }); + expect(source?.providerBindingConflict).toBeUndefined(); + expect(source?.modelSelectionByProvider[CODEX_INSTANCE]).toBeUndefined(); + expect(source?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]).toEqual(boundSelection); + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const options = persistApi.getOptions(); + const hydrated = options.merge( + options.partialize(useComposerDraftStore.getState()), + useComposerDraftStore.getInitialState(), + ); + const hydratedDestination = hydrated.getComposerDraft(destinationDraftId); + expect(hydratedDestination?.files).toMatchObject([ + { + id: file.id, + uploadedAttachmentId: "pending-move-conflict-file", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + ]); + expect(hydratedDestination?.terminalContexts).toMatchObject([ + { id: "move-conflict-terminal", threadId: destinationThreadId }, + ]); + expect(hydratedDestination?.elementContexts).toMatchObject([{ threadId: destinationThreadId }]); + expect(hydratedDestination?.previewAnnotations).toEqual([previewAnnotation]); + expect(hydratedDestination?.reviewComments).toEqual([reviewComment]); + expect(hydratedDestination?.modelSelectionByProvider[CODEX_INSTANCE]).toEqual( + originalSelection, + ); + }); + + it("does not overwrite an existing destination composer during conflict recovery", () => { + const store = useComposerDraftStore.getState(); + const destinationDraftId = DraftId.make("draft-provider-conflict-existing"); + const originalSelection = modelSelection(CODEX_DRIVER, "gpt-5.4"); + store.setPrompt(threadRef, "source content"); + store.setModelSelection(threadRef, originalSelection, { explicit: true }); + store.setProviderBindingConflict(threadRef, CLAUDE_AGENT_INSTANCE); + store.setProjectDraftThreadId( + scopeProjectRef(TEST_ENVIRONMENT_ID, ProjectId.make("project-provider-conflict-existing")), + destinationDraftId, + { threadId: ThreadId.make("thread-provider-conflict-existing") }, + ); + store.setPrompt(destinationDraftId, "unrelated destination content"); + + store.transferComposerContentSnapshotForProviderConflict(threadRef, destinationDraftId); + + expect(store.getComposerDraft(threadRef)?.prompt).toBe("source content"); + expect(store.getComposerDraft(threadRef)?.providerBindingConflict).toBeDefined(); + expect(store.getComposerDraft(destinationDraftId)?.prompt).toBe( + "unrelated destination content", + ); }); it("stores a model selection in the draft", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e8f5ef125..2bb7c1e17 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -64,7 +64,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 9; +const COMPOSER_DRAFT_STORAGE_VERSION = 10; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -198,6 +198,16 @@ const PersistedElementContextDraft = Schema.Struct({ }); type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; +const PersistedComposerProviderBindingConflict = Schema.Struct({ + boundInstanceId: ProviderInstanceId, + originalSelection: ModelSelection, + runtimeMode: Schema.NullOr(RuntimeMode), + interactionMode: Schema.NullOr(ProviderInteractionMode), +}); + +type PersistedComposerProviderBindingConflict = + typeof PersistedComposerProviderBindingConflict.Type; + const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), @@ -223,6 +233,7 @@ const PersistedComposerThreadDraftState = Schema.Struct({ // selections (project default / sticky) leave it unset so later seeds can // replace them; legacy entries predate the flag and read as seeded too. modelSelectionExplicit: Schema.optionalKey(Schema.Boolean), + providerBindingConflict: Schema.optionalKey(PersistedComposerProviderBindingConflict), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); @@ -325,6 +336,16 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ * Composer content keyed by either a draft session (`DraftId`) or a real server * thread (`ScopedThreadRef`). This is the editable payload shown in the composer. */ +export interface ComposerProviderBindingConflict { + /** Live provider/account another client bound this thread to. */ + boundInstanceId: ProviderInstanceId; + /** Exact explicit target the unsent draft had before that binding appeared. */ + originalSelection: ModelSelection; + /** Provider-shaped mode snapshots that follow the draft only into a new thread. */ + runtimeMode: RuntimeMode | null; + interactionMode: ProviderInteractionMode | null; +} + export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; @@ -358,6 +379,8 @@ export interface ComposerThreadDraftState { * may replace it. Legacy entries predate the flag and read as seeded. */ modelSelectionExplicit?: boolean; + /** Durable user-decision barrier for a cross-client provider binding conflict. */ + providerBindingConflict?: ComposerProviderBindingConflict; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -519,11 +542,25 @@ interface ComposerDraftStoreState { setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; - /** Reconcile a stale cross-instance draft after a live session binds. */ - reconcileProviderBinding: ( + /** + * Record or clear a durable cross-client provider binding conflict without + * changing the draft's selected provider, model, modes, text, or attachments. + */ + setProviderBindingConflict: ( threadRef: ComposerThreadTarget, - providerInstanceId: ProviderInstanceId, + boundInstanceId: ProviderInstanceId | null, ) => void; + /** Apply the user's explicit choice to continue on the live bound provider. */ + continueOnBoundProvider: ( + threadRef: ComposerThreadTarget, + modelSelection: ModelSelection, + ) => void; + /** + * Atomically moves a complete unsent composer snapshot into a fresh draft + * after a provider-binding conflict. The snapshot includes every attachment + * and context type plus the exact original provider/model and mode choices. + */ + transferComposerContentSnapshotForProviderConflict: (from: ScopedThreadRef, to: DraftId) => void; setModelSelection: ( threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, @@ -853,6 +890,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.reviewComments.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && + draft.providerBindingConflict === undefined && draft.runtimeMode === null && draft.interactionMode === null ); @@ -1033,6 +1071,31 @@ type NormalizedModelSelection = Omit & { readonly instanceId: ProviderInstanceId; }; +function normalizeProviderBindingConflict( + value: unknown, +): ComposerProviderBindingConflict | undefined { + if (!value || typeof value !== "object") return undefined; + const candidate = value as Record; + const boundInstanceId = normalizeProviderInstanceId(candidate.boundInstanceId); + const originalSelection = normalizeModelSelection(candidate.originalSelection); + if ( + boundInstanceId === null || + originalSelection === null || + originalSelection.instanceId === boundInstanceId + ) { + return undefined; + } + return { + boundInstanceId, + originalSelection, + runtimeMode: isRuntimeMode(candidate.runtimeMode) ? candidate.runtimeMode : null, + interactionMode: + candidate.interactionMode === "plan" || candidate.interactionMode === "default" + ? candidate.interactionMode + : null, + }; +} + // ── Legacy sync helpers (used only during migration from v2 storage) ── // // These operate against the legacy kind-keyed `modelOptions` map. The @@ -1864,6 +1927,19 @@ function normalizePersistedDraftsByThreadId( let modelSelectionByProvider: Partial> = {}; let activeProvider: ProviderInstanceId | null = null; let modelSelectionExplicit: true | undefined = undefined; + const normalizedProviderBindingConflict = normalizeProviderBindingConflict( + draftCandidate.providerBindingConflict, + ); + const providerBindingConflict: + | DeepMutable + | undefined = normalizedProviderBindingConflict + ? { + ...normalizedProviderBindingConflict, + originalSelection: cloneModelSelection( + normalizedProviderBindingConflict.originalSelection, + ), + } + : undefined; if ( draftCandidate.modelSelectionByProvider && @@ -1917,6 +1993,7 @@ function normalizePersistedDraftsByThreadId( elementContexts.length === 0 && reviewComments.length === 0 && !hasModelData && + providerBindingConflict === undefined && !runtimeMode && !interactionMode ) { @@ -1948,6 +2025,7 @@ function normalizePersistedDraftsByThreadId( ...(modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), } : {}), + ...(providerBindingConflict ? { providerBindingConflict } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), }; @@ -1977,6 +2055,7 @@ function stripLegacyModelSeedsFromEmptyDraftSessions( if ( draftThreadsByThreadKey[threadKey] === undefined || draft.modelSelectionExplicit === true || + draft.providerBindingConflict !== undefined || persistedComposerDraftHasUserContent(draft) ) { return [[threadKey, draft]]; @@ -2050,6 +2129,7 @@ function partializeComposerDraftStoreState( draft.previewAnnotations.length === 0 && draft.reviewComments.length === 0 && !hasModelData && + draft.providerBindingConflict === undefined && draft.runtimeMode === null && draft.interactionMode === null ) { @@ -2128,6 +2208,18 @@ function partializeComposerDraftStoreState( ...(draft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), } : {}), + ...(draft.providerBindingConflict + ? { + providerBindingConflict: { + boundInstanceId: draft.providerBindingConflict.boundInstanceId, + originalSelection: cloneModelSelection( + draft.providerBindingConflict.originalSelection, + ), + runtimeMode: draft.providerBindingConflict.runtimeMode, + interactionMode: draft.providerBindingConflict.interactionMode, + }, + } + : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), }; @@ -2390,6 +2482,16 @@ function toHydratedThreadDraft( modelSelectionByProvider, activeProvider, ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), + ...(persistedDraft.providerBindingConflict + ? { + providerBindingConflict: { + ...persistedDraft.providerBindingConflict, + originalSelection: cloneModelSelection( + persistedDraft.providerBindingConflict.originalSelection, + ), + }, + } + : {}), runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, }; @@ -2913,37 +3015,172 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, - reconcileProviderBinding: (threadRef, providerInstanceId) => { + setProviderBindingConflict: (threadRef, boundInstanceId) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) return; set((state) => { const existing = state.draftsByThreadKey[threadKey]; - const conflictingInstanceId = existing?.activeProvider; + if (!existing) return state; + if (boundInstanceId === null) { + if (existing.providerBindingConflict === undefined) return state; + const { providerBindingConflict: _conflict, ...nextDraft } = existing; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: nextDraft, + }, + }; + } + const originalSelection = existing.activeProvider + ? existing.modelSelectionByProvider[existing.activeProvider] + : undefined; if ( - !existing || - conflictingInstanceId == null || - conflictingInstanceId === providerInstanceId + originalSelection === undefined || + originalSelection.instanceId === boundInstanceId ) { return state; } - const modelSelectionByProvider = { ...existing.modelSelectionByProvider }; - delete modelSelectionByProvider[conflictingInstanceId]; - const { modelSelectionExplicit: _explicit, ...rest } = existing; - const nextDraft: ComposerThreadDraftState = { - ...rest, - modelSelectionByProvider, - activeProvider: providerInstanceId, - runtimeMode: null, - interactionMode: null, + const providerBindingConflict: ComposerProviderBindingConflict = { + boundInstanceId, + originalSelection: cloneModelSelection(originalSelection), + runtimeMode: existing.runtimeMode, + interactionMode: existing.interactionMode, }; + if (Equal.equals(existing.providerBindingConflict, providerBindingConflict)) { + return state; + } return { draftsByThreadKey: { ...state.draftsByThreadKey, - [threadKey]: nextDraft, + [threadKey]: { + ...existing, + providerBindingConflict, + }, }, }; }); }, + continueOnBoundProvider: (threadRef, modelSelection) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) return; + const normalized = normalizeModelSelection(modelSelection); + if (!normalized) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if ( + !existing?.providerBindingConflict || + existing.providerBindingConflict.boundInstanceId !== normalized.instanceId + ) { + return state; + } + const { providerBindingConflict: _conflict, ...retained } = existing; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...retained, + modelSelectionByProvider: { + ...existing.modelSelectionByProvider, + [normalized.instanceId]: normalized, + }, + activeProvider: normalized.instanceId, + modelSelectionExplicit: true, + // Provider-shaped modes from the conflicting selection do + // not cross onto the bound runtime. The thread snapshot is + // authoritative until the user changes them afterwards. + runtimeMode: null, + interactionMode: null, + }, + }, + }; + }); + }, + transferComposerContentSnapshotForProviderConflict: (from, to) => { + const fromKey = resolveComposerDraftKey(get(), from) ?? ""; + const toKey = resolveComposerDraftKey(get(), to) ?? ""; + if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) return; + set((state) => { + const source = state.draftsByThreadKey[fromKey]; + const destinationSession = state.draftThreadsByThreadKey[toKey]; + const conflict = source?.providerBindingConflict; + // This is intentionally not a general composer move. Conflict + // recovery always creates a fresh draft in the same environment. + // Refuse to overwrite another composer's state or to carry + // environment-bound uploads somewhere they cannot be reached. + if ( + !source || + !conflict || + !destinationSession || + destinationSession.environmentId !== from.environmentId || + state.draftsByThreadKey[toKey] !== undefined + ) { + return state; + } + + const originalSelection = cloneModelSelection(conflict.originalSelection); + const destinationThreadId = destinationSession.threadId; + const nextDestination: ComposerThreadDraftState = { + prompt: source.prompt, + images: source.images, + files: source.files, + nonPersistedImageIds: source.nonPersistedImageIds, + persistedAttachments: source.persistedAttachments, + terminalContexts: source.terminalContexts.map((context) => ({ + ...context, + threadId: destinationThreadId, + })), + elementContexts: source.elementContexts.map((context) => ({ + ...context, + threadId: destinationThreadId, + })), + previewAnnotations: source.previewAnnotations, + reviewComments: source.reviewComments, + modelSelectionByProvider: { + [originalSelection.instanceId]: originalSelection, + }, + activeProvider: originalSelection.instanceId, + modelSelectionExplicit: true, + runtimeMode: conflict.runtimeMode ?? destinationSession.runtimeMode, + interactionMode: conflict.interactionMode ?? destinationSession.interactionMode, + }; + + const modelSelectionByProvider = { ...source.modelSelectionByProvider }; + delete modelSelectionByProvider[originalSelection.instanceId]; + const { + providerBindingConflict: _conflict, + modelSelectionExplicit: _explicit, + ...retainedSource + } = source; + // Do not revoke image preview URLs: ownership moves to the new + // composer in this same transaction. + const nextSource: ComposerThreadDraftState = { + ...retainedSource, + prompt: "", + images: [], + files: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + elementContexts: [], + previewAnnotations: [], + reviewComments: [], + modelSelectionByProvider, + activeProvider: conflict.boundInstanceId, + runtimeMode: null, + interactionMode: null, + }; + const nextDraftsByThreadKey = { + ...state.draftsByThreadKey, + [toKey]: nextDestination, + }; + if (shouldRemoveDraft(nextSource)) { + delete nextDraftsByThreadKey[fromKey]; + } else { + nextDraftsByThreadKey[fromKey] = nextSource; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, setModelSelection: (threadRef, modelSelection, opts) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -3933,6 +4170,11 @@ const composerDraftStore = create()( export const useComposerDraftStore = composerDraftStore; +/** Force the latest conflict/move transaction into localStorage before navigation. */ +export function flushComposerDraftStore(): void { + composerDebouncedStorage.flush(); +} + export function beginBackgroundDraftSubmissionByRef(threadRef: ScopedThreadRef): void { const threadKey = scopedThreadKey(threadRef); useComposerDraftStore.setState((state) => { From 183ad68d7a0e392fa3e9f2569f361209a0b037aa Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Tue, 1 Sep 2026 18:24:37 -0600 Subject: [PATCH 5/5] test(mobile): isolate outbox drain state atoms --- .../src/state/use-thread-outbox-drain.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index b991fa4ee..2e1d5a8d5 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -80,6 +80,42 @@ vi.mock("./entities", () => ({ useThreadShells: () => [], })); +vi.mock("./projects", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + environmentProjects: { + projectsAtom: Atom.make([]).pipe(Atom.keepAlive), + }, + }; +}); + +vi.mock("./presentation", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + environmentPresentations: { + presentationAtom: Atom.family(() => Atom.make(null).pipe(Atom.keepAlive)), + }, + }; +}); + +vi.mock("./shell", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + environmentShell: { + stateValueAtom: Atom.family(() => + Atom.make({ status: "empty" as const }).pipe(Atom.keepAlive), + ), + }, + }; +}); + +vi.mock("./server", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive), + }; +}); + vi.mock("./threads", () => ({ threadEnvironment: {}, }));