From dbaabecad5bc793454f69d2413e879e6396d2bea Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:44:40 -0700 Subject: [PATCH] fix(web): resume imported custom-provider threads Resolve imported thread instance IDs through the environment provider catalog before locking the composer to a driver. Keep an existing session authoritative and preserve unavailable instance locks. Prepared with GPT 6 Astra using the Codex harness. --- .../web/src/components/ChatView.logic.test.ts | 108 ++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 31 +++-- apps/web/src/components/ChatView.tsx | 13 ++- 3 files changed, 128 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a5a8503985b2..47173520087a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -28,6 +28,7 @@ import { buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + deriveLockedProvider, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getAntigravitySendBlockReason, @@ -792,6 +793,113 @@ describe("resolveComposerProviderSelection", () => { ])[0]!; } + function importedThread(instanceId: ProviderInstanceId) { + return makeThread({ + modelSelection: { instanceId, model: "default" }, + messages: [ + { + id: MessageId.make(`import:${instanceId}:session:000000`), + role: "user", + text: "Continue the imported conversation", + turnId: null, + createdAt: now, + updatedAt: now, + streaming: false, + }, + ], + }); + } + + it.each([ + ["claudeAgent", "claude_work"], + ["codex", "codex_work"], + ["ollama", "local_models"], + ])("keeps imported %s history selectable through its custom instance", (driver, instanceId) => { + const importedEntry = entry(driver, instanceId); + const entries = [entry(driver === "codex" ? "claudeAgent" : "codex"), importedEntry]; + const thread = importedThread(importedEntry.instanceId); + const lockedProvider = deriveLockedProvider({ + thread, + selectedProvider: entries[0]!.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: entries.map((entry) => entry.snapshot), + }); + + expect(thread.session).toBeNull(); + expect(lockedProvider).toBe(driver); + expect( + resolveComposerProviderSelection({ + entries, + candidateInstanceIds: [thread.modelSelection.instanceId], + lockedProvider, + lockedInstanceId: thread.modelSelection.instanceId, + }).selectedProviderEntry?.instanceId, + ).toBe(importedEntry.instanceId); + }); + + it("keeps the session driver authoritative over instance and draft selections", () => { + const selected = entry("claudeAgent", "claude_work"); + const sessionEntry = entry("ollama", "local_models"); + const thread = importedThread(selected.instanceId); + + expect( + deriveLockedProvider({ + thread: { + ...thread, + session: { + ...readySession, + providerName: sessionEntry.driverKind, + providerInstanceId: sessionEntry.instanceId, + }, + }, + selectedProvider: selected.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: [selected.snapshot, sessionEntry.snapshot], + }), + ).toBe(sessionEntry.driverKind); + }); + + it.each(["missing", "disabled"] as const)( + "does not move imported history to another driver when its instance is %s", + (state) => { + const imported = entry("claudeAgent", "claude_work", { enabled: false }); + const other = entry("codex"); + const entries = state === "missing" ? [other] : [other, imported]; + const thread = importedThread(imported.instanceId); + const lockedProvider = deriveLockedProvider({ + thread, + selectedProvider: other.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: entries.map((entry) => entry.snapshot), + }); + + expect(lockedProvider).not.toBeNull(); + expect( + resolveComposerProviderSelection({ + entries, + candidateInstanceIds: [other.instanceId, imported.instanceId], + lockedProvider, + lockedInstanceId: imported.instanceId, + }).selectedProviderEntry, + ).toBeUndefined(); + }, + ); + + it("leaves a new draft free to select a different driver", () => { + const original = entry("claudeAgent", "claude_work"); + const selected = entry("codex", "codex_work"); + expect( + deriveLockedProvider({ + thread: makeThread({ + modelSelection: { instanceId: original.instanceId, model: "default" }, + }), + selectedProvider: selected.instanceId, + threadProvider: original.instanceId, + providers: [original.snapshot, selected.snapshot], + }), + ).toBeNull(); + }); + it("uses the custom instance's capability instead of the default instance", () => { const defaultEntry = entry("antigravity", "antigravity", { showInteractionModeToggle: true, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bf576a3c7635..ff0b15071955 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -795,22 +795,13 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } -// `threadProvider` is the open branded driver kind carried by the session. -// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe -// rollback / fork behavior — the routing layer is the right place to surface -// "driver not installed" errors, not the lock state. -// -// `selectedProvider` takes the same open-string shape because the composer -// now tracks the picker selection as a `ProviderInstanceId` (e.g. -// `codex_personal`). Custom instance ids that don't directly match a -// registered driver resolve to `null` here, which matches the existing -// "unknown driver -> unlocked" semantics. Callers that want the lock to track -// a custom instance's underlying driver kind should resolve the instance id -// upstream and pass the correlated kind. +// Imported history has no session until its first prompt. Resolve its instance +// through the environment's provider catalog before locking to a driver. export function deriveLockedProvider(input: { thread: Thread | null | undefined; selectedProvider: string | null; threadProvider: string | null; + providers: ReadonlyArray>; }): ProviderDriverKind | null { if (!threadHasStarted(input.thread)) { return null; @@ -819,14 +810,18 @@ export function deriveLockedProvider(input: { if (sessionProvider && isProviderDriverKind(sessionProvider)) { return sessionProvider; } + // Preserve the existing lock while an instance is missing from the catalog; + // a started thread must not silently fall back to a different driver. + const threadProvider = + input.providers.find((provider) => provider.instanceId === input.threadProvider)?.driver ?? + input.threadProvider; + const selectedProvider = + input.providers.find((provider) => provider.instanceId === input.selectedProvider)?.driver ?? + input.selectedProvider; const narrowedThreadProvider = - input.threadProvider && isProviderDriverKind(input.threadProvider) - ? input.threadProvider - : null; + threadProvider && isProviderDriverKind(threadProvider) ? threadProvider : null; const narrowedSelectedProvider = - input.selectedProvider && isProviderDriverKind(input.selectedProvider) - ? input.selectedProvider - : null; + selectedProvider && isProviderDriverKind(selectedProvider) ? selectedProvider : null; return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5bba2212d5e1..6173c760ceeb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2258,6 +2258,12 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); + // Once a thread selects an environment, never substitute the primary + // environment's config while the selected environment is still loading. + const serverConfig = activeThread + ? (activeEnvironment?.serverConfig ?? null) + : (primaryEnvironment?.serverConfig ?? null); + const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -2267,12 +2273,8 @@ export default function ChatView(props: ChatViewProps) { thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, + providers: providerStatuses, }); - // Once a thread selects an environment, never substitute the primary - // environment's config while the selected environment is still loading. - const serverConfig = activeThread - ? (activeEnvironment?.serverConfig ?? null) - : (primaryEnvironment?.serverConfig ?? null); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; @@ -2484,7 +2486,6 @@ export default function ChatView(props: ChatViewProps) { versionMismatchThreadContinuation, versionMismatchServerLabel, ]); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const providerInstanceEntries = useMemo( () => sortProviderInstanceEntries(