Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
buildThreadTurnInterruptInput,
createLocalDispatchSnapshot,
deriveComposerSendState,
deriveLockedProvider,
dismissBranchMismatchForSession,
ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
getAntigravitySendBlockReason,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 13 additions & 18 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<ServerProvider, "instanceId" | "driver">>;
}): ProviderDriverKind | null {
if (!threadHasStarted(input.thread)) {
return null;
Expand All @@ -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;
}

Expand Down
13 changes: 7 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ??
Expand All @@ -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;
Expand Down Expand Up @@ -2484,7 +2486,6 @@ export default function ChatView(props: ChatViewProps) {
versionMismatchThreadContinuation,
versionMismatchServerLabel,
]);
const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
const providerInstanceEntries = useMemo(
() =>
sortProviderInstanceEntries(
Expand Down
Loading