| author | |
| committer | |
| log | dadba6d95d2c4515956a6c7fef184378987e13b7 |
| tree | 7986edd858d38ed994f22078769a00505619dbf2 |
| parent | 02443335b155e46248f96d80e037603abc742a61 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>4 files changed, 183 insertions(+), 8 deletions(-)
apps/web/src/components/chat/composerProviderState.test.tsx+98-2| ... | @@ -5,11 +5,14 @@ import { | ... | @@ -5,11 +5,14 @@ import { |
| 5 | type ProviderOptionSelection, | 5 | type ProviderOptionSelection, |
| 6 | type ServerProviderModel, | 6 | type ServerProviderModel, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { getProviderOptionDescriptors } from "@t3tools/shared/model"; | ||
| 9 | import { getProviderModelCapabilities } from "../../providerModels"; | ||
| 8 | import { | 10 | import { |
| 9 | getComposerPromptInjectionState, | 11 | getComposerPromptInjectionState, |
| 10 | getComposerProviderState, | 12 | getComposerProviderState, |
| 11 | renderProviderTraitsMenuContent, | 13 | renderProviderTraitsMenuContent, |
| 12 | renderProviderTraitsPicker, | 14 | renderProviderTraitsPicker, |
| 15 | withImplicitFastModeDefault, | ||
| 13 | } from "./composerProviderState"; | 16 | } from "./composerProviderState"; |
| 14 | 17 | ||
| 15 | // Everything in composerProviderState is now data-driven by the model's | 18 | // Everything in composerProviderState is now data-driven by the model's |
| ... | @@ -37,8 +40,16 @@ function selectDescriptor( | ... | @@ -37,8 +40,16 @@ function selectDescriptor( |
| 37 | }; | 40 | }; |
| 38 | } | 41 | } |
| 39 | 42 | ||
| 40 | function booleanDescriptor(id: string): Extract<ProviderOptionDescriptor, { type: "boolean" }> { | 43 | function booleanDescriptor( |
| 41 | return { id, label: id, type: "boolean" }; | 44 | id: string, |
| 45 | currentValue?: boolean, | ||
| 46 | ): Extract<ProviderOptionDescriptor, { type: "boolean" }> { | ||
| 47 | return { | ||
| 48 | id, | ||
| 49 | label: id, | ||
| 50 | type: "boolean", | ||
| 51 | ...(typeof currentValue === "boolean" ? { currentValue } : {}), | ||
| 52 | }; | ||
| 42 | } | 53 | } |
| 43 | 54 | ||
| 44 | function modelWith( | 55 | function modelWith( |
| ... | @@ -373,6 +384,91 @@ describe("getComposerProviderState", () => { | ... | @@ -373,6 +384,91 @@ describe("getComposerProviderState", () => { |
| 373 | expect(state).not.toHaveProperty("composerSurfaceClassName"); | 384 | expect(state).not.toHaveProperty("composerSurfaceClassName"); |
| 374 | expect(state).not.toHaveProperty("modelPickerIconClassName"); | 385 | expect(state).not.toHaveProperty("modelPickerIconClassName"); |
| 375 | }); | 386 | }); |
| 387 | |||
| 388 | it("defaults fastMode to false when the provider reports true but the user has not selected it", () => { | ||
| 389 | const state = getComposerProviderState({ | ||
| 390 | provider: ProviderDriverKind.make("cursor"), | ||
| 391 | model: MODEL, | ||
| 392 | models: modelWith([booleanDescriptor("fastMode", true)]), | ||
| 393 | modelOptions: undefined, | ||
| 394 | planModeEnabled: true, | ||
| 395 | }); | ||
| 396 | |||
| 397 | expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false])); | ||
| 398 | }); | ||
| 399 | |||
| 400 | it("keeps explicit fastMode true when the user selected Fast", () => { | ||
| 401 | const state = getComposerProviderState({ | ||
| 402 | provider: ProviderDriverKind.make("cursor"), | ||
| 403 | model: MODEL, | ||
| 404 | models: modelWith([booleanDescriptor("fastMode", true)]), | ||
| 405 | modelOptions: selections(["fastMode", true]), | ||
| 406 | planModeEnabled: true, | ||
| 407 | }); | ||
| 408 | |||
| 409 | expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", true])); | ||
| 410 | }); | ||
| 411 | |||
| 412 | it("keeps explicit fastMode false when the user selected Normal", () => { | ||
| 413 | const state = getComposerProviderState({ | ||
| 414 | provider: ProviderDriverKind.make("cursor"), | ||
| 415 | model: MODEL, | ||
| 416 | models: modelWith([booleanDescriptor("fastMode", true)]), | ||
| 417 | modelOptions: selections(["fastMode", false]), | ||
| 418 | planModeEnabled: true, | ||
| 419 | }); | ||
| 420 | |||
| 421 | expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false])); | ||
| 422 | }); | ||
| 423 | }); | ||
| 424 | |||
| 425 | describe("withImplicitFastModeDefault", () => { | ||
| 426 | it("injects fastMode false only when the model exposes fastMode and no selection exists", () => { | ||
| 427 | expect( | ||
| 428 | withImplicitFastModeDefault( | ||
| 429 | { | ||
| 430 | optionDescriptors: [booleanDescriptor("fastMode", true)], | ||
| 431 | }, | ||
| 432 | undefined, | ||
| 433 | ), | ||
| 434 | ).toEqual(selections(["fastMode", false])); | ||
| 435 | |||
| 436 | expect( | ||
| 437 | withImplicitFastModeDefault( | ||
| 438 | { | ||
| 439 | optionDescriptors: [booleanDescriptor("fastMode", true)], | ||
| 440 | }, | ||
| 441 | selections(["fastMode", true]), | ||
| 442 | ), | ||
| 443 | ).toEqual(selections(["fastMode", true])); | ||
| 444 | }); | ||
| 445 | |||
| 446 | it("does not add fastMode when the model does not expose it", () => { | ||
| 447 | expect( | ||
| 448 | withImplicitFastModeDefault( | ||
| 449 | { | ||
| 450 | optionDescriptors: [booleanDescriptor("thinking", true)], | ||
| 451 | }, | ||
| 452 | undefined, | ||
| 453 | ), | ||
| 454 | ).toBeUndefined(); | ||
| 455 | }); | ||
| 456 | }); | ||
| 457 | |||
| 458 | describe("trait controls fastMode display", () => { | ||
| 459 | it("resolves traits fastMode to Normal when the provider defaults to true without a user selection", () => { | ||
| 460 | const models = modelWith([booleanDescriptor("fastMode", true)]); | ||
| 461 | const provider = ProviderDriverKind.make("cursor"); | ||
| 462 | const caps = getProviderModelCapabilities(models, MODEL, provider); | ||
| 463 | const resolved = withImplicitFastModeDefault(caps, undefined); | ||
| 464 | const descriptors = getProviderOptionDescriptors({ caps, selections: resolved }); | ||
| 465 | const fastMode = descriptors.find((descriptor) => descriptor.id === "fastMode"); | ||
| 466 | |||
| 467 | expect(fastMode?.type).toBe("boolean"); | ||
| 468 | if (fastMode?.type === "boolean") { | ||
| 469 | expect(fastMode.currentValue).toBe(false); | ||
| 470 | } | ||
| 471 | }); | ||
| 376 | }); | 472 | }); |
| 377 | 473 | ||
| 378 | describe("provider traits render guards", () => { | 474 | describe("provider traits render guards", () => { |
apps/web/src/components/chat/composerProviderState.tsx+57-5| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type ModelCapabilities, | ||
| 2 | type ProviderDriverKind, | 3 | type ProviderDriverKind, |
| 3 | type ProviderInstanceId, | 4 | type ProviderInstanceId, |
| 4 | type ProviderOptionSelection, | 5 | type ProviderOptionSelection, |
| ... | @@ -63,6 +64,44 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI | ... | @@ -63,6 +64,44 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI |
| 63 | return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; | 64 | return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; |
| 64 | } | 65 | } |
| 65 | 66 | ||
| 67 | /** | ||
| 68 | * Cursor ACP can report `fastMode: true` as the provider default. T3 only | ||
| 69 | * treats Fast as selected when the user chose it (draft/sticky/settings). | ||
| 70 | * Otherwise inject an explicit `false` so new chats stay Normal and the | ||
| 71 | * send path can overwrite a prior Fast session — descriptor defaults are | ||
| 72 | * otherwise omitted by `buildExplicitProviderOptionSelectionsFromDescriptors`. | ||
| 73 | */ | ||
| 74 | export function withImplicitFastModeDefault( | ||
| 75 | caps: ModelCapabilities, | ||
| 76 | modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, | ||
| 77 | ): ReadonlyArray<ProviderOptionSelection> | undefined { | ||
| 78 | const hasExplicitFastMode = modelOptions?.some((selection) => selection.id === "fastMode"); | ||
| 79 | if (hasExplicitFastMode) { | ||
| 80 | return modelOptions ?? undefined; | ||
| 81 | } | ||
| 82 | const hasFastModeDescriptor = caps.optionDescriptors?.some( | ||
| 83 | (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", | ||
| 84 | ); | ||
| 85 | if (!hasFastModeDescriptor) { | ||
| 86 | return modelOptions ?? undefined; | ||
| 87 | } | ||
| 88 | return [...(modelOptions ?? []), { id: "fastMode", value: false }]; | ||
| 89 | } | ||
| 90 | |||
| 91 | function resolveComposerOptionSelections( | ||
| 92 | models: ReadonlyArray<ServerProviderModel>, | ||
| 93 | model: string, | ||
| 94 | provider: ProviderDriverKind, | ||
| 95 | modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, | ||
| 96 | planModeEnabled: boolean, | ||
| 97 | ): { | ||
| 98 | caps: ModelCapabilities; | ||
| 99 | selections: ReadonlyArray<ProviderOptionSelection> | undefined; | ||
| 100 | } { | ||
| 101 | const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); | ||
| 102 | return { caps, selections: withImplicitFastModeDefault(caps, modelOptions) }; | ||
| 103 | } | ||
| 104 | |||
| 66 | export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { | 105 | export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { |
| 67 | const { | 106 | const { |
| 68 | provider, | 107 | provider, |
| ... | @@ -87,8 +126,14 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com | ... | @@ -87,8 +126,14 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com |
| 87 | }; | 126 | }; |
| 88 | } | 127 | } |
| 89 | } | 128 | } |
| 90 | const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); | 129 | const { caps, selections } = resolveComposerOptionSelections( |
| 91 | const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); | 130 | models, |
| 131 | model, | ||
| 132 | provider, | ||
| 133 | modelOptions, | ||
| 134 | planModeEnabled, | ||
| 135 | ); | ||
| 136 | const descriptors = getProviderOptionDescriptors({ caps, selections }); | ||
| 92 | const primarySelectDescriptor = descriptors.find( | 137 | const primarySelectDescriptor = descriptors.find( |
| 93 | (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => | 138 | (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => |
| 94 | descriptor.type === "select", | 139 | descriptor.type === "select", |
| ... | @@ -104,7 +149,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com | ... | @@ -104,7 +149,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com |
| 104 | promptEffort, | 149 | promptEffort, |
| 105 | modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors( | 150 | modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors( |
| 106 | descriptors, | 151 | descriptors, |
| 107 | modelOptions, | 152 | selections, |
| 108 | ), | 153 | ), |
| 109 | ...(ultrathinkActive | 154 | ...(ultrathinkActive |
| 110 | ? { | 155 | ? { |
| ... | @@ -138,13 +183,20 @@ function renderTraitsControl( | ... | @@ -138,13 +183,20 @@ function renderTraitsControl( |
| 138 | isComposerOwned, | 183 | isComposerOwned, |
| 139 | } = input; | 184 | } = input; |
| 140 | const hasTarget = threadRef !== undefined || draftId !== undefined; | 185 | const hasTarget = threadRef !== undefined || draftId !== undefined; |
| 186 | const { selections: resolvedModelOptions } = resolveComposerOptionSelections( | ||
| 187 | models, | ||
| 188 | model, | ||
| 189 | provider, | ||
| 190 | modelOptions, | ||
| 191 | planModeEnabled, | ||
| 192 | ); | ||
| 141 | if ( | 193 | if ( |
| 142 | !hasTarget || | 194 | !hasTarget || |
| 143 | !shouldRenderTraitsControls({ | 195 | !shouldRenderTraitsControls({ |
| 144 | provider, | 196 | provider, |
| 145 | models, | 197 | models, |
| 146 | model, | 198 | model, |
| 147 | modelOptions, | 199 | modelOptions: resolvedModelOptions, |
| 148 | prompt, | 200 | prompt, |
| 149 | planModeEnabled, | 201 | planModeEnabled, |
| 150 | }) | 202 | }) |
| ... | @@ -159,7 +211,7 @@ function renderTraitsControl( | ... | @@ -159,7 +211,7 @@ function renderTraitsControl( |
| 159 | {...(threadRef ? { threadRef } : {})} | 211 | {...(threadRef ? { threadRef } : {})} |
| 160 | {...(draftId ? { draftId } : {})} | 212 | {...(draftId ? { draftId } : {})} |
| 161 | model={model} | 213 | model={model} |
| 162 | modelOptions={modelOptions} | 214 | modelOptions={resolvedModelOptions} |
| 163 | prompt={prompt} | 215 | prompt={prompt} |
| 164 | onPromptChange={onPromptChange} | 216 | onPromptChange={onPromptChange} |
| 165 | planModeEnabled={planModeEnabled} | 217 | planModeEnabled={planModeEnabled} |
apps/web/src/composerDraftStore.test.ts+19| ... | @@ -2177,6 +2177,25 @@ describe("composerDraftStore sticky composer settings", () => { | ... | @@ -2177,6 +2177,25 @@ describe("composerDraftStore sticky composer settings", () => { |
| 2177 | expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor"); | 2177 | expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor"); |
| 2178 | }); | 2178 | }); |
| 2179 | 2179 | ||
| 2180 | it("preserves sticky provider options when model selection omits options", () => { | ||
| 2181 | const store = useComposerDraftStore.getState(); | ||
| 2182 | |||
| 2183 | store.setStickyModelSelection( | ||
| 2184 | modelSelection(CURSOR_DRIVER, "composer-2", { | ||
| 2185 | fastMode: false, | ||
| 2186 | }), | ||
| 2187 | ); | ||
| 2188 | store.setStickyModelSelection(modelSelection(CURSOR_DRIVER, "composer-2.5")); | ||
| 2189 | |||
| 2190 | expect( | ||
| 2191 | useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE], | ||
| 2192 | ).toEqual( | ||
| 2193 | modelSelection(CURSOR_DRIVER, "composer-2.5", { | ||
| 2194 | fastMode: false, | ||
| 2195 | }), | ||
| 2196 | ); | ||
| 2197 | }); | ||
| 2198 | |||
| 2180 | it("applies sticky activeProvider to new drafts", () => { | 2199 | it("applies sticky activeProvider to new drafts", () => { |
| 2181 | const store = useComposerDraftStore.getState(); | 2200 | const store = useComposerDraftStore.getState(); |
| 2182 | const threadId = ThreadId.make("thread-sticky-active-provider"); | 2201 | const threadId = ThreadId.make("thread-sticky-active-provider"); |
apps/web/src/composerDraftStore.ts+9-1| ... | @@ -2914,9 +2914,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2914,9 +2914,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2914 | if (!normalized) { | 2914 | if (!normalized) { |
| 2915 | return state; | 2915 | return state; |
| 2916 | } | 2916 | } |
| 2917 | const current = state.stickyModelSelectionByProvider[normalized.instanceId]; | ||
| 2918 | // Model-only picker updates omit options (same contract as | ||
| 2919 | // setModelSelection). Keep the last sticky traits so Fast/Normal | ||
| 2920 | // survives Composer 2 → 2.5 and new chats. | ||
| 2921 | const nextSelection = | ||
| 2922 | normalized.options !== undefined | ||
| 2923 | ? normalized | ||
| 2924 | : createModelSelection(normalized.instanceId, normalized.model, current?.options); | ||
| 2917 | const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = { | 2925 | const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = { |
| 2918 | ...state.stickyModelSelectionByProvider, | 2926 | ...state.stickyModelSelectionByProvider, |
| 2919 | [normalized.instanceId]: normalized, | 2927 | [normalized.instanceId]: nextSelection, |
| 2920 | }; | 2928 | }; |
| 2921 | if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { | 2929 | if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { |
| 2922 | return state.stickyActiveProvider === normalized.instanceId | 2930 | return state.stickyActiveProvider === normalized.instanceId |