| author | |
| committer | |
| log | d92dca74eb7b7c6068619752f5b9a55c0e36f352 |
| tree | ac5dfd631b0677193471f5beb8d11d848aff6392 |
| parent | 4ca71463a289e7b96b5776b6f6bbdecefe4757e4 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 794 | 795 | ||
| 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 | } |
| 797 | 797 | ||
| 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 safe | 799 | // 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. | ||
| 810 | export function deriveLockedProvider(input: { | 800 | export 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 | } |
| 832 | 827 |
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 | ); |
| 2260 | 2260 | ||
| 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( |