| 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 | 5 | type ProviderOptionSelection, |
| 6 | 6 | type ServerProviderModel, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | import { getProviderOptionDescriptors } from "@t3tools/shared/model"; | |
| 9 | import { getProviderModelCapabilities } from "../../providerModels"; | |
| 8 | 10 | import { |
| 9 | 11 | getComposerPromptInjectionState, |
| 10 | 12 | getComposerProviderState, |
| 11 | 13 | renderProviderTraitsMenuContent, |
| 12 | 14 | renderProviderTraitsPicker, |
| 15 | withImplicitFastModeDefault, | |
| 13 | 16 | } from "./composerProviderState"; |
| 14 | 17 | |
| 15 | 18 | // Everything in composerProviderState is now data-driven by the model's |
| ... | ... | @@ -37,8 +40,16 @@ function selectDescriptor( |
| 37 | 40 | }; |
| 38 | 41 | } |
| 39 | 42 | |
| 40 | function booleanDescriptor(id: string): Extract<ProviderOptionDescriptor, { type: "boolean" }> { | |
| 41 | return { id, label: id, type: "boolean" }; | |
| 43 | function booleanDescriptor( | |
| 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 | 55 | function modelWith( |
| ... | ... | @@ -373,6 +384,91 @@ describe("getComposerProviderState", () => { |
| 373 | 384 | expect(state).not.toHaveProperty("composerSurfaceClassName"); |
| 374 | 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 | 474 | describe("provider traits render guards", () => { |
apps/web/src/components/chat/composerProviderState.tsx+57-5| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | type ModelCapabilities, | |
| 2 | 3 | type ProviderDriverKind, |
| 3 | 4 | type ProviderInstanceId, |
| 4 | 5 | type ProviderOptionSelection, |
| ... | ... | @@ -63,6 +64,44 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI |
| 63 | 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 | 105 | export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { |
| 67 | 106 | const { |
| 68 | 107 | provider, |
| ... | ... | @@ -87,8 +126,14 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com |
| 87 | 126 | }; |
| 88 | 127 | } |
| 89 | 128 | } |
| 90 | const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); | |
| 91 | const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); | |
| 129 | const { caps, selections } = resolveComposerOptionSelections( | |
| 130 | models, | |
| 131 | model, | |
| 132 | provider, | |
| 133 | modelOptions, | |
| 134 | planModeEnabled, | |
| 135 | ); | |
| 136 | const descriptors = getProviderOptionDescriptors({ caps, selections }); | |
| 92 | 137 | const primarySelectDescriptor = descriptors.find( |
| 93 | 138 | (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => |
| 94 | 139 | descriptor.type === "select", |
| ... | ... | @@ -104,7 +149,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com |
| 104 | 149 | promptEffort, |
| 105 | 150 | modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors( |
| 106 | 151 | descriptors, |
| 107 | modelOptions, | |
| 152 | selections, | |
| 108 | 153 | ), |
| 109 | 154 | ...(ultrathinkActive |
| 110 | 155 | ? { |
| ... | ... | @@ -138,13 +183,20 @@ function renderTraitsControl( |
| 138 | 183 | isComposerOwned, |
| 139 | 184 | } = input; |
| 140 | 185 | const hasTarget = threadRef !== undefined || draftId !== undefined; |
| 186 | const { selections: resolvedModelOptions } = resolveComposerOptionSelections( | |
| 187 | models, | |
| 188 | model, | |
| 189 | provider, | |
| 190 | modelOptions, | |
| 191 | planModeEnabled, | |
| 192 | ); | |
| 141 | 193 | if ( |
| 142 | 194 | !hasTarget || |
| 143 | 195 | !shouldRenderTraitsControls({ |
| 144 | 196 | provider, |
| 145 | 197 | models, |
| 146 | 198 | model, |
| 147 | modelOptions, | |
| 199 | modelOptions: resolvedModelOptions, | |
| 148 | 200 | prompt, |
| 149 | 201 | planModeEnabled, |
| 150 | 202 | }) |
| ... | ... | @@ -159,7 +211,7 @@ function renderTraitsControl( |
| 159 | 211 | {...(threadRef ? { threadRef } : {})} |
| 160 | 212 | {...(draftId ? { draftId } : {})} |
| 161 | 213 | model={model} |
| 162 | modelOptions={modelOptions} | |
| 214 | modelOptions={resolvedModelOptions} | |
| 163 | 215 | prompt={prompt} |
| 164 | 216 | onPromptChange={onPromptChange} |
| 165 | 217 | planModeEnabled={planModeEnabled} |
apps/web/src/composerDraftStore.test.ts+19| ... | ... | @@ -2177,6 +2177,25 @@ describe("composerDraftStore sticky composer settings", () => { |
| 2177 | 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 | 2199 | it("applies sticky activeProvider to new drafts", () => { |
| 2181 | 2200 | const store = useComposerDraftStore.getState(); |
| 2182 | 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 | 2914 | if (!normalized) { |
| 2915 | 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 | 2925 | const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = { |
| 2918 | 2926 | ...state.stickyModelSelectionByProvider, |
| 2919 | [normalized.instanceId]: normalized, | |
| 2927 | [normalized.instanceId]: nextSelection, | |
| 2920 | 2928 | }; |
| 2921 | 2929 | if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { |
| 2922 | 2930 | return state.stickyActiveProvider === normalized.instanceId |