authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 12:10:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 12:10:20-07:00
logd92dca74eb7b7c6068619752f5b9a55c0e36f352
treeac5dfd631b0677193471f5beb8d11d848aff6392
parent4ca71463a289e7b96b5776b6f6bbdecefe4757e4
signature Signed by PGP key B5690EEEBB952194

fix(web): resume imported custom-provider threads (#10184)


3 files changed, 128 insertions(+), 24 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+108
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 buildThreadTurnInterruptInput,28 buildThreadTurnInterruptInput,
29 createLocalDispatchSnapshot,29 createLocalDispatchSnapshot,
30 deriveComposerSendState,30 deriveComposerSendState,
31 deriveLockedProvider,
31 dismissBranchMismatchForSession,32 dismissBranchMismatchForSession,
32 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,33 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
33 getAntigravitySendBlockReason,34 getAntigravitySendBlockReason,
...@@ -792,6 +793,113 @@ describe("resolveComposerProviderSelection", () => {...@@ -792,6 +793,113 @@ describe("resolveComposerProviderSelection", () => {
792 ])[0]!;793 ])[0]!;
793 }794 }
794795
796 function importedThread(instanceId: ProviderInstanceId) {
797 return makeThread({
798 modelSelection: { instanceId, model: "default" },
799 messages: [
800 {
801 id: MessageId.make(`import:${instanceId}:session:000000`),
802 role: "user",
803 text: "Continue the imported conversation",
804 turnId: null,
805 createdAt: now,
806 updatedAt: now,
807 streaming: false,
808 },
809 ],
810 });
811 }
812
813 it.each([
814 ["claudeAgent", "claude_work"],
815 ["codex", "codex_work"],
816 ["ollama", "local_models"],
817 ])("keeps imported %s history selectable through its custom instance", (driver, instanceId) => {
818 const importedEntry = entry(driver, instanceId);
819 const entries = [entry(driver === "codex" ? "claudeAgent" : "codex"), importedEntry];
820 const thread = importedThread(importedEntry.instanceId);
821 const lockedProvider = deriveLockedProvider({
822 thread,
823 selectedProvider: entries[0]!.instanceId,
824 threadProvider: thread.modelSelection.instanceId,
825 providers: entries.map((entry) => entry.snapshot),
826 });
827
828 expect(thread.session).toBeNull();
829 expect(lockedProvider).toBe(driver);
830 expect(
831 resolveComposerProviderSelection({
832 entries,
833 candidateInstanceIds: [thread.modelSelection.instanceId],
834 lockedProvider,
835 lockedInstanceId: thread.modelSelection.instanceId,
836 }).selectedProviderEntry?.instanceId,
837 ).toBe(importedEntry.instanceId);
838 });
839
840 it("keeps the session driver authoritative over instance and draft selections", () => {
841 const selected = entry("claudeAgent", "claude_work");
842 const sessionEntry = entry("ollama", "local_models");
843 const thread = importedThread(selected.instanceId);
844
845 expect(
846 deriveLockedProvider({
847 thread: {
848 ...thread,
849 session: {
850 ...readySession,
851 providerName: sessionEntry.driverKind,
852 providerInstanceId: sessionEntry.instanceId,
853 },
854 },
855 selectedProvider: selected.instanceId,
856 threadProvider: thread.modelSelection.instanceId,
857 providers: [selected.snapshot, sessionEntry.snapshot],
858 }),
859 ).toBe(sessionEntry.driverKind);
860 });
861
862 it.each(["missing", "disabled"] as const)(
863 "does not move imported history to another driver when its instance is %s",
864 (state) => {
865 const imported = entry("claudeAgent", "claude_work", { enabled: false });
866 const other = entry("codex");
867 const entries = state === "missing" ? [other] : [other, imported];
868 const thread = importedThread(imported.instanceId);
869 const lockedProvider = deriveLockedProvider({
870 thread,
871 selectedProvider: other.instanceId,
872 threadProvider: thread.modelSelection.instanceId,
873 providers: entries.map((entry) => entry.snapshot),
874 });
875
876 expect(lockedProvider).not.toBeNull();
877 expect(
878 resolveComposerProviderSelection({
879 entries,
880 candidateInstanceIds: [other.instanceId, imported.instanceId],
881 lockedProvider,
882 lockedInstanceId: imported.instanceId,
883 }).selectedProviderEntry,
884 ).toBeUndefined();
885 },
886 );
887
888 it("leaves a new draft free to select a different driver", () => {
889 const original = entry("claudeAgent", "claude_work");
890 const selected = entry("codex", "codex_work");
891 expect(
892 deriveLockedProvider({
893 thread: makeThread({
894 modelSelection: { instanceId: original.instanceId, model: "default" },
895 }),
896 selectedProvider: selected.instanceId,
897 threadProvider: original.instanceId,
898 providers: [original.snapshot, selected.snapshot],
899 }),
900 ).toBeNull();
901 });
902
795 it("uses the custom instance's capability instead of the default instance", () => {903 it("uses the custom instance's capability instead of the default instance", () => {
796 const defaultEntry = entry("antigravity", "antigravity", {904 const defaultEntry = entry("antigravity", "antigravity", {
797 showInteractionModeToggle: true,905 showInteractionModeToggle: true,
apps/web/src/components/ChatView.logic.ts+13-18
...@@ -795,22 +795,13 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean {...@@ -795,22 +795,13 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean {
795 );795 );
796}796}
797797
798// `threadProvider` is the open branded driver kind carried by the session.798// Imported history has no session until its first prompt. Resolve its instance
799// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe799// through the environment's provider catalog before locking to a driver.
800// rollback / fork behavior — the routing layer is the right place to surface
801// "driver not installed" errors, not the lock state.
802//
803// `selectedProvider` takes the same open-string shape because the composer
804// now tracks the picker selection as a `ProviderInstanceId` (e.g.
805// `codex_personal`). Custom instance ids that don't directly match a
806// registered driver resolve to `null` here, which matches the existing
807// "unknown driver -> unlocked" semantics. Callers that want the lock to track
808// a custom instance's underlying driver kind should resolve the instance id
809// upstream and pass the correlated kind.
810export function deriveLockedProvider(input: {800export function deriveLockedProvider(input: {
811 thread: Thread | null | undefined;801 thread: Thread | null | undefined;
812 selectedProvider: string | null;802 selectedProvider: string | null;
813 threadProvider: string | null;803 threadProvider: string | null;
804 providers: ReadonlyArray<Pick<ServerProvider, "instanceId" | "driver">>;
814}): ProviderDriverKind | null {805}): ProviderDriverKind | null {
815 if (!threadHasStarted(input.thread)) {806 if (!threadHasStarted(input.thread)) {
816 return null;807 return null;
...@@ -819,14 +810,18 @@ export function deriveLockedProvider(input: {...@@ -819,14 +810,18 @@ export function deriveLockedProvider(input: {
819 if (sessionProvider && isProviderDriverKind(sessionProvider)) {810 if (sessionProvider && isProviderDriverKind(sessionProvider)) {
820 return sessionProvider;811 return sessionProvider;
821 }812 }
813 // Preserve the existing lock while an instance is missing from the catalog;
814 // a started thread must not silently fall back to a different driver.
815 const threadProvider =
816 input.providers.find((provider) => provider.instanceId === input.threadProvider)?.driver ??
817 input.threadProvider;
818 const selectedProvider =
819 input.providers.find((provider) => provider.instanceId === input.selectedProvider)?.driver ??
820 input.selectedProvider;
822 const narrowedThreadProvider =821 const narrowedThreadProvider =
823 input.threadProvider && isProviderDriverKind(input.threadProvider)822 threadProvider && isProviderDriverKind(threadProvider) ? threadProvider : null;
824 ? input.threadProvider
825 : null;
826 const narrowedSelectedProvider =823 const narrowedSelectedProvider =
827 input.selectedProvider && isProviderDriverKind(input.selectedProvider)824 selectedProvider && isProviderDriverKind(selectedProvider) ? selectedProvider : null;
828 ? input.selectedProvider
829 : null;
830 return narrowedThreadProvider ?? narrowedSelectedProvider ?? null;825 return narrowedThreadProvider ?? narrowedSelectedProvider ?? null;
831}826}
832827
apps/web/src/components/ChatView.tsx+7-6
...@@ -2258,6 +2258,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -2258,6 +2258,12 @@ export default function ChatView(props: ChatViewProps) {
2258 [openOrReuseProjectDraftThread],2258 [openOrReuseProjectDraftThread],
2259 );2259 );
22602260
2261 // Once a thread selects an environment, never substitute the primary
2262 // environment's config while the selected environment is still loading.
2263 const serverConfig = activeThread
2264 ? (activeEnvironment?.serverConfig ?? null)
2265 : (primaryEnvironment?.serverConfig ?? null);
2266 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
2261 const selectedProviderByThreadId = composerActiveProvider ?? null;2267 const selectedProviderByThreadId = composerActiveProvider ?? null;
2262 const threadProvider =2268 const threadProvider =
2263 activeThread?.modelSelection.instanceId ??2269 activeThread?.modelSelection.instanceId ??
...@@ -2267,12 +2273,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -2267,12 +2273,8 @@ export default function ChatView(props: ChatViewProps) {
2267 thread: activeThread,2273 thread: activeThread,
2268 selectedProvider: selectedProviderByThreadId,2274 selectedProvider: selectedProviderByThreadId,
2269 threadProvider,2275 threadProvider,
2276 providers: providerStatuses,
2270 });2277 });
2271 // Once a thread selects an environment, never substitute the primary
2272 // environment's config while the selected environment is still loading.
2273 const serverConfig = activeThread
2274 ? (activeEnvironment?.serverConfig ?? null)
2275 : (primaryEnvironment?.serverConfig ?? null);
2276 const pullRequestsCapabilityKnown = serverConfig !== null;2278 const pullRequestsCapabilityKnown = serverConfig !== null;
2277 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;2279 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
2278 const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null;2280 const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null;
...@@ -2484,7 +2486,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -2484,7 +2486,6 @@ export default function ChatView(props: ChatViewProps) {
2484 versionMismatchThreadContinuation,2486 versionMismatchThreadContinuation,
2485 versionMismatchServerLabel,2487 versionMismatchServerLabel,
2486 ]);2488 ]);
2487 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
2488 const providerInstanceEntries = useMemo(2489 const providerInstanceEntries = useMemo(
2489 () =>2490 () =>
2490 sortProviderInstanceEntries(2491 sortProviderInstanceEntries(