diff --git a/apps/mobile/src/components/ui/Dialog.android.tsx b/apps/mobile/src/components/ui/Dialog.android.tsx index af05275b7f270..da4636a1bc8ba 100644 --- a/apps/mobile/src/components/ui/Dialog.android.tsx +++ b/apps/mobile/src/components/ui/Dialog.android.tsx @@ -1,3 +1,4 @@ +import { fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import { NativeThemeHost } from "./NativeThemeHost.android"; import { AlertDialog, @@ -17,15 +18,23 @@ import { useScaledTextRole } from "../../features/settings/appearance/useScaledT import { useSyncExternalStore } from "react"; import { themeColorWithContrast } from "../../lib/mobileTheme"; import type { Alert, AlertButton } from "react-native"; -import type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types"; +import type { + ConfirmDialogRequest, + DialogAlertOptions, + TextInputDialogRequest, +} from "./Dialog.types"; -export type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types"; +export type { + ConfirmDialogRequest, + DialogAlertOptions, + TextInputDialogRequest, +} from "./Dialog.types"; type AlertRequest = { readonly title: Parameters[0]; readonly message: Parameters[1]; readonly buttons: Parameters[2]; - readonly options: Parameters[3]; + readonly options: DialogAlertOptions | undefined; }; type DialogRequest = @@ -67,7 +76,12 @@ function getPresented() { return presented; } -export function alert(...[title, message, buttons, options]: Parameters): void { +export function alert( + title: string, + message?: string, + buttons?: Parameters[2], + options?: DialogAlertOptions, +): void { const actions = buttons?.length ? buttons.slice(0, 3) : [{ text: "OK" }]; const normalized = actions.map((action, index) => index === actions.length - 1 ? { ...action, text: action.text || "OK" } : action, @@ -117,6 +131,7 @@ export function Host() { }} alertActions={presented.request.buttons ?? [{ text: "OK" }]} dismissable={presented.request.options?.cancelable ?? false} + equalWeightActions={presented.request.options?.equalWeightActions} onAlertAction={(action) => { dismiss(presented.id); action.onPress?.(); @@ -165,11 +180,12 @@ function DialogPresentation(props: DialogPresentationProps) { const destructiveFill = themeColorWithContrast(colors["--color-danger-foreground"], "#ffffff"); const renderAction = (action: AlertButton, index: number) => { const isDestructive = action.style === "destructive"; - const isPrimary = isDestructive || index === actions.length - 1; + const isPrimary = !props.equalWeightActions && (isDestructive || index === actions.length - 1); const Component = isPrimary ? Button : OutlinedButton; return ( (props.alertActions ? props.onAlertAction?.(action) : action.onPress?.())} colors={{ containerColor: isDestructive @@ -255,14 +271,20 @@ function DialogPresentation(props: DialogPresentationProps) { {props.request.message} ) : null} - {actions.length > 1 ? ( + {!props.equalWeightActions && actions.length > 1 ? ( {actions.slice(0, -1).map(renderAction)} ) : null} {actions.length > 0 ? ( - {renderAction(actions[actions.length - 1]!, actions.length - 1)} + {props.equalWeightActions ? ( + + {actions.map(renderAction)} + + ) : ( + renderAction(actions[actions.length - 1]!, actions.length - 1) + )} ) : null} @@ -274,6 +296,7 @@ interface DialogPresentationProps { readonly alertActions?: readonly AlertButton[]; readonly onAlertAction?: (action: AlertButton) => void; readonly dismissable?: boolean; + readonly equalWeightActions?: boolean | undefined; readonly request: Pick< ConfirmDialogRequest, "title" | "message" | "cancelText" | "confirmText" | "destructive" diff --git a/apps/mobile/src/components/ui/Dialog.tsx b/apps/mobile/src/components/ui/Dialog.tsx index afef71264cc6d..3f2580bbb0484 100644 --- a/apps/mobile/src/components/ui/Dialog.tsx +++ b/apps/mobile/src/components/ui/Dialog.tsx @@ -1,9 +1,24 @@ import { Alert } from "react-native"; -import type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types"; +import type { + ConfirmDialogRequest, + DialogAlertOptions, + TextInputDialogRequest, +} from "./Dialog.types"; -export type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types"; +export type { + ConfirmDialogRequest, + DialogAlertOptions, + TextInputDialogRequest, +} from "./Dialog.types"; -export function alert(...args: Parameters): void { +export function alert( + ...args: [ + title: string, + message?: string, + buttons?: Parameters[2], + options?: DialogAlertOptions, + ] +): void { Alert.alert(...args); } diff --git a/apps/mobile/src/components/ui/Dialog.types.ts b/apps/mobile/src/components/ui/Dialog.types.ts index 8f1cc5cba27ba..373b6c373c3a6 100644 --- a/apps/mobile/src/components/ui/Dialog.types.ts +++ b/apps/mobile/src/components/ui/Dialog.types.ts @@ -1,3 +1,9 @@ +import type { AlertOptions } from "react-native"; + +export type DialogAlertOptions = AlertOptions & { + readonly equalWeightActions?: boolean; +}; + export type ConfirmDialogRequest = { readonly title: string; readonly message?: string; diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index a80ec75151530..25c068632c1af 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,5 +1,9 @@ import { getPromptControlledTraits, + getModelTraitDescriptors, + visibleModelTraitDescriptors, + preserveExplicitDaybreakOption, + DAYBREAK_OPTION_ID, getTraitSelectChange, } from "@t3tools/client-runtime/model-traits"; import { SheetHeader } from "../../components/ui/SheetHeader"; @@ -28,7 +32,6 @@ import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { getProviderOptionCurrentLabel, getProviderOptionCurrentValue, - getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { useNavigation, @@ -124,6 +127,7 @@ import { modelFavoriteKey, modelMatchesCatalogQuery, pendingModelAfterPress, + pendingDaybreakModelChange, providerSectionIsCollapsed, } from "./thread-settings-sheet-state"; import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; @@ -317,7 +321,7 @@ type ThreadSettingsSessionValue = { readonly searchQuery: string; readonly showLegacy: boolean; readonly applyOptionChange: (id: string, value: string | boolean) => void; - readonly commitPendingModel: () => boolean; + readonly commitPendingModel: () => Promise; readonly isApplied: (option: ModelOption) => boolean; readonly isDisplayed: (option: ModelOption) => boolean; readonly pressModel: (option: ModelOption) => void; @@ -498,14 +502,20 @@ function ThreadSettingsSessionProvider( const isDisplayed = (option: ModelOption) => pendingModel ? option.key === pendingModel.key : isApplied(option); - const displayedDescriptors = pendingModel - ? pendingModel.capabilities - ? getProviderOptionDescriptors({ - caps: pendingModel.capabilities, + const livePendingModel = pendingModel + ? providerGroups + .flatMap((group) => group.models) + .find((model) => model.key === pendingModel.key) + : null; + const optionDescriptors = pendingModel + ? livePendingModel?.capabilities + ? getModelTraitDescriptors({ + caps: livePendingModel.capabilities, selections: pendingModel.selection.options, }) : [] : props.optionDescriptors; + const displayedDescriptors = visibleModelTraitDescriptors(optionDescriptors); const promptTraits = getPromptControlledTraits(displayedDescriptors, displayedPrompt); const displayedModel = pendingModel ?? @@ -522,20 +532,80 @@ function ThreadSettingsSessionProvider( const hasLegacyModels = props.providerGroups.some((group) => group.models.some((model) => model.isLegacy), ); - const commitPendingModel = () => { + const getLatestGroups = () => { + const latestConfig = props.environmentId + ? registry.get(environmentServerConfigsAtom).get(props.environmentId) + : null; + return latestConfig + ? groupByProvider( + buildModelOptions( + latestConfig, + props.selectedModel, + props.lockedProviderInstanceId, + props.projectId, + manageHidden, + ), + ) + : providerGroups; + }; + const confirmModelChange = async ( + sourceOptions: ReadonlyArray | undefined, + target: ModelOption, + ): Promise => { + const decide = () => pendingDaybreakModelChange(sourceOptions, target, getLatestGroups()); + const decision = decide(); + if (decision.type === "ready") return { ...target, selection: decision.selection }; + const selection = await new Promise((resolve) => { + Dialog.alert( + `${decision.activeLabel} isn’t available on ${target.label}`, + "Choose an available option to switch models, or cancel to keep your current selection.", + [ + ...decision.choices.map((choice) => ({ + text: choice.label, + onPress: () => resolve(choice.selection), + })), + { text: "Cancel", style: "cancel", onPress: () => resolve(null) }, + ], + { cancelable: true, equalWeightActions: true, onDismiss: () => resolve(null) }, + ); + }); + if (!selection) return null; + if (!canCommitPendingModel(target, getLatestGroups())) return null; + const latest = pendingDaybreakModelChange( + selection.options, + { ...target, selection }, + getLatestGroups(), + ); + const accepted = latest.type === "ready" ? latest.selection : null; + if (!accepted) { + Dialog.alert( + "Daybreak availability changed", + "Choose the model again using the refreshed catalog.", + ); + return null; + } + return { ...target, selection: accepted }; + }; + const commitPendingModel = async () => { if (pendingModel) { - if (!canCommitPendingModel(pendingModel, props.providerGroups)) { + if (!canCommitPendingModel(pendingModel, getLatestGroups())) { Dialog.alert( "Model unavailable", "Set up this provider on web or desktop, or select another model.", ); return false; } + const accepted = await confirmModelChange(pendingModel.selection.options, pendingModel); + if (!accepted || !canCommitPendingModel(accepted, getLatestGroups())) return false; void Haptics.selectionAsync(); - props.onSelectModel(pendingModel); - if (pendingPrompt !== null && pendingPrompt !== props.prompt) { + rememberModelOptions( + accepted.selection.instanceId, + accepted.selection.model, + accepted.selection.options ?? [], + ); + props.onSelectModel(accepted); + if (pendingPrompt !== null && pendingPrompt !== props.prompt) props.onPromptChange(pendingPrompt); - } } return true; }; @@ -545,7 +615,11 @@ function ThreadSettingsSessionProvider( const change = descriptor?.type === "select" && typeof value === "string" ? getTraitSelectChange( - { ...promptTraits, descriptors: displayedDescriptors }, + { + ...promptTraits, + descriptors: optionDescriptors, + modelOptions: pendingModel?.selection.options ?? props.selectedModel?.options, + }, descriptor, displayedPrompt, value, @@ -561,14 +635,23 @@ function ThreadSettingsSessionProvider( } } } - const next = change - ? (change.modelOptions ?? []) - : applyProviderOptionSelection(displayedDescriptors, { id, value }); + const changed = + change && "modelOptions" in change + ? (change.modelOptions ?? []) + : applyProviderOptionSelection(displayedDescriptors, { id, value }); + const next = + changed === null + ? null + : (preserveExplicitDaybreakOption( + changed, + id === DAYBREAK_OPTION_ID + ? undefined + : (pendingModel?.selection.options ?? props.selectedModel?.options), + ) ?? []); if (!next) { return; } if (pendingModel) { - rememberModelOptions(pendingModel.selection.instanceId, pendingModel.selection.model, next); setPendingModel({ ...pendingModel, selection: { ...pendingModel.selection, options: next }, @@ -588,19 +671,21 @@ function ThreadSettingsSessionProvider( }); }; - const pressModel = (option: ModelOption) => { + const pressModel = async (option: ModelOption) => { + const next = pendingModelAfterPress({ + current: pendingModel, + pressed: option, + pressedIsApplied: isApplied(option), + restoreSelection: props.restoreSelection, + }); + if (next === pendingModel) return; + const accepted = next + ? await confirmModelChange((pendingModel?.selection ?? props.selectedModel)?.options, next) + : null; + if (next && !accepted) return; void Haptics.selectionAsync(); - if (isApplied(option) || option.key !== pendingModel?.key) { - setPendingPrompt(null); - } - setPendingModel((current) => - pendingModelAfterPress({ - current, - pressed: option, - pressedIsApplied: isApplied(option), - restoreSelection: props.restoreSelection, - }), - ); + setPendingPrompt(null); + setPendingModel(accepted); }; const value = { @@ -1215,8 +1300,8 @@ function ThreadSettingsModelsScreen() { const navigation = useNavigation>(); const usesNativeMailSearchToolbar = useThreadSettingsMailSearchToolbar(); const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; - const commitAndClose = useEffectEvent(() => { - if (!session.commitPendingModel()) return; + const commitAndClose = useEffectEvent(async () => { + if (!(await session.commitPendingModel())) return; presentation.onClose(); }); const providerFilters = [ @@ -1554,7 +1639,7 @@ export function ExistingThreadSettingsRouteScreen({ prompt={prompt} onPromptChange={(text) => setComposerDraftText(draftKey, text)} reportedModelSelection={reportedModelSelection} - onSelectModel={(option) => updateModelSelection(withRememberedModelOptions(option.selection))} + onSelectModel={(option) => updateModelSelection(option.selection)} restoreSelection={withRememberedModelOptions} optionDescriptors={resolveProviderOptionDescriptors({ capabilities: currentModelOption?.capabilities, diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx index e611b95d3f683..984d6f226c3d2 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx @@ -38,8 +38,10 @@ vi.mock("../../../state/use-thread-selection", () => ({ selectedThreadProject: { workspaceRoot: checkout.projectRoot }, }), })); -vi.mock("../../../state/use-thread-detail", () => ({ - useSelectedThreadWorktreePath: () => checkout.worktreePath, +vi.mock("../../../state/use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ + selectedThreadCwd: checkout.worktreePath ?? checkout.projectRoot, + }), })); vi.mock("../../../state/use-selected-thread-git-state", () => ({ useSelectedThreadGitState: () => ({ 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 4aa44e73c5586..ffdec09373872 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -492,9 +492,10 @@ export function NewTaskFlowProvider( if (!option) { return; } - const selection = withRememberedModelOptions( - options ? { ...option.selection, options } : option.selection, - ); + const selection = + options !== undefined + ? { ...option.selection, options } + : withRememberedModelOptions(option.selection); const provider = selectedEnvironmentServerConfig?.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 53b5874c7e3a7..c79b37693960b 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -11,7 +11,11 @@ import { rememberModelOptions, withRememberedModelOptions, } from "../../state/use-model-option-memory"; -import { canCommitPendingModel, pendingModelAfterPress } from "./thread-settings-sheet-state"; +import { + canCommitPendingModel, + pendingModelAfterPress, + pendingDaybreakModelChange, +} from "./thread-settings-sheet-state"; function modelOption( model: string, @@ -159,3 +163,41 @@ describe("thread settings sheet state", () => { ).toBe(false); }); }); + +describe("Daybreak staged choices", () => { + it("uses refreshed model capabilities when saving a staged choice", () => { + const red = [{ id: "cyberAccessProgram", value: "daybreakRed" }]; + const pending = modelOption("target", red); + const refreshed = { + ...pending, + capabilities: { + optionDescriptors: [ + { + id: "cyberAccessProgram", + label: "Daybreak", + type: "select" as const, + options: [ + { id: "standard", label: "Off" }, + { id: "daybreakBlue", label: "Blue" }, + ], + }, + ], + }, + }; + const decision = pendingDaybreakModelChange(red, pending, [ + { providerKey: "codex", providerLabel: "Codex", models: [refreshed] }, + ]); + expect(decision.type === "confirm" && decision.choices.map((choice) => choice.label)).toEqual([ + "Use Blue", + "Turn Off", + ]); + expect(pending.selection.options).toEqual(red); + }); + it("records an empty option set so an old Daybreak selection cannot return", () => { + rememberModelOptions("codex", "target", [{ id: "cyberAccessProgram", value: "daybreakRed" }]); + rememberModelOptions("codex", "target", []); + expect( + withRememberedModelOptions({ instanceId: "codex", model: "target", options: [] }).options, + ).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index 4fff09253ab4b..f1a59363f3aaa 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,3 +1,5 @@ +import { decideDaybreakModelChange } from "@t3tools/client-runtime/model-traits"; +import type { ProviderOptionSelection } from "@t3tools/contracts"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; @@ -73,3 +75,16 @@ export function providerSectionIsCollapsed(input: { } return input.defaultExpanded ? input.hasExpansionOverride : !input.hasExpansionOverride; } + +export function pendingDaybreakModelChange( + currentOptions: ReadonlyArray | null | undefined, + pending: ModelOption, + groups: ReadonlyArray, +) { + const live = groups.flatMap((group) => group.models).find((model) => model.key === pending.key); + return decideDaybreakModelChange( + currentOptions, + pending.selection, + live?.capabilities?.optionDescriptors ?? [], + ); +} diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 00c6cb74ed623..1b421f3cf7687 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,3 +1,7 @@ +import { + getModelTraitDescriptors, + preserveExplicitDaybreakOption, +} from "@t3tools/client-runtime/model-traits"; import { normalizeProviderAccentColor, resolveProviderInstanceDisplayName, @@ -12,10 +16,7 @@ import type { ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; import { isProviderDriverKind, resolveProjectProviderInstanceEnabled } from "@t3tools/contracts"; -import { - buildExplicitProviderOptionSelectionsFromDescriptors, - getProviderOptionDescriptors, -} from "@t3tools/shared/model"; +import { buildExplicitProviderOptionSelectionsFromDescriptors } from "@t3tools/shared/model"; import { resolveModelShortName } from "@t3tools/client-runtime/provider-clients"; export type ModelOption = { @@ -120,11 +121,14 @@ function normalizeSelectionOptions( if (!selection.options?.length) { return { instanceId: selection.instanceId, model: selection.model }; } - const options = buildExplicitProviderOptionSelectionsFromDescriptors( - getProviderOptionDescriptors({ - caps: capabilities, - selections: selection.options, - }), + const options = preserveExplicitDaybreakOption( + buildExplicitProviderOptionSelectionsFromDescriptors( + getModelTraitDescriptors({ + caps: capabilities, + selections: selection.options, + }), + selection.options, + ), selection.options, ); return options diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index 0199b0fbd0fd1..1105e441967ff 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -1,12 +1,10 @@ +import { getModelTraitDescriptors } from "@t3tools/client-runtime/model-traits"; import type { ModelCapabilities, ProviderOptionDescriptor, ProviderOptionSelection, } from "@t3tools/contracts"; -import { - buildProviderOptionSelectionsFromDescriptors, - getProviderOptionDescriptors, -} from "@t3tools/shared/model"; +import { buildProviderOptionSelectionsFromDescriptors } from "@t3tools/shared/model"; export function resolveProviderOptionDescriptors(input: { readonly capabilities: ModelCapabilities | null | undefined; @@ -15,7 +13,7 @@ export function resolveProviderOptionDescriptors(input: { if (!input.capabilities) { return []; } - return getProviderOptionDescriptors({ + return getModelTraitDescriptors({ caps: input.capabilities, selections: input.selections, }); diff --git a/apps/mobile/src/state/use-model-option-memory.ts b/apps/mobile/src/state/use-model-option-memory.ts index d0b28a0475945..82281525c0f43 100644 --- a/apps/mobile/src/state/use-model-option-memory.ts +++ b/apps/mobile/src/state/use-model-option-memory.ts @@ -12,9 +12,6 @@ function recordModelOptionsInState( model: string, options: ReadonlyArray, ): ModelOptionMemoryState { - if (options.length === 0) { - return state; - } return { ...state, [instanceId]: { @@ -37,9 +34,6 @@ export function rememberModelOptions( model: string, options: ReadonlyArray, ): void { - if (options.length === 0) { - return; - } const current = appAtomRegistry.get(modelOptionMemoryAtom); const next = recordModelOptionsInState(current, String(instanceId), model, options); if (next !== current) { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 90ee44f3d5b6f..d051b67e80d44 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -11,6 +11,7 @@ import { EnvironmentId, MessageId, type ModelSelection, + type ServerProviderModel, NodeId, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, @@ -71,6 +72,7 @@ import type * as ProviderContinuationRequests from "@t3tools/provider-core/serve import * as ModelManifest from "../../provider/ModelManifest.ts"; import { resolveProviderCompatibility } from "../../provider/providerCompatibility.ts"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; +import { mapCodexModelCapabilities } from "../../provider/CodexProvider.ts"; import { withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; @@ -83,6 +85,31 @@ const isProviderAdapterEventStreamError = Schema.is( ); const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +function codexDaybreakTestModel( + programs: ReadonlyArray | null = [ + "standard", + "daybreakBlue", + "daybreakRed", + ], +): ServerProviderModel { + return { + slug: CODEX_TEST_MODEL_SELECTION.model, + name: "Test", + isCustom: false, + capabilities: mapCodexModelCapabilities({ + id: "test", + model: CODEX_TEST_MODEL_SELECTION.model, + displayName: "Test", + description: "Test", + hidden: false, + isDefault: false, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [], + ...(programs === null ? {} : { availableAccessPrograms: { cyber: programs } }), + }), + }; +} + describe("CodexAdapterV2 context usage", () => { it("uses the current context rather than cumulative processed tokens", () => { const usage = CodexAdapterV2.codexProviderTurnTokenUsage( @@ -573,6 +600,7 @@ function codexReplayPreamble(input: { readonly prompt: string; readonly sentPrompt?: string; readonly effort?: string; + readonly cyberAccessProgram?: string; }): Array { return [ { @@ -679,6 +707,9 @@ function codexReplayPreamble(input: { ...(input.effort === undefined ? {} : { reasoning_effort: input.effort }), }, }, + ...(input.cyberAccessProgram === undefined + ? {} + : { cyberAccessProgram: input.cyberAccessProgram }), }, }, }, @@ -1142,6 +1173,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, readChildMetadata?: Parameters[2], beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], + models?: CodexAdapterV2.CodexAdapterV2Options["models"], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1196,6 +1228,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { fileSystem, idAllocator, serverConfig, + ...(models === undefined ? {} : { models }), continuationRequests: { offer: (request) => Effect.sync(() => { @@ -7422,7 +7455,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - describe("native goals", () => { + describe("Daybreak and native goals", () => { const nativeThreadId = "goal-thread"; const objective = "Ship the feature"; const codexGoal = (status: string, tokensUsed: number) => ({ @@ -7486,6 +7519,306 @@ describe("CodexAdapterV2 post-settle continuation", () => { event.type === "provider_thread.updated" ? [event.providerThread.goal?.status ?? null] : [], ); + it.effect.each([ + ["daybreakBlue", ["standard", "daybreakRed"], null], + ["daybreakBlue", [], null], + ["daybreakBlue", ["daybreakRed"], null], + ["standard", ["daybreakRed"], null], + [undefined, ["daybreakRed"], null], + [undefined, ["standard", "daybreakRed"], "standard"], + ["daybreakRed", ["daybreakRed"], "daybreakRed"], + ["standard", ["standard"], "standard"], + ["standard", null, undefined], + ["standard", [], undefined], + ["daybreakBlue", ["standard"], null], + ["daybreakRed", ["standard"], null], + ["daybreakBlue", null, null], + ["daybreakRed", null, null], + [undefined, ["standard"], "standard"], + [undefined, null, undefined], + ] as const)( + "revalidates saved %s against refreshed %j after resume (wire: %s, null rejects)", + ([program, nextPrograms, expected]) => + Effect.gen(function* () { + const catalog = yield* Ref.make([codexDaybreakTestModel()]); + const transcript = makeCodexReplayTranscript({ + scenario: `daybreak-refresh-${program}-${expected}`, + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "first", + prompt: "work", + cyberAccessProgram: program ?? "standard", + }), + notification("first turn done", "turn/completed", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "first", status: "completed" }), + }), + ...request( + 4, + "thread/resume", + { + threadId: nativeThreadId, + excludeTurns: true, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + { thread: { id: nativeThreadId, updatedAt: 1782622450 } }, + ), + ...(expected === null + ? [] + : withReplayRequestId( + codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "second", + prompt: "work", + ...(expected === undefined ? {} : { cyberAccessProgram: expected }), + }).slice(5), + 5, + )), + ], + }); + const harness = yield* makeCodexReplayHarness( + transcript, + undefined, + undefined, + undefined, + undefined, + Ref.get(catalog), + ); + const turnInput = { + ...(yield* goalTurnInput(harness, "work")), + modelSelection: { + ...CODEX_TEST_MODEL_SELECTION, + options: program === undefined ? [] : [{ id: "cyberAccessProgram", value: program }], + }, + }; + yield* harness.runtime.startTurn(turnInput); + yield* harness.firstTerminal; + yield* Ref.set( + catalog, + nextPrograms === null || nextPrograms.length + ? [codexDaybreakTestModel(nextPrograms)] + : [], + ); + const providerThread = yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + }); + const nextTurn = harness.runtime.startTurn({ + ...turnInput, + providerThread, + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("second-attempt"), + }); + if (expected === null) { + const error = yield* Effect.flip(nextTurn); + assert.include( + errorCauseChainText(error), + program === undefined + ? "requires an explicit Daybreak option" + : "selected Daybreak option is no longer available", + ); + } else { + yield* nextTurn; + } + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each([ + ["daybreakBlue", ["standard", "daybreakRed"], false, false], + ["daybreakRed", [], false, false], + ["invalid", ["standard", "daybreakBlue"], false, false], + ["daybreakBlue", ["standard", "daybreakBlue"], true, false], + ["daybreakRed", ["standard", "daybreakRed"], false, true], + [true, ["standard", "daybreakBlue"], false, false], + ] as const)( + "rejects unsupported Daybreak %s (catalog %j, wrong instance %s, custom %s)", + ([program, programs, wrongInstance, isCustom]) => + Effect.gen(function* () { + const requests: string[] = []; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: "daybreak-reject", entries: sessionStart }), + undefined, + (method) => + Effect.sync(() => { + requests.push(method); + }), + undefined, + undefined, + Effect.succeed([{ ...codexDaybreakTestModel(programs), isCustom }]), + ); + const input = yield* goalTurnInput(harness, "work"); + const error = yield* Effect.flip( + harness.runtime.startTurn({ + ...input, + modelSelection: { + ...input.modelSelection, + ...(wrongInstance ? { instanceId: ProviderInstanceId.make("other-codex") } : {}), + options: [{ id: "cyberAccessProgram", value: program }], + }, + }), + ); + assert.include( + errorCauseChainText(error), + "selected Daybreak option is no longer available", + ); + assert.notInclude(requests, "turn/start"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each([ + ["other-instance", undefined], + ["other-instance", "standard"], + ["custom-model", undefined], + ["custom-model", "standard"], + ] as const)("omits account access for %s with saved choice %s", ([scope, program]) => + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: "daybreak-scope", + entries: codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "scope-turn", + prompt: "work", + }), + }), + undefined, + undefined, + undefined, + undefined, + Effect.succeed([{ ...codexDaybreakTestModel(), isCustom: scope === "custom-model" }]), + ); + const input = yield* goalTurnInput(harness, "work"); + yield* harness.runtime.startTurn({ + ...input, + modelSelection: { + ...input.modelSelection, + ...(scope === "other-instance" + ? { instanceId: ProviderInstanceId.make("other-codex") } + : {}), + ...(program === undefined + ? {} + : { options: [{ id: "cyberAccessProgram", value: program }] }), + }, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each(["/goal Ship the feature", "/goal resume"])( + "rejects revoked Daybreak before dispatching %s", + (text) => + Effect.gen(function* () { + const requests: string[] = []; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: "daybreak-goal-reject", entries: sessionStart }), + undefined, + (method) => + Effect.sync(() => { + requests.push(method); + }), + undefined, + undefined, + Effect.succeed([codexDaybreakTestModel(["standard", "daybreakRed"])]), + ); + const input = yield* goalTurnInput(harness, text); + const error = yield* Effect.flip( + harness.runtime.startTurn({ + ...input, + modelSelection: { + ...input.modelSelection, + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }), + ); + assert.include( + errorCauseChainText(error), + "selected Daybreak option is no longer available", + ); + assert.deepEqual(requests, ["initialize", "thread/start"]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("forwards the selected Daybreak treatment when resuming a native goal", () => + Effect.gen(function* () { + const turnEntries = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "resumed-goal-turn", + prompt: "unused", + cyberAccessProgram: "daybreakBlue", + }) + .slice(5) + .map((entry) => + entry.type === "expect_outbound" && + Predicate.isObject(entry.frame) && + Predicate.isObject(entry.frame.params) + ? { + ...entry, + frame: { ...entry.frame, params: { ...entry.frame.params, input: [] } }, + } + : entry, + ); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: "daybreak-goal-resume", + entries: [ + ...sessionStart, + ...request( + 3, + "thread/goal/get", + { threadId: nativeThreadId }, + { goal: codexGoal("paused", 0) }, + ), + ...withReplayRequestId(turnEntries, 4), + ...request( + 5, + "thread/goal/set", + { threadId: nativeThreadId, status: "active" }, + { goal: codexGoal("active", 0) }, + ), + ], + }), + undefined, + undefined, + undefined, + undefined, + Effect.succeed([codexDaybreakTestModel(["daybreakBlue"])]), + ); + const input = yield* goalTurnInput(harness, "/goal resume"); + yield* harness.runtime.startTurn({ + ...input, + modelSelection: { + ...input.modelSelection, + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("keeps a /goal run open across the turns Codex continues on its own", () => Effect.gen(function* () { const transcript = makeCodexReplayTranscript({ @@ -7505,6 +7838,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { nativeTurnId: "goal-turn-1", prompt: objective, sentPrompt: `Start working toward this goal: ${objective}`, + cyberAccessProgram: "daybreakRed", }).slice(5), 5, ), @@ -7538,8 +7872,22 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), ], }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn(yield* goalTurnInput(harness, `/goal ${objective}`)); + const harness = yield* makeCodexReplayHarness( + transcript, + undefined, + undefined, + undefined, + undefined, + Effect.succeed([codexDaybreakTestModel(["daybreakRed"])]), + ); + const turnInput = yield* goalTurnInput(harness, `/goal ${objective}`); + yield* harness.runtime.startTurn({ + ...turnInput, + modelSelection: { + ...turnInput.modelSelection, + options: [{ id: "cyberAccessProgram", value: "daybreakRed" }], + }, + }); yield* harness.firstTerminal; const idAllocator = yield* IdAllocator.IdAllocatorV2; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 821cf051c3235..8b68971b10755 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -74,6 +74,7 @@ import type { ProviderThreadId, ProviderTurnId, ProviderInstanceId, + ServerProviderModel, RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -680,6 +681,7 @@ const decodeTurnReasoningEffort = Schema.decodeUnknownEffect( const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), @@ -742,6 +744,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur readonly codexInput: ReadonlyArray; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly modelSelection: ModelSelection; + readonly cyberAccessProgram?: CodexSchema.V2TurnStartParams__CyberAccessProgram; readonly hasT3Mcp?: boolean; readonly browserToolsAvailable?: boolean; readonly deviceToolsAvailable?: boolean; @@ -810,6 +813,9 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur ...(effort === undefined ? {} : { reasoning_effort: effort }), }, }, + ...(input.cyberAccessProgram === undefined + ? {} + : { cyberAccessProgram: input.cyberAccessProgram }), }); }, ); @@ -1563,7 +1569,10 @@ export type CodexAdapterV2DriverEnv = export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapterV2")(function* ( { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick< + CodexAdapterV2Options, + "models" | "onUsageLimits" | "resolveRuntime" | "compatibility" + > = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; @@ -1612,6 +1621,7 @@ export interface CodexAdapterV2Options { readonly settings: CodexSettings; readonly environment: NodeJS.ProcessEnv; readonly clientFactory: CodexAppServerClientFactoryShape; + readonly models?: Effect.Effect>; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; readonly resolveRuntime?: Effect.Effect; readonly compatibility?: ( @@ -6635,6 +6645,48 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ); }); + const resolveCyberAccessProgram = Effect.fn("CodexAdapterV2.resolveCyberAccessProgram")( + function* (selection: ModelSelection) { + const models = + selection.instanceId === adapterOptions.instanceId && adapterOptions.models + ? yield* adapterOptions.models + : []; + const descriptor = models + .find((model) => !model.isCustom && model.slug === selection.model) + ?.capabilities?.optionDescriptors?.find( + (option) => option.id === "cyberAccessProgram", + ); + const programs = + descriptor?.type === "select" + ? CodexSchema.V2TurnStartParams__CyberAccessProgram.literals.filter((program) => + descriptor.options.some((option) => option.id === program), + ) + : []; + const requestedProgram = selection.options?.find( + (option) => option.id === "cyberAccessProgram", + )?.value; + // A saved Off can outlive the catalog metadata and its hidden control. Omit the + // unsupported field in that case, while saved colors still require advertised access. + if (requestedProgram === "standard" && programs.length === 0) return undefined; + // Omission lets Codex choose automatically, so only an advertised Standard is safe + // as a default when the catalog exposes Daybreak treatments. + const cyberAccessProgram = programs.find( + (program) => program === (requestedProgram ?? "standard"), + ); + if ( + cyberAccessProgram === undefined && + (requestedProgram !== undefined || programs.length > 0) + ) { + return yield* toProtocolError( + requestedProgram === undefined + ? "This model requires an explicit Daybreak option; choose an available Blue or Red option before starting a turn." + : "The selected Daybreak option is no longer available for this model; refresh the model catalog and choose an available Daybreak option or a different model.", + ); + } + return cyberAccessProgram; + }, + ); + const startNativeTurn = Effect.fn("CodexAdapterV2.startNativeTurn")( function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, @@ -6642,11 +6694,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const cyberAccessProgram = yield* resolveCyberAccessProgram(turnInput.modelSelection); const turnStartParams = yield* buildCodexTurnStartParams({ nativeThreadId: threadId, codexInput, runtimePolicy: turnInput.runtimePolicy, modelSelection: turnInput.modelSelection, + ...(cyberAccessProgram === undefined ? {} : { cyberAccessProgram }), hasT3Mcp: mcpSession !== undefined, browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true, deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false, @@ -6719,6 +6773,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( command: OrchestrationV2GoalCommand, ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); + if (command.type === "set" || command.type === "resume") { + yield* resolveCyberAccessProgram(turnInput.modelSelection); + } rootProviderThreads.set(threadId, turnInput.providerThread); const readGoal = client .request("thread/goal/get", { threadId }) diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 30f63b21f318e..be1250c37c5b8 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,8 +1,20 @@ import { assert, it } from "@effect/vitest"; +import { CodexSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Schema from "effect/Schema"; +import type * as CodexSchema from "effect-codex-app-server/schema"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + checkCodexProviderStatus, + makePendingCodexProvider, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; -it("maps current Codex model capability fields", () => { +const decodeCodexSettings = Schema.decodeEffect(CodexSettings); + +it.effect("maps Codex capabilities while keeping Daybreak out of custom models", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], defaultReasoningEffort: "super-high", @@ -12,6 +24,7 @@ it("maps current Codex model capability fields", () => { id: "gpt-test", isDefault: true, model: "gpt-test", + availableAccessPrograms: { cyber: ["daybreakRed"] }, defaultServiceTier: "flex", serviceTiers: [ { @@ -61,7 +74,23 @@ it("maps current Codex model capability fields", () => { ], currentValue: "flex", }, + { + id: "cyberAccessProgram", + label: "Daybreak", + type: "select", + options: [{ id: "daybreakRed", label: "Red" }], + }, ]); + + return decodeCodexSettings({ customModels: [{ slug: "custom", capabilities }] }).pipe( + Effect.flatMap(makePendingCodexProvider), + Effect.map((provider) => + assert.deepStrictEqual( + provider.models[0]?.capabilities?.optionDescriptors, + capabilities.optionDescriptors?.slice(0, 2), + ), + ), + ); }); it("uses standard routing when the catalog has no default service tier", () => { @@ -153,3 +182,125 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it.each([ + [undefined, undefined], + [null, undefined], + [[], undefined], + [["standard"], [{ id: "standard", label: "Off", isDefault: true }]], + [["daybreakBlue"], [{ id: "daybreakBlue", label: "Blue" }]], + [["daybreakRed"], [{ id: "daybreakRed", label: "Red" }]], + [ + ["daybreakRed", "daybreakBlue", "standard"], + [ + { id: "standard", label: "Off", isDefault: true }, + { id: "daybreakBlue", label: "Blue" }, + { id: "daybreakRed", label: "Red" }, + ], + ], + [ + ["standard", "daybreakBlue", "daybreakRed"], + [ + { id: "standard", label: "Off", isDefault: true }, + { id: "daybreakBlue", label: "Blue" }, + { id: "daybreakRed", label: "Red" }, + ], + ], +] as const)("maps only advertised Daybreak programs %j", (programs, options) => { + const model: CodexSchema.V2ModelListResponse__Model = { + id: "gpt-test", + model: "gpt-test", + displayName: "Test", + description: "Test", + hidden: false, + isDefault: false, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [], + ...(programs === undefined + ? {} + : { availableAccessPrograms: programs === null ? null : { cyber: programs } }), + }; + const descriptor = mapCodexModelCapabilities(model).optionDescriptors?.find( + (option) => option.id === "cyberAccessProgram", + ); + assert.deepEqual( + descriptor, + options === undefined + ? undefined + : { + id: "cyberAccessProgram", + label: "Daybreak", + type: "select", + options, + ...(programs?.some((program) => program === "standard") + ? { currentValue: "standard" } + : {}), + }, + ); +}); + +it.effect( + "keeps native account access for renamed discovery models and strips custom-only access", + () => + Effect.gen(function* () { + const capabilities = { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: [{ id: "high", label: "High" }], + }, + { + id: "cyberAccessProgram", + label: "Daybreak", + type: "select" as const, + options: [{ id: "daybreakBlue", label: "Blue" }], + }, + ], + }; + const settings = yield* decodeCodexSettings({ + customModels: [ + { slug: "native", name: "Renamed native", capabilities: { optionDescriptors: [] } }, + "bare-custom", + { slug: "injected-custom", capabilities }, + ], + }); + const provider = yield* checkCodexProviderStatus(settings, () => + Effect.succeed({ + account: { account: { type: "apiKey" }, requiresOpenaiAuth: false }, + version: "test", + skills: [], + models: [{ slug: "native", name: "Native", isCustom: false, capabilities }], + }), + ); + assert.deepEqual( + provider.models.map((model) => ({ + slug: model.slug, + name: model.name, + isCustom: model.isCustom, + descriptors: model.capabilities?.optionDescriptors, + })), + [ + { + slug: "native", + name: "Renamed native", + isCustom: false, + descriptors: capabilities.optionDescriptors, + }, + { + slug: "bare-custom", + name: "bare-custom", + isCustom: true, + descriptors: capabilities.optionDescriptors.slice(0, 1), + }, + { + slug: "injected-custom", + name: "injected-custom", + isCustom: true, + descriptors: capabilities.optionDescriptors.slice(0, 1), + }, + ], + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index ed9e3d0cde684..bee38875fb7b8 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -242,6 +242,21 @@ export function mapCodexModelCapabilities( }); } + const cyberPrograms = model.availableAccessPrograms?.cyber ?? []; + if (cyberPrograms.length > 0) { + optionDescriptors.push({ + id: "cyberAccessProgram", + label: "Daybreak", + type: "select", + options: [ + { id: "standard", label: "Off", isDefault: true }, + { id: "daybreakBlue", label: "Blue" }, + { id: "daybreakRed", label: "Red" }, + ].filter((option) => cyberPrograms.some((program) => program === option.id)), + ...(cyberPrograms.includes("standard") ? { currentValue: "standard" } : {}), + }); + } + return createModelCapabilities({ optionDescriptors, }); @@ -313,15 +328,27 @@ function applyConfiguredCodexServiceTier( /** * Codex has no static default capability set, so a bare custom slug borrows - * the first built-in's descriptors; an entry with its own capabilities keeps - * them. + * the first built-in's descriptors. Account access requires native discovery, + * so custom-only slugs cannot borrow it or supply it through settings. */ function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, ): ReadonlyArray { const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; - return providerModelsFromSettings(models, customModels, fallbackCapabilities); + return providerModelsFromSettings(models, customModels, fallbackCapabilities).map((model) => + model.isCustom && model.capabilities + ? { + ...model, + capabilities: { + ...model.capabilities, + optionDescriptors: model.capabilities.optionDescriptors?.filter( + (descriptor) => descriptor.id !== "cyberAccessProgram", + ), + }, + } + : model, + ); } function parseCodexSkillsListResponse( diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 97ec4954ed840..b9e01c14cb77e 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -201,6 +201,9 @@ export const CodexDriver: ProviderDriver< config, }, { + models: Effect.suspend(() => snapshot.getSnapshot).pipe( + Effect.map((snapshot) => snapshot.models), + ), onUsageLimits: (update) => snapshot.applyUsageLimits(update), compatibility: installation.compatibility, }, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index f9728f9a24973..01547426348a4 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -243,6 +243,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu // Launch settings bind the refresh command to the signed-in profile. The registry // already wraps openSession in withAccess, so resolve without re-entering it. const orchestrationAdapter = yield* createCodexAdapterV2(input, { + models: snapshot.getSnapshot.pipe(Effect.map((snapshot) => snapshot.models)), onUsageLimits: (update) => snapshot.applyUsageLimits(update), resolveRuntime: runtime.resolve, }).pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d2a176a444369..0ab91d41805ea 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2827,6 +2827,7 @@ export default function ChatView(props: ChatViewProps) { }; const { + daybreakDialog, getModelDisabledReason, handleInteractionModeChange, handleInteractionModeChangeStable, @@ -3992,6 +3993,7 @@ export default function ChatView(props: ChatViewProps) { ) : null} + {daybreakDialog} {embedded ? null : } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c423773fb6fc6..f1802e33084ea 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -485,7 +485,7 @@ export interface ChatComposerProps { onProviderModelSelect: ( instanceId: ProviderInstanceId, model: string, - options?: { focusComposer?: boolean }, + options?: { focusComposer?: boolean; currentSelection?: ModelSelection; onCommit?: () => void }, ) => void; onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; diff --git a/apps/web/src/components/chat/ComposerFooterControls.tsx b/apps/web/src/components/chat/ComposerFooterControls.tsx index 31c5889db9b22..02896c36315e1 100644 --- a/apps/web/src/components/chat/ComposerFooterControls.tsx +++ b/apps/web/src/components/chat/ComposerFooterControls.tsx @@ -1,3 +1,5 @@ +import { useDaybreakModelChange } from "./useDaybreakModelChange"; +import { getProviderModelCapabilities } from "../../providerModels"; import type { ModelSelection, ProviderInstanceId, @@ -351,10 +353,30 @@ export function ComposerModelPicker({ readonly onProviderModelSelect: ( instanceId: ProviderInstanceId, model: string, - options?: { focusComposer?: boolean }, + options?: { focusComposer?: boolean; currentSelection?: ModelSelection; onCommit?: () => void }, ) => void; }) { const { instanceEntries, modelOptionsByInstance } = pickerProps; + const { requestModelChange, daybreakDialog } = useDaybreakModelChange( + (selection) => { + const entry = instanceEntries.find((entry) => entry.instanceId === selection.instanceId); + const available = + modelOptionsByInstance + ?.get(selection.instanceId) + ?.some((model) => model.slug === selection.model && !model.isUnavailable) ?? + entry?.models.some((model) => model.slug === selection.model); + return entry && + entry.enabled && + entry.installed && + entry.isAvailable && + entry.snapshot.auth.status !== "unauthenticated" && + available + ? (getProviderModelCapabilities(entry.models, selection.model, entry.driverKind, true) + .optionDescriptors ?? []) + : null; + }, + JSON.stringify([pickerProps.environmentId, selectedModelSelection]), + ); const toggleModel = (instanceId: ProviderInstanceId, model: string) => { const current = multipleModelSelections ?? [selectedModelSelection]; const matchesModel = (selection: ModelSelection) => { @@ -372,30 +394,42 @@ export function ComposerModelPicker({ ? current.filter((selection) => !matchesModel(selection)) : [...current, createModelSelection(instanceId, model)]; if (next.length > 1) { - onMultipleModelSelectionsChange(next); + if (exists) onMultipleModelSelectionsChange(next); + else + requestModelChange( + selectedModelSelection.options, + createModelSelection(instanceId, model), + (selection) => onMultipleModelSelectionsChange([...current, selection]), + ); } else { - onMultipleModelSelectionsChange(null); const remaining = next[0] ?? selectedModelSelection; onProviderModelSelect(remaining.instanceId, remaining.model, { focusComposer: false, + currentSelection: remaining, + onCommit: () => onMultipleModelSelectionsChange(null), }); } }; return ( - { - onMultipleModelSelectionsChange(null); - onProviderModelSelect(instanceId, model); - }} - /> + <> + {daybreakDialog} + { + onProviderModelSelect(instanceId, model, { + currentSelection: selectedModelSelection, + onCommit: () => onMultipleModelSelectionsChange(null), + }); + }} + /> + ); } diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 4639c0a3ec043..439374abbe699 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,5 +1,9 @@ import { buildTraitsTriggerDisplay, + DAYBREAK_OPTION_ID, + getModelTraitDescriptors, + visibleModelTraitDescriptors, + preserveExplicitDaybreakOption, getDescriptorStringValue, getTraitSelectChange, replaceDescriptorCurrentValue, @@ -18,7 +22,6 @@ import { import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, - getProviderOptionDescriptors, isClaudeUltrathinkPrompt, normalizeModelSlug, } from "@t3tools/shared/model"; @@ -116,6 +119,7 @@ function getSelectedTraits( modelOptions: ProviderOptions | null | undefined, allowPromptInjectedEffort: boolean, planModeEnabled: boolean, + allowDaybreak = true, ) { const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); const modelIsUnavailable = @@ -127,19 +131,23 @@ function getSelectedTraits( ? modelOptions : modelOptions?.filter((option) => option.id !== "agent" || option.value !== "plan"), ) - : getProviderOptionDescriptors({ + : getModelTraitDescriptors({ caps, selections: modelOptions, }); - const selectDescriptors = descriptors.filter( + const visibleDescriptors = visibleModelTraitDescriptors(descriptors, allowDaybreak); + const selectDescriptors = visibleDescriptors.filter( (descriptor): descriptor is Extract => descriptor.type === "select", ); - const booleanDescriptors = descriptors.filter( + const booleanDescriptors = visibleDescriptors.filter( (descriptor): descriptor is Extract => descriptor.type === "boolean", ); - const primarySelectDescriptor = selectDescriptors[0] ?? null; + const primarySelectDescriptor = + selectDescriptors.find( + (descriptor) => descriptor.id !== DAYBREAK_OPTION_ID && descriptor.id !== "serviceTier", + ) ?? null; const contextWindowDescriptor = selectDescriptors.find((descriptor) => descriptor.id === "contextWindow") ?? null; const agentDescriptor = selectDescriptors.find((descriptor) => descriptor.id === "agent") ?? null; @@ -167,6 +175,7 @@ function getSelectedTraits( return { caps, + modelOptions, descriptors, selectDescriptors, booleanDescriptors, @@ -194,6 +203,7 @@ export function getTraitsSectionVisibility(input: { modelOptions?: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; planModeEnabled: boolean; + allowDaybreak?: boolean; }) { const selected = getSelectedTraits( input.provider, @@ -203,6 +213,7 @@ export function getTraitsSectionVisibility(input: { input.modelOptions, input.allowPromptInjectedEffort ?? true, input.planModeEnabled, + input.allowDaybreak ?? true, ); const showEffort = selected.primarySelectDescriptor !== null; @@ -219,6 +230,7 @@ export function getTraitsSectionVisibility(input: { showContextWindow, showAgent, hasAnyControls: + selected.selectDescriptors.length > 0 || showEffort || showThinking || showFastMode || @@ -242,6 +254,7 @@ export function getReasoningTraitStep(input: { reportedModelSelection: ModelSelection | null | undefined; prompt: string; planModeEnabled: boolean; + allowDaybreak?: boolean; direction: 1 | -1; }) { const selected = getSelectedTraits( @@ -252,6 +265,7 @@ export function getReasoningTraitStep(input: { input.modelOptions, true, input.planModeEnabled, + input.allowDaybreak ?? true, ); if (selected.modelIsUnavailable || selected.ultrathinkInBodyText) return null; const descriptor = selected.selectDescriptors.find((entry) => @@ -281,6 +295,19 @@ export function getReasoningTraitStep(input: { return change?.modelOptions ? { prompt: change.prompt, modelOptions: change.modelOptions } : null; } +export function shouldRenderTraitsControls(input: { + provider: ProviderDriverKind; + models: ReadonlyArray; + model: string | null | undefined; + promptUltrathink?: PromptUltrathink | undefined; + modelOptions: ProviderOptions | null | undefined; + allowPromptInjectedEffort?: boolean; + planModeEnabled: boolean; + allowDaybreak?: boolean; +}): boolean { + return getTraitsSectionVisibility(input).hasAnyControls; +} + export interface TraitsMenuContentProps { provider: ProviderDriverKind; instanceId?: ProviderInstanceId; @@ -293,6 +320,7 @@ export interface TraitsMenuContentProps { reportedModelSelection?: ModelSelection | null | undefined; allowPromptInjectedEffort?: boolean; planModeEnabled: boolean; + allowDaybreak?: boolean; triggerClassName?: string; } @@ -310,6 +338,7 @@ export function useTraitsSelection({ reportedModelSelection, allowPromptInjectedEffort = true, planModeEnabled, + allowDaybreak = true, triggerClassName: _triggerClassName, ...persistence }: TraitsSelectionInput) { @@ -339,6 +368,7 @@ export function useTraitsSelection({ modelOptions, allowPromptInjectedEffort, planModeEnabled, + allowDaybreak, }); return { ...selected, @@ -359,12 +389,19 @@ export function useTraitsSelection({ onPromptEdit?.( (prompt) => getTraitSelectChange(selected, descriptor, prompt, value)?.prompt ?? prompt, ); - updateModelOptions(change.modelOptions); + updateModelOptions( + descriptor.id === DAYBREAK_OPTION_ID + ? change.modelOptions + : preserveExplicitDaybreakOption(change.modelOptions, modelOptions), + ); }, changeBoolean: (descriptorId: string, value: boolean) => { updateModelOptions( - buildProviderOptionSelectionsFromDescriptors( - replaceDescriptorCurrentValue(selected.descriptors, descriptorId, value), + preserveExplicitDaybreakOption( + buildProviderOptionSelectionsFromDescriptors( + replaceDescriptorCurrentValue(selected.descriptors, descriptorId, value), + ), + modelOptions, ), ); }, @@ -498,7 +535,7 @@ export const TraitsPicker = memo(function TraitsPicker({ } const { label: triggerLabel } = buildTraitsTriggerDisplay({ provider: props.provider, - descriptors: traits.descriptors, + descriptors: visibleModelTraitDescriptors(traits.descriptors, props.allowDaybreak), primarySelectDescriptorId: traits.primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled: traits.ultrathinkPromptControlled, modelSelection: diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 41bf3f213a0a9..c41fee63ee472 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -11,7 +11,7 @@ import { getComposerReasoningStep, withImplicitFastModeDefault, } from "./composerProviderState"; -import { readPromptUltrathink } from "./TraitsPicker"; +import { readPromptUltrathink, shouldRenderTraitsControls } from "./TraitsPicker"; const PROVIDER: ProviderDriverKind = ProviderDriverKind.make("codex"); const MODEL = "test-model"; @@ -421,3 +421,94 @@ describe("getComposerReasoningStep", () => { } }); }); + +describe("Daybreak dispatch", () => { + it("keeps an explicit revoked program for backend validation", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("cyberAccessProgram", [ + { id: "standard", label: "Off", isDefault: true }, + { id: "daybreakBlue", label: "Blue" }, + ]), + ]), + modelOptions: selections(["cyberAccessProgram", "daybreakRed"]), + planModeEnabled: true, + }); + expect(state.modelOptionsForDispatch).toEqual( + selections(["cyberAccessProgram", "daybreakRed"]), + ); + expect(state.promptEffort).toBeNull(); + }); + it("does not treat Daybreak or speed as reasoning", () => { + const models = modelWith([ + selectDescriptor("cyberAccessProgram", [ + { id: "daybreakBlue", label: "Blue", isDefault: true }, + ]), + selectDescriptor("serviceTier", [{ id: "fast", label: "Fast", isDefault: true }]), + ]); + expect( + getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models, + modelOptions: undefined, + planModeEnabled: true, + }).promptEffort, + ).toBeNull(); + expect( + getComposerReasoningStep({ + provider: PROVIDER, + instanceId: ProviderInstanceId.make("codex"), + model: MODEL, + models, + modelOptions: undefined, + prompt: "Keep this", + cursor: 3, + planModeEnabled: true, + direction: 1, + }), + ).toBeNull(); + }); +}); + +it("keeps a Daybreak-only traits control reachable while hiding standard-only controls", () => { + const input = { + provider: PROVIDER, + model: MODEL, + modelOptions: undefined, + planModeEnabled: true, + }; + expect( + shouldRenderTraitsControls({ + ...input, + models: modelWith([ + selectDescriptor("cyberAccessProgram", [ + { id: "standard", label: "Off", isDefault: true }, + { id: "daybreakBlue", label: "Blue" }, + ]), + ]), + }), + ).toBe(true); + expect( + shouldRenderTraitsControls({ + ...input, + models: modelWith([ + selectDescriptor("cyberAccessProgram", [{ id: "standard", label: "Off", isDefault: true }]), + ]), + }), + ).toBe(false); +}); + +it("retains an explicit program in dispatch when its descriptor disappears entirely", () => { + expect( + getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([]), + modelOptions: selections(["cyberAccessProgram", "daybreakRed"]), + planModeEnabled: true, + }).modelOptionsForDispatch, + ).toEqual(selections(["cyberAccessProgram", "daybreakRed"])); +}); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index c3ec70d0d5f7d..362ac1965cf88 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -1,3 +1,8 @@ +import { + getModelTraitDescriptors, + preserveExplicitDaybreakOption, + REASONING_SELECT_DESCRIPTOR_IDS, +} from "@t3tools/client-runtime/model-traits"; import { type ModelCapabilities, type ModelSelection, @@ -10,7 +15,6 @@ import { import { buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentValue, - getProviderOptionDescriptors, isClaudeUltrathinkPrompt, normalizeModelSlug, } from "@t3tools/shared/model"; @@ -124,10 +128,10 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com modelOptions, planModeEnabled, ); - const descriptors = getProviderOptionDescriptors({ caps, selections }); + const descriptors = getModelTraitDescriptors({ caps, selections }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => - descriptor.type === "select", + descriptor.type === "select" && REASONING_SELECT_DESCRIPTOR_IDS.has(descriptor.id), ); const primaryValue = getProviderOptionCurrentValue(primarySelectDescriptor ?? null); const promptEffort = typeof primaryValue === "string" ? primaryValue : null; @@ -138,8 +142,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com return { provider, promptEffort, - modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors( - descriptors, + modelOptionsForDispatch: preserveExplicitDaybreakOption( + buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, selections), selections, ), ...(ultrathinkActive diff --git a/apps/web/src/components/chat/useComposerSelection.ts b/apps/web/src/components/chat/useComposerSelection.ts index a6312466d9747..1386fbe3f333c 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -1,3 +1,6 @@ +import { useDaybreakModelChange } from "./useDaybreakModelChange"; +import { getProviderModelCapabilities } from "../../providerModels"; +import { useComposerDraftModelState } from "../../composerDraftStore"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { UnifiedSettings } from "@t3tools/contracts"; import type { Dispatch } from "react"; @@ -13,11 +16,13 @@ import { RuntimeMode, } from "@t3tools/contracts"; import type { deriveThreadStructureRuntime } from "@t3tools/client-runtime/state/thread-execution"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type SetStateAction } from "react"; import { useNavigate } from "@tanstack/react-router"; import { toastManager } from "../ui/toast"; -import { resolveAppModelSelectionForInstance } from "../../modelSelection"; +import { + getCustomModelOptionsByInstance, + resolveAppModelSelectionForInstance, +} from "../../modelSelection"; import { resolveNewDraftStartFromOrigin } from "../../lib/chatThreadActions"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../../composerDraftStore"; import { @@ -80,6 +85,26 @@ export function useComposerSelection(input: ComposerSelectionInput) { scheduleComposerFocus, } = input; const navigate = useNavigate(); + const draftModelState = useComposerDraftModelState(composerDraftTarget); + const { requestModelChange, daybreakDialog } = useDaybreakModelChange((selection) => { + const provider = providerStatuses.find((entry) => entry.instanceId === selection.instanceId); + const available = getCustomModelOptionsByInstance(settings, providerStatuses) + .get(selection.instanceId) + ?.some((model) => model.slug === selection.model && !model.isUnavailable); + return provider && + provider.enabled && + provider.installed && + provider.availability !== "unavailable" && + provider.auth.status !== "unauthenticated" && + available + ? (getProviderModelCapabilities( + provider.models, + selection.model, + provider.driver, + settings.planModeEnabled, + ).optionDescriptors ?? []) + : null; + }, JSON.stringify(composerDraftTarget)); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -142,7 +167,7 @@ export function useComposerSelection(input: ComposerSelectionInput) { const onProviderModelSelect = ( instanceId: ProviderInstanceId, model: string, - options?: { focusComposer?: boolean }, + options?: { focusComposer?: boolean; currentSelection?: ModelSelection; onCommit?: () => void }, ) => { if (!activeThread) return; const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); @@ -205,13 +230,22 @@ export function useComposerSelection(input: ComposerSelectionInput) { if (options?.focusComposer !== false) scheduleComposerFocus(); return; } - setComposerDraftModelSelection( - scopeThreadRef(activeThread.environmentId, activeThread.id), - nextModelSelection, - { explicit: true, replaceOptions: true }, - ); - setStickyComposerModelSelection(nextModelSelection); - if (options?.focusComposer !== false) scheduleComposerFocus(); + const currentSelection = + options?.currentSelection ?? + (draftModelState.activeProvider + ? draftModelState.modelSelectionByProvider[draftModelState.activeProvider] + : null) ?? + activeThread.modelSelection ?? + settings.defaultModelSelection; + requestModelChange(currentSelection?.options, nextModelSelection, (selection) => { + setComposerDraftModelSelection(composerDraftTarget, selection, { + explicit: true, + replaceOptions: true, + }); + setStickyComposerModelSelection(selection); + options?.onCommit?.(); + if (options?.focusComposer !== false) scheduleComposerFocus(); + }); }; const onProviderModelSelectStable = useStableCallback(onProviderModelSelect); const onEnvModeChange = (mode: DraftThreadEnvMode) => { @@ -254,6 +288,7 @@ export function useComposerSelection(input: ComposerSelectionInput) { } }; return { + daybreakDialog, getModelDisabledReason, handleInteractionModeChange, handleInteractionModeChangeStable, diff --git a/apps/web/src/components/chat/useDaybreakModelChange.browser.test.tsx b/apps/web/src/components/chat/useDaybreakModelChange.browser.test.tsx new file mode 100644 index 0000000000000..a1572f8918197 --- /dev/null +++ b/apps/web/src/components/chat/useDaybreakModelChange.browser.test.tsx @@ -0,0 +1,57 @@ +import "~/index.css"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { + ProviderInstanceId, + type ModelSelection, + type ProviderOptionDescriptor, +} from "@t3tools/contracts"; +import { render } from "../../../test/render"; +import { useDaybreakModelChange } from "./useDaybreakModelChange"; + +const option = (value: string) => [{ id: "cyberAccessProgram", value }]; + +function ModelChange({ + onCommit, + restored = false, +}: { + onCommit: (selection: ModelSelection) => void; + restored?: boolean; +}) { + const descriptors: ProviderOptionDescriptor[] = [ + { + id: "cyberAccessProgram", + label: "Daybreak", + type: "select", + options: ["standard", "daybreakBlue", ...(restored ? ["daybreakRed"] : [])].map((id) => ({ + id, + label: id, + })), + }, + ]; + const { requestModelChange, daybreakDialog } = useDaybreakModelChange(() => descriptors, "test"); + const target = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6-luna" }; + return ( + <> + + {daybreakDialog} + + ); +} + +it("keeps an explicit Off choice when the source program is restored before confirmation", async () => { + const onCommit = vi.fn(); + await using screen = await render(); + await page.getByRole("button", { name: "Switch model", exact: true }).click(); + await expect.element(page.getByRole("alertdialog")).toBeVisible(); + // A catalog refresh is independent of the user's pending decision. + await screen.rerender(); + await page.getByRole("button", { name: "Turn Off", exact: true }).click(); + expect(onCommit).toHaveBeenCalledWith({ + instanceId: "codex", + model: "gpt-6-luna", + options: option("standard"), + }); +}); diff --git a/apps/web/src/components/chat/useDaybreakModelChange.tsx b/apps/web/src/components/chat/useDaybreakModelChange.tsx new file mode 100644 index 0000000000000..5d617b5e8310a --- /dev/null +++ b/apps/web/src/components/chat/useDaybreakModelChange.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { formatModelSlugName } from "@t3tools/shared/model"; +import { + decideDaybreakModelChange, + getDaybreakModelChangeChoices, +} from "@t3tools/client-runtime/model-traits"; +import type { + ModelSelection, + ProviderOptionDescriptor, + ProviderOptionSelection, +} from "@t3tools/contracts"; +import { Button } from "../ui/button"; +import { + AlertDialog, + AlertDialogPopup, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, +} from "../ui/alert-dialog"; + +type PendingChange = { + currentOptions: ReadonlyArray | null | undefined; + target: ModelSelection; + commit: (selection: ModelSelection) => void; + clearsDaybreak?: boolean; + destinationContext?: string | undefined; + contextKey: string; +}; + +export function useDaybreakModelChange( + getDescriptors: ( + selection: ModelSelection, + destinationContext?: string, + ) => ReadonlyArray | null, + contextKey: string, +) { + const [pendingChange, setPending] = useState(null); + const pending = pendingChange?.contextKey === contextKey ? pendingChange : null; + if (pendingChange && pendingChange.contextKey !== contextKey) setPending(null); + const decide = (change: PendingChange) => { + const descriptors = change.clearsDaybreak + ? [] + : getDescriptors(change.target, change.destinationContext); + return descriptors === null + ? null + : decideDaybreakModelChange(change.currentOptions, change.target, descriptors); + }; + const requestModelChange = ( + currentOptions: PendingChange["currentOptions"], + target: ModelSelection, + commit: PendingChange["commit"], + clearsDaybreak = false, + destinationContext?: string, + ) => { + const change = { + currentOptions, + target, + commit, + clearsDaybreak, + contextKey, + destinationContext, + }; + const decision = decide(change); + if (!decision) return; + if (decision.type === "ready") commit(decision.selection); + else setPending(change); + }; + const decision = pending ? decide(pending) : null; + const choose = (selection: ModelSelection) => { + if (!pending) return; + // A catalog refresh can revoke an offered program while the dialog is open. + const latest = decide({ ...pending, currentOptions: selection.options, target: selection }); + if (!latest || latest.type !== "ready") { + setPending({ ...pending }); + return; + } + setPending(null); + pending.commit(latest.selection); + }; + const choices = + pending && decision + ? getDaybreakModelChangeChoices( + pending.target, + pending.clearsDaybreak + ? [] + : (getDescriptors(pending.target, pending.destinationContext) ?? []), + ) + : []; + const daybreakDialog = ( + { + if (!open) setPending(null); + }} + > + + + + {decision?.type === "confirm" && !pending?.clearsDaybreak + ? `${decision.activeLabel} isn’t available on ${formatModelSlugName(pending?.target.model ?? "this model")}` + : "Change Daybreak program?"} + + + {decision?.type === "confirm" + ? pending?.clearsDaybreak + ? `Resetting the default model clears its saved ${decision.activeLabel} selection.` + : "Choose an available option to switch models, or cancel to keep your current selection." + : decision + ? "Daybreak availability has changed. Confirm the model change to continue." + : "This model or account is no longer available. Cancel and choose another model."} + + + + + {choices.map((choice) => ( + + ))} + + + + ); + return { requestModelChange, daybreakDialog }; +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.logic.test.ts b/apps/web/src/components/settings/ProjectDefaultsSettings.logic.test.ts new file mode 100644 index 0000000000000..442729fcdfd97 --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.logic.test.ts @@ -0,0 +1,113 @@ +import { decideDaybreakModelChange } from "@t3tools/client-runtime/model-traits"; +import { + ProviderInstanceId, + type ModelSelection, + type ProviderOptionDescriptor, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + blocksBulkDaybreakChange, + intersectDaybreakDescriptors, +} from "./ProjectDefaultsSettings.logic"; + +const instanceId = ProviderInstanceId.make("codex"); +const selection = (program: string, effort = "high"): ModelSelection => ({ + instanceId, + model: "gpt-5.4", + options: [ + { id: "cyberAccessProgram", value: program }, + { id: "reasoning", value: effort }, + ], +}); +const descriptor = (...programs: string[]): ProviderOptionDescriptor => ({ + id: "cyberAccessProgram", + type: "select", + label: "Daybreak", + options: programs.map((id) => ({ id, label: id })), +}); + +describe("bulk default model Daybreak compatibility", () => { + it.each([false, true])( + "keeps Off compatible with a missing descriptor (reversed: %s)", + (reverse) => { + const sets = [[descriptor("standard", "daybreakBlue")], []]; + const descriptors = intersectDaybreakDescriptors(reverse ? sets.toReversed() : sets); + expect(decideDaybreakModelChange([], selection("standard"), descriptors).type).toBe("ready"); + const downgrade = decideDaybreakModelChange( + selection("daybreakBlue").options, + { instanceId, model: "other-model" }, + descriptors, + ); + expect(downgrade.type).toBe("confirm"); + if (downgrade.type !== "confirm") throw new Error("Expected explicit downgrade"); + expect(downgrade.choices.map((choice) => choice.label)).toEqual(["Turn Off"]); + }, + ); + + it("preserves programs advertised on every target and does not invent Off", () => { + const descriptors = intersectDaybreakDescriptors([ + [descriptor("standard", "daybreakBlue", "daybreakRed")], + [descriptor("daybreakBlue")], + ]); + const compatible = decideDaybreakModelChange( + selection("daybreakBlue").options, + { instanceId, model: "other-model" }, + descriptors, + ); + expect(compatible).toMatchObject({ + type: "ready", + selection: { options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }] }, + }); + const off = decideDaybreakModelChange([], { instanceId, model: "other-model" }, descriptors); + expect(off.type).toBe("confirm"); + if (off.type !== "confirm") throw new Error("Expected a program decision"); + expect(off.choices.map((choice) => choice.label)).toEqual(["Use Blue"]); + }); + + it.each([false, true])( + "does not invent Off when another target requires Daybreak (reversed: %s)", + (reverse) => { + const sets = [[], [descriptor("daybreakBlue")]]; + const result = decideDaybreakModelChange( + [], + { instanceId, model: "other-model" }, + intersectDaybreakDescriptors(reverse ? sets.toReversed() : sets), + ); + expect(result).toMatchObject({ type: "confirm", choices: [] }); + }, + ); + + it("allows ordinary trait changes while preserving every active program", () => { + expect( + blocksBulkDaybreakChange( + [selection("daybreakBlue"), selection("daybreakBlue")], + selection("daybreakBlue", "low"), + ), + ).toBe(false); + }); + + it("keeps ambiguous model, reset, and Daybreak edits restricted to one target", () => { + const current = [selection("daybreakBlue"), selection("daybreakBlue")]; + for (const next of [ + null, + selection("standard"), + selection("daybreakRed"), + { ...selection("daybreakBlue"), model: "other-model" }, + ]) { + expect(blocksBulkDaybreakChange(current, next)).toBe(true); + } + expect( + blocksBulkDaybreakChange( + [selection("daybreakBlue"), selection("daybreakRed")], + selection("daybreakBlue", "low"), + ), + ).toBe(true); + expect(blocksBulkDaybreakChange([selection("daybreakBlue")], null)).toBe(false); + expect( + blocksBulkDaybreakChange([selection("standard"), selection("standard")], { + instanceId, + model: "other-model", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.logic.ts b/apps/web/src/components/settings/ProjectDefaultsSettings.logic.ts new file mode 100644 index 0000000000000..47b98e3cd66b3 --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.logic.ts @@ -0,0 +1,54 @@ +import { DAYBREAK_OPTION_ID, daybreakLabel } from "@t3tools/client-runtime/model-traits"; +import type { ModelSelection, ProviderOptionDescriptor } from "@t3tools/contracts"; + +export function intersectDaybreakDescriptors( + descriptorSets: ReadonlyArray>, +) { + const representative = descriptorSets[0] ?? []; + const program = descriptorSets + .flat() + .find((descriptor) => descriptor.id === DAYBREAK_OPTION_ID && descriptor.type === "select"); + const descriptors = representative.some((descriptor) => descriptor.id === DAYBREAK_OPTION_ID) + ? representative + : [...representative, ...(program ? [program] : [])]; + return descriptors.map((descriptor) => + descriptor.id === DAYBREAK_OPTION_ID && descriptor.type === "select" + ? { + ...descriptor, + options: descriptor.options.filter((option) => + descriptorSets.every((set) => { + const matching = set.find( + (entry) => entry.id === descriptor.id && entry.type === "select", + ); + // A provider without Daybreak support can still run with it Off. + return matching?.type === "select" + ? matching.options.some((choice) => choice.id === option.id) + : option.id === "standard"; + }), + ), + } + : descriptor, + ); +} + +export function blocksBulkDaybreakChange( + selections: ReadonlyArray, + next: ModelSelection | null, +) { + const program = next?.options?.find((option) => option.id === DAYBREAK_OPTION_ID)?.value; + return ( + selections.length !== 1 && + selections.some((selection) => + selection?.options?.some( + (option) => option.id === DAYBREAK_OPTION_ID && daybreakLabel(option.value), + ), + ) && + !selections.every( + (selection) => + next?.options !== undefined && + selection?.instanceId === next.instanceId && + selection.model === next.model && + selection.options?.find((option) => option.id === DAYBREAK_OPTION_ID)?.value === program, + ) + ); +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 43c4419962a02..6e3a283c9b4bd 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -1,3 +1,5 @@ +import { useDaybreakModelChange } from "../chat/useDaybreakModelChange"; +import { getProviderModelCapabilities } from "../../providerModels"; import { DEFAULT_SERVER_SETTINGS, type ModelSelection, @@ -41,6 +43,10 @@ import { useScopedSettingSource, useUpdateScopedSettings, } from "./useScopedSettings"; +import { + blocksBulkDaybreakChange, + intersectDaybreakDescriptors, +} from "./ProjectDefaultsSettings.logic"; const WORKTREE_SUBMODULES_OPTIONS = ["recursive", "top-level", "none"] as const; function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules { @@ -48,7 +54,7 @@ function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules } export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) { - const { scope, target, connectedEnvironments } = useSettingsScope(); + const { scope, target, targets, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const navigate = useNavigate(); @@ -76,6 +82,24 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting selection?.model, ); const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); + const modelDisabledReason = useScopedModelDisabledReason(settings, entries); + const { requestModelChange, daybreakDialog } = useDaybreakModelChange((next) => { + if (modelDisabledReason(next.instanceId, next.model)) return null; + const descriptorSets = targets.map((target) => { + const provider = providersByEnvironment + .get(target.environmentId) + ?.find((entry) => entry.instanceId === next.instanceId); + return provider + ? (getProviderModelCapabilities( + provider.models, + next.model, + provider.driver, + settings.planModeEnabled, + ).optionDescriptors ?? []) + : []; + }); + return intersectDaybreakDescriptors(descriptorSets); + }, JSON.stringify(scope)); const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); const mixedFallback = useScopedSettingsMixed(["fallbackModelSelection"]); const fallbackSelection = settings.fallbackModelSelection ?? selection; @@ -98,15 +122,50 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ? resolveProjectSettings(target.settings, null, null, null).settings : null; - const modelDisabledReason = useScopedModelDisabledReason(settings, entries); - const setModel = (value: ModelSelection | null) => { + if ( + blocksBulkDaybreakChange( + targets.map((target) => target.settings.defaultModelSelection), + value, + ) + ) { + toastManager.add({ + type: "warning", + title: "Select one target to change Daybreak", + description: + "Choose a single environment or project before changing a model with an active Daybreak program.", + }); + return; + } const reason = value ? modelDisabledReason(value.instanceId, value.model) : null; if (reason) { toastManager.add({ type: "error", title: "Default model not saved", description: reason }); return; } - updateSettings({ defaultModelSelection: value }); + if ( + value && + value.instanceId === selection?.instanceId && + value.model === selection.model && + value.options !== undefined + ) { + updateSettings({ defaultModelSelection: value }); + return; + } + const next = + value ?? + resolveDefaultProviderModelSelection(providers, null) ?? + selection ?? + settings.defaultModelSelection; + if (!next) { + updateSettings({ defaultModelSelection: value }); + return; + } + requestModelChange( + selection?.options ?? settings.defaultModelSelection?.options, + next, + (resolved) => updateSettings({ defaultModelSelection: value === null ? null : resolved }), + value === null, + ); }; const modelRow = ( @@ -157,6 +216,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting /> {!mixedModel ? ( + {daybreakDialog} {category === "project" ? ( <> {modelRow} diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 934436d8dce4e..3763bea6e08c1 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1,3 +1,6 @@ +import { useDaybreakModelChange } from "../chat/useDaybreakModelChange"; +import { daybreakLabel } from "@t3tools/client-runtime/model-traits"; +import { TraitsPicker } from "../chat/TraitsPicker"; import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { AsyncResult } from "effect/reactivity"; @@ -45,7 +48,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { formatRelativeTime } from "../../timestampFormat"; -import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { mergeEnvironmentSettings, useEnvironmentSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { providerInstanceEntriesFor } from "../../providerInstances"; import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; @@ -56,6 +59,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useNowMinute } from "../../hooks/useNowMinute"; import { useEnvironment, + useEnvironmentProviders, useEnvironmentHttpBaseUrl, type EnvironmentPresentation, } from "../../state/environments"; @@ -83,6 +87,8 @@ import { matchesScheduledTaskScope, scheduleFromDraft, scheduledTaskDefaultModel, + scheduledTaskModelDescriptors, + selectScheduledTaskModel, taskToDraft, type DraftState, type ScheduleMode, @@ -1169,6 +1175,7 @@ function ScheduledTaskEditorForm({ readonly onClose: () => void; }) { const { scope, connectedEnvironments } = useSettingsScope(); + const providersByEnvironment = useEnvironmentProviders(); const [environmentId, setEnvironmentId] = useState(initialEnvironmentId); const environment = useEnvironment(environmentId); const connected = @@ -1241,6 +1248,50 @@ function ScheduledTaskEditorForm({ activeModel, ); + const effectiveSelection = + draft.baseModelSelection && + draft.baseModelSelection.instanceId === activeSelection?.instanceId && + draft.baseModelSelection.model === activeSelection.model + ? draft.baseModelSelection + : activeSelection; + const activeEntry = instanceEntries.find((entry) => entry.instanceId === activeInstanceId); + const activeDaybreak = effectiveSelection?.options?.some( + (option) => option.id === "cyberAccessProgram" && daybreakLabel(option.value) !== null, + ); + const { requestModelChange, daybreakDialog } = useDaybreakModelChange( + (next, destinationContext) => { + const destinationScope = destinationContext + ? (JSON.parse(destinationContext) as { + environmentId: EnvironmentId; + projectId: ProjectId | null; + }) + : { environmentId, projectId: selectedProject?.id ?? null }; + const destination = destinationContext + ? connectedEnvironments.find( + (entry) => entry.environmentId === destinationScope.environmentId, + ) + : environment; + if (!destination?.serverConfig) return null; + const destinationProviders = + providersByEnvironment.get(destination.environmentId) ?? EMPTY_SERVER_PROVIDERS; + const destinationSettings = mergeEnvironmentSettings( + destination.serverConfig.settings, + settings, + ); + return scheduledTaskModelDescriptors( + destinationSettings, + destinationProviders, + next, + destinationScope.projectId, + ); + }, + `${environmentId}:${selectedProjectId}`, + ); + const selectModel = (next: ModelSelection) => + requestModelChange(effectiveSelection?.options, next, (selection) => + setDraft((current) => selectScheduledTaskModel(current, selection)), + ); + const reportFailure = (title: string, error: unknown) => { toastManager.add( stackedThreadToast({ @@ -1349,6 +1400,7 @@ function ScheduledTaskEditorForm({ return ( <> + {daybreakDialog} {draft.editingId ? "Edit task" : "New task"} @@ -1369,16 +1421,63 @@ function ScheduledTaskEditorForm({ onValueChange={(id) => { const next = connectedEnvironments.find((entry) => entry.environmentId === id); if (!next) return; - setEnvironmentId(next.environmentId); - setDraft((current) => ({ - ...current, - projectId: defaultScheduledTaskProjectId(projectsOn(next.environmentId), scope), - modelKey: "", - baseModelSelection: null, - baseRef: "main", - startFromOrigin: true, - existingWorktreePath: "", - })); + if (!activeDaybreak) { + setEnvironmentId(next.environmentId); + setDraft((current) => ({ + ...current, + projectId: defaultScheduledTaskProjectId(projectsOn(next.environmentId), scope), + modelKey: "", + baseModelSelection: null, + baseRef: "main", + startFromOrigin: true, + existingWorktreePath: "", + })); + return; + } + if (!next.serverConfig) return; + const nextProviders = + providersByEnvironment.get(next.environmentId) ?? EMPTY_SERVER_PROVIDERS; + const nextSettings = mergeEnvironmentSettings(next.serverConfig.settings, settings); + const nextProjectId = defaultScheduledTaskProjectId( + projectsOn(next.environmentId), + scope, + ); + const nextProject = allProjects.find( + (project) => + project.environmentId === next.environmentId && project.id === nextProjectId, + ); + const nextEntries = providerInstanceEntriesFor( + nextProviders, + nextSettings, + nextProject?.id ?? null, + ); + const nextSelection = scheduledTaskDefaultModel( + nextSettings, + nextProject ?? null, + nextEntries, + ); + if (!nextSelection) return; + requestModelChange( + effectiveSelection?.options, + nextSelection, + (selection) => { + setEnvironmentId(next.environmentId); + setDraft((current) => ({ + ...current, + projectId: nextProject?.id ?? "", + modelKey: `${selection.instanceId}:${selection.model}`, + baseModelSelection: selection, + baseRef: "main", + startFromOrigin: true, + existingWorktreePath: "", + })); + }, + false, + JSON.stringify({ + environmentId: next.environmentId, + projectId: nextProject?.id ?? null, + }), + ); }} > @@ -1454,9 +1553,33 @@ function ScheduledTaskEditorForm({