| author | |
| committer | |
| log | 80c37f1a721d36bde86b31c475fcc8d3db75f706 |
| tree | 315bfa72da44695100104cb32c3ebe2cde606312 |
| parent | 07f8027d9a542f72b12db927caceebb2c9c72aa7 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 342 insertions(+), 8 deletions(-)
apps/web/src/components/chat/ChatComposer.tsx+4| ... | ... | @@ -873,6 +873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 873 | 873 | models: selectedProviderModels, |
| 874 | 874 | promptInjectionState: composerPromptInjectionState, |
| 875 | 875 | modelOptions: composerModelOptions?.[selectedInstanceId], |
| 876 | planModeEnabled: settings.planModeEnabled, | |
| 876 | 877 | }), |
| 877 | 878 | [ |
| 878 | 879 | composerModelOptions, |
| ... | ... | @@ -881,6 +882,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 881 | 882 | selectedModel, |
| 882 | 883 | selectedProvider, |
| 883 | 884 | selectedProviderModels, |
| 885 | settings.planModeEnabled, | |
| 884 | 886 | ], |
| 885 | 887 | ); |
| 886 | 888 | |
| ... | ... | @@ -1215,6 +1217,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1215 | 1217 | modelOptions: composerModelOptions?.[selectedInstanceId], |
| 1216 | 1218 | prompt, |
| 1217 | 1219 | onPromptChange: setPromptFromTraits, |
| 1220 | planModeEnabled: settings.planModeEnabled, | |
| 1218 | 1221 | }); |
| 1219 | 1222 | const providerTraitsPicker = renderProviderTraitsPicker({ |
| 1220 | 1223 | provider: selectedProvider, |
| ... | ... | @@ -1226,6 +1229,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1226 | 1229 | modelOptions: composerModelOptions?.[selectedInstanceId], |
| 1227 | 1230 | prompt, |
| 1228 | 1231 | onPromptChange: setPromptFromTraits, |
| 1232 | planModeEnabled: settings.planModeEnabled, | |
| 1229 | 1233 | }); |
| 1230 | 1234 | const pendingPrimaryAction = useMemo( |
| 1231 | 1235 | () => |
apps/web/src/components/chat/TraitsPicker.tsx+12-1| ... | ... | @@ -96,8 +96,9 @@ function getSelectedTraits( |
| 96 | 96 | prompt: string, |
| 97 | 97 | modelOptions: ProviderOptions | null | undefined, |
| 98 | 98 | allowPromptInjectedEffort: boolean, |
| 99 | planModeEnabled: boolean, | |
| 99 | 100 | ) { |
| 100 | const caps = getProviderModelCapabilities(models, model, provider); | |
| 101 | const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); | |
| 101 | 102 | const descriptors = getProviderOptionDescriptors({ |
| 102 | 103 | caps, |
| 103 | 104 | selections: modelOptions, |
| ... | ... | @@ -167,6 +168,7 @@ function getTraitsSectionVisibility(input: { |
| 167 | 168 | prompt: string; |
| 168 | 169 | modelOptions: ProviderOptions | null | undefined; |
| 169 | 170 | allowPromptInjectedEffort?: boolean; |
| 171 | planModeEnabled: boolean; | |
| 170 | 172 | }) { |
| 171 | 173 | const selected = getSelectedTraits( |
| 172 | 174 | input.provider, |
| ... | ... | @@ -175,6 +177,7 @@ function getTraitsSectionVisibility(input: { |
| 175 | 177 | input.prompt, |
| 176 | 178 | input.modelOptions, |
| 177 | 179 | input.allowPromptInjectedEffort ?? true, |
| 180 | input.planModeEnabled, | |
| 178 | 181 | ); |
| 179 | 182 | |
| 180 | 183 | const showEffort = selected.primarySelectDescriptor !== null; |
| ... | ... | @@ -201,6 +204,7 @@ export function shouldRenderTraitsControls(input: { |
| 201 | 204 | prompt: string; |
| 202 | 205 | modelOptions: ProviderOptions | null | undefined; |
| 203 | 206 | allowPromptInjectedEffort?: boolean; |
| 207 | planModeEnabled: boolean; | |
| 204 | 208 | }): boolean { |
| 205 | 209 | return getTraitsSectionVisibility(input).hasAnyControls; |
| 206 | 210 | } |
| ... | ... | @@ -214,6 +218,7 @@ export interface TraitsMenuContentProps { |
| 214 | 218 | onPromptChange: (prompt: string) => void; |
| 215 | 219 | modelOptions?: ProviderOptions | null | undefined; |
| 216 | 220 | allowPromptInjectedEffort?: boolean; |
| 221 | planModeEnabled: boolean; | |
| 217 | 222 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; |
| 218 | 223 | triggerClassName?: string; |
| 219 | 224 | } |
| ... | ... | @@ -227,6 +232,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 227 | 232 | onPromptChange, |
| 228 | 233 | modelOptions, |
| 229 | 234 | allowPromptInjectedEffort = true, |
| 235 | planModeEnabled, | |
| 230 | 236 | ...persistence |
| 231 | 237 | }: TraitsMenuContentProps & TraitsPersistence) { |
| 232 | 238 | const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); |
| ... | ... | @@ -263,6 +269,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 263 | 269 | prompt, |
| 264 | 270 | modelOptions, |
| 265 | 271 | allowPromptInjectedEffort, |
| 272 | planModeEnabled, | |
| 266 | 273 | }); |
| 267 | 274 | const updateDescriptors = (nextDescriptors: ReadonlyArray<ProviderOptionDescriptor>) => { |
| 268 | 275 | updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); |
| ... | ... | @@ -451,6 +458,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 451 | 458 | onPromptChange, |
| 452 | 459 | modelOptions, |
| 453 | 460 | allowPromptInjectedEffort = true, |
| 461 | planModeEnabled, | |
| 454 | 462 | triggerVariant, |
| 455 | 463 | triggerClassName, |
| 456 | 464 | ...persistence |
| ... | ... | @@ -464,6 +472,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 464 | 472 | prompt, |
| 465 | 473 | modelOptions, |
| 466 | 474 | allowPromptInjectedEffort, |
| 475 | planModeEnabled, | |
| 467 | 476 | }); |
| 468 | 477 | if ( |
| 469 | 478 | !shouldRenderTraitsControls({ |
| ... | ... | @@ -473,6 +482,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 473 | 482 | prompt, |
| 474 | 483 | modelOptions, |
| 475 | 484 | allowPromptInjectedEffort, |
| 485 | planModeEnabled, | |
| 476 | 486 | }) |
| 477 | 487 | ) { |
| 478 | 488 | return null; |
| ... | ... | @@ -543,6 +553,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 543 | 553 | onPromptChange={onPromptChange} |
| 544 | 554 | modelOptions={modelOptions} |
| 545 | 555 | allowPromptInjectedEffort={allowPromptInjectedEffort} |
| 556 | planModeEnabled={planModeEnabled} | |
| 546 | 557 | {...persistence} |
| 547 | 558 | /> |
| 548 | 559 | </MenuPopup> |
apps/web/src/components/chat/composerProviderState.test.tsx+61| ... | ... | @@ -80,6 +80,7 @@ describe("getComposerProviderState", () => { |
| 80 | 80 | ]), |
| 81 | 81 | ]), |
| 82 | 82 | modelOptions: undefined, |
| 83 | planModeEnabled: true, | |
| 83 | 84 | }); |
| 84 | 85 | |
| 85 | 86 | expect(state).toEqual({ |
| ... | ... | @@ -101,6 +102,7 @@ describe("getComposerProviderState", () => { |
| 101 | 102 | booleanDescriptor("fastMode"), |
| 102 | 103 | ]), |
| 103 | 104 | modelOptions: selections(["effort", "low"], ["fastMode", true]), |
| 105 | planModeEnabled: true, | |
| 104 | 106 | }); |
| 105 | 107 | |
| 106 | 108 | expect(state).toEqual({ |
| ... | ... | @@ -119,6 +121,7 @@ describe("getComposerProviderState", () => { |
| 119 | 121 | booleanDescriptor("fastMode"), |
| 120 | 122 | ]), |
| 121 | 123 | modelOptions: selections(["effort", "high"], ["fastMode", false]), |
| 124 | planModeEnabled: true, | |
| 122 | 125 | }); |
| 123 | 126 | |
| 124 | 127 | expect(state.modelOptionsForDispatch).toEqual( |
| ... | ... | @@ -132,6 +135,7 @@ describe("getComposerProviderState", () => { |
| 132 | 135 | model: MODEL, |
| 133 | 136 | models: modelWith([booleanDescriptor("thinking")]), |
| 134 | 137 | modelOptions: selections(["effort", "max"], ["thinking", false]), |
| 138 | planModeEnabled: true, | |
| 135 | 139 | }); |
| 136 | 140 | |
| 137 | 141 | expect(state).toEqual({ |
| ... | ... | @@ -157,6 +161,7 @@ describe("getComposerProviderState", () => { |
| 157 | 161 | ]), |
| 158 | 162 | ]), |
| 159 | 163 | modelOptions: selections(["agent", "plan"]), |
| 164 | planModeEnabled: true, | |
| 160 | 165 | }); |
| 161 | 166 | |
| 162 | 167 | expect(state.promptEffort).toBe("high"); |
| ... | ... | @@ -165,12 +170,65 @@ describe("getComposerProviderState", () => { |
| 165 | 170 | ); |
| 166 | 171 | }); |
| 167 | 172 | |
| 173 | it("drops the plan agent from dispatch when legacy plan mode is disabled", () => { | |
| 174 | const state = getComposerProviderState({ | |
| 175 | provider: PROVIDER, | |
| 176 | model: MODEL, | |
| 177 | models: modelWith([ | |
| 178 | selectDescriptor("agent", [ | |
| 179 | { id: "build", label: "Build", isDefault: true }, | |
| 180 | { id: "plan", label: "Plan" }, | |
| 181 | ]), | |
| 182 | ]), | |
| 183 | modelOptions: selections(["agent", "plan"]), | |
| 184 | planModeEnabled: false, | |
| 185 | }); | |
| 186 | ||
| 187 | expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "build"])); | |
| 188 | }); | |
| 189 | ||
| 190 | it("drops the agent descriptor entirely when plan is the only option and plan mode is disabled", () => { | |
| 191 | const state = getComposerProviderState({ | |
| 192 | provider: PROVIDER, | |
| 193 | model: MODEL, | |
| 194 | models: modelWith([ | |
| 195 | selectDescriptor("agent", [{ id: "plan", label: "Plan", isDefault: true }]), | |
| 196 | ]), | |
| 197 | modelOptions: selections(["agent", "plan"]), | |
| 198 | planModeEnabled: false, | |
| 199 | }); | |
| 200 | ||
| 201 | expect(state).toEqual({ | |
| 202 | provider: PROVIDER, | |
| 203 | promptEffort: null, | |
| 204 | modelOptionsForDispatch: undefined, | |
| 205 | }); | |
| 206 | }); | |
| 207 | ||
| 208 | it("falls back to a surviving agent when plan was the descriptor default and plan mode is disabled", () => { | |
| 209 | const state = getComposerProviderState({ | |
| 210 | provider: PROVIDER, | |
| 211 | model: MODEL, | |
| 212 | models: modelWith([ | |
| 213 | selectDescriptor("agent", [ | |
| 214 | { id: "plan", label: "Plan", isDefault: true }, | |
| 215 | { id: "research", label: "Research" }, | |
| 216 | ]), | |
| 217 | ]), | |
| 218 | modelOptions: undefined, | |
| 219 | planModeEnabled: false, | |
| 220 | }); | |
| 221 | ||
| 222 | expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "research"])); | |
| 223 | }); | |
| 224 | ||
| 168 | 225 | it("returns undefined dispatch options when the model declares no descriptors", () => { |
| 169 | 226 | const state = getComposerProviderState({ |
| 170 | 227 | provider: PROVIDER, |
| 171 | 228 | model: MODEL, |
| 172 | 229 | models: modelWith([]), |
| 173 | 230 | modelOptions: selections(["anything", "value"]), |
| 231 | planModeEnabled: true, | |
| 174 | 232 | }); |
| 175 | 233 | |
| 176 | 234 | expect(state).toEqual({ |
| ... | ... | @@ -199,6 +257,7 @@ describe("getComposerProviderState", () => { |
| 199 | 257 | "Ultrathink:\nInvestigate this failure", |
| 200 | 258 | ), |
| 201 | 259 | modelOptions: selections(["effort", "medium"]), |
| 260 | planModeEnabled: true, | |
| 202 | 261 | }); |
| 203 | 262 | |
| 204 | 263 | expect(state).toEqual({ |
| ... | ... | @@ -220,6 +279,7 @@ describe("getComposerProviderState", () => { |
| 220 | 279 | "Ultrathink:\nInvestigate this failure", |
| 221 | 280 | ), |
| 222 | 281 | modelOptions: undefined, |
| 282 | planModeEnabled: true, | |
| 223 | 283 | }); |
| 224 | 284 | |
| 225 | 285 | expect(state).not.toHaveProperty("composerFrameClassName"); |
| ... | ... | @@ -240,6 +300,7 @@ describe("provider traits render guards", () => { |
| 240 | 300 | modelOptions: undefined, |
| 241 | 301 | prompt: "", |
| 242 | 302 | onPromptChange: () => {}, |
| 303 | planModeEnabled: true, | |
| 243 | 304 | }; |
| 244 | 305 | |
| 245 | 306 | expect(renderProviderTraitsPicker(args)).toBeNull(); |
apps/web/src/components/chat/composerProviderState.tsx+21-3| ... | ... | @@ -23,6 +23,7 @@ export type ComposerProviderStateInput = { |
| 23 | 23 | models: ReadonlyArray<ServerProviderModel>; |
| 24 | 24 | promptInjectionState?: ComposerPromptInjectionState; |
| 25 | 25 | modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined; |
| 26 | planModeEnabled: boolean; | |
| 26 | 27 | }; |
| 27 | 28 | |
| 28 | 29 | export type ComposerPromptInjectionState = "none" | "ultrathink"; |
| ... | ... | @@ -46,6 +47,7 @@ type TraitsRenderInput = { |
| 46 | 47 | modelOptions: ReadonlyArray<ProviderOptionSelection> | undefined; |
| 47 | 48 | prompt: string; |
| 48 | 49 | onPromptChange: (prompt: string) => void; |
| 50 | planModeEnabled: boolean; | |
| 49 | 51 | }; |
| 50 | 52 | |
| 51 | 53 | export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { |
| ... | ... | @@ -53,8 +55,15 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI |
| 53 | 55 | } |
| 54 | 56 | |
| 55 | 57 | export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { |
| 56 | const { provider, model, models, modelOptions, promptInjectionState = "none" } = input; | |
| 57 | const caps = getProviderModelCapabilities(models, model, provider); | |
| 58 | const { | |
| 59 | provider, | |
| 60 | model, | |
| 61 | models, | |
| 62 | modelOptions, | |
| 63 | promptInjectionState = "none", | |
| 64 | planModeEnabled, | |
| 65 | } = input; | |
| 66 | const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); | |
| 58 | 67 | const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); |
| 59 | 68 | const primarySelectDescriptor = descriptors.find( |
| 60 | 69 | (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => |
| ... | ... | @@ -94,11 +103,19 @@ function renderTraitsControl( |
| 94 | 103 | modelOptions, |
| 95 | 104 | prompt, |
| 96 | 105 | onPromptChange, |
| 106 | planModeEnabled, | |
| 97 | 107 | } = input; |
| 98 | 108 | const hasTarget = threadRef !== undefined || draftId !== undefined; |
| 99 | 109 | if ( |
| 100 | 110 | !hasTarget || |
| 101 | !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt }) | |
| 111 | !shouldRenderTraitsControls({ | |
| 112 | provider, | |
| 113 | models, | |
| 114 | model, | |
| 115 | modelOptions, | |
| 116 | prompt, | |
| 117 | planModeEnabled, | |
| 118 | }) | |
| 102 | 119 | ) { |
| 103 | 120 | return null; |
| 104 | 121 | } |
| ... | ... | @@ -113,6 +130,7 @@ function renderTraitsControl( |
| 113 | 130 | modelOptions={modelOptions} |
| 114 | 131 | prompt={prompt} |
| 115 | 132 | onPromptChange={onPromptChange} |
| 133 | planModeEnabled={planModeEnabled} | |
| 116 | 134 | /> |
| 117 | 135 | ); |
| 118 | 136 | } |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+1| ... | ... | @@ -831,6 +831,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 831 | 831 | onPromptChange={() => {}} |
| 832 | 832 | modelOptions={resolvedSelection.options ?? []} |
| 833 | 833 | allowPromptInjectedEffort={false} |
| 834 | planModeEnabled={settings.planModeEnabled} | |
| 834 | 835 | triggerVariant="outline" |
| 835 | 836 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" |
| 836 | 837 | onModelOptionsChange={(nextOptions) => { |
apps/web/src/components/settings/SettingsPanels.tsx+27-3| ... | ... | @@ -68,6 +68,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 68 | 68 | import { |
| 69 | 69 | getCustomModelOptionsByInstance, |
| 70 | 70 | resolveAppModelSelectionState, |
| 71 | withoutPlanAgentSelection, | |
| 71 | 72 | } from "../../modelSelection"; |
| 72 | 73 | import { |
| 73 | 74 | applyProviderInstanceSettings, |
| ... | ... | @@ -1710,9 +1711,31 @@ function LegacyFeaturesSection() { |
| 1710 | 1711 | control={ |
| 1711 | 1712 | <Switch |
| 1712 | 1713 | checked={settings.planModeEnabled} |
| 1713 | onCheckedChange={(checked) => | |
| 1714 | updateSettings({ planModeEnabled: Boolean(checked) }) | |
| 1715 | } | |
| 1714 | onCheckedChange={(checked) => { | |
| 1715 | const planModeEnabled = Boolean(checked); | |
| 1716 | const textGenerationModelSelection = withoutPlanAgentSelection( | |
| 1717 | settings.textGenerationModelSelection, | |
| 1718 | ); | |
| 1719 | const sourceControlWriterModelSelection = withoutPlanAgentSelection( | |
| 1720 | settings.sourceControlWriterModelSelection, | |
| 1721 | ); | |
| 1722 | updateSettings({ | |
| 1723 | planModeEnabled, | |
| 1724 | ...(planModeEnabled | |
| 1725 | ? {} | |
| 1726 | : { | |
| 1727 | ...(textGenerationModelSelection && | |
| 1728 | textGenerationModelSelection !== settings.textGenerationModelSelection | |
| 1729 | ? { textGenerationModelSelection } | |
| 1730 | : {}), | |
| 1731 | ...(sourceControlWriterModelSelection && | |
| 1732 | sourceControlWriterModelSelection !== | |
| 1733 | settings.sourceControlWriterModelSelection | |
| 1734 | ? { sourceControlWriterModelSelection } | |
| 1735 | : {}), | |
| 1736 | }), | |
| 1737 | }); | |
| 1738 | }} | |
| 1716 | 1739 | aria-label="Plan mode (legacy)" |
| 1717 | 1740 | /> |
| 1718 | 1741 | } |
| ... | ... | @@ -2332,6 +2355,7 @@ export function GeneralSettingsPanel() { |
| 2332 | 2355 | onPromptChange={() => {}} |
| 2333 | 2356 | modelOptions={textGenModelOptions} |
| 2334 | 2357 | allowPromptInjectedEffort={false} |
| 2358 | planModeEnabled={settings.planModeEnabled} | |
| 2335 | 2359 | triggerVariant="outline" |
| 2336 | 2360 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" |
| 2337 | 2361 | onModelOptionsChange={(nextOptions) => { |
apps/web/src/modelSelection.test.ts+82| ... | ... | @@ -1,11 +1,14 @@ |
| 1 | 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 2 | 2 | import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; |
| 3 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 4 | 5 | import { deriveProviderInstanceEntries } from "./providerInstances"; |
| 5 | 6 | import { |
| 6 | 7 | getAppModelOptionsForInstance, |
| 7 | 8 | resolveAppModelSelectionForInstance, |
| 8 | 9 | resolveAppModelSelectionState, |
| 10 | resolvePlanAgentHealPatch, | |
| 11 | withoutPlanAgentSelection, | |
| 9 | 12 | } from "./modelSelection"; |
| 10 | 13 | |
| 11 | 14 | function provider(input: { |
| ... | ... | @@ -321,3 +324,82 @@ describe("instance-scoped model selection", () => { |
| 321 | 324 | }); |
| 322 | 325 | }); |
| 323 | 326 | }); |
| 327 | ||
| 328 | describe("withoutPlanAgentSelection", () => { | |
| 329 | const instance = ProviderInstanceId.make("opencode"); | |
| 330 | const model = "opencode/gpt-5.4"; | |
| 331 | ||
| 332 | it("drops a stored plan agent option", () => { | |
| 333 | const selection = createModelSelection(instance, model, [ | |
| 334 | { id: "variant", value: "high" }, | |
| 335 | { id: "agent", value: "plan" }, | |
| 336 | ]); | |
| 337 | expect(withoutPlanAgentSelection(selection)).toEqual( | |
| 338 | createModelSelection(instance, model, [{ id: "variant", value: "high" }]), | |
| 339 | ); | |
| 340 | }); | |
| 341 | ||
| 342 | it("keeps non-plan agent options", () => { | |
| 343 | const selection = createModelSelection(instance, model, [{ id: "agent", value: "build" }]); | |
| 344 | expect(withoutPlanAgentSelection(selection)).toBe(selection); | |
| 345 | }); | |
| 346 | ||
| 347 | it("omits options entirely when plan was the only stored option", () => { | |
| 348 | const selection = createModelSelection(instance, model, [{ id: "agent", value: "plan" }]); | |
| 349 | expect(withoutPlanAgentSelection(selection)).toEqual({ instanceId: instance, model }); | |
| 350 | }); | |
| 351 | ||
| 352 | it("returns null and undefined selections unchanged", () => { | |
| 353 | expect(withoutPlanAgentSelection(null)).toBeNull(); | |
| 354 | expect(withoutPlanAgentSelection(undefined)).toBeUndefined(); | |
| 355 | }); | |
| 356 | }); | |
| 357 | ||
| 358 | describe("resolvePlanAgentHealPatch", () => { | |
| 359 | const instance = ProviderInstanceId.make("opencode"); | |
| 360 | const model = "opencode/gpt-5.4"; | |
| 361 | const healed = createModelSelection(instance, model, [{ id: "variant", value: "high" }]); | |
| 362 | const storedPlan = createModelSelection(instance, model, [ | |
| 363 | { id: "variant", value: "high" }, | |
| 364 | { id: "agent", value: "plan" }, | |
| 365 | ]); | |
| 366 | const nullPatch = { | |
| 367 | planModeEnabled: true, | |
| 368 | textGenerationModelSelection: storedPlan, | |
| 369 | sourceControlWriterModelSelection: null, | |
| 370 | }; | |
| 371 | ||
| 372 | it("returns null when plan mode is on", () => { | |
| 373 | expect(resolvePlanAgentHealPatch(nullPatch)).toBeNull(); | |
| 374 | }); | |
| 375 | ||
| 376 | it("returns null when nothing needs healing", () => { | |
| 377 | expect( | |
| 378 | resolvePlanAgentHealPatch({ | |
| 379 | planModeEnabled: false, | |
| 380 | textGenerationModelSelection: healed, | |
| 381 | sourceControlWriterModelSelection: null, | |
| 382 | }), | |
| 383 | ).toBeNull(); | |
| 384 | }); | |
| 385 | ||
| 386 | it("patches the stored text generation selection to drop the plan agent", () => { | |
| 387 | expect( | |
| 388 | resolvePlanAgentHealPatch({ | |
| 389 | planModeEnabled: false, | |
| 390 | textGenerationModelSelection: storedPlan, | |
| 391 | sourceControlWriterModelSelection: null, | |
| 392 | }), | |
| 393 | ).toEqual({ textGenerationModelSelection: healed }); | |
| 394 | }); | |
| 395 | ||
| 396 | it("patches a stored source control writer selection that uses the plan agent", () => { | |
| 397 | expect( | |
| 398 | resolvePlanAgentHealPatch({ | |
| 399 | planModeEnabled: false, | |
| 400 | textGenerationModelSelection: healed, | |
| 401 | sourceControlWriterModelSelection: storedPlan, | |
| 402 | }), | |
| 403 | ).toEqual({ sourceControlWriterModelSelection: healed }); | |
| 404 | }); | |
| 405 | }); |
apps/web/src/modelSelection.ts+48| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ProviderDriverKind, |
| 7 | 7 | ProviderInstanceId, |
| 8 | 8 | type ServerProvider, |
| 9 | type ServerSettingsPatch, | |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | import { |
| 11 | 12 | createModelSelection, |
| ... | ... | @@ -277,6 +278,51 @@ export function getCustomModelOptionsByInstance( |
| 277 | 278 | return out; |
| 278 | 279 | } |
| 279 | 280 | |
| 281 | /** | |
| 282 | * Drop the opencode "plan" agent option from a stored model selection. | |
| 283 | * Used when legacy plan mode is turned off so server-side text-generation | |
| 284 | * tasks (title, branch, PR) cannot keep dispatching the plan agent. | |
| 285 | */ | |
| 286 | export function withoutPlanAgentSelection( | |
| 287 | selection: ModelSelection | null | undefined, | |
| 288 | ): ModelSelection | null | undefined { | |
| 289 | if (!selection?.options) { | |
| 290 | return selection; | |
| 291 | } | |
| 292 | const options = selection.options.filter( | |
| 293 | (option) => !(option.id === "agent" && option.value === "plan"), | |
| 294 | ); | |
| 295 | if (options.length === selection.options.length) { | |
| 296 | return selection; | |
| 297 | } | |
| 298 | return createModelSelection(selection.instanceId, selection.model, options); | |
| 299 | } | |
| 300 | ||
| 301 | // The dropdown hides the opencode "plan" agent while legacy plan mode is off, | |
| 302 | // but the persisted text-generation selections are only healed when the toggle | |
| 303 | // flips. Users who already have plan mode off and a stored "plan" selection | |
| 304 | // never trip the toggle handler, so resolve the heal once per settings load. | |
| 305 | export function resolvePlanAgentHealPatch(input: { | |
| 306 | readonly planModeEnabled: boolean; | |
| 307 | readonly textGenerationModelSelection: ModelSelection | null | undefined; | |
| 308 | readonly sourceControlWriterModelSelection: ModelSelection | null | undefined; | |
| 309 | }): ServerSettingsPatch | null { | |
| 310 | if (input.planModeEnabled) { | |
| 311 | return null; | |
| 312 | } | |
| 313 | const healedText = withoutPlanAgentSelection(input.textGenerationModelSelection); | |
| 314 | const healedSourceControl = withoutPlanAgentSelection(input.sourceControlWriterModelSelection); | |
| 315 | const patch: ServerSettingsPatch = { | |
| 316 | ...(healedText && healedText !== input.textGenerationModelSelection | |
| 317 | ? { textGenerationModelSelection: healedText } | |
| 318 | : {}), | |
| 319 | ...(healedSourceControl && healedSourceControl !== input.sourceControlWriterModelSelection | |
| 320 | ? { sourceControlWriterModelSelection: healedSourceControl } | |
| 321 | : {}), | |
| 322 | }; | |
| 323 | return Object.keys(patch).length > 0 ? patch : null; | |
| 324 | } | |
| 325 | ||
| 280 | 326 | export function resolveAppModelSelectionState( |
| 281 | 327 | settings: UnifiedSettings, |
| 282 | 328 | providers: ReadonlyArray<ServerProvider>, |
| ... | ... | @@ -308,6 +354,7 @@ export function resolveAppModelSelectionState( |
| 308 | 354 | model, |
| 309 | 355 | models: entry.models, |
| 310 | 356 | modelOptions: selectedEntry ? selection.options : undefined, |
| 357 | planModeEnabled: settings.planModeEnabled, | |
| 311 | 358 | }); |
| 312 | 359 | |
| 313 | 360 | return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); |
| ... | ... | @@ -325,6 +372,7 @@ export function resolveAppModelSelectionState( |
| 325 | 372 | model, |
| 326 | 373 | models: getProviderModels(providers, provider), |
| 327 | 374 | modelOptions: keptSelectedProvider ? selection.options : undefined, |
| 375 | planModeEnabled: settings.planModeEnabled, | |
| 328 | 376 | }); |
| 329 | 377 | |
| 330 | 378 | return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch); |
apps/web/src/planAgentSelectionHeal.tsx created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | import { useEffect } from "react"; | |
| 2 | ||
| 3 | import { | |
| 4 | useClientSettingsHydrated, | |
| 5 | usePrimarySettings, | |
| 6 | useUpdatePrimarySettings, | |
| 7 | } from "./hooks/useSettings"; | |
| 8 | import { resolvePlanAgentHealPatch } from "./modelSelection"; | |
| 9 | ||
| 10 | /** | |
| 11 | * Heals persisted text-generation model selections that still reference the | |
| 12 | * opencode "plan" agent. The dropdown hides the option while legacy plan mode | |
| 13 | * is off, but the toggle handler only runs when the setting flips; users who | |
| 14 | * already have plan mode off with a stored "plan" selection need this pass | |
| 15 | * whenever the settings load. | |
| 16 | */ | |
| 17 | export function PlanAgentSelectionHeal() { | |
| 18 | const planModeEnabled = usePrimarySettings((settings) => settings.planModeEnabled); | |
| 19 | const textGenerationModelSelection = usePrimarySettings( | |
| 20 | (settings) => settings.textGenerationModelSelection, | |
| 21 | ); | |
| 22 | const sourceControlWriterModelSelection = usePrimarySettings( | |
| 23 | (settings) => settings.sourceControlWriterModelSelection, | |
| 24 | ); | |
| 25 | const settingsHydrated = useClientSettingsHydrated(); | |
| 26 | const updateSettings = useUpdatePrimarySettings(); | |
| 27 | ||
| 28 | useEffect(() => { | |
| 29 | // planModeEnabled reads as false until client settings hydrate, so never | |
| 30 | // heal before then: we would strip a stored plan selection from a user | |
| 31 | // whose plan mode is actually on. | |
| 32 | if (!settingsHydrated) { | |
| 33 | return; | |
| 34 | } | |
| 35 | const patch = resolvePlanAgentHealPatch({ | |
| 36 | planModeEnabled, | |
| 37 | textGenerationModelSelection, | |
| 38 | sourceControlWriterModelSelection, | |
| 39 | }); | |
| 40 | if (patch) { | |
| 41 | updateSettings(patch); | |
| 42 | } | |
| 43 | }, [ | |
| 44 | planModeEnabled, | |
| 45 | settingsHydrated, | |
| 46 | textGenerationModelSelection, | |
| 47 | sourceControlWriterModelSelection, | |
| 48 | updateSettings, | |
| 49 | ]); | |
| 50 | ||
| 51 | return null; | |
| 52 | } |
apps/web/src/providerModels.ts+32-1| ... | ... | @@ -81,9 +81,40 @@ export function getProviderModelCapabilities( |
| 81 | 81 | models: ReadonlyArray<ServerProviderModel>, |
| 82 | 82 | model: string | null | undefined, |
| 83 | 83 | provider: ProviderDriverKind, |
| 84 | planModeEnabled = true, | |
| 84 | 85 | ): ModelCapabilities { |
| 85 | 86 | const slug = normalizeModelSlug(model, provider); |
| 86 | return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; | |
| 87 | const caps = | |
| 88 | models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; | |
| 89 | if (planModeEnabled) { | |
| 90 | return caps; | |
| 91 | } | |
| 92 | return withoutPlanAgentOption(caps); | |
| 93 | } | |
| 94 | ||
| 95 | // The opencode "plan" agent is only reachable while legacy plan mode is on. | |
| 96 | // With it off, drop the option so it cannot be selected or dispatched, and | |
| 97 | // drop the descriptor entirely when nothing remains selectable. currentValue | |
| 98 | // is re-resolved against the surviving options so a stale or defaulted "plan" | |
| 99 | // value cannot leak back into dispatch. | |
| 100 | function withoutPlanAgentOption(caps: ModelCapabilities): ModelCapabilities { | |
| 101 | return { | |
| 102 | ...caps, | |
| 103 | optionDescriptors: (caps.optionDescriptors ?? []).flatMap((descriptor) => { | |
| 104 | if (descriptor.type !== "select" || descriptor.id !== "agent") { | |
| 105 | return [descriptor]; | |
| 106 | } | |
| 107 | const options = descriptor.options.filter((option) => option.id !== "plan"); | |
| 108 | if (options.length === 0) { | |
| 109 | return []; | |
| 110 | } | |
| 111 | const currentValue = | |
| 112 | descriptor.currentValue && options.some((option) => option.id === descriptor.currentValue) | |
| 113 | ? descriptor.currentValue | |
| 114 | : (options.find((option) => option.isDefault)?.id ?? options[0]?.id); | |
| 115 | return [{ ...descriptor, options, ...(currentValue ? { currentValue } : {}) }]; | |
| 116 | }), | |
| 117 | }; | |
| 87 | 118 | } |
| 88 | 119 | |
| 89 | 120 | export function getDefaultServerModel( |
apps/web/src/routes/__root.tsx+2| ... | ... | @@ -31,6 +31,7 @@ import { |
| 31 | 31 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 32 | 32 | import { applyAppearanceFontVariables } from "~/appearanceFonts"; |
| 33 | 33 | import { useClientSettings } from "../hooks/useSettings"; |
| 34 | import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; | |
| 34 | 35 | import { |
| 35 | 36 | deriveLogicalProjectKeyFromSettings, |
| 36 | 37 | derivePhysicalProjectKeyFromPath, |
| ... | ... | @@ -140,6 +141,7 @@ function RootRouteView() { |
| 140 | 141 | <SlowRpcRequestToastCoordinator /> |
| 141 | 142 | <HostedStaticEnvironmentBootstrap /> |
| 142 | 143 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
| 144 | {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null} | |
| 143 | 145 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} |
| 144 | 146 | {appShell} |
| 145 | 147 | {/* Above the router: a theme draft is judged by walking the app, so the |