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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions apps/web/src/components/chat/composerProviderState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import {
type ProviderOptionSelection,
type ServerProviderModel,
} from "@t3tools/contracts";
import { getProviderOptionDescriptors } from "@t3tools/shared/model";
import {
getComposerProviderState,
renderProviderTraitsMenuContent,
renderProviderTraitsPicker,
withImplicitFastModeDefault,
} from "./composerProviderState";
import { getProviderModelCapabilities } from "../../providerModels";

// Everything in composerProviderState is now data-driven by the model's
// optionDescriptors, so these tests use a single synthetic provider/model and
Expand All @@ -36,8 +39,16 @@ function selectDescriptor(
};
}

function booleanDescriptor(id: string): Extract<ProviderOptionDescriptor, { type: "boolean" }> {
return { id, label: id, type: "boolean" };
function booleanDescriptor(
id: string,
currentValue?: boolean,
): Extract<ProviderOptionDescriptor, { type: "boolean" }> {
return {
id,
label: id,
type: "boolean",
...(typeof currentValue === "boolean" ? { currentValue } : {}),
};
}

function modelWith(
Expand Down Expand Up @@ -205,6 +216,77 @@ describe("getComposerProviderState", () => {
});
});

it("defaults fastMode to false when the provider reports true but the user has not selected it", () => {
const state = getComposerProviderState({
provider: ProviderDriverKind.make("cursor"),
model: MODEL,
models: modelWith([booleanDescriptor("fastMode", true)]),
prompt: "",
modelOptions: undefined,
});

expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false]));
});

it("keeps explicit fastMode true when the user selected Fast", () => {
const state = getComposerProviderState({
provider: ProviderDriverKind.make("cursor"),
model: MODEL,
models: modelWith([booleanDescriptor("fastMode", true)]),
prompt: "",
modelOptions: selections(["fastMode", true]),
});

expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", true]));
});

it("keeps explicit fastMode false when the user selected Normal", () => {
const state = getComposerProviderState({
provider: ProviderDriverKind.make("cursor"),
model: MODEL,
models: modelWith([booleanDescriptor("fastMode", true)]),
prompt: "",
modelOptions: selections(["fastMode", false]),
});

expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false]));
});
});

describe("withImplicitFastModeDefault", () => {
it("injects fastMode false only when the model exposes fastMode and no selection exists", () => {
expect(
withImplicitFastModeDefault(
{
optionDescriptors: [booleanDescriptor("fastMode", true)],
},
undefined,
),
).toEqual(selections(["fastMode", false]));

expect(
withImplicitFastModeDefault(
{
optionDescriptors: [booleanDescriptor("fastMode", true)],
},
selections(["fastMode", true]),
),
).toEqual(selections(["fastMode", true]));
});

it("does not add fastMode when the model does not expose it", () => {
expect(
withImplicitFastModeDefault(
{
optionDescriptors: [booleanDescriptor("thinking", true)],
},
undefined,
),
).toBeUndefined();
});
});

describe("getComposerProviderState ultrathink styling", () => {
it("does not add ultrathink class names when the descriptor has no promptInjectedValues", () => {
const state = getComposerProviderState({
provider: PROVIDER,
Expand All @@ -222,6 +304,22 @@ describe("getComposerProviderState", () => {
});
});

describe("trait controls fastMode display", () => {
it("resolves traits fastMode to Normal when the provider defaults to true without a user selection", () => {
const models = modelWith([booleanDescriptor("fastMode", true)]);
const provider = ProviderDriverKind.make("cursor");
const caps = getProviderModelCapabilities(models, MODEL, provider);
const resolved = withImplicitFastModeDefault(caps, undefined);
const descriptors = getProviderOptionDescriptors({ caps, selections: resolved });
const fastMode = descriptors.find((descriptor) => descriptor.id === "fastMode");

expect(fastMode?.type).toBe("boolean");
if (fastMode?.type === "boolean") {
expect(fastMode.currentValue).toBe(false);
}
});
});

describe("provider traits render guards", () => {
it("returns null when no thread target is provided", () => {
const models = modelWith([
Expand Down
38 changes: 35 additions & 3 deletions apps/web/src/components/chat/composerProviderState.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type ModelCapabilities,
type ProviderDriverKind,
type ProviderInstanceId,
type ProviderOptionSelection,
Expand Down Expand Up @@ -46,10 +47,33 @@ type TraitsRenderInput = {
onPromptChange: (prompt: string) => void;
};

/**
* Cursor ACP can report `fastMode: true` as the provider default. T3 should only
* use Fast when the user explicitly selected it (draft/sticky/settings); otherwise
* default to Normal so new chats do not inherit the provider default.
*/
export function withImplicitFastModeDefault(
caps: ModelCapabilities,
modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
): ReadonlyArray<ProviderOptionSelection> | undefined {
const hasExplicitFastMode = modelOptions?.some((selection) => selection.id === "fastMode");
if (hasExplicitFastMode) {
return modelOptions ?? undefined;
}
const hasFastModeDescriptor = caps.optionDescriptors?.some(
(descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode",
);
if (!hasFastModeDescriptor) {
return modelOptions ?? undefined;
}
return [...(modelOptions ?? []), { id: "fastMode", value: false }];
}

export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
const { provider, model, models, prompt, modelOptions } = input;
const caps = getProviderModelCapabilities(models, model, provider);
const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions });
const selections = withImplicitFastModeDefault(caps, modelOptions);
const descriptors = getProviderOptionDescriptors({ caps, selections });
Comment on lines +75 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply implicit Fast default to trait controls too

When Cursor reports fastMode.currentValue === true and the user has no explicit selection, this implicit false is only fed into getComposerProviderState for dispatch. The rendered trait controls still receive the raw modelOptions via renderProviderTraitsPicker/MenuContent, so TraitsPicker builds descriptors from the provider default and shows the toggle/label as Fast even though the send path dispatches Normal. This leaves new Composer chats in a misleading state until the user toggles Fast/Normal.

Useful? React with 👍 / 👎.

const primarySelectDescriptor = descriptors.find(
(descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> =>
descriptor.type === "select",
Expand Down Expand Up @@ -90,9 +114,17 @@ function renderTraitsControl(
onPromptChange,
} = input;
const hasTarget = threadRef !== undefined || draftId !== undefined;
const caps = getProviderModelCapabilities(models, model, provider);
const resolvedModelOptions = withImplicitFastModeDefault(caps, modelOptions);
if (
!hasTarget ||
!shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt })
!shouldRenderTraitsControls({
provider,
models,
model,
modelOptions: resolvedModelOptions,
prompt,
})
) {
return null;
}
Expand All @@ -104,7 +136,7 @@ function renderTraitsControl(
{...(threadRef ? { threadRef } : {})}
{...(draftId ? { draftId } : {})}
model={model}
modelOptions={modelOptions}
modelOptions={resolvedModelOptions}
prompt={prompt}
onPromptChange={onPromptChange}
/>
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,25 @@ describe("composerDraftStore sticky composer settings", () => {
expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor");
});

it("preserves sticky provider options when model selection omits options", () => {
const store = useComposerDraftStore.getState();

store.setStickyModelSelection(
modelSelection(CURSOR_DRIVER, "composer-2", {
fastMode: false,
}),
);
store.setStickyModelSelection(modelSelection(CURSOR_DRIVER, "composer-2.5"));

expect(
useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE],
).toEqual(
modelSelection(CURSOR_DRIVER, "composer-2.5", {
fastMode: false,
}),
);
});

it("applies sticky activeProvider to new drafts", () => {
const store = useComposerDraftStore.getState();
const threadId = ThreadId.make("thread-sticky-active-provider");
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2252,9 +2252,14 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
if (!normalized) {
return state;
}
const current = state.stickyModelSelectionByProvider[normalized.instanceId];
const nextSelection =
normalized.options !== undefined
? normalized
: createModelSelection(normalized.instanceId, normalized.model, current?.options);
const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = {
...state.stickyModelSelectionByProvider,
[normalized.instanceId]: normalized,
[normalized.instanceId]: nextSelection,
};
if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
return state.stickyActiveProvider === normalized.instanceId
Expand Down
Loading