diff --git a/README.md b/README.md index 1e9b0517945..961a2223444 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) -- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) +- Providers: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) · [Grok](./docs/user/providers-grok.md) - Linux: [run T3 Code as a background service](./docs/user/background-service.md) Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 2d7b674eb0c..54181ff6c75 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -18,6 +18,7 @@ import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { ActivityIndicator, + Alert, Image, Platform, Pressable, @@ -53,7 +54,11 @@ import { import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { + buildModelOptions, + groupByProvider, + isSameInstanceSessionBoundChangeBlocked, +} from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { @@ -62,10 +67,10 @@ import { scoreQueryMatch, } from "@t3tools/shared/searchRanking"; import { - applyProviderOptionMenuEvent, buildProviderOptionMenuActions, providerOptionsConfigurationLabel, resolveProviderOptionDescriptors, + resolveProviderOptionMenuChange, } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; @@ -114,6 +119,13 @@ export interface ThreadComposerProps { readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; + /** + * Session-bound lock for the started provider instance, derived once in + * composer state from the projection-first runtime. Same-instance model and + * option changes are rejected; cross-provider handoff stays allowed. + */ + readonly optionChangeBlocked?: boolean; + readonly optionChangeBlockedInstanceId?: string | null; readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; } @@ -600,6 +612,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [currentModelOption?.capabilities, currentModelSelection.options], ); + const providerOptionsLocked = isSameInstanceSessionBoundChangeBlocked({ + optionChangeBlocked: props.optionChangeBlocked === true, + committedInstanceId: props.optionChangeBlockedInstanceId ?? "", + requestedInstanceId: currentModelSelection.instanceId, + }); const configurationLabel = useMemo( () => providerOptionsConfigurationLabel(providerOptionDescriptors), [providerOptionDescriptors], @@ -689,11 +706,23 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } function handleOptionsMenuAction(event: string) { - const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); - if (providerOptions) { + // Session-bound options stay tappable; the pure resolver ignores the + // current value, warns on locked alternates, and only returns an update + // payload when the change may apply. + const decision = resolveProviderOptionMenuChange(providerOptionDescriptors, event, { + optionsLocked: providerOptionsLocked, + }); + if (decision) { + if (decision.action === "ignore") { + return; + } + if (decision.action === "warn") { + Alert.alert(decision.title, decision.description); + return; + } props.onUpdateModelSelection({ ...currentModelSelection, - options: providerOptions, + options: decision.options, }); return; } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index eb254734664..c9a0aa5f21c 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -88,6 +88,8 @@ export interface ThreadDetailScreenProps { readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; + readonly optionChangeBlocked?: boolean; + readonly optionChangeBlockedInstanceId?: string | null; readonly onRespondToApproval: ( requestId: RuntimeRequestId, decision: ProviderApprovalDecision, @@ -496,6 +498,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onUpdateModelSelection={props.onUpdateThreadModelSelection} onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} + optionChangeBlocked={props.optionChangeBlocked} + optionChangeBlockedInstanceId={props.optionChangeBlockedInstanceId} onExpandedChange={setComposerExpanded} /> diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 713f4b3acee..e7277307bae 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -781,6 +781,8 @@ function ThreadRouteContent( onUpdateThreadModelSelection={composer.onUpdateModelSelection} onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} onUpdateThreadInteractionMode={composer.onUpdateInteractionMode} + optionChangeBlocked={composer.optionChangeBlocked} + optionChangeBlockedInstanceId={composer.optionChangeBlockedInstanceId} onRespondToApproval={requests.onRespondToApproval} onSelectUserInputOption={requests.onSelectUserInputOption} onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 9a71640b45a..fe8807f5b52 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -1,8 +1,17 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; +import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts"; -import { buildModelOptions } from "./modelOptions"; +import { + buildModelOptions, + isSameInstanceSessionBoundChangeBlocked, + resolveEffectiveModelSelection, + resolveOutboxModelSelection, + resolveOutboxModelSelectionForEnvironment, + resolveSessionBoundModelSelectionUpdate, + startedThreadOptionChangeBlocked, + threadShellHasStarted, +} from "./modelOptions"; describe("mobile model options", () => { it("normalizes a legacy fallback selection against current capabilities", () => { @@ -50,3 +59,468 @@ describe("mobile model options", () => { expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); }); }); + +describe("startedThreadOptionChangeBlocked", () => { + const config = { + providers: [ + { + instanceId: "grok", + driver: "grok", + requiresNewThreadForModelChange: true, + }, + { + instanceId: "codex", + driver: "codex", + }, + ], + } as unknown as ServerConfig; + + it("allows option changes before a provider session exists", () => { + expect( + startedThreadOptionChangeBlocked({ + config, + threadHasStarted: false, + threadRuntime: null, + selectionInstanceId: "grok", + }), + ).toBe(false); + }); + + it("allows started-session option changes for unrestricted providers", () => { + expect( + startedThreadOptionChangeBlocked({ + config, + threadHasStarted: true, + threadRuntime: { providerInstanceId: "codex" }, + selectionInstanceId: "codex", + }), + ).toBe(false); + }); + + it("does not treat a different provider instance as the locked session", () => { + expect( + startedThreadOptionChangeBlocked({ + config, + threadHasStarted: true, + threadRuntime: { providerInstanceId: "grok" }, + selectionInstanceId: "codex", + }), + ).toBe(false); + }); + + it("blocks started-session option changes for session-bound providers", () => { + expect( + startedThreadOptionChangeBlocked({ + config, + threadHasStarted: true, + threadRuntime: { providerInstanceId: "grok" }, + selectionInstanceId: "grok", + }), + ).toBe(true); + }); + + it("blocks from committed instance metadata when runtime is temporarily absent", () => { + expect( + startedThreadOptionChangeBlocked({ + config, + threadHasStarted: true, + threadRuntime: null, + selectionInstanceId: "grok", + }), + ).toBe(true); + }); +}); + +describe("threadShellHasStarted", () => { + it("recognizes shell history when runtime is absent", () => { + expect(threadShellHasStarted({ itemCount: 1, latestRun: null, runtime: null })).toBe(true); + expect(threadShellHasStarted({ itemCount: 0, latestRun: {} as never, runtime: null })).toBe( + true, + ); + expect(threadShellHasStarted({ itemCount: 0, latestRun: null, runtime: null })).toBe(false); + }); +}); + +describe("isSameInstanceSessionBoundChangeBlocked", () => { + it("blocks same-instance changes when the session-bound lock is active", () => { + expect( + isSameInstanceSessionBoundChangeBlocked({ + optionChangeBlocked: true, + committedInstanceId: "grok", + requestedInstanceId: "grok", + }), + ).toBe(true); + }); + + it("allows a different provider instance for handoff", () => { + expect( + isSameInstanceSessionBoundChangeBlocked({ + optionChangeBlocked: true, + committedInstanceId: "grok", + requestedInstanceId: "codex", + }), + ).toBe(false); + }); + + it("allows all changes when the lock is inactive", () => { + expect( + isSameInstanceSessionBoundChangeBlocked({ + optionChangeBlocked: false, + committedInstanceId: "grok", + requestedInstanceId: "grok", + }), + ).toBe(false); + }); +}); + +describe("resolveEffectiveModelSelection", () => { + const committedLow: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "low" }], + }; + const draftHigh: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const draftOtherModel: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const draftHandoff: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }; + + it("uses committed Low when locked even if same-instance draft is High", () => { + expect( + resolveEffectiveModelSelection({ + draftModelSelection: draftHigh, + committedModelSelection: committedLow, + optionChangeBlocked: true, + }), + ).toEqual(committedLow); + }); + + it("keeps a legacy same-instance model draft visible instead of silently substituting", () => { + expect( + resolveEffectiveModelSelection({ + draftModelSelection: draftOtherModel, + committedModelSelection: committedLow, + optionChangeBlocked: true, + }), + ).toEqual(draftOtherModel); + }); + + it("keeps a cross-provider handoff draft visible while locked", () => { + expect( + resolveEffectiveModelSelection({ + draftModelSelection: draftHandoff, + committedModelSelection: committedLow, + optionChangeBlocked: true, + }), + ).toEqual(draftHandoff); + }); + + it("keeps draft High when unlocked", () => { + expect( + resolveEffectiveModelSelection({ + draftModelSelection: draftHigh, + committedModelSelection: committedLow, + optionChangeBlocked: false, + }), + ).toEqual(draftHigh); + }); + + it("falls back to committed when unlocked and draft is absent", () => { + expect( + resolveEffectiveModelSelection({ + draftModelSelection: undefined, + committedModelSelection: committedLow, + optionChangeBlocked: false, + }), + ).toEqual(committedLow); + }); +}); + +describe("resolveSessionBoundModelSelectionUpdate", () => { + const committedLow: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "low" }], + }; + const committedExact: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "low" }], + }; + const normalizedDefault: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const otherModel: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const handoffCodex: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }; + + it("applies any selection when the lock is inactive", () => { + expect( + resolveSessionBoundModelSelectionUpdate({ + optionChangeBlocked: false, + committed: committedLow, + requested: otherModel, + }), + ).toEqual({ type: "apply", selection: otherModel }); + }); + + it("applies a cross-provider handoff while locked", () => { + expect( + resolveSessionBoundModelSelectionUpdate({ + optionChangeBlocked: true, + committed: committedLow, + requested: handoffCodex, + }), + ).toEqual({ type: "apply", selection: handoffCodex }); + }); + + it("rejects a different model on the same committed instance", () => { + expect( + resolveSessionBoundModelSelectionUpdate({ + optionChangeBlocked: true, + committed: committedLow, + requested: otherModel, + }), + ).toEqual({ type: "reject_model_change" }); + }); + + it("restores committed selection when reselecting the committed model", () => { + expect( + resolveSessionBoundModelSelectionUpdate({ + optionChangeBlocked: true, + committed: committedLow, + requested: committedExact, + }), + ).toEqual({ type: "restore_committed", selection: committedLow }); + }); + + it("restores committed Low when menu normalization supplies default High", () => { + // Cancels a cross-provider draft and preserves applied effort even though + // the menu item carried default options rather than the committed ones. + expect( + resolveSessionBoundModelSelectionUpdate({ + optionChangeBlocked: true, + committed: committedLow, + requested: normalizedDefault, + }), + ).toEqual({ type: "restore_committed", selection: committedLow }); + }); +}); + +describe("resolveOutboxModelSelection", () => { + const grokConfig = { + providers: [ + { + instanceId: "grok", + driver: "grok", + enabled: true, + installed: true, + requiresNewThreadForModelChange: true, + auth: { status: "authenticated" }, + models: [], + }, + { + instanceId: "codex", + driver: "codex", + enabled: true, + installed: true, + requiresNewThreadForModelChange: false, + auth: { status: "authenticated" }, + models: [], + }, + ], + } as unknown as ServerConfig; + + const committedLow: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "low" }], + }; + const queuedStaleHigh: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const queuedHandoff: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }; + const runtime = { providerInstanceId: "grok" }; + + it("pins stale same-instance effort to committed when the session is locked", () => { + expect( + resolveOutboxModelSelection({ + config: grokConfig, + threadHasStarted: true, + threadRuntime: runtime, + committedModelSelection: committedLow, + queuedModelSelection: queuedStaleHigh, + }), + ).toEqual(committedLow); + }); + + it("keeps a cross-provider handoff queued selection intact", () => { + expect( + resolveOutboxModelSelection({ + config: grokConfig, + threadHasStarted: true, + threadRuntime: runtime, + committedModelSelection: committedLow, + queuedModelSelection: queuedHandoff, + }), + ).toEqual(queuedHandoff); + }); + + it("uses the queued selection when no provider session is active", () => { + expect( + resolveOutboxModelSelection({ + config: grokConfig, + threadHasStarted: false, + threadRuntime: null, + committedModelSelection: committedLow, + queuedModelSelection: queuedStaleHigh, + }), + ).toEqual(queuedStaleHigh); + }); +}); + +describe("resolveOutboxModelSelectionForEnvironment", () => { + const envA = "env-a"; + const envB = "env-b"; + const lockedGrokConfig = { + providers: [ + { + instanceId: "grok", + driver: "grok", + enabled: true, + installed: true, + requiresNewThreadForModelChange: true, + auth: { status: "authenticated" }, + models: [], + }, + ], + } as unknown as ServerConfig; + const unlockedGrokConfig = { + providers: [ + { + instanceId: "grok", + driver: "grok", + enabled: true, + installed: true, + requiresNewThreadForModelChange: false, + auth: { status: "authenticated" }, + models: [], + }, + ], + } as unknown as ServerConfig; + const configsByEnvironment = new Map([ + [envA, lockedGrokConfig], + [envB, unlockedGrokConfig], + ]); + const committedLow: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "low" }], + }; + const queuedStaleHigh: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const runtime = { providerInstanceId: "grok" }; + + it("normalizes outbox selection using the message environment's lock flag", () => { + // Message targets envA (locked): pin stale high to committed low. + expect( + resolveOutboxModelSelectionForEnvironment({ + serverConfigsByEnvironment: configsByEnvironment, + environmentId: envA, + threadHasStarted: true, + threadRuntime: runtime, + committedModelSelection: committedLow, + queuedModelSelection: queuedStaleHigh, + }), + ).toEqual(committedLow); + + // Same selection against envB (unlocked) keeps the queued high value. + expect( + resolveOutboxModelSelectionForEnvironment({ + serverConfigsByEnvironment: configsByEnvironment, + environmentId: envB, + threadHasStarted: true, + threadRuntime: runtime, + committedModelSelection: committedLow, + queuedModelSelection: queuedStaleHigh, + }), + ).toEqual(queuedStaleHigh); + }); + + it("defers when the environment config is temporarily missing", () => { + expect( + resolveOutboxModelSelectionForEnvironment({ + serverConfigsByEnvironment: configsByEnvironment, + environmentId: "missing", + threadHasStarted: true, + threadRuntime: runtime, + committedModelSelection: committedLow, + queuedModelSelection: queuedStaleHigh, + }), + ).toBeNull(); + }); + + it("does not gate unchanged or cross-instance delivery on a missing config", () => { + expect( + resolveOutboxModelSelectionForEnvironment({ + serverConfigsByEnvironment: configsByEnvironment, + environmentId: "missing", + threadHasStarted: true, + threadRuntime: runtime, + committedModelSelection: committedLow, + queuedModelSelection: committedLow, + }), + ).toEqual(committedLow); + + const queuedHandoff: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }; + expect( + resolveOutboxModelSelectionForEnvironment({ + serverConfigsByEnvironment: configsByEnvironment, + environmentId: "missing", + threadHasStarted: true, + threadRuntime: runtime, + committedModelSelection: committedLow, + queuedModelSelection: queuedHandoff, + }), + ).toEqual(queuedHandoff); + + expect( + resolveOutboxModelSelectionForEnvironment({ + serverConfigsByEnvironment: configsByEnvironment, + environmentId: "missing", + threadHasStarted: false, + threadRuntime: null, + committedModelSelection: committedLow, + queuedModelSelection: queuedStaleHigh, + }), + ).toEqual(queuedStaleHigh); + }); +}); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index ab859c73b46..846ff81167c 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -3,9 +3,11 @@ import type { ModelSelection, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, + modelSelectionsEqual, } from "@t3tools/shared/model"; export type ModelOption = { @@ -26,6 +28,178 @@ export type ProviderGroup = { readonly models: ReadonlyArray; }; +export function threadShellHasStarted( + thread: Pick | null | undefined, +): boolean { + return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime)); +} + +/** + * Providers that cannot change models mid-thread bind option values the same + * way (Grok reasoning effort is applied as an agent spawn flag at session + * start and a loaded session keeps its original value), so a started thread + * cannot apply a new option selection on that provider instance. Mirrors the + * web composer's `isStartedThreadOptionChangeBlocked`. Cross-provider handoff + * drafts use a different instance id and stay editable. + */ +export function startedThreadOptionChangeBlocked(input: { + readonly config: T3ServerConfig | null | undefined; + readonly threadHasStarted: boolean; + readonly threadRuntime: { readonly providerInstanceId: string } | null | undefined; + readonly selectionInstanceId: string; +}): boolean { + if (!input.threadHasStarted) { + return false; + } + const lockedInstanceId = input.threadRuntime?.providerInstanceId ?? input.selectionInstanceId; + if (lockedInstanceId !== input.selectionInstanceId) { + return false; + } + const provider = input.config?.providers.find( + (snapshot) => snapshot.instanceId === input.selectionInstanceId, + ); + return provider?.requiresNewThreadForModelChange === true; +} + +/** + * Same-instance model/option changes are rejected on a session-bound started + * thread. A draft that targets another provider instance is handoff and must + * not be blocked. + */ +export function isSameInstanceSessionBoundChangeBlocked(input: { + readonly optionChangeBlocked: boolean; + readonly committedInstanceId: string; + readonly requestedInstanceId: string; +}): boolean { + return input.optionChangeBlocked && input.requestedInstanceId === input.committedInstanceId; +} + +/** + * Decide how a model-menu selection should update composer draft state on a + * started session-bound thread. + * + * - Cross-provider (different instance) handoff remains allowed. + * - Reselecting the committed instance and model restores the exact committed + * selection so a handoff draft is cancelled and applied effort survives menu + * normalization that may supply default options. + * - A different model on the same committed instance is rejected. + */ +export type SessionBoundModelSelectionUpdate = + | { readonly type: "apply"; readonly selection: ModelSelection } + | { readonly type: "restore_committed"; readonly selection: ModelSelection } + | { readonly type: "reject_model_change" }; + +export function resolveSessionBoundModelSelectionUpdate(input: { + readonly optionChangeBlocked: boolean; + readonly committed: ModelSelection; + readonly requested: ModelSelection; +}): SessionBoundModelSelectionUpdate { + if (!input.optionChangeBlocked) { + return { type: "apply", selection: input.requested }; + } + if (input.requested.instanceId !== input.committed.instanceId) { + return { type: "apply", selection: input.requested }; + } + if (input.requested.model !== input.committed.model) { + return { type: "reject_model_change" }; + } + // Same instance + same model: always pin to the exact committed selection. + // Menu normalization may have replaced applied effort with defaults; a prior + // cross-provider draft is also replaced so the handoff is cancelled. + return { type: "restore_committed", selection: input.committed }; +} + +/** + * On a started session-bound thread, same-instance and same-model display uses + * the committed modelSelection. Handoff and legacy model-change drafts remain + * visible so send can handle them explicitly instead of silently substituting + * the committed model. Draft runtime/interaction settings stay independent. + */ +export function resolveEffectiveModelSelection(input: { + readonly draftModelSelection: ModelSelection | null | undefined; + readonly committedModelSelection: ModelSelection; + readonly optionChangeBlocked: boolean; +}): ModelSelection { + const draft = input.draftModelSelection; + if ( + input.optionChangeBlocked && + (draft == null || + (draft.instanceId === input.committedModelSelection.instanceId && + draft.model === input.committedModelSelection.model)) + ) { + return input.committedModelSelection; + } + return draft ?? input.committedModelSelection; +} + +/** + * Resolve a durable outbox entry's model selection against the committed + * thread selection using the same session-bound decision as live send. Stale + * same-instance model or effort changes pin to committed so settings sync does + * not retry a permanent rejection; cross-provider handoff drafts remain intact. + */ +export function resolveOutboxModelSelection(input: { + readonly config: T3ServerConfig | null | undefined; + readonly threadHasStarted: boolean; + readonly threadRuntime: { readonly providerInstanceId: string } | null | undefined; + readonly committedModelSelection: ModelSelection; + readonly queuedModelSelection: ModelSelection | null | undefined; +}): ModelSelection { + const optionChangeBlocked = startedThreadOptionChangeBlocked({ + config: input.config, + threadHasStarted: input.threadHasStarted, + threadRuntime: input.threadRuntime, + selectionInstanceId: input.committedModelSelection.instanceId, + }); + const decision = resolveSessionBoundModelSelectionUpdate({ + optionChangeBlocked, + committed: input.committedModelSelection, + requested: input.queuedModelSelection ?? input.committedModelSelection, + }); + return decision.type === "reject_model_change" + ? input.committedModelSelection + : decision.selection; +} + +/** + * Delivery-time outbox selection: pick the queued message's environment from + * the multi-environment config map, then pin stale same-instance spawn-bound + * options to committed. Cross-provider handoff drafts remain intact. A missing + * environment config defers only a conflicting same-instance selection; other + * deliveries do not need provider lock metadata. + */ +export function resolveOutboxModelSelectionForEnvironment(input: { + readonly serverConfigsByEnvironment: ReadonlyMap; + readonly environmentId: string; + readonly threadHasStarted: boolean; + readonly threadRuntime: { readonly providerInstanceId: string } | null | undefined; + readonly committedModelSelection: ModelSelection; + readonly queuedModelSelection: ModelSelection | null | undefined; +}): ModelSelection | null { + const config = input.serverConfigsByEnvironment.get(input.environmentId); + if (config === undefined) { + const queued = input.queuedModelSelection ?? input.committedModelSelection; + const lockedInstanceId = + input.threadRuntime?.providerInstanceId ?? input.committedModelSelection.instanceId; + if ( + !input.threadHasStarted || + lockedInstanceId !== input.committedModelSelection.instanceId || + queued.instanceId !== input.committedModelSelection.instanceId || + modelSelectionsEqual(queued, input.committedModelSelection) + ) { + return queued; + } + return null; + } + return resolveOutboxModelSelection({ + config, + threadHasStarted: input.threadHasStarted, + threadRuntime: input.threadRuntime, + committedModelSelection: input.committedModelSelection, + queuedModelSelection: input.queuedModelSelection, + }); +} + function providerDisplayLabel(provider: { readonly displayName?: string | undefined; readonly driver: string; diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index d7f99a3dab7..c04c323e059 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -5,8 +5,10 @@ import type { ModelCapabilities } from "@t3tools/contracts"; import { applyProviderOptionMenuEvent, buildProviderOptionMenuActions, + LOCKED_PROVIDER_OPTION_ALERT, providerOptionsConfigurationLabel, resolveProviderOptionDescriptors, + resolveProviderOptionMenuChange, } from "./providerOptions"; const CODEX_CAPABILITIES: ModelCapabilities = { @@ -34,6 +36,20 @@ const CODEX_CAPABILITIES: ModelCapabilities = { ], }; +const BOOLEAN_CAPABILITIES: ModelCapabilities = { + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], +}; + +function optionEventId( + descriptors: ReturnType, + groupIndex: number, + choiceIndex: number, +): string { + const id = buildProviderOptionMenuActions(descriptors)[groupIndex]?.subactions?.[choiceIndex]?.id; + expect(id).toBeDefined(); + return id!; +} + describe("mobile provider options", () => { it("renders the option descriptors advertised by the selected model", () => { const descriptors = resolveProviderOptionDescriptors({ @@ -79,9 +95,7 @@ describe("mobile provider options", () => { it("treats an unspecified boolean capability as off", () => { const descriptors = resolveProviderOptionDescriptors({ - capabilities: { - optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], - }, + capabilities: BOOLEAN_CAPABILITIES, selections: undefined, }); @@ -97,4 +111,102 @@ describe("mobile provider options", () => { ]); expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); }); + + it("does not mark provider option choices as disabled", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + + for (const action of buildProviderOptionMenuActions(descriptors)) { + for (const choice of action.subactions ?? []) { + expect(choice.attributes).toBeUndefined(); + } + } + }); +}); + +describe("resolveProviderOptionMenuChange", () => { + it("ignores re-selecting the current select value without an update payload", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + const currentEvent = optionEventId(descriptors, 0, 0); + + expect( + resolveProviderOptionMenuChange(descriptors, currentEvent, { optionsLocked: true }), + ).toEqual({ action: "ignore" }); + }); + + it("ignores re-selecting the current boolean value without an update payload", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: BOOLEAN_CAPABILITIES, + selections: undefined, + }); + const currentOffEvent = optionEventId(descriptors, 0, 0); + + expect(resolveProviderOptionMenuChange(descriptors, currentOffEvent)).toEqual({ + action: "ignore", + }); + }); + + it("warns for a locked select change with no update payload", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + const highEvent = optionEventId(descriptors, 0, 1); + + expect( + resolveProviderOptionMenuChange(descriptors, highEvent, { optionsLocked: true }), + ).toEqual({ + action: "warn", + title: LOCKED_PROVIDER_OPTION_ALERT.title, + description: LOCKED_PROVIDER_OPTION_ALERT.description, + }); + }); + + it("warns for a locked boolean change with no update payload", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: BOOLEAN_CAPABILITIES, + selections: undefined, + }); + const onEvent = optionEventId(descriptors, 0, 1); + + expect(resolveProviderOptionMenuChange(descriptors, onEvent, { optionsLocked: true })).toEqual({ + action: "warn", + title: LOCKED_PROVIDER_OPTION_ALERT.title, + description: LOCKED_PROVIDER_OPTION_ALERT.description, + }); + }); + + it("returns an update payload for an unlocked select change", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + const highEvent = optionEventId(descriptors, 0, 1); + + expect(resolveProviderOptionMenuChange(descriptors, highEvent)).toEqual({ + action: "apply", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "serviceTier", value: "default" }, + ], + }); + }); + + it("returns an update payload for an unlocked boolean change", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: BOOLEAN_CAPABILITIES, + selections: undefined, + }); + const onEvent = optionEventId(descriptors, 0, 1); + + expect(resolveProviderOptionMenuChange(descriptors, onEvent)).toEqual({ + action: "apply", + options: [{ id: "fastMode", value: true }], + }); + }); }); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index ae195498962..c1954e17c72 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -13,6 +13,20 @@ import { const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:"; +export const LOCKED_PROVIDER_OPTION_ALERT = { + title: "Start a new chat to change options", + description: "This provider applies these options when a conversation starts.", +} as const; + +export type ProviderOptionMenuChangeResolution = + | { action: "ignore" } + | { + action: "warn"; + title: (typeof LOCKED_PROVIDER_OPTION_ALERT)["title"]; + description: (typeof LOCKED_PROVIDER_OPTION_ALERT)["description"]; + } + | { action: "apply"; options: ReadonlyArray }; + function providerOptionEvent(id: string, value: string | boolean): string { return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`; } @@ -45,6 +59,57 @@ function parseProviderOptionEvent( return null; } +function descriptorCurrentValue( + descriptor: ProviderOptionDescriptor, +): string | boolean | undefined { + if (descriptor.type === "boolean") { + return descriptor.currentValue ?? false; + } + return getProviderOptionCurrentValue(descriptor); +} + +function tryBuildProviderOptionUpdate( + descriptors: ReadonlyArray, + event: string, +): { + readonly currentValue: string | boolean | undefined; + readonly nextValue: string | boolean; + readonly options: ReadonlyArray; +} | null { + const selection = parseProviderOptionEvent(event); + if (!selection) { + return null; + } + + const descriptor = descriptors.find((candidate) => candidate.id === selection.id); + if (!descriptor) { + return null; + } + if ( + (descriptor.type === "boolean" && typeof selection.value !== "boolean") || + (descriptor.type === "select" && + (typeof selection.value !== "string" || + !descriptor.options.some((option) => option.id === selection.value))) + ) { + return null; + } + + const nextDescriptors = descriptors.map((candidate) => + candidate.id === descriptor.id + ? { + ...candidate, + currentValue: selection.value, + } + : candidate, + ) as ReadonlyArray; + + return { + currentValue: descriptorCurrentValue(descriptor), + nextValue: selection.value, + options: buildProviderOptionSelectionsFromDescriptors(nextDescriptors) ?? [], + }; +} + export function resolveProviderOptionDescriptors(input: { readonly capabilities: ModelCapabilities | null | undefined; readonly selections: ReadonlyArray | null | undefined; @@ -62,10 +127,7 @@ export function buildProviderOptionMenuActions( descriptors: ReadonlyArray, ): ReadonlyArray { return descriptors.map((descriptor) => { - const currentValue = - descriptor.type === "boolean" - ? (descriptor.currentValue ?? false) - : getProviderOptionCurrentValue(descriptor); + const currentValue = descriptorCurrentValue(descriptor); const choices = descriptor.type === "select" ? descriptor.options.map((option) => ({ @@ -106,36 +168,37 @@ export function providerOptionsConfigurationLabel( return labels.length > 0 ? labels.join(" · ") : "Configuration"; } -export function applyProviderOptionMenuEvent( +/** + * Parse a provider-option menu event and decide ignore / warn / apply. + * Used by ThreadComposer so locked alternates stay tappable and warn, while + * re-selecting the current value is a silent no-op. + */ +export function resolveProviderOptionMenuChange( descriptors: ReadonlyArray, event: string, -): ReadonlyArray | null { - const selection = parseProviderOptionEvent(event); - if (!selection) { + input?: { readonly optionsLocked?: boolean }, +): ProviderOptionMenuChangeResolution | null { + const update = tryBuildProviderOptionUpdate(descriptors, event); + if (!update) { return null; } - - const descriptor = descriptors.find((candidate) => candidate.id === selection.id); - if (!descriptor) { - return null; + if (update.currentValue === update.nextValue) { + return { action: "ignore" }; } - if ( - (descriptor.type === "boolean" && typeof selection.value !== "boolean") || - (descriptor.type === "select" && - (typeof selection.value !== "string" || - !descriptor.options.some((option) => option.id === selection.value))) - ) { - return null; + if (input?.optionsLocked === true) { + return { + action: "warn", + title: LOCKED_PROVIDER_OPTION_ALERT.title, + description: LOCKED_PROVIDER_OPTION_ALERT.description, + }; } + return { action: "apply", options: update.options }; +} - const nextDescriptors = descriptors.map((candidate) => - candidate.id === descriptor.id - ? { - ...candidate, - currentValue: selection.value, - } - : candidate, - ) as ReadonlyArray; - - return buildProviderOptionSelectionsFromDescriptors(nextDescriptors) ?? []; +/** Always applies a valid provider-option event (new-thread drafts stay fully mutable). */ +export function applyProviderOptionMenuEvent( + descriptors: ReadonlyArray, + event: string, +): ReadonlyArray | null { + return tryBuildProviderOptionUpdate(descriptors, event)?.options ?? null; } diff --git a/apps/mobile/src/state/environment-server-configs-access.test.ts b/apps/mobile/src/state/environment-server-configs-access.test.ts new file mode 100644 index 00000000000..29bbac412cf --- /dev/null +++ b/apps/mobile/src/state/environment-server-configs-access.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; + +const mockEnvironmentServerConfigsAtom = vi.hoisted(() => ({ + label: "environment-server-configs", +})); + +vi.mock("./server", () => ({ + environmentServerConfigsAtom: mockEnvironmentServerConfigsAtom, +})); + +import { + readEnvironmentServerConfigs, + subscribeEnvironmentServerConfigs, +} from "./environment-server-configs-access"; +import { environmentServerConfigsAtom } from "./server"; + +describe("environment server configs access boundary", () => { + it("reads environmentServerConfigsAtom from the registry", () => { + const configs = new Map(); + const get = vi.fn(() => configs); + + expect(readEnvironmentServerConfigs({ get })).toBe(configs); + expect(get).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledWith(environmentServerConfigsAtom); + }); + + it("subscribes to config changes and exposes cleanup", () => { + const cleanup = vi.fn(); + const subscribe = vi.fn(() => cleanup); + const listener = vi.fn(); + + const unsubscribe = subscribeEnvironmentServerConfigs(listener, { subscribe }); + + expect(subscribe).toHaveBeenCalledTimes(1); + expect(subscribe).toHaveBeenCalledWith(environmentServerConfigsAtom, listener); + unsubscribe(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it("returns a changed registry value on repeated reads instead of a captured map", () => { + const first = new Map(); + const second = new Map(); + let liveConfigs = first; + const get = vi.fn(() => liveConfigs); + + expect(readEnvironmentServerConfigs({ get })).toBe(first); + + liveConfigs = second; + expect(readEnvironmentServerConfigs({ get })).toBe(second); + expect(readEnvironmentServerConfigs({ get })).not.toBe(first); + expect(get).toHaveBeenCalledTimes(3); + expect(get).toHaveBeenNthCalledWith(1, environmentServerConfigsAtom); + expect(get).toHaveBeenNthCalledWith(2, environmentServerConfigsAtom); + expect(get).toHaveBeenNthCalledWith(3, environmentServerConfigsAtom); + }); +}); diff --git a/apps/mobile/src/state/environment-server-configs-access.ts b/apps/mobile/src/state/environment-server-configs-access.ts new file mode 100644 index 00000000000..4533eec2d56 --- /dev/null +++ b/apps/mobile/src/state/environment-server-configs-access.ts @@ -0,0 +1,36 @@ +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; + +import { appAtomRegistry } from "./atom-registry"; +import { environmentServerConfigsAtom } from "./server"; + +export type EnvironmentServerConfigsMap = ReadonlyMap; + +/** + * Narrow registry surface used by outbox delivery to keep the multi-environment + * config graph live and read it at send time without a hook subscription. + */ +export type EnvironmentServerConfigsRegistry = { + readonly get: (atom: typeof environmentServerConfigsAtom) => EnvironmentServerConfigsMap; + readonly subscribe: ( + atom: typeof environmentServerConfigsAtom, + listener: (configs: EnvironmentServerConfigsMap) => void, + ) => () => void; +}; + +/** Keep the config graph live and notify a non-hook consumer when it changes. */ +export function subscribeEnvironmentServerConfigs( + listener: (configs: EnvironmentServerConfigsMap) => void, + registry: Pick = appAtomRegistry, +): () => void { + return registry.subscribe(environmentServerConfigsAtom, listener); +} + +/** + * Fresh delivery-time read of the multi-environment server-config map. + * Callers must re-invoke on each use rather than capturing the map identity. + */ +export function readEnvironmentServerConfigs( + registry: Pick = appAtomRegistry, +): EnvironmentServerConfigsMap { + return registry.get(environmentServerConfigsAtom); +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 4b199fbb6f2..6d42fd6e479 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -5,6 +5,7 @@ import { deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; import { useCallback, useEffect, useMemo } from "react"; +import { Alert } from "react-native"; import { CommandId, @@ -17,7 +18,6 @@ import { } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; - import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { convertPastedImagesToAttachments, @@ -25,9 +25,16 @@ import { pickComposerImages, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { + resolveEffectiveModelSelection, + resolveSessionBoundModelSelectionUpdate, + startedThreadOptionChangeBlocked, + threadShellHasStarted, +} from "../lib/modelOptions"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed } from "../lib/threadActivity"; import { appAtomRegistry } from "../state/atom-registry"; +import { useEnvironmentServerConfig } from "./entities"; import { appendComposerDraftAttachments, appendComposerDraftText, @@ -109,7 +116,7 @@ export function useThreadComposerState() { const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadShell; - const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; + const serverConfig = useEnvironmentServerConfig(selectedThreadShell?.environmentId ?? null); const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; const selectedThreadRuntime = useMemo( @@ -119,6 +126,22 @@ export function useThreadComposerState() { : (selectedThreadShell?.runtime ?? null), [selectedThreadProjection, selectedThreadShell?.runtime], ); + const optionChangeBlocked = startedThreadOptionChangeBlocked({ + config: serverConfig, + threadHasStarted: threadShellHasStarted(selectedThread), + threadRuntime: selectedThreadRuntime, + selectionInstanceId: selectedThread?.modelSelection.instanceId ?? "", + }); + const optionChangeBlockedInstanceId = optionChangeBlocked + ? (selectedThread?.modelSelection.instanceId ?? null) + : null; + const modelSelection = selectedThread + ? resolveEffectiveModelSelection({ + draftModelSelection: selectedDraft?.modelSelection, + committedModelSelection: selectedThread.modelSelection, + optionChangeBlocked, + }) + : null; const selectedThreadActivityRun = useMemo( () => selectedThreadProjection @@ -166,6 +189,19 @@ export function useThreadComposerState() { return null; } + const modelSelectionDecision = resolveSessionBoundModelSelectionUpdate({ + optionChangeBlocked, + committed: thread.modelSelection, + requested: draft.modelSelection ?? thread.modelSelection, + }); + if (modelSelectionDecision.type === "reject_model_change") { + Alert.alert( + "Start a new chat to change models", + "This provider does not allow switching models after a conversation has started.", + ); + return null; + } + const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); // Enqueue publishes the queued atom synchronously (the durable write @@ -180,7 +216,7 @@ export function useThreadComposerState() { commandId: CommandId.make(metadata.commandId), text, attachments, - modelSelection: draft.modelSelection ?? thread.modelSelection, + modelSelection: modelSelectionDecision.selection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: draft.interactionMode ?? thread.interactionMode, createdAt: metadata.createdAt, @@ -198,7 +234,7 @@ export function useThreadComposerState() { ); }); return messageId; - }, [selectedThreadShell]); + }, [optionChangeBlocked, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { @@ -290,12 +326,30 @@ export function useThreadComposerState() { const onUpdateModelSelection = useCallback( (value: ModelSelection) => { - if (!selectedThreadKey) { + if (!selectedThreadKey || !selectedThreadShell) { return; } - updateComposerDraftSettings(selectedThreadKey, { modelSelection: value }); + const committed = selectedThreadShell.modelSelection; + const decision = resolveSessionBoundModelSelectionUpdate({ + optionChangeBlocked, + committed, + requested: value, + }); + if (decision.type === "reject_model_change") { + Alert.alert( + "Start a new chat to change models", + "This provider does not allow switching models after a conversation has started.", + ); + return; + } + // apply and restore_committed both write the resolved selection: restore + // cancels a cross-provider draft and keeps the applied effort when the + // menu supplied default options for the same model. + updateComposerDraftSettings(selectedThreadKey, { + modelSelection: decision.selection, + }); }, - [selectedThreadKey], + [optionChangeBlocked, selectedThreadKey, selectedThreadShell], ); const onUpdateRuntimeMode = useCallback( @@ -326,6 +380,8 @@ export function useThreadComposerState() { draftMessage, draftAttachments, modelSelection, + optionChangeBlocked, + optionChangeBlockedInstanceId, runtimeMode, interactionMode, activeThreadBusy, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 4936731101a..12bc937b122 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -16,12 +16,20 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; +import { + resolveOutboxModelSelectionForEnvironment, + threadShellHasStarted, +} from "../lib/modelOptions"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { toUploadChatImageAttachments } from "../lib/composerImages"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useThreadShells } from "./entities"; +import { + readEnvironmentServerConfigs, + subscribeEnvironmentServerConfigs, +} from "./environment-server-configs-access"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, @@ -105,13 +113,21 @@ export function useThreadOutboxDrain(): void { const projects = useProjects(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); + const deferredConfigMessageIdsRef = useRef(new Set()); const retryAttemptRef = useRef(new Map()); const retryNotBeforeRef = useRef(new Map()); const retryTimersRef = useRef(new Map>()); useEffect(() => { ensureThreadOutboxLoaded(); + // Keep the multi-environment config graph live for delivery-time reads and + // wake the drain when a previously missing environment config arrives. + const unsubscribeServerConfigs = subscribeEnvironmentServerConfigs(() => { + setRetryTick((current) => current + 1); + }); return () => { + unsubscribeServerConfigs(); + deferredConfigMessageIdsRef.current.clear(); for (const timer of retryTimersRef.current.values()) { clearTimeout(timer); } @@ -168,7 +184,28 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { - const settings = resolveQueuedThreadSettings(queuedMessage, thread); + const baseSettings = resolveQueuedThreadSettings(queuedMessage, thread); + // Pin stale same-instance spawn-bound options to the committed selection + // before settings sync / send so permanent rejections are not retried. + // Read configs at delivery time so unrelated environment updates do not + // recreate this callback or capture a stale map identity. + const modelSelection = resolveOutboxModelSelectionForEnvironment({ + serverConfigsByEnvironment: readEnvironmentServerConfigs(), + environmentId: queuedMessage.environmentId, + threadHasStarted: threadShellHasStarted(thread), + threadRuntime: thread.runtime, + committedModelSelection: thread.modelSelection, + queuedModelSelection: queuedMessage.modelSelection, + }); + if (modelSelection === null) { + // Config projection changes wake the drain subscription above. This is + // a loading wait, not a failed delivery that should increase backoff. + return null; + } + const settings = { + ...baseSettings, + modelSelection, + }; const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage); if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { @@ -321,6 +358,28 @@ export function useThreadOutboxDrain(): void { if (deliveryAction === "wait") { continue; } + if (deliveryAction === "send" && creation === undefined && thread !== undefined) { + const modelSelection = resolveOutboxModelSelectionForEnvironment({ + serverConfigsByEnvironment: readEnvironmentServerConfigs(), + environmentId: nextQueuedMessage.environmentId, + threadHasStarted: threadShellHasStarted(thread), + threadRuntime: thread.runtime, + committedModelSelection: thread.modelSelection, + queuedModelSelection: nextQueuedMessage.modelSelection, + }); + if (modelSelection === null) { + if (!deferredConfigMessageIdsRef.current.has(nextQueuedMessage.messageId)) { + deferredConfigMessageIdsRef.current.add(nextQueuedMessage.messageId); + console.warn("[thread-outbox] waiting for provider config before queued delivery", { + environmentId: nextQueuedMessage.environmentId, + threadId: nextQueuedMessage.threadId, + messageId: nextQueuedMessage.messageId, + }); + } + continue; + } + deferredConfigMessageIdsRef.current.delete(nextQueuedMessage.messageId); + } // The live project shell is preferred for the workspace path, with the // snapshot taken at enqueue time as the fallback so a task never dies // just because its project shell is not loaded. @@ -385,12 +444,15 @@ export function useThreadOutboxDrain(): void { ? creationProjectCwd !== null ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd) : removeQueuedMessage("[thread-outbox] dropped pending task for a missing project") - : thread !== undefined - ? sendQueuedMessage(nextQueuedMessage, thread) + : freshThread !== undefined + ? sendQueuedMessage(nextQueuedMessage, freshThread) : Promise.resolve(false); }); void delivery .then((sent) => { + if (sent === null) { + return; + } if (sent) { retryAttemptRef.current.delete(nextQueuedMessage.messageId); retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 161b2a6b38b..d9ff76c3075 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -32,6 +32,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; @@ -49,6 +50,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { resolveGrokSpawnOptionValue } from "../../provider/acp/GrokAcpSupport.ts"; import { normalizeXAiAcpToolCallState, registerXAiBackgroundTaskTracking, @@ -74,6 +76,8 @@ import { acpPostSettleWakeShouldBuffer, acpProjectedCommandExitCode, makeAcpAdapterV2, + resolveAcpConfigureSessionPrior, + resolveEffectiveAcpSelection, type AcpAdapterV2ExtensionContext, type AcpAdapterV2Flavor, type AcpAdapterV2RuntimeInput, @@ -1313,6 +1317,664 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(testLayer), Effect.scoped), ); + it.effect("excludes flavor spawn options from session config validation and apply", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("acp-test"); + const runtimeSelections: Array = []; + const mockRuntime = makeMockRuntime({ childProcessSpawner, mockAgentPath }); + const adapter = makeAcpAdapterV2({ + crypto: yield* Crypto.Crypto, + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: ["reasoningEffort"], + makeRuntime: (input) => { + runtimeSelections.push(input.modelSelection); + return mockRuntime(input); + }, + }, + fileSystem, + idAllocator, + serverConfig, + }); + const threadId = ThreadId.make("thread-acp-spawn-option"); + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { + instanceId, + model: "default", + options: [{ id: "reasoningEffort", value: "low" }], + } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-spawn-option"), + modelSelection, + runtimePolicy, + }); + + assert.equal(runtime.providerSession.status, "ready"); + assert.deepEqual(runtimeSelections, [modelSelection]); + + // Non-spawn option ids stay subject to advertised-id validation. + const mixedAdapter = makeAcpAdapterV2({ + crypto: yield* Crypto.Crypto, + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: ["reasoningEffort"], + makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }), + }, + fileSystem, + idAllocator, + serverConfig, + }); + const mixedError = yield* mixedAdapter + .openSession({ + threadId: ThreadId.make("thread-acp-spawn-option:mixed"), + providerSessionId: ProviderSessionId.make("provider-session-acp-spawn-option-mixed"), + modelSelection: { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "low" }, + { id: "missing-option", value: "high" }, + ], + }, + runtimePolicy, + }) + .pipe(Effect.flip); + assert.equal(mixedError._tag, "ProviderAdapterOpenSessionError"); + assert.include(String(mixedError.cause), "missing-option"); + assert.notInclude(String(mixedError.cause), "reasoningEffort"); + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it("preserves same-session spawn-bound values in the effective selection", () => { + const instanceId = ProviderInstanceId.make("acp-test"); + const prior: ModelSelection = { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "low" }, + { id: "model", value: "default" }, + ], + }; + const requested: ModelSelection = { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "model", value: "composer-2" }, + ], + }; + + assert.deepEqual( + resolveEffectiveAcpSelection({ + requested, + priorSelection: prior, + spawnOptionIds: ["reasoningEffort"], + }), + { + instanceId, + model: "default", + options: [ + { id: "model", value: "composer-2" }, + { id: "reasoningEffort", value: "low" }, + ], + }, + ); + + assert.deepEqual( + resolveEffectiveAcpSelection({ + requested: { + instanceId, + model: "grok-build", + }, + priorSelection: { + instanceId, + model: "grok-4.5", + }, + spawnOptionIds: ["reasoningEffort"], + resolveSpawnOptionValue: resolveGrokSpawnOptionValue, + }), + { + instanceId, + model: "grok-build", + }, + ); + + assert.deepEqual( + resolveEffectiveAcpSelection({ + requested: { + instanceId, + model: "grok-4.5", + }, + priorSelection: null, + spawnOptionIds: ["reasoningEffort"], + resolveSpawnOptionValue: resolveGrokSpawnOptionValue, + }), + { + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "high" }], + }, + ); + + assert.deepEqual( + resolveEffectiveAcpSelection({ + requested, + priorSelection: null, + spawnOptionIds: ["reasoningEffort"], + }), + { + instanceId, + model: "default", + options: [ + { id: "model", value: "composer-2" }, + { id: "reasoningEffort", value: "high" }, + ], + }, + ); + + assert.deepEqual( + resolveEffectiveAcpSelection({ + requested, + priorSelection: { + instanceId, + model: "default", + options: [{ id: "model", value: "default" }], + }, + spawnOptionIds: ["reasoningEffort"], + }), + { + instanceId, + model: "default", + options: [{ id: "model", value: "composer-2" }], + }, + ); + }); + + it("uses spawn-time selection as configure prior when activeSelection is null", () => { + // Snapshot load and fork clear activeSelection while the process still + // holds spawn-bound options. The next-turn path must not pass null prior. + const instanceId = ProviderInstanceId.make("acp-test"); + const spawnTime: ModelSelection = { + instanceId, + model: "default", + options: [{ id: "reasoningEffort", value: "low" }], + }; + const active: ModelSelection = { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "low" }, + { id: "model", value: "composer-2" }, + ], + }; + + assert.deepEqual( + resolveAcpConfigureSessionPrior({ + activeSelection: null, + spawnTimeSelection: spawnTime, + }), + spawnTime, + ); + assert.deepEqual( + resolveAcpConfigureSessionPrior({ + activeSelection: active, + spawnTimeSelection: spawnTime, + }), + active, + ); + // Combined with effective selection: null active + spawn prior pins effort. + assert.deepEqual( + resolveEffectiveAcpSelection({ + requested: { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "model", value: "composer-2" }, + ], + }, + priorSelection: resolveAcpConfigureSessionPrior({ + activeSelection: null, + spawnTimeSelection: spawnTime, + }), + spawnOptionIds: ["reasoningEffort"], + }), + { + instanceId, + model: "default", + options: [ + { id: "model", value: "composer-2" }, + { id: "reasoningEffort", value: "low" }, + ], + }, + ); + }); + + it.live("keeps applied spawn-bound selection after a same-session stale request", () => { + const configOptionCalls: Array<{ readonly id: string; readonly value: string | boolean }> = []; + const warningMessages: string[] = []; + const captureLogger = Logger.make(({ logLevel, message }) => { + if (logLevel !== "Warn") { + return; + } + const text = Array.isArray(message) ? message.map(String).join(" ") : String(message); + warningMessages.push(text); + }); + const spawnBoundWarning = (message: string) => + message.includes("spawn-bound option cannot change on an active session"); + + return Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("acp-test"); + const adapter = makeAcpAdapterV2({ + crypto: yield* Crypto.Crypto, + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: ["reasoningEffort"], + resolveSpawnOptionValue: resolveGrokSpawnOptionValue, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + wrapRuntime: (runtime) => ({ + ...runtime, + setConfigOption: (id, value) => + Effect.sync(() => { + configOptionCalls.push({ id, value }); + }).pipe(Effect.andThen(runtime.setConfigOption(id, value))), + }), + }), + }, + fileSystem, + idAllocator, + serverConfig, + }); + const threadId = ThreadId.make("thread-acp-spawn-option-bookkeeping"); + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const lowSelection = { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "low" }, + { id: "model", value: "default" }, + ], + } as const satisfies ModelSelection; + const highSelection = { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "model", value: "composer-2" }, + ], + } as const satisfies ModelSelection; + const lowAgainSelection = { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "low" }, + { id: "model", value: "composer-2" }, + ], + } as const satisfies ModelSelection; + const buildSelection = { + instanceId, + model: "grok-build", + } as const satisfies ModelSelection; + + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-spawn-option-bookkeeping"), + modelSelection: lowSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: lowSelection, + runtimePolicy, + }); + + warningMessages.length = 0; + configOptionCalls.length = 0; + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection: highSelection, + now, + ordinal: 1, + }), + ); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isTrue(warningMessages.some(spawnBoundWarning)); + assert.deepEqual( + configOptionCalls.filter((call) => call.id === "model"), + [{ id: "model", value: "composer-2" }], + ); + assert.isFalse(configOptionCalls.some((call) => call.id === "reasoningEffort")); + + // Effective state remains Low. The same stale High request normalizes to + // the already configured state, so it does not reconfigure or warn again. + warningMessages.length = 0; + configOptionCalls.length = 0; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection: highSelection, + now: yield* DateTime.now, + ordinal: 2, + }), + ); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isFalse(warningMessages.some(spawnBoundWarning)); + assert.deepEqual(configOptionCalls, []); + + warningMessages.length = 0; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection: lowAgainSelection, + now: yield* DateTime.now, + ordinal: 3, + }), + ); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isFalse(warningMessages.some(spawnBoundWarning)); + + // A model change has its own spawn-option semantics. Do not carry the + // prior model's effort into the new selection or warn about its absence. + warningMessages.length = 0; + configOptionCalls.length = 0; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection: buildSelection, + now: yield* DateTime.now, + ordinal: 4, + }), + ); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isFalse(warningMessages.some(spawnBoundWarning)); + assert.isFalse(configOptionCalls.some((call) => call.id === "reasoningEffort")); + + warningMessages.length = 0; + configOptionCalls.length = 0; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection: buildSelection, + now: yield* DateTime.now, + ordinal: 5, + }), + ); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isFalse(warningMessages.some(spawnBoundWarning)); + assert.deepEqual(configOptionCalls, []); + }).pipe( + Effect.provide( + Layer.mergeAll(testLayer, Logger.layer([captureLogger], { mergeWithExisting: false })), + ), + Effect.scoped, + ); + }); + + it.live("preserves spawn-bound selection across user Stop runtime restart", () => { + const configOptionCalls: Array<{ readonly id: string; readonly value: string | boolean }> = []; + const warningMessages: string[] = []; + const runtimeSelections: Array = []; + const captureLogger = Logger.make(({ logLevel, message }) => { + if (logLevel !== "Warn") { + return; + } + const text = Array.isArray(message) ? message.map(String).join(" ") : String(message); + warningMessages.push(text); + }); + const spawnBoundWarning = (message: string) => + message.includes("spawn-bound option cannot change on an active session"); + const reasoningEffortOf = (selection: ModelSelection | undefined) => + selection?.options?.find((option) => option.id === "reasoningEffort")?.value; + + return Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.bounded(256); + const instanceId = ProviderInstanceId.make("acp-test"); + const baseMakeRuntime = makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + protocolEvents, + wrapRuntime: (runtime) => ({ + ...runtime, + setConfigOption: (id, value) => + Effect.sync(() => { + configOptionCalls.push({ id, value }); + }).pipe(Effect.andThen(runtime.setConfigOption(id, value))), + }), + }); + const adapter = makeAcpAdapterV2({ + crypto: yield* Crypto.Crypto, + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: ["reasoningEffort"], + resolveSpawnOptionValue: resolveGrokSpawnOptionValue, + // Same restart path as "restarts the ACP child process before the + // next prompt after interrupt": user Stop sets runtimeRestartRequired + // and the following turn reactivates after respawn. + restartRuntimeAfterInterrupt: true, + makeRuntime: (input) => { + runtimeSelections.push(input.modelSelection); + return baseMakeRuntime(input); + }, + }, + fileSystem, + idAllocator, + serverConfig, + }); + const threadId = ThreadId.make("thread-acp-spawn-option-stop-restart"); + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const lowSelection = { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "low" }, + { id: "model", value: "default" }, + ], + } as const satisfies ModelSelection; + const highSelection = { + instanceId, + model: "default", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "model", value: "composer-2" }, + ], + } as const satisfies ModelSelection; + + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-spawn-option-stop-restart"), + modelSelection: lowSelection, + runtimePolicy, + }); + assert.lengthOf(runtimeSelections, 1); + assert.equal(reasoningEffortOf(runtimeSelections[0]), "low"); + + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: lowSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection: lowSelection, + now, + ordinal: 1, + }), + ); + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", + ), + Stream.runHead, + ); + const firstProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: `${providerThread.nativeThreadRef?.nativeId}:turn:1`, + }); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: firstProviderTurnId, + requestRuntimeRestart: true, + }); + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel", + ), + Stream.runHead, + ); + // Drain the interrupted turn so later terminals belong to post-restart work. + yield* runtime.events.pipe( + Stream.filter( + (event) => event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId, + ), + Stream.runHead, + ); + + // After Stop, activeSelection is cleared. The next turn reactivates and + // must treat spawn-time Low as prior, so a stale High still warns and + // mutable config options still apply. + warningMessages.length = 0; + configOptionCalls.length = 0; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection: highSelection, + now: yield* DateTime.now, + ordinal: 2, + }), + ); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isTrue(warningMessages.some(spawnBoundWarning)); + assert.deepEqual( + configOptionCalls.filter((call) => call.id === "model"), + [{ id: "model", value: "composer-2" }], + ); + assert.isFalse(configOptionCalls.some((call) => call.id === "reasoningEffort")); + // Both the initial openSession spawn and the post-Stop restart receive Low. + assert.isAtLeast(runtimeSelections.length, 2); + assert.equal(reasoningEffortOf(runtimeSelections[0]), "low"); + assert.equal(reasoningEffortOf(runtimeSelections[1]), "low"); + + // Effective bookkeeping retains spawn-time Low. Another stale High + // normalizes to the already configured state without repeated work. + warningMessages.length = 0; + configOptionCalls.length = 0; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection: highSelection, + now: yield* DateTime.now, + ordinal: 3, + }), + ); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isFalse(warningMessages.some(spawnBoundWarning)); + assert.deepEqual(configOptionCalls, []); + }).pipe( + Effect.provide( + Layer.mergeAll(testLayer, Logger.layer([captureLogger], { mergeWithExisting: false })), + ), + Effect.scoped, + ); + }); + it.effect("reconfigures a loaded ACP session from its own active setup metadata", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 6ae3c65078a..080cce28400 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -27,7 +27,7 @@ import { type RuntimeRequestId, type ThreadId, } from "@t3tools/contracts"; -import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { getModelSelectionOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -64,7 +64,12 @@ import { t3OrchestrationPromptForFirstRun } from "../../provider/T3Orchestration import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; -import { acpSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { + acpSelectionTransition, + type ProviderSelectionTransitionInput, + type ProviderSelectionTransitionPlan, + type SpawnOptionValueResolver, +} from "../ProviderSelectionTransition.ts"; import { makeSubagentChildThread, makeSubagentConversationArtifacts, @@ -99,6 +104,11 @@ export interface AcpAdapterV2RuntimeInput { readonly cwd: string; readonly mcpServers: ReadonlyArray; readonly interruptPromptOnCancel?: boolean; + /** + * The session's initial selection, for flavors that apply option values as + * process spawn arguments (see `AcpAdapterV2Flavor.spawnOptionIds`). + */ + readonly modelSelection?: ModelSelection; readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"]; readonly requestLogger?: NonNullable; @@ -202,6 +212,18 @@ export interface AcpAdapterV2Flavor { Crypto.Crypto | Scope.Scope >; readonly resolveModelId?: (selection: ModelSelection) => string | undefined; + /** + * Selection option ids the flavor consumes as process spawn arguments + * (via `AcpAdapterV2RuntimeInput.modelSelection`) because the agent does not + * expose them as ACP config options. They are excluded from the + * `session/set_config_option` path and from its advertised-id validation. + */ + readonly spawnOptionIds?: ReadonlyArray; + /** Provider-aware semantic values for spawn-bound option comparison. */ + readonly resolveSpawnOptionValue?: SpawnOptionValueResolver; + readonly planSelectionTransition?: ( + input: ProviderSelectionTransitionInput, + ) => Effect.Effect; readonly registerExtensions?: ( context: AcpAdapterV2ExtensionContext, ) => Effect.Effect; @@ -1275,6 +1297,62 @@ interface SnapshotMessageState { loadingIndex: number; } +/** + * Spawn-bound options are fixed for the runtime process. When a prior selection + * is available for the same model (same-session reconfigure, or + * activation/resume after a runtime restart that reuses the process's + * spawn-time selection), keep prior values for those ids while accepting the + * rest of the request. A model change owns its own spawn-option semantics. + * Pass `priorSelection: null` only on a fresh openSession configuration. + */ +export function resolveEffectiveAcpSelection(input: { + readonly requested: ModelSelection; + readonly priorSelection: ModelSelection | null; + readonly spawnOptionIds: ReadonlyArray; + readonly resolveSpawnOptionValue?: SpawnOptionValueResolver; +}): ModelSelection { + const spawnOptionIds = new Set(input.spawnOptionIds); + if (spawnOptionIds.size === 0) { + return input.requested; + } + + const requestedOptions = input.requested.options ?? []; + const spawnSelection = + input.priorSelection?.model === input.requested.model ? input.priorSelection : input.requested; + const resolveSpawnOptionValue = input.resolveSpawnOptionValue ?? getModelSelectionOptionValue; + const nextOptions = [ + ...requestedOptions.filter((selection) => !spawnOptionIds.has(selection.id)), + ...Array.from(spawnOptionIds).flatMap((id) => { + const value = resolveSpawnOptionValue(spawnSelection, id); + return value === undefined ? [] : [{ id, value }]; + }), + ]; + if (nextOptions.length === 0) { + return { + instanceId: input.requested.instanceId, + model: input.requested.model, + }; + } + return { + instanceId: input.requested.instanceId, + model: input.requested.model, + options: nextOptions, + }; +} + +/** + * Prior selection for configureSession on an already-active process. After + * snapshot load or fork, activeSelection is cleared while spawn-bound options + * still belong to the process; fall back to the runtime's spawn-time selection. + * Fresh openSession still passes null prior directly and never uses this helper. + */ +export function resolveAcpConfigureSessionPrior(input: { + readonly activeSelection: ModelSelection | null; + readonly spawnTimeSelection: ModelSelection; +}): ModelSelection { + return input.activeSelection ?? input.spawnTimeSelection; +} + export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV2Shape { const { flavor, fileSystem, idAllocator, serverConfig } = options; const driver = flavor.driver; @@ -1286,7 +1364,18 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV instanceId: options.instanceId, driver, getCapabilities: () => Effect.succeed(flavor.capabilities), - planSelectionTransition: (input) => Effect.succeed(acpSelectionTransition(input)), + planSelectionTransition: + flavor.planSelectionTransition ?? + ((input) => + Effect.succeed( + acpSelectionTransition({ + ...input, + spawnOptionIds: flavor.spawnOptionIds ?? [], + ...(flavor.resolveSpawnOptionValue === undefined + ? {} + : { resolveSpawnOptionValue: flavor.resolveSpawnOptionValue }), + }), + )), openSession: Effect.fn("AcpAdapterV2.openSession")( function* (input: ProviderAdapterV2OpenSessionInput) { const sessionScope = yield* Effect.scope; @@ -1685,6 +1774,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV cwd: input.runtimePolicy.cwd ?? process.cwd(), mcpServers: acpMcpServers(input.threadId), interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false, + modelSelection: input.modelSelection, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, @@ -4171,6 +4261,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV startResult: AcpSessionRuntimeStartResult, modelSelection: ModelSelection, runtimePolicy: ProviderAdapterV2RuntimePolicy, + priorSelection: ModelSelection | null, ) { const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; if ( @@ -4191,9 +4282,36 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV } } } + const spawnOptionIds = flavor.spawnOptionIds ?? []; + const spawnOptionIdSet = new Set(spawnOptionIds); + const resolveSpawnOptionValue = + flavor.resolveSpawnOptionValue ?? getModelSelectionOptionValue; + const sessionConfigSelections = (modelSelection.options ?? []).filter( + (selection) => !spawnOptionIdSet.has(selection.id), + ); + // Orchestrator transitions reject spawn-bound changes before metadata + // or dispatch. Direct adapter and version-skew callers can still + // reach this defensive seam, so pin the active semantic value and + // warn once when reconfiguration is necessary. + if ( + spawnOptionIdSet.size > 0 && + priorSelection !== null && + priorSelection.model === modelSelection.model + ) { + for (const id of spawnOptionIdSet) { + const nextValue = resolveSpawnOptionValue(modelSelection, id); + const configuredValue = resolveSpawnOptionValue(priorSelection, id); + if (nextValue !== configuredValue) { + yield* Effect.logWarning( + "ACP spawn-bound option cannot change on an active session; keeping the session's original value.", + { driver, optionId: id }, + ); + } + } + } const configOptions = yield* runtime.getConfigOptions; const availableConfigIds = new Set(configOptions.map((option) => option.id)); - const unsupportedConfigIds = (modelSelection.options ?? []) + const unsupportedConfigIds = sessionConfigSelections .map((selection) => selection.id) .filter((id) => !availableConfigIds.has(id)); if (unsupportedConfigIds.length > 0) { @@ -4202,7 +4320,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV detail: `ACP session ${startResult.sessionId} does not expose requested configuration option(s): ${unsupportedConfigIds.join(", ")}`, }); } - for (const selection of modelSelection.options ?? []) { + for (const selection of sessionConfigSelections) { yield* runtime.setConfigOption(selection.id, selection.value); } const modeState = yield* runtime.getModeState; @@ -4212,10 +4330,21 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV ); if (planMode !== undefined) yield* runtime.setMode(planMode.id); } + return resolveEffectiveAcpSelection({ + requested: modelSelection, + priorSelection, + spawnOptionIds, + resolveSpawnOptionValue, + }); }); - yield* configureSession(started, input.modelSelection, input.runtimePolicy); - yield* Ref.set(activeSelection, input.modelSelection); + const openedSelection = yield* configureSession( + started, + input.modelSelection, + input.runtimePolicy, + null, + ); + yield* Ref.set(activeSelection, openedSelection); const createdAt = yield* DateTime.now; const providerSession: OrchestrationV2ProviderSession = { id: input.providerSessionId, @@ -4605,13 +4734,32 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV const activated = yield* activateSession(requestedSessionId, turnInput.threadId); yield* Ref.set(activeSessionId, activated.sessionId); yield* Ref.set(activeSessionSetup, activated); - yield* configureSession(activated, turnInput.modelSelection, turnInput.runtimePolicy); - yield* Ref.set(activeSelection, turnInput.modelSelection); + // Activation after restart (or first load) reuses the process's + // spawn-time selection as prior so spawn-bound options stay fixed. + const activatedSelection = yield* configureSession( + activated, + turnInput.modelSelection, + turnInput.runtimePolicy, + input.modelSelection, + ); + yield* Ref.set(activeSelection, activatedSelection); } else { const configuredSelection = yield* Ref.get(activeSelection); + const priorSelection = resolveAcpConfigureSessionPrior({ + activeSelection: configuredSelection, + spawnTimeSelection: input.modelSelection, + }); + const effectiveRequestedSelection = resolveEffectiveAcpSelection({ + requested: turnInput.modelSelection, + priorSelection, + spawnOptionIds: flavor.spawnOptionIds ?? [], + ...(flavor.resolveSpawnOptionValue === undefined + ? {} + : { resolveSpawnOptionValue: flavor.resolveSpawnOptionValue }), + }); if ( configuredSelection === null || - !modelSelectionsEqual(configuredSelection, turnInput.modelSelection) + !modelSelectionsEqual(configuredSelection, effectiveRequestedSelection) ) { const currentSessionSetup = yield* Ref.get(activeSessionSetup); if (currentSessionSetup === null) { @@ -4620,12 +4768,16 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV detail: `ACP session ${requestedSessionId} has no active setup metadata`, }); } - yield* configureSession( + // Snapshot load / fork clear activeSelection; use spawn-time + // selection as prior so spawn-bound options stay fixed. Fresh + // openSession still configures with priorSelection: null above. + const effectiveSelection = yield* configureSession( currentSessionSetup, turnInput.modelSelection, turnInput.runtimePolicy, + priorSelection, ); - yield* Ref.set(activeSelection, turnInput.modelSelection); + yield* Ref.set(activeSelection, effectiveSelection); } } yield* Ref.set(lastTurnRoute, { @@ -5013,12 +5165,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV yield* Ref.set(activeSessionId, activated.sessionId); yield* Ref.set(activeSessionSetup, activated); const nextSelection = threadInput.modelSelection ?? input.modelSelection; - yield* configureSession( + // resumeThread activation reuses spawn-time selection so + // spawn-bound options survive runtime restart. + const resumedSelection = yield* configureSession( activated, nextSelection, threadInput.runtimePolicy ?? input.runtimePolicy, + input.modelSelection, ); - yield* Ref.set(activeSelection, nextSelection); + yield* Ref.set(activeSelection, resumedSelection); } const now = yield* DateTime.now; return { diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 27f8a603801..5cb232f54df 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import type * as EffectAcpSchema from "effect-acp/schema"; +import { ProviderInstanceId } from "@t3tools/contracts"; import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; import { @@ -223,8 +224,69 @@ describe("GrokAdapterV2 capabilities", () => { // session; the cancelled work backgrounds and the model decides its fate. assert.isUndefined(flavor.restartRuntimeOnEveryInterrupt); assert.isTrue(flavor.preserveRuntimeOnSettledInterrupt); + assert.equal( + flavor.resolveSpawnOptionValue?.( + { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + }, + "reasoningEffort", + ), + "high", + ); }); + it.effect("validates spawn-bound effort against the current advertised menu", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("grok"); + const flavor = makeGrokAcpAdapterFlavor({ + getModelCapabilities: () => + Effect.succeed({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "turbo_v2", label: "Turbo V2" }, + ], + currentValue: "high", + }, + ], + }), + makeRuntime: () => Effect.never, + } as unknown as GrokAdapterV2Options); + const current = { instanceId, model: "grok-4.5" }; + const stale = { + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "ultra" }], + }; + const future = { + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "turbo_v2" }], + }; + + const stalePlan = yield* flavor.planSelectionTransition!({ + current, + target: stale, + sessionCapabilities: GrokProviderCapabilitiesV2, + }); + assert.deepStrictEqual(stalePlan, { type: "apply_on_next_turn" }); + assert.equal(flavor.resolveSpawnOptionValue?.(stale, "reasoningEffort"), "high"); + + const futurePlan = yield* flavor.planSelectionTransition!({ + current, + target: future, + sessionCapabilities: GrokProviderCapabilitiesV2, + }); + assert.equal(futurePlan.type, "reject"); + assert.equal(flavor.resolveSpawnOptionValue?.(future, "reasoningEffort"), "turbo_v2"); + }), + ); + it("terminalizes only foreground tools under the actual Grok flavor", () => { const flavor = makeGrokAcpAdapterFlavor({ makeRuntime: () => Effect.never, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index 163bb325819..e69580df73a 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -2,6 +2,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { defaultInstanceIdForDriver, GrokSettings, + type ModelCapabilities, ProviderDriverKind, type OrchestrationV2ProviderCapabilities, } from "@t3tools/contracts"; @@ -17,8 +18,12 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import { ServerConfig } from "../../config.ts"; import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; import { + GROK_REASONING_EFFORT_OPTION_ID, + grokReasoningEffortConstraintsFromCapabilities, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, + resolveGrokReasoningEffortForSpawn, + resolveGrokSpawnOptionValue, } from "../../provider/acp/GrokAcpSupport.ts"; import { extractXAiAcpBackgroundToolMutation, @@ -42,6 +47,7 @@ import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers import { IdAllocatorV2 } from "../IdAllocator.ts"; import { ProviderContinuationRequests } from "../ProviderContinuationRequests.ts"; import { ProviderAdapterV2 } from "../ProviderAdapter.ts"; +import { acpSelectionTransition } from "../ProviderSelectionTransition.ts"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, @@ -98,6 +104,9 @@ export interface GrokAdapterV2Options { readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocatorV2["Service"]; readonly serverConfig: ServerConfig["Service"]; + readonly getModelCapabilities?: ( + model: string, + ) => Effect.Effect; readonly nativeLogging?: Parameters[0]["nativeLogging"]; readonly continuationRequests?: Parameters[0]["continuationRequests"]; readonly makeRuntime?: ( @@ -154,6 +163,71 @@ const registerGrokAskUserQuestionExtensions = ({ ); export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor { + const reasoningEffortConstraintsByModel = new Map< + string, + ReturnType + >(); + const refreshReasoningEffortConstraints = ( + selection: Parameters[0], + ) => { + if (options.getModelCapabilities === undefined || selection == null) { + return Effect.void; + } + const modelId = resolveGrokAcpBaseModelId(selection.model); + return options.getModelCapabilities(modelId).pipe( + Effect.tap((capabilities) => + capabilities === undefined + ? Effect.void + : Effect.sync(() => { + reasoningEffortConstraintsByModel.set( + modelId, + grokReasoningEffortConstraintsFromCapabilities(capabilities), + ); + }), + ), + Effect.asVoid, + ); + }; + const refreshTransitionReasoningEffortConstraints = ( + input: Parameters>[0], + ) => + Effect.all( + [ + refreshReasoningEffortConstraints(input.current), + refreshReasoningEffortConstraints(input.target), + ], + { discard: true }, + ); + const reasoningEffortConstraints = ( + selection: Parameters[0], + ) => { + if (selection == null) { + return undefined; + } + const modelId = resolveGrokAcpBaseModelId(selection.model); + return reasoningEffortConstraintsByModel.has(modelId) + ? reasoningEffortConstraintsByModel.get(modelId) + : undefined; + }; + const resolveSpawnOptionValue: NonNullable = ( + selection, + optionId, + ) => resolveGrokSpawnOptionValue(selection, optionId, reasoningEffortConstraints(selection)); + const makeRuntime = + options.makeRuntime ?? + ((input: AcpAdapterV2RuntimeInput) => + makeGrokAcpRuntime({ + ...input, + interruptPromptOnCancel: input.interruptPromptOnCancel ?? false, + grokSettings: options.settings, + environment: options.environment, + childProcessSpawner: options.childProcessSpawner, + reasoningEffort: resolveGrokReasoningEffortForSpawn( + input.modelSelection, + reasoningEffortConstraints(input.modelSelection), + ), + })); + return { driver: GROK_PROVIDER, capabilities: GrokProviderCapabilitiesV2, @@ -179,16 +253,24 @@ export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdap // still accepts image content blocks (verified with real screenshots). supportsImagePrompts: true, resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model), - makeRuntime: - options.makeRuntime ?? - ((input) => - makeGrokAcpRuntime({ - ...input, - interruptPromptOnCancel: input.interruptPromptOnCancel ?? false, - grokSettings: options.settings, - environment: options.environment, - childProcessSpawner: options.childProcessSpawner, - })), + // Grok ACP does not implement session/set_config_option; effort is only + // honored as an agent spawn flag (probe: grok 0.2.117, 2026-07-31). + spawnOptionIds: [GROK_REASONING_EFFORT_OPTION_ID], + resolveSpawnOptionValue, + planSelectionTransition: (input) => + refreshTransitionReasoningEffortConstraints(input).pipe( + Effect.map(() => + acpSelectionTransition({ + ...input, + spawnOptionIds: [GROK_REASONING_EFFORT_OPTION_ID], + resolveSpawnOptionValue, + }), + ), + ), + makeRuntime: (input) => + refreshReasoningEffortConstraints(input.modelSelection).pipe( + Effect.flatMap(() => makeRuntime(input)), + ), registerExtensions: registerGrokAcpExtensions, extractSubagentUpdate: extractXAiAcpSubagentUpdate, extractSubagentEndNotice: extractXAiAcpSubagentEndNotice, @@ -254,6 +336,9 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver makeNativeLogger({ diff --git a/apps/server/src/orchestration-v2/ProviderAdapterDriver.ts b/apps/server/src/orchestration-v2/ProviderAdapterDriver.ts index 6534386c274..21f790da7c5 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterDriver.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterDriver.ts @@ -1,4 +1,5 @@ import { + type ModelCapabilities, ProviderDriverKind, ProviderInstanceId, type ProviderInstanceEnvironment, @@ -30,6 +31,9 @@ export interface ProviderAdapterDriverCreateInput { readonly environment: ProviderInstanceEnvironment; readonly enabled: boolean; readonly config: Config; + readonly getModelCapabilities?: ( + model: string, + ) => Effect.Effect; } export interface ProviderAdapterDriver { diff --git a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts b/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts index 2e1911bceb8..c78c475722c 100644 --- a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts @@ -7,6 +7,10 @@ import { } from "@t3tools/contracts"; import { AcpProviderCapabilitiesV2 } from "./Adapters/AcpAdapterV2.ts"; +import { + GROK_REASONING_EFFORT_OPTION_ID, + resolveGrokSpawnOptionValue, +} from "../provider/acp/GrokAcpSupport.ts"; import { acpSelectionTransition } from "./ProviderSelectionTransition.ts"; const selection = (model: string, effort = "medium"): ModelSelection => ({ @@ -52,4 +56,166 @@ describe("acpSelectionTransition", () => { }), ).toEqual({ type: "apply_on_next_turn" }); }); + + it("rejects a changed spawn-bound option on an active session", () => { + expect( + acpSelectionTransition({ + current: selection("same", "low"), + target: selection("same", "high"), + sessionCapabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: ["effort"], + }), + ).toEqual({ + type: "reject", + reason: 'The active ACP session cannot change spawn-bound option "effort" after start.', + }); + }); + + it("rejects presence/absence changes for spawn-bound options", () => { + const withEffort: ModelSelection = selection("same", "low"); + const withoutEffort: ModelSelection = { + instanceId: ProviderInstanceId.make("acp_test"), + model: "same", + }; + expect( + acpSelectionTransition({ + current: withEffort, + target: withoutEffort, + sessionCapabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: ["effort"], + }).type, + ).toBe("reject"); + expect( + acpSelectionTransition({ + current: withoutEffort, + target: withEffort, + sessionCapabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: ["effort"], + }).type, + ).toBe("reject"); + }); + + it("allows semantically equal absent and explicit provider defaults", () => { + const withoutEffort: ModelSelection = { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + }; + const withEffort = (effort: string): ModelSelection => ({ + ...withoutEffort, + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: effort }], + }); + + expect( + acpSelectionTransition({ + current: withoutEffort, + target: withEffort("high"), + sessionCapabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: [GROK_REASONING_EFFORT_OPTION_ID], + resolveSpawnOptionValue: resolveGrokSpawnOptionValue, + }), + ).toEqual({ type: "apply_on_next_turn" }); + expect( + acpSelectionTransition({ + current: withoutEffort, + target: withEffort("low"), + sessionCapabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: [GROK_REASONING_EFFORT_OPTION_ID], + resolveSpawnOptionValue: resolveGrokSpawnOptionValue, + }).type, + ).toBe("reject"); + }); + + it("allows unchanged spawn-bound options with normal ACP planning", () => { + expect( + acpSelectionTransition({ + current: selection("same", "low"), + target: selection("same", "low"), + sessionCapabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: ["effort"], + }), + ).toEqual({ type: "apply_on_next_turn" }); + }); + + it("classifies model changes independently from model-specific spawn defaults", () => { + const sessionCapabilities: OrchestrationV2ProviderCapabilities = { + ...AcpProviderCapabilitiesV2, + sessions: { + ...AcpProviderCapabilitiesV2.sessions, + supportsModelSwitchInSession: true, + }, + }; + expect( + acpSelectionTransition({ + current: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + }, + target: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + sessionCapabilities, + spawnOptionIds: [GROK_REASONING_EFFORT_OPTION_ID], + resolveSpawnOptionValue: resolveGrokSpawnOptionValue, + }), + ).toEqual({ type: "apply_on_next_turn" }); + }); + + it("rejects model changes that explicitly change a spawn-bound option", () => { + const sessionCapabilities: OrchestrationV2ProviderCapabilities = { + ...AcpProviderCapabilitiesV2, + sessions: { + ...AcpProviderCapabilitiesV2.sessions, + supportsModelSwitchInSession: true, + }, + }; + expect( + acpSelectionTransition({ + current: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: "high" }], + }, + target: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: "low" }], + }, + sessionCapabilities, + spawnOptionIds: [GROK_REASONING_EFFORT_OPTION_ID], + resolveSpawnOptionValue: resolveGrokSpawnOptionValue, + }), + ).toEqual({ + type: "reject", + reason: + 'The active ACP session cannot change spawn-bound option "reasoningEffort" after start.', + }); + }); + + it("allows mutable apply_on_next_turn config when spawn-bound options are unchanged", () => { + const current: ModelSelection = { + instanceId: ProviderInstanceId.make("acp_test"), + model: "same", + options: [ + { id: "effort", value: "low" }, + { id: "model", value: "default" }, + ], + }; + const target: ModelSelection = { + instanceId: ProviderInstanceId.make("acp_test"), + model: "same", + options: [ + { id: "effort", value: "low" }, + { id: "model", value: "composer-2" }, + ], + }; + expect( + acpSelectionTransition({ + current, + target, + sessionCapabilities: AcpProviderCapabilitiesV2, + spawnOptionIds: ["effort"], + }), + ).toEqual({ type: "apply_on_next_turn" }); + }); }); diff --git a/apps/server/src/orchestration-v2/ProviderSelectionTransition.ts b/apps/server/src/orchestration-v2/ProviderSelectionTransition.ts index d4ca82d314a..ef461b6ab4a 100644 --- a/apps/server/src/orchestration-v2/ProviderSelectionTransition.ts +++ b/apps/server/src/orchestration-v2/ProviderSelectionTransition.ts @@ -1,11 +1,25 @@ import type { ModelSelection, OrchestrationV2ProviderCapabilities } from "@t3tools/contracts"; +import { getModelSelectionOptionValue } from "@t3tools/shared/model"; export interface ProviderSelectionTransitionInput { readonly current: ModelSelection; readonly target: ModelSelection; readonly sessionCapabilities: OrchestrationV2ProviderCapabilities; + /** + * Option ids bound at process spawn (e.g. Grok reasoning effort). When set, + * the orchestrator consumes this provider classification before metadata or + * dispatch. Adapter-level pinning also protects direct adapter callers. + */ + readonly spawnOptionIds?: ReadonlyArray; + /** Resolve provider defaults so absent and explicit equivalent values match. */ + readonly resolveSpawnOptionValue?: SpawnOptionValueResolver; } +export type SpawnOptionValueResolver = ( + selection: ModelSelection, + optionId: string, +) => string | boolean | undefined; + /** * Provider-owned classification of how a complete selection can be applied. * The orchestrator remains responsible for attempts and resource lifecycle. @@ -33,5 +47,24 @@ export function acpSelectionTransition( reason: "The active ACP session does not expose a model-switch capability.", }; } + + const spawnOptionIds = input.spawnOptionIds ?? []; + const resolveSpawnOptionValue = input.resolveSpawnOptionValue ?? getModelSelectionOptionValue; + for (const optionId of spawnOptionIds) { + const targetExplicitlySelectsOption = + getModelSelectionOptionValue(input.target, optionId) !== undefined; + if (input.current.model !== input.target.model && !targetExplicitlySelectsOption) { + continue; + } + const currentValue = resolveSpawnOptionValue(input.current, optionId); + const targetValue = resolveSpawnOptionValue(input.target, optionId); + if (currentValue !== targetValue) { + return { + type: "reject", + reason: `The active ACP session cannot change spawn-bound option "${optionId}" after start.`, + }; + } + } + return { type: "apply_on_next_turn" }; } diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts index 35bc1b80362..03d4be92345 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts @@ -9,12 +9,21 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { AcpProviderCapabilitiesV2 } from "./Adapters/AcpAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { + GROK_REASONING_EFFORT_OPTION_ID, + resolveGrokSpawnOptionValue, +} from "../provider/acp/GrokAcpSupport.ts"; import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import { acpSelectionTransition } from "./ProviderSelectionTransition.ts"; import * as ProviderSwitch from "./ProviderSwitchService.ts"; +const isProviderSwitchPlanError = Schema.is(ProviderSwitch.ProviderSwitchPlanError); + const driver = ProviderDriverKind.make("codex"); const currentInstanceId = ProviderInstanceId.make("codex_primary"); const currentSessionId = ProviderSessionId.make("session_primary"); @@ -26,6 +35,13 @@ const capabilitiesWithoutModelSwitch = { supportsModelSwitchInSession: false, }, }; +const grokCapabilitiesWithModelSwitch = { + ...AcpProviderCapabilitiesV2, + sessions: { + ...AcpProviderCapabilitiesV2.sessions, + supportsModelSwitchInSession: true, + }, +}; function projection(): OrchestrationV2ThreadProjection { return { @@ -83,6 +99,51 @@ function testLayer(metadata: Readonly Effect.succeed(grokCapabilitiesWithModelSwitch), + planSelectionTransition: (input) => + Effect.succeed( + acpSelectionTransition({ + ...input, + spawnOptionIds: [GROK_REASONING_EFFORT_OPTION_ID], + resolveSpawnOptionValue: resolveGrokSpawnOptionValue, + }), + ), + openSession: () => Effect.die("ProviderSwitchService tests do not open sessions."), + }; + const registry = Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + get: () => Effect.succeed(adapter), + list: () => Effect.succeed([currentInstanceId]), + getMetadata: () => + Effect.succeed({ + driver: grokDriver, + continuationKey: "grok:account:primary", + enabled: true, + capabilities: grokCapabilitiesWithModelSwitch, + }), + }); + return ProviderSwitch.layer.pipe(Layer.provide(registry)); +} + +function grokProjection(): OrchestrationV2ThreadProjection { + const current = projection(); + return { + ...current, + thread: { + ...current.thread, + modelSelection: { instanceId: currentInstanceId, model: "grok-4.5" }, + }, + providerSessions: current.providerSessions.map((session) => ({ + ...session, + capabilities: grokCapabilitiesWithModelSwitch, + })), + }; +} + it.effect( "restarts and releases the current session for unsupported in-session model changes", () => @@ -126,3 +187,39 @@ it.effect("distinguishes compatible and incompatible instances of the same drive ), ), ); + +it.effect("plans Grok spawn-bound option changes through the live service seam", () => + Effect.gen(function* () { + const service = yield* ProviderSwitch.ProviderSwitchServiceV2; + const explicitHigh = yield* service.plan({ + projection: grokProjection(), + targetModelSelection: { + instanceId: currentInstanceId, + model: "grok-4.5", + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: "high" }], + }, + }); + assert.equal(explicitHigh.transition.type, "switch_model_in_session"); + + const changedEffort = yield* Effect.flip( + service.plan({ + projection: grokProjection(), + targetModelSelection: { + instanceId: currentInstanceId, + model: "grok-4.5", + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: "low" }], + }, + }), + ); + assert.isTrue(isProviderSwitchPlanError(changedEffort.cause)); + if (isProviderSwitchPlanError(changedEffort.cause)) { + assert.include(String(changedEffort.cause.cause), "spawn-bound option"); + } + + const changedModel = yield* service.plan({ + projection: grokProjection(), + targetModelSelection: { instanceId: currentInstanceId, model: "grok-build" }, + }); + assert.equal(changedModel.transition.type, "switch_model_in_session"); + }).pipe(Effect.provide(grokTestLayer())), +); diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 0a15de1715d..175a7d38b9d 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -9,6 +9,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; import { @@ -107,24 +108,6 @@ export const GrokDriver: ProviderDriver = { env: processEnv, }); - const orchestrationAdapter = yield* GrokAdapterV2Driver.create({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Grok orchestration adapter.", - cause, - }), - ), - ); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( @@ -162,6 +145,37 @@ export const GrokDriver: ProviderDriver = { ), ); + const orchestrationAdapter = yield* GrokAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + getModelCapabilities: (model) => + snapshot.getSnapshot.pipe( + Effect.map((current) => { + if (current.status !== "ready") { + return undefined; + } + const modelId = resolveGrokAcpBaseModelId(model); + return current.models.find( + (candidate) => resolveGrokAcpBaseModelId(candidate.slug) === modelId, + )?.capabilities; + }), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Grok orchestration adapter.", + cause, + }), + ), + ); + return { instanceId, driverKind: DRIVER_KIND, diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9..bf57422178e 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,10 +6,35 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { GrokSettings } from "@t3tools/contracts"; -import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; +import { + buildGrokDiscoveredModelsFromSessionModelState, + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, + grokModelCapabilitiesFromReasoningEffortMenu, + grokReasoningEffortMenuFromAcpMeta, +} from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); +/** Shape observed live from `grok agent stdio` `session/new` (grok 0.2.117). */ +const GROK_4_5_ACP_META = { + totalContextTokens: 500000, + agentType: "grok-build-plan", + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [ + { + id: "high", + value: "high", + label: "High Effort", + description: "Highest implementation quality with extensive reasoning", + default: true, + }, + { id: "medium", value: "medium", label: "Medium Effort", default: false }, + { id: "low", value: "low", label: "Low Effort", default: false }, + ], +}; + describe("buildInitialGrokProviderSnapshot", () => { it.effect("returns a disabled snapshot when settings.enabled is false", () => Effect.gen(function* () { @@ -36,6 +61,211 @@ describe("buildInitialGrokProviderSnapshot", () => { ); }); +describe("grokReasoningEffortMenuFromAcpMeta", () => { + it("maps the advertised menu with labels, descriptions, and the default", () => { + const menu = grokReasoningEffortMenuFromAcpMeta(GROK_4_5_ACP_META); + expect(menu).toEqual({ + entries: [ + { + id: "high", + label: "High", + description: "Highest implementation quality with extensive reasoning", + isDefault: true, + }, + { id: "medium", label: "Medium", isDefault: false }, + { id: "low", label: "Low", isDefault: false }, + ], + currentValue: "high", + }); + }); + + it("prefers the advertised current effort over the menu default", () => { + const menu = grokReasoningEffortMenuFromAcpMeta({ + ...GROK_4_5_ACP_META, + reasoningEffort: "low", + }); + expect(menu?.currentValue).toBe("low"); + }); + + it("falls back to the default entry when the current effort is unknown", () => { + const menu = grokReasoningEffortMenuFromAcpMeta({ + ...GROK_4_5_ACP_META, + reasoningEffort: "turbo", + }); + expect(menu?.currentValue).toBe("high"); + }); + + it("prefers the advertised spawn value over the menu id", () => { + const menu = grokReasoningEffortMenuFromAcpMeta({ + supportsReasoningEffort: true, + reasoningEfforts: [{ id: "menu-high", value: "high", label: "High Effort", default: true }], + }); + expect(menu?.entries[0]?.id).toBe("high"); + }); + + it("falls back to a valid menu id when the advertised spawn value is malformed", () => { + const menu = grokReasoningEffortMenuFromAcpMeta({ + supportsReasoningEffort: true, + reasoningEfforts: [ + { id: "high", value: 2, label: "High Effort", default: true }, + { id: "low", value: "", label: "Low Effort" }, + ], + }); + expect(menu?.entries.map((entry) => entry.id)).toEqual(["high", "low"]); + }); + + it("returns undefined when meta carries no effort information", () => { + expect(grokReasoningEffortMenuFromAcpMeta(undefined)).toBeUndefined(); + expect(grokReasoningEffortMenuFromAcpMeta(null)).toBeUndefined(); + expect(grokReasoningEffortMenuFromAcpMeta({ totalContextTokens: 1 })).toBeUndefined(); + expect(grokReasoningEffortMenuFromAcpMeta({ supportsReasoningEffort: true })).toBeUndefined(); + }); + + it("returns null when the agent explicitly reports no usable menu", () => { + expect(grokReasoningEffortMenuFromAcpMeta({ supportsReasoningEffort: false })).toBeNull(); + expect( + grokReasoningEffortMenuFromAcpMeta({ supportsReasoningEffort: true, reasoningEfforts: [] }), + ).toBeNull(); + expect( + grokReasoningEffortMenuFromAcpMeta({ + supportsReasoningEffort: true, + reasoningEfforts: [{ label: "No id" }], + }), + ).toBeNull(); + expect( + grokReasoningEffortMenuFromAcpMeta({ + supportsReasoningEffort: true, + reasoningEfforts: { high: true }, + }), + ).toBeNull(); + expect( + grokReasoningEffortMenuFromAcpMeta({ + supportsReasoningEffort: true, + reasoningEfforts: "high", + }), + ).toBeNull(); + }); + + it("excludes malformed effort tokens that spawn would drop", () => { + const menu = grokReasoningEffortMenuFromAcpMeta({ + supportsReasoningEffort: true, + reasoningEfforts: [ + { id: "high", value: "high", label: "High Effort", default: true }, + { id: "bad space", value: "not a token", label: "Bad" }, + { id: "dash", value: "-leading-dash", label: "Dash" }, + { id: "long", value: "x".repeat(33), label: "Long" }, + { id: "future", value: "turbo_v2", label: "Turbo V2 Effort" }, + ], + }); + expect(menu?.entries.map((entry) => entry.id)).toEqual(["high", "turbo_v2"]); + expect(menu?.entries.find((entry) => entry.id === "turbo_v2")?.label).toBe("Turbo V2"); + }); + + it("keeps prototype-key effort ids as string labels via the fallback path", () => { + // A plain-object label lookup of "constructor" would yield a function and + // break the provider snapshot string schema; Map lookup falls through to + // the raw-label / id string path. + const menu = grokReasoningEffortMenuFromAcpMeta({ + supportsReasoningEffort: true, + reasoningEfforts: [ + { id: "constructor", value: "constructor", label: "Constructor Effort", default: true }, + { id: "toString", value: "toString", label: "ToString Effort" }, + ], + }); + expect(menu?.entries).toEqual([ + { id: "constructor", label: "Constructor", isDefault: true }, + { id: "toString", label: "ToString", isDefault: false }, + ]); + for (const entry of menu?.entries ?? []) { + expect(typeof entry.label).toBe("string"); + } + }); +}); + +describe("grokModelCapabilitiesFromReasoningEffortMenu", () => { + it("emits a Reasoning select descriptor for an advertised menu", () => { + const menu = grokReasoningEffortMenuFromAcpMeta(GROK_4_5_ACP_META); + const capabilities = grokModelCapabilitiesFromReasoningEffortMenu(menu); + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { + id: "high", + label: "High", + description: "Highest implementation quality with extensive reasoning", + isDefault: true, + }, + { id: "medium", label: "Medium" }, + { id: "low", label: "Low" }, + ], + currentValue: "high", + }, + ]); + }); + + it("returns empty descriptors without a menu", () => { + expect(grokModelCapabilitiesFromReasoningEffortMenu(undefined).optionDescriptors).toEqual([]); + }); +}); + +describe("buildGrokDiscoveredModelsFromSessionModelState", () => { + it("advertises the reasoning menu from model _meta", () => { + const models = buildGrokDiscoveredModelsFromSessionModelState({ + currentModelId: "grok-4.5", + availableModels: [ + { modelId: "grok-4.5", name: "Grok 4.5", _meta: GROK_4_5_ACP_META }, + { modelId: "grok-lite", name: "Grok Lite" }, + ], + }); + expect(models.map((model) => model.slug)).toEqual(["grok-4.5", "grok-lite"]); + expect(models[0]?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id)).toEqual([ + "reasoningEffort", + ]); + expect(models[1]?.capabilities?.optionDescriptors).toEqual([]); + }); + + it("falls back to the known grok-4.5 menu when _meta omits the efforts", () => { + const models = buildGrokDiscoveredModelsFromSessionModelState({ + currentModelId: "grok-4.5", + availableModels: [{ modelId: "grok-4.5", name: "Grok 4.5" }], + }); + const descriptor = models[0]?.capabilities?.optionDescriptors?.[0]; + expect(descriptor).toMatchObject({ id: "reasoningEffort", currentValue: "high" }); + expect( + descriptor?.type === "select" ? descriptor.options.map((option) => option.id) : [], + ).toEqual(["high", "medium", "low"]); + }); + + it("does not resolve prototype-inherited keys as fallback menus", () => { + // A Record lookup of "constructor" would yield Object.prototype.constructor; + // Map / Object.hasOwn must keep prototype keys out of the fallback catalog. + const models = buildGrokDiscoveredModelsFromSessionModelState({ + currentModelId: "constructor", + availableModels: [ + { modelId: "constructor", name: "Constructor" }, + { modelId: "toString", name: "ToString" }, + { modelId: "__proto__", name: "Proto" }, + ], + }); + for (const model of models) { + expect(model.capabilities?.optionDescriptors ?? []).toEqual([]); + } + }); + + it("suppresses the fallback when _meta explicitly reports no support", () => { + const models = buildGrokDiscoveredModelsFromSessionModelState({ + currentModelId: "grok-4.5", + availableModels: [ + { modelId: "grok-4.5", name: "Grok 4.5", _meta: { supportsReasoningEffort: false } }, + ], + }); + expect(models[0]?.capabilities?.optionDescriptors).toEqual([]); + }); +}); + it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { it.effect("reports the binary as missing when the binary path does not resolve", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 5280a8b61ba..99fcc98c8ae 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -29,7 +29,13 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { + GROK_FALLBACK_REASONING_EFFORTS_BY_MODEL, + GROK_REASONING_EFFORT_OPTION_ID, + isValidGrokReasoningEffortToken, + makeGrokAcpRuntime, + resolveGrokAcpBaseModelId, +} from "../acp/GrokAcpSupport.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -98,7 +104,159 @@ function grokModelsFromSettings( return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } -function buildGrokDiscoveredModelsFromSessionModelState( +interface GrokReasoningEffortMenuEntry { + readonly id: string; + readonly label: string; + readonly description?: string; + readonly isDefault: boolean; +} + +interface GrokReasoningEffortMenu { + readonly entries: ReadonlyArray; + readonly currentValue: string | undefined; +} + +/** + * Same list-item shape as the other providers' reasoning menus. Map lookup so + * an advertised id such as `constructor` cannot resolve Object.prototype and + * produce a non-string label. + */ +const GROK_REASONING_EFFORT_LABELS = new Map([ + ["none", "None"], + ["minimal", "Minimal"], + ["low", "Low"], + ["medium", "Medium"], + ["high", "High"], + ["xhigh", "Extra High"], + ["max", "Max"], +]); + +/** + * Grok's menu labels read "High Effort" / "Low Effort"; the other providers' + * reasoning menus list bare levels under the same "Reasoning" header, so + * prefer the canonical label and otherwise drop a trailing "Effort" word. + */ +function grokReasoningEffortLabel(id: string, rawLabel: string): string { + const canonical = GROK_REASONING_EFFORT_LABELS.get(id); + if (canonical !== undefined) { + return canonical; + } + return rawLabel.replace(/\s+effort$/i, "").trim() || rawLabel || id; +} + +/** Known menus used when ACP omits effort metadata for a supported model. */ +const GROK_FALLBACK_REASONING_EFFORT_MENUS = new Map( + Array.from(GROK_FALLBACK_REASONING_EFFORTS_BY_MODEL, ([model, fallback]) => [ + model, + { + entries: fallback.values.map((id) => ({ + id, + label: grokReasoningEffortLabel(id, id), + isDefault: id === fallback.defaultValue, + })), + currentValue: fallback.defaultValue, + }, + ]), +); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Grok advertises reasoning effort on ACP model `_meta` (verified against + * grok 0.2.117): + * + * ```json + * { + * "supportsReasoningEffort": true, + * "reasoningEffort": "high", + * "reasoningEfforts": [ + * { "id": "high", "value": "high", "label": "High Effort", + * "description": "...", "default": true } + * ] + * } + * ``` + * + * Returns `undefined` when `_meta` carries no effort information at all (the + * fallback catalog may apply), and `null` when the agent explicitly reports no + * usable menu (`supportsReasoningEffort: false`, or an advertised menu with no + * valid entries), which suppresses the fallback. + */ +export function grokReasoningEffortMenuFromAcpMeta( + meta: unknown, +): GrokReasoningEffortMenu | null | undefined { + if (!isRecord(meta)) { + return undefined; + } + if (meta["supportsReasoningEffort"] === false) { + return null; + } + const rawEfforts = meta["reasoningEfforts"]; + if (!Array.isArray(rawEfforts)) { + return rawEfforts === undefined ? undefined : null; + } + const seen = new Set(); + const entries: Array = []; + for (const rawEntry of rawEfforts) { + if (!isRecord(rawEntry)) continue; + // The flag value is what the spawn consumes; prefer it over the menu id. + // Reject tokens the spawn guard would later drop so discovery and spawn + // stay aligned without a fixed known-level catalog. + const rawValue = typeof rawEntry["value"] === "string" ? rawEntry["value"].trim() : ""; + const rawId = typeof rawEntry["id"] === "string" ? rawEntry["id"].trim() : ""; + const id = rawValue || rawId; + if (!id || !isValidGrokReasoningEffortToken(id) || seen.has(id)) continue; + seen.add(id); + const label = typeof rawEntry["label"] === "string" ? rawEntry["label"].trim() : ""; + const description = + typeof rawEntry["description"] === "string" ? rawEntry["description"].trim() : ""; + entries.push({ + id, + label: grokReasoningEffortLabel(id, label || id), + ...(description ? { description } : {}), + isDefault: rawEntry["default"] === true || rawEntry["isDefault"] === true, + }); + } + if (entries.length === 0) { + return null; + } + const rawCurrent = meta["reasoningEffort"]; + const current = + typeof rawCurrent === "string" && entries.some((entry) => entry.id === rawCurrent.trim()) + ? rawCurrent.trim() + : undefined; + return { + entries, + currentValue: current ?? entries.find((entry) => entry.isDefault)?.id, + }; +} + +export function grokModelCapabilitiesFromReasoningEffortMenu( + menu: GrokReasoningEffortMenu | null | undefined, +): ModelCapabilities { + if (!menu || menu.entries.length === 0) { + return EMPTY_CAPABILITIES; + } + return createModelCapabilities({ + optionDescriptors: [ + { + id: GROK_REASONING_EFFORT_OPTION_ID, + label: "Reasoning", + type: "select", + options: menu.entries.map((entry) => ({ + id: entry.id, + label: entry.label, + ...(entry.description ? { description: entry.description } : {}), + ...(entry.isDefault ? { isDefault: true } : {}), + })), + ...(menu.currentValue ? { currentValue: menu.currentValue } : {}), + }, + ], + }); +} + +export function buildGrokDiscoveredModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, ): ReadonlyArray { if (!modelState || modelState.availableModels.length === 0) { @@ -112,11 +270,15 @@ function buildGrokDiscoveredModelsFromSessionModelState( return undefined; } seen.add(slug); + const parsedMenu = grokReasoningEffortMenuFromAcpMeta(model._meta); + // null is an explicit "no usable menu" signal and must not fall back. + const effortMenu = + parsedMenu === undefined ? GROK_FALLBACK_REASONING_EFFORT_MENUS.get(slug) : parsedMenu; return { slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: grokModelCapabilitiesFromReasoningEffortMenu(effortMenu), }; }) .filter((model): model is ServerProviderModel => model !== undefined); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index fb9c85cc4e8..e80ed9c349c 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -2,11 +2,17 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as EffectAcpErrors from "effect-acp/errors"; +import { ProviderInstanceId } from "@t3tools/contracts"; + import { applyGrokAcpModelSelection, buildGrokAcpSpawnInput, grokAcpRuntimeProcessOwnership, + grokReasoningEffortConstraintsFromCapabilities, + isValidGrokReasoningEffortToken, resolveGrokAcpBaseModelId, + resolveGrokReasoningEffortForSpawn, + resolveGrokSpawnOptionValue, } from "./GrokAcpSupport.ts"; describe("grokAcpRuntimeProcessOwnership", () => { @@ -57,6 +63,190 @@ describe("buildGrokAcpSpawnInput", () => { }, }); }); + + it("passes the reasoning effort as an agent spawn flag when selected", () => { + const spawn = buildGrokAcpSpawnInput(null, "/tmp/project", {}, "low"); + + expect(spawn.command).toBe("grok"); + expect(spawn.args).toEqual(["agent", "--reasoning-effort", "low", "stdio"]); + }); + + it("omits the reasoning effort flag when no effort is selected", () => { + const spawn = buildGrokAcpSpawnInput(null, "/tmp/project", {}, undefined); + + expect(spawn.args).toEqual(["agent", "stdio"]); + }); +}); + +describe("isValidGrokReasoningEffortToken", () => { + it("accepts CLI-safe current and future token shapes", () => { + for (const token of ["low", "medium", "high", "xhigh", "turbo_v2", "max.2", "A1"]) { + expect(isValidGrokReasoningEffortToken(token)).toBe(true); + } + }); + + it("rejects empty, spaced, leading-dash, and overlong tokens", () => { + for (const token of ["", "not a token", "-leading-dash", "x".repeat(33)]) { + expect(isValidGrokReasoningEffortToken(token)).toBe(false); + } + }); +}); + +describe("resolveGrokReasoningEffortForSpawn", () => { + const instanceId = ProviderInstanceId.make("grok"); + + it("returns the selected effort for canonical levels", () => { + for (const effort of ["low", "medium", "high"]) { + expect( + resolveGrokReasoningEffortForSpawn({ + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: effort }], + }), + ).toBe(effort); + } + }); + + it("passes through well-formed menu ids it does not recognize", () => { + // The agent clamps levels outside the model's menu to the model default + // itself, and future menus may advertise new ids. + expect( + resolveGrokReasoningEffortForSpawn({ + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }), + ).toBe("xhigh"); + expect( + resolveGrokReasoningEffortForSpawn({ + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "turbo_v2" }], + }), + ).toBe("turbo_v2"); + }); + + it("uses the advertised menu to accept future values and normalize stale ones", () => { + const constraints = grokReasoningEffortConstraintsFromCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "turbo_v2", label: "Turbo V2" }, + ], + currentValue: "high", + }, + ], + }); + expect(constraints).toEqual({ values: ["high", "turbo_v2"], defaultValue: "high" }); + expect( + resolveGrokReasoningEffortForSpawn( + { + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "turbo_v2" }], + }, + constraints, + ), + ).toBe("turbo_v2"); + expect( + resolveGrokReasoningEffortForSpawn( + { + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "ultra" }], + }, + constraints, + ), + ).toBe("high"); + }); + + it("drops effort when the discovered model advertises no menu", () => { + expect( + resolveGrokReasoningEffortForSpawn( + { + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "high" }], + }, + null, + ), + ).toBeUndefined(); + }); + + it("drops malformed or non-string stored efforts", () => { + for (const value of ["not a token", "-leading-dash", "x".repeat(33), "", " "]) { + expect( + resolveGrokReasoningEffortForSpawn({ + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value }], + }), + ).toBeUndefined(); + } + expect( + resolveGrokReasoningEffortForSpawn({ + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: true }], + }), + ).toBeUndefined(); + }); + + it("returns undefined when the selection has no effort option", () => { + expect(resolveGrokReasoningEffortForSpawn(undefined)).toBeUndefined(); + expect(resolveGrokReasoningEffortForSpawn({ instanceId, model: "grok-4.5" })).toBeUndefined(); + expect( + resolveGrokReasoningEffortForSpawn({ + instanceId, + model: "grok-4.5", + options: [{ id: "serviceTier", value: "fast" }], + }), + ).toBeUndefined(); + }); +}); + +describe("resolveGrokSpawnOptionValue", () => { + const instanceId = ProviderInstanceId.make("grok"); + + it("treats absent Grok 4.5 effort as the verified High default", () => { + expect(resolveGrokSpawnOptionValue({ instanceId, model: "grok-4.5" }, "reasoningEffort")).toBe( + "high", + ); + expect( + resolveGrokSpawnOptionValue( + { + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "low" }], + }, + "reasoningEffort", + ), + ).toBe("low"); + }); + + it("does not invent a default for other Grok models", () => { + expect( + resolveGrokSpawnOptionValue({ instanceId, model: "grok-build" }, "reasoningEffort"), + ).toBeUndefined(); + }); + + it("uses the advertised default for unsupported stored values", () => { + expect( + resolveGrokSpawnOptionValue( + { + instanceId, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "ultra" }], + }, + "reasoningEffort", + { values: ["high", "turbo_v2"], defaultValue: "high" }, + ), + ).toBe("high"); + }); }); describe("applyGrokAcpModelSelection", () => { diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 0e0705164af..18401107a15 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,5 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { + type GrokSettings, + type ModelCapabilities, + type ModelSelection, + ProviderDriverKind, +} from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -8,7 +13,13 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { + getModelSelectionOptionValue, + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, + normalizeModelSlug, +} from "@t3tools/shared/model"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; @@ -20,6 +31,116 @@ const GROK_AUTH_METHOD_API_KEY = "xai.api_key"; const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token"; const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +export const GROK_REASONING_EFFORT_OPTION_ID = "reasoningEffort"; +export const GROK_FALLBACK_REASONING_EFFORTS_BY_MODEL: ReadonlyMap< + string, + { readonly values: ReadonlyArray; readonly defaultValue: string } +> = new Map([ + [ + "grok-4.5", + { + values: ["high", "medium", "low"], + defaultValue: "high", + }, + ], +]); + +/** + * Effort levels are advertised per model via ACP `_meta`, so the token guard + * is syntactic rather than a fixed catalog: a fixed set would silently drop a + * future menu-advertised level. Shared by discovery parsing and spawn so a + * menu entry cannot advertise a value the spawn later omits. The agent itself + * clamps levels outside the model's menu to the model default (verified + * against grok 0.2.117). + */ +const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; + +export interface GrokReasoningEffortConstraints { + readonly defaultValue: string | undefined; + readonly values: ReadonlyArray; +} + +/** CLI-safe effort token for discovery menus and `--reasoning-effort` spawn. */ +export function isValidGrokReasoningEffortToken(value: string): boolean { + return GROK_REASONING_EFFORT_TOKEN.test(value); +} + +/** Extract the authoritative effort menu advertised for one Grok model. */ +export function grokReasoningEffortConstraintsFromCapabilities( + capabilities: ModelCapabilities | null | undefined, +): GrokReasoningEffortConstraints | null { + if (capabilities == null) { + return null; + } + const descriptor = getProviderOptionDescriptors({ caps: capabilities }).find( + (candidate) => candidate.id === GROK_REASONING_EFFORT_OPTION_ID && candidate.type === "select", + ); + if (descriptor?.type !== "select") { + return null; + } + const values = descriptor.options + .map((option) => option.id) + .filter(isValidGrokReasoningEffortToken); + if (values.length === 0) { + return null; + } + const currentValue = getProviderOptionCurrentValue(descriptor); + return { + values, + defaultValue: + typeof currentValue === "string" && values.includes(currentValue) ? currentValue : undefined, + }; +} + +/** + * Grok ACP has no session/set_config_option (configOptions is null as of + * 0.2.x), so reasoning effort can only be applied via the agent spawn flag. + * Malformed stored values are dropped. Well-formed stale values normalize to + * the advertised model default when discovered constraints are available. + */ +export function resolveGrokReasoningEffortForSpawn( + modelSelection: ModelSelection | null | undefined, + constraints?: GrokReasoningEffortConstraints | null, +): string | undefined { + const effort = getModelSelectionStringOptionValue( + modelSelection, + GROK_REASONING_EFFORT_OPTION_ID, + )?.trim(); + if (!effort || !isValidGrokReasoningEffortToken(effort)) { + return undefined; + } + if (constraints === null) { + return undefined; + } + if (constraints === undefined) { + return effort; + } + return constraints.values.includes(effort) ? effort : constraints.defaultValue; +} + +/** Semantic spawn value used when comparing and tracking active Grok sessions. */ +export function resolveGrokSpawnOptionValue( + modelSelection: ModelSelection, + optionId: string, + constraints?: GrokReasoningEffortConstraints | null, +): string | boolean | undefined { + if (optionId !== GROK_REASONING_EFFORT_OPTION_ID) { + return getModelSelectionOptionValue(modelSelection, optionId); + } + if (constraints === null) { + return undefined; + } + return ( + resolveGrokReasoningEffortForSpawn(modelSelection, constraints) ?? + constraints?.defaultValue ?? + (constraints === undefined + ? GROK_FALLBACK_REASONING_EFFORTS_BY_MODEL.get( + resolveGrokAcpBaseModelId(modelSelection.model), + )?.defaultValue + : undefined) + ); +} + type GrokAcpRuntimeGrokSettings = Pick; interface GrokAcpRuntimeInput extends Omit< @@ -29,16 +150,21 @@ interface GrokAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly reasoningEffort?: string | undefined; } export function buildGrokAcpSpawnInput( grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + reasoningEffort?: string | undefined, ): AcpSessionRuntime.AcpSpawnInput { return { command: grokSettings?.binaryPath || "grok", - args: ["agent", "stdio"], + args: + reasoningEffort === undefined + ? ["agent", "stdio"] + : ["agent", "--reasoning-effort", reasoningEffort, "stdio"], cwd, env: { ...environment, @@ -82,7 +208,12 @@ export const makeGrokAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), + spawn: buildGrokAcpSpawnInput( + input.grokSettings, + input.cwd, + input.environment, + input.reasoningEffort, + ), authMethodId: resolveGrokAuthMethodId(input.environment), // Current Grok treats Ctrl+C cancellation as a barrier against stale // background-task wake prompts until the next genuine user turn. diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1cf3d13e225..4588b15f3ee 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -28,6 +28,7 @@ import { currentGrokModelIdFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, + resolveGrokReasoningEffortForSpawn, } from "../provider/acp/GrokAcpSupport.ts"; const GROK_TIMEOUT_MS = 180_000; @@ -67,6 +68,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + reasoningEffort: resolveGrokReasoningEffortForSpawn(modelSelection), }).pipe(Effect.provideService(Crypto.Crypto, crypto)); yield* runtime.handleSessionUpdate((notification) => { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 279b1381863..eb74146deb0 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -23,11 +23,17 @@ import { deriveComposerSendState, dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, + isStartedThreadOptionChangeBlocked, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveComposerDisplayModelOptions, + resolveDispatchedModelSelection, + resolveModelChangeRuntime, + resolveSessionLockedInstanceId, resolveThreadMetadataUpdateForNextTurn, + resolveTraitsOptionChangeBlocked, resolveSendEnvMode, startNewThreadForProject, shouldShowBranchMismatchBanner, @@ -305,6 +311,381 @@ describe("getStartedThreadModelChangeBlockReason", () => { }); }); +describe("resolveModelChangeRuntime", () => { + it("uses shell runtime while the detailed projection is loading", () => { + const shellRuntime = makeThread({ runtime: readySession }).runtime; + expect( + resolveModelChangeRuntime({ + projectedRuntime: null, + shellRuntime, + }), + ).toBe(shellRuntime); + }); + + it("keeps a failed first turn without a runtime unlocked", () => { + expect( + resolveModelChangeRuntime({ + projectedRuntime: null, + shellRuntime: null, + }), + ).toBeNull(); + }); +}); + +describe("isStartedThreadOptionChangeBlocked", () => { + const grokPrimary = ProviderInstanceId.make("grok"); + const grokSecondary = ProviderInstanceId.make("grok_work"); + const providers = [ + { + instanceId: ProviderInstanceId.make("codex"), + }, + { + instanceId: grokPrimary, + requiresNewThreadForModelChange: true, + }, + { + instanceId: grokSecondary, + requiresNewThreadForModelChange: true, + }, + ]; + + it("allows option changes before a provider session has started", () => { + expect( + isStartedThreadOptionChangeBlocked({ + providers, + lockedInstanceId: null, + instanceId: grokPrimary, + }), + ).toBe(false); + }); + + it("allows started-session option changes for unrestricted providers", () => { + expect( + isStartedThreadOptionChangeBlocked({ + providers, + lockedInstanceId: ProviderInstanceId.make("codex"), + instanceId: ProviderInstanceId.make("codex"), + }), + ).toBe(false); + }); + + it("allows option changes when composing for a provider other than the running one", () => { + expect( + isStartedThreadOptionChangeBlocked({ + providers, + lockedInstanceId: ProviderInstanceId.make("codex"), + instanceId: grokPrimary, + }), + ).toBe(false); + }); + + it("blocks started-session option changes for the active session-bound instance", () => { + expect( + isStartedThreadOptionChangeBlocked({ + providers, + lockedInstanceId: grokPrimary, + instanceId: grokPrimary, + }), + ).toBe(true); + }); + + it("allows options when switching between two Grok instances", () => { + // Active session is grok; composing for grok_work must keep that + // instance's options editable and independently dispatchable. + expect( + isStartedThreadOptionChangeBlocked({ + providers, + lockedInstanceId: grokPrimary, + instanceId: grokSecondary, + }), + ).toBe(false); + }); +}); + +describe("resolveDispatchedModelSelection", () => { + const grokInstance = ProviderInstanceId.make("grok"); + const committedAbsentOptions = { + instanceId: grokInstance, + model: "grok-4.5", + }; + const materializedDefaultHigh = { + instanceId: grokInstance, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "high" as const }], + }; + const handoffSelection = { + instanceId: ProviderInstanceId.make("grok_work"), + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "low" as const }], + }; + + it("dispatches the exact committed selection when locked with absent options", () => { + // Descriptor normalization would materialize default High for display; + // dispatch must not rewrite pre-feature threads that never stored options. + const dispatched = resolveDispatchedModelSelection({ + optionChangeBlocked: true, + committedModelSelection: committedAbsentOptions, + selectedInstanceId: grokInstance, + selectedModel: "grok-4.5", + selectedModelOptionsForDispatch: materializedDefaultHigh.options, + }); + expect(dispatched).toEqual(committedAbsentOptions); + expect(dispatched).not.toHaveProperty("options"); + expect( + resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: committedAbsentOptions, + nextModelSelection: dispatched, + currentBranch: "main", + nextBranch: "main", + }), + ).toBeNull(); + }); + + it("dispatches normalized options when unlocked", () => { + expect( + resolveDispatchedModelSelection({ + optionChangeBlocked: false, + committedModelSelection: committedAbsentOptions, + selectedInstanceId: grokInstance, + selectedModel: "grok-4.5", + selectedModelOptionsForDispatch: materializedDefaultHigh.options, + }), + ).toEqual(materializedDefaultHigh); + }); + + it("dispatches the new instance selection for cross-instance handoff", () => { + // optionChangeBlocked is false when selected instance differs from lock. + expect( + resolveDispatchedModelSelection({ + optionChangeBlocked: false, + committedModelSelection: committedAbsentOptions, + selectedInstanceId: handoffSelection.instanceId, + selectedModel: handoffSelection.model, + selectedModelOptionsForDispatch: handoffSelection.options, + }), + ).toEqual(handoffSelection); + }); + + it("does not silently substitute the committed model for a locked draft model change", () => { + expect( + resolveDispatchedModelSelection({ + optionChangeBlocked: true, + committedModelSelection: committedAbsentOptions, + selectedInstanceId: grokInstance, + selectedModel: "grok-build", + selectedModelOptionsForDispatch: undefined, + }), + ).toEqual({ + instanceId: grokInstance, + model: "grok-build", + }); + }); +}); + +describe("composed ChatComposer traits lock source", () => { + const grokPrimary = ProviderInstanceId.make("grok"); + const grokSecondary = ProviderInstanceId.make("grok_work"); + const providers = [ + { instanceId: grokPrimary, requiresNewThreadForModelChange: true }, + { instanceId: grokSecondary, requiresNewThreadForModelChange: true }, + ]; + const committedLow = { + instanceId: grokPrimary, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "low" as const }], + }; + const draftHigh = { + instanceId: grokPrimary, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "high" as const }], + }; + const handoffSelection = { + instanceId: grokSecondary, + model: "grok-4.5", + options: [{ id: "reasoningEffort", value: "medium" as const }], + }; + + it("keys the lock to the active session instance, not merely the driver kind", () => { + expect( + resolveSessionLockedInstanceId({ + hasStartedThread: true, + runtimeProviderInstanceId: grokPrimary, + committedModelSelectionInstanceId: grokSecondary, + }), + ).toBe(grokPrimary); + + // Selecting the other Grok instance (same driver kind) stays unlocked. + expect( + resolveTraitsOptionChangeBlocked({ + providers, + hasStartedThread: true, + runtimeProviderInstanceId: grokPrimary, + committedModelSelectionInstanceId: grokPrimary, + selectedInstanceId: grokSecondary, + }), + ).toBe(false); + }); + + it("locks traits and dispatches the committed selection for the active instance", () => { + const optionChangeBlocked = resolveTraitsOptionChangeBlocked({ + providers, + hasStartedThread: true, + runtimeProviderInstanceId: grokPrimary, + committedModelSelectionInstanceId: grokPrimary, + selectedInstanceId: grokPrimary, + }); + expect(optionChangeBlocked).toBe(true); + expect( + resolveDispatchedModelSelection({ + optionChangeBlocked, + committedModelSelection: committedLow, + selectedInstanceId: draftHigh.instanceId, + selectedModel: draftHigh.model, + selectedModelOptionsForDispatch: draftHigh.options, + }), + ).toEqual(committedLow); + }); + + it("does not lock when the draft handoff targets another instance", () => { + const optionChangeBlocked = resolveTraitsOptionChangeBlocked({ + providers, + hasStartedThread: true, + runtimeProviderInstanceId: grokPrimary, + committedModelSelectionInstanceId: grokPrimary, + selectedInstanceId: grokSecondary, + }); + expect(optionChangeBlocked).toBe(false); + expect( + resolveDispatchedModelSelection({ + optionChangeBlocked, + committedModelSelection: committedLow, + selectedInstanceId: handoffSelection.instanceId, + selectedModel: handoffSelection.model, + selectedModelOptionsForDispatch: handoffSelection.options, + }), + ).toEqual(handoffSelection); + }); + + it("leaves options unlocked when there is no session lock", () => { + expect( + resolveSessionLockedInstanceId({ + hasStartedThread: false, + runtimeProviderInstanceId: grokPrimary, + committedModelSelectionInstanceId: grokPrimary, + }), + ).toBeNull(); + expect( + resolveTraitsOptionChangeBlocked({ + providers, + hasStartedThread: false, + runtimeProviderInstanceId: grokPrimary, + committedModelSelectionInstanceId: grokPrimary, + selectedInstanceId: grokPrimary, + }), + ).toBe(false); + }); + + it("locks a custom Grok instance from committed metadata while runtime is loading", () => { + expect( + resolveTraitsOptionChangeBlocked({ + providers, + hasStartedThread: true, + runtimeProviderInstanceId: null, + committedModelSelectionInstanceId: grokSecondary, + selectedInstanceId: grokSecondary, + }), + ).toBe(true); + }); + + it("swaps display options to committed while locked and draft while unlocked", () => { + const committedOptions = committedLow.options; + const draftOptions = draftHigh.options; + // Locked same-instance: ChatComposer feeds committed options into + // getComposerProviderState so the traits UI matches the in-force value. + expect( + resolveComposerDisplayModelOptions({ + optionChangeBlocked: true, + selectedInstanceId: grokPrimary, + selectedModel: "grok-4.5", + committedModelSelectionInstanceId: grokPrimary, + committedModel: "grok-4.5", + committedModelOptions: committedOptions, + draftModelOptions: draftOptions, + }), + ).toBe(committedOptions); + // Cross-instance handoff and unlocked threads keep draft options. + expect( + resolveComposerDisplayModelOptions({ + optionChangeBlocked: false, + selectedInstanceId: grokSecondary, + selectedModel: "grok-4.5", + committedModelSelectionInstanceId: grokPrimary, + committedModel: "grok-4.5", + committedModelOptions: committedOptions, + draftModelOptions: handoffSelection.options, + }), + ).toBe(handoffSelection.options); + // Pre-feature threads with absent committed options stay absent on display. + expect( + resolveComposerDisplayModelOptions({ + optionChangeBlocked: true, + selectedInstanceId: grokPrimary, + selectedModel: "grok-4.5", + committedModelSelectionInstanceId: grokPrimary, + committedModel: "grok-4.5", + committedModelOptions: undefined, + draftModelOptions: draftOptions, + }), + ).toBeUndefined(); + // Empty committed options arrays stay empty (not rewritten to draft). + expect( + resolveComposerDisplayModelOptions({ + optionChangeBlocked: true, + selectedInstanceId: grokPrimary, + selectedModel: "grok-4.5", + committedModelSelectionInstanceId: grokPrimary, + committedModel: "grok-4.5", + committedModelOptions: [], + draftModelOptions: draftOptions, + }), + ).toEqual([]); + + // A legacy or stale draft for another model keeps its own option state; + // dispatch must not silently substitute the committed model. + expect( + resolveComposerDisplayModelOptions({ + optionChangeBlocked: true, + selectedInstanceId: grokPrimary, + selectedModel: "grok-build", + committedModelSelectionInstanceId: grokPrimary, + committedModel: "grok-4.5", + committedModelOptions: committedOptions, + draftModelOptions: draftOptions, + }), + ).toBe(draftOptions); + }); + + it("uses draft options when runtime lock is one Grok instance but committed metadata is another", () => { + // Narrow handoff window: session/runtime still on grokPrimary so selecting + // that instance reports optionChangeBlocked, but committed metadata has + // already moved to grokSecondary. Display must not show secondary's + // options while primary is selected. + const primaryDraftOptions = draftHigh.options; + const secondaryCommittedOptions = handoffSelection.options; + expect( + resolveComposerDisplayModelOptions({ + optionChangeBlocked: true, + selectedInstanceId: grokPrimary, + selectedModel: "grok-4.5", + committedModelSelectionInstanceId: grokSecondary, + committedModel: "grok-4.5", + committedModelOptions: secondaryCommittedOptions, + draftModelOptions: primaryDraftOptions, + }), + ).toBe(primaryDraftOptions); + }); +}); + describe("resolveSendEnvMode", () => { it("keeps worktree mode only for git repositories", () => { expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 3264951bedd..c36eb38537a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -5,12 +5,14 @@ import { type ModelSelection, type OrchestrationV2ProjectedTurnItem, type ProviderDriverKind, + type ProviderOptionSelection, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, type ThreadId, type RunId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; import { type ChatMessage, type SessionPhase, type Thread } from "../types"; @@ -354,6 +356,13 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime)); } +export function resolveModelChangeRuntime(input: { + projectedRuntime: Thread["runtime"] | null | undefined; + shellRuntime: Thread["runtime"] | null | undefined; +}): Thread["runtime"] { + return input.projectedRuntime ?? input.shellRuntime ?? null; +} + // `threadProvider` is the open branded driver kind carried by the session. // Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe // rollback / fork behavior — the routing layer is the right place to surface @@ -437,6 +446,127 @@ export function getStartedThreadModelChangeBlockReason(input: { }; } +/** + * Providers that cannot change models mid-thread bind their option values the + * same way: Grok reasoning effort is only honored as an agent spawn flag at + * session start (grok 0.2.117 keeps a loaded session's original effort even + * when respawned with a different flag), so a started thread cannot apply a + * new value. Locked provider-option rows render disabled and the committed + * selection stays in force; there is no toast or click-to-apply path. + * + * Compare provider *instance* ids, not driver kinds: two Grok instances must + * keep independent, editable options when composing a handoff away from the + * active session instance. + */ +export function isStartedThreadOptionChangeBlocked(input: { + providers: ReadonlyArray>; + lockedInstanceId: ModelSelection["instanceId"] | null; + instanceId: ModelSelection["instanceId"]; +}): boolean { + if (input.lockedInstanceId === null) { + return false; + } + if (input.instanceId !== input.lockedInstanceId) { + return false; + } + const provider = input.providers.find((snapshot) => snapshot.instanceId === input.instanceId); + return provider?.requiresNewThreadForModelChange === true; +} + +/** + * ChatComposer keys session-bound option locks to the active provider + * instance, not the draft/handoff selection and not merely the driver kind. + * Shell history keeps the lock active while a detailed projection is loading. + */ +export function resolveSessionLockedInstanceId(input: { + hasStartedThread: boolean; + runtimeProviderInstanceId: ModelSelection["instanceId"] | null | undefined; + committedModelSelectionInstanceId: ModelSelection["instanceId"] | null | undefined; +}): ModelSelection["instanceId"] | null { + if (!input.hasStartedThread) { + return null; + } + return input.runtimeProviderInstanceId ?? input.committedModelSelectionInstanceId ?? null; +} + +/** + * Compose the ChatComposer traits lock: active session instance + selected + * instance + provider flags. Used for both UI disable and dispatch pinning. + */ +export function resolveTraitsOptionChangeBlocked(input: { + providers: ReadonlyArray>; + hasStartedThread: boolean; + runtimeProviderInstanceId: ModelSelection["instanceId"] | null | undefined; + committedModelSelectionInstanceId: ModelSelection["instanceId"] | null | undefined; + selectedInstanceId: ModelSelection["instanceId"]; +}): boolean { + return isStartedThreadOptionChangeBlocked({ + providers: input.providers, + lockedInstanceId: resolveSessionLockedInstanceId(input), + instanceId: input.selectedInstanceId, + }); +} + +/** + * Model options fed into getComposerProviderState while composing. + * + * When session options are locked on the same instance and model as committed + * metadata, show the committed thread selection so the traits UI matches the + * in-force spawn-bound values rather than draft or sticky values the session + * never applied. Cross-instance handoff, model changes, and the narrow window + * where runtime lock still points at an old instance while committed metadata + * has moved keep the selected draft options. + */ +export function resolveComposerDisplayModelOptions(input: { + optionChangeBlocked: boolean; + selectedInstanceId: ModelSelection["instanceId"]; + selectedModel: string; + committedModelSelectionInstanceId: ModelSelection["instanceId"] | null | undefined; + committedModel: string | null | undefined; + committedModelOptions: ReadonlyArray | null | undefined; + draftModelOptions: ReadonlyArray | null | undefined; +}): ReadonlyArray | undefined { + const selected = + input.optionChangeBlocked && + input.selectedInstanceId === input.committedModelSelectionInstanceId && + input.selectedModel === input.committedModel + ? input.committedModelOptions + : input.draftModelOptions; + return selected ?? undefined; +} + +/** + * Build the ModelSelection used for metadata updates and turn dispatch. + * + * When the selected instance and model are session-option locked, return the + * exact committed thread selection wholesale so pre-feature threads with + * absent `options` stay absent (descriptor normalization for display may still + * show menu defaults and must not be written back). Handoffs and model changes + * use the selected draft so they cannot be silently substituted at dispatch. + */ +export function resolveDispatchedModelSelection(input: { + optionChangeBlocked: boolean; + committedModelSelection: ModelSelection | null | undefined; + selectedInstanceId: ModelSelection["instanceId"]; + selectedModel: string; + selectedModelOptionsForDispatch: ReadonlyArray | undefined; +}): ModelSelection { + const committed = input.committedModelSelection; + if ( + input.optionChangeBlocked && + committed != null && + input.selectedInstanceId === committed.instanceId && + input.selectedModel === committed.model + ) { + return committed; + } + return createModelSelection( + input.selectedInstanceId, + input.selectedModel, + input.selectedModelOptionsForDispatch, + ); +} + export async function waitForStartedServerThread( threadRef: ScopedThreadRef, timeoutMs = 1_000, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 174535cbca8..c039ea99d96 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -293,12 +293,14 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveModelChangeRuntime, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldShowComposerContextStrip, startNewThreadForProject, + threadHasStarted, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -1503,6 +1505,11 @@ function ChatViewContent(props: ChatViewProps) { const activeLatestRun = isServerThread ? serverLatestRun : (activeThread?.latestRun ?? null); const activeActivityRun = isServerThread ? serverActivityRun : (activeThread?.latestRun ?? null); const activeRuntime = isServerThread ? serverRuntime : (activeThread?.runtime ?? null); + const modelChangeRuntime = resolveModelChangeRuntime({ + projectedRuntime: activeRuntime, + shellRuntime: activeThread?.runtime, + }); + const hasStartedModelSession = modelChangeRuntime !== null || threadHasStarted(activeThread); const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId @@ -4942,6 +4949,24 @@ function ChatViewContent(props: ChatViewProps) { selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; + if (isServerThread) { + const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ + providers: providerStatuses, + hasStartedSession: hasStartedModelSession, + supportsProviderSwitchingViaHandoff, + currentModelSelection: activeThread.modelSelection, + currentProviderInstanceId: modelChangeRuntime?.providerInstanceId ?? null, + nextModelSelection: ctxSelectedModelSelection, + }); + if (modelChangeBlockReason) { + toastManager.add({ + type: "warning", + title: modelChangeBlockReason.title, + description: modelChangeBlockReason.description, + }); + return; + } + } const promptForSend = promptRef.current; const { trimmedPrompt: trimmed, @@ -5821,15 +5846,21 @@ function ChatViewContent(props: ChatViewProps) { } const reason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, - hasStartedSession: activeRuntime !== null, + hasStartedSession: hasStartedModelSession, supportsProviderSwitchingViaHandoff, currentModelSelection: activeThread.modelSelection, - currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, + currentProviderInstanceId: modelChangeRuntime?.providerInstanceId ?? null, nextModelSelection: { instanceId, model }, }); return reason ? `${reason.description} Start a new thread to use this model.` : null; }, - [activeRuntime, activeThread, providerStatuses, supportsProviderSwitchingViaHandoff], + [ + activeThread, + hasStartedModelSession, + modelChangeRuntime, + providerStatuses, + supportsProviderSwitchingViaHandoff, + ], ); const onProviderModelSelect = useCallback( @@ -5882,10 +5913,10 @@ function ChatViewContent(props: ChatViewProps) { }; const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, - hasStartedSession: activeRuntime !== null, + hasStartedSession: hasStartedModelSession, supportsProviderSwitchingViaHandoff, currentModelSelection: activeThread.modelSelection, - currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, + currentProviderInstanceId: modelChangeRuntime?.providerInstanceId ?? null, nextModelSelection, }); if (modelChangeBlockReason) { @@ -5906,8 +5937,9 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeThread, - activeRuntime, + hasStartedModelSession, lockedProvider, + modelChangeRuntime, supportsProviderSwitchingViaHandoff, scheduleComposerFocus, setComposerDraftModelSelection, @@ -6391,6 +6423,7 @@ function ChatViewContent(props: ChatViewProps) { runtimeMode={runtimeMode} interactionMode={interactionMode} lockedProvider={modelPickerLockedProvider} + hasStartedThread={threadHasStarted(activeThread)} providerCatalogLoaded={serverConfig !== null} providerStatuses={providerStatuses as ServerProvider[]} activeProjectDefaultModelSelection={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bc8763ab7d6..0441567f13f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -21,7 +21,7 @@ import { } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import { normalizeModelSlug } from "@t3tools/shared/model"; import { memo, type ReactNode, @@ -43,7 +43,13 @@ import { replaceTextRange, shouldSubmitComposerOnEnter, } from "../../composer-logic"; -import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + deriveComposerSendState, + readFileAsDataUrl, + resolveComposerDisplayModelOptions, + resolveDispatchedModelSelection, + resolveTraitsOptionChangeBlocked, +} from "../ChatView.logic"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, @@ -575,6 +581,8 @@ export interface ChatComposerProps { // Provider / model lockedProvider: ProviderDriverKind | null; + /** Shell-backed started state keeps session-bound options locked while loading. */ + hasStartedThread: boolean; providerCatalogLoaded: boolean; providerStatuses: ServerProvider[]; activeProjectDefaultModelSelection: ModelSelection | null | undefined; @@ -674,6 +682,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) runtimeMode, interactionMode, lockedProvider, + hasStartedThread, providerCatalogLoaded, providerStatuses, activeProjectDefaultModelSelection, @@ -917,6 +926,37 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => getComposerPromptInjectionState(prompt), [prompt], ); + // Key option lock to the active/committed provider instance, not driver + // kind, so a second Grok instance keeps independent editable options. + const traitsOptionChangeBlocked = useMemo( + () => + resolveTraitsOptionChangeBlocked({ + providers: providerStatuses, + hasStartedThread, + runtimeProviderInstanceId: activeThread?.runtime?.providerInstanceId, + committedModelSelectionInstanceId: activeThreadModelSelection?.instanceId, + selectedInstanceId, + }), + [ + activeThread?.runtime?.providerInstanceId, + activeThreadModelSelection?.instanceId, + providerStatuses, + selectedInstanceId, + hasStartedThread, + ], + ); + // Session-bound options were fixed when the provider session started, so a + // locked thread displays and dispatches its committed selection rather than + // draft or sticky values the session never applied. + const effectiveComposerModelOptions = resolveComposerDisplayModelOptions({ + optionChangeBlocked: traitsOptionChangeBlocked, + selectedInstanceId, + selectedModel, + committedModelSelectionInstanceId: activeThreadModelSelection?.instanceId, + committedModel: activeThreadModelSelection?.model, + committedModelOptions: activeThreadModelSelection?.options, + draftModelOptions: composerModelOptions?.[selectedInstanceId], + }); const composerProviderState = useMemo( () => getComposerProviderState({ @@ -924,12 +964,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) model: selectedModel, models: selectedProviderModels, promptInjectionState: composerPromptInjectionState, - modelOptions: composerModelOptions?.[selectedInstanceId], + modelOptions: effectiveComposerModelOptions, }), [ - composerModelOptions, composerPromptInjectionState, - selectedInstanceId, + effectiveComposerModelOptions, selectedModel, selectedProvider, selectedProviderModels, @@ -947,9 +986,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [providerStatuses, selectedProvider], ); + // Locked same-instance dispatch keeps the exact committed selection so + // absent options on pre-feature threads are not rewritten as menu defaults. + // Display still uses descriptor-normalized options via composerProviderState. const selectedModelSelection = useMemo( - () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), - [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], + () => + resolveDispatchedModelSelection({ + optionChangeBlocked: traitsOptionChangeBlocked, + committedModelSelection: activeThreadModelSelection, + selectedInstanceId, + selectedModel, + selectedModelOptionsForDispatch, + }), + [ + activeThreadModelSelection, + selectedInstanceId, + selectedModel, + selectedModelOptionsForDispatch, + traitsOptionChangeBlocked, + ], ); const selectedModelForPicker = selectedModel; // Instance-keyed option list so the picker can show each configured @@ -1256,7 +1311,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, - modelOptions: composerModelOptions?.[selectedInstanceId], + modelOptions: effectiveComposerModelOptions, + optionChangeBlocked: traitsOptionChangeBlocked, prompt, onPromptChange: setPromptFromTraits, }); @@ -1267,7 +1323,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, - modelOptions: composerModelOptions?.[selectedInstanceId], + modelOptions: effectiveComposerModelOptions, + optionChangeBlocked: traitsOptionChangeBlocked, prompt, onPromptChange: setPromptFromTraits, }); diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index b457f515ff7..929a35ac838 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; -import { buildTraitsTriggerDisplay } from "./TraitsPicker"; +import { + buildTraitsTriggerDisplay, + LOCKED_PROVIDER_OPTION_TOAST, + resolveProviderOptionChange, +} from "./TraitsPicker"; function selectDescriptor( id: string, @@ -44,6 +48,94 @@ function display(descriptors: ReadonlyArray) { }); } +describe("resolveProviderOptionChange", () => { + it("ignores re-selecting the current select value (locked or unlocked)", () => { + expect( + resolveProviderOptionChange({ + descriptors: [EFFORT, CONTEXT_WINDOW], + descriptorId: "reasoningEffort", + nextValue: "high", + optionChangeBlocked: true, + }), + ).toEqual({ action: "ignore" }); + }); + + it("ignores re-selecting the current boolean value", () => { + expect( + resolveProviderOptionChange({ + descriptors: [fastModeDescriptor(true)], + descriptorId: "fastMode", + nextValue: true, + optionChangeBlocked: false, + }), + ).toEqual({ action: "ignore" }); + }); + + it("ignores currentValue ultrathink re-select even when the descriptor store differs", () => { + // Prompt-controlled ultrathink uses currentValue while the descriptor may still + // hold the sticky session value (e.g. "high"). Same-value clicks must stay silent. + expect( + resolveProviderOptionChange({ + descriptors: [EFFORT], + descriptorId: "reasoningEffort", + nextValue: "ultrathink", + optionChangeBlocked: true, + currentValue: "ultrathink", + }), + ).toEqual({ action: "ignore" }); + }); + + it("warns for a locked select change with no next descriptors", () => { + expect( + resolveProviderOptionChange({ + descriptors: [EFFORT, CONTEXT_WINDOW], + descriptorId: "reasoningEffort", + nextValue: "max", + optionChangeBlocked: true, + }), + ).toEqual({ action: "warn", toast: LOCKED_PROVIDER_OPTION_TOAST }); + }); + + it("warns for a locked boolean change with no next descriptors", () => { + expect( + resolveProviderOptionChange({ + descriptors: [fastModeDescriptor(false)], + descriptorId: "fastMode", + nextValue: true, + optionChangeBlocked: true, + }), + ).toEqual({ action: "warn", toast: LOCKED_PROVIDER_OPTION_TOAST }); + }); + + it("returns next descriptors for an unlocked select change", () => { + expect( + resolveProviderOptionChange({ + descriptors: [EFFORT, CONTEXT_WINDOW], + descriptorId: "reasoningEffort", + nextValue: "max", + optionChangeBlocked: false, + }), + ).toEqual({ + action: "apply", + nextDescriptors: [{ ...EFFORT, currentValue: "max" }, CONTEXT_WINDOW], + }); + }); + + it("returns next descriptors for an unlocked boolean change", () => { + expect( + resolveProviderOptionChange({ + descriptors: [EFFORT, fastModeDescriptor(false)], + descriptorId: "fastMode", + nextValue: true, + optionChangeBlocked: false, + }), + ).toEqual({ + action: "apply", + nextDescriptors: [EFFORT, fastModeDescriptor(true)], + }); + }); +}); + describe("buildTraitsTriggerDisplay", () => { it("omits fast mode from the label entirely when it is off", () => { expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW])).toEqual({ diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index a9f910ec064..4ec3daa926a 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -14,7 +14,7 @@ import { getProviderOptionDescriptors, isClaudeUltrathinkPrompt, } from "@t3tools/shared/model"; -import { memo, useCallback, useState } from "react"; +import { memo, useCallback, useRef, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { ZapIcon } from "lucide-react"; import { buttonVariants } from "../ui/button"; @@ -31,8 +31,11 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; +import { toastManager } from "../ui/toast"; import { ComposerControl, ComposerControlChevron, ComposerControlIcon } from "./ComposerControl"; +type LockedOptionWarningToastId = ReturnType; + type ProviderOptions = ReadonlyArray; type TraitsPersistence = @@ -205,6 +208,60 @@ export function shouldRenderTraitsControls(input: { return getTraitsSectionVisibility(input).hasAnyControls; } +export const LOCKED_PROVIDER_OPTION_TOAST = { + type: "warning" as const, + title: "Start a new chat to change options", + description: "This provider applies these options when a conversation starts.", +}; + +export type ProviderOptionChangeResolution = + | { action: "ignore" } + | { action: "warn"; toast: typeof LOCKED_PROVIDER_OPTION_TOAST } + | { action: "apply"; nextDescriptors: ReadonlyArray }; + +/** + * Pure select/boolean change resolver for session-bound provider options. + * Radios stay clickable; callers toast on warn and apply only the returned + * nextDescriptors on apply. Same-value clicks are silent. + */ +export function resolveProviderOptionChange(input: { + readonly descriptors: ReadonlyArray; + readonly descriptorId: string; + readonly nextValue: string | boolean; + readonly optionChangeBlocked: boolean; + /** Effective UI value when it differs from the descriptor (e.g. ultrathink). */ + readonly currentValue?: string | boolean; +}): ProviderOptionChangeResolution { + const descriptor = input.descriptors.find((candidate) => candidate.id === input.descriptorId); + if (!descriptor) { + return { action: "ignore" }; + } + + const currentValue = + input.currentValue !== undefined + ? input.currentValue + : descriptor.type === "boolean" + ? (descriptor.currentValue ?? false) + : getProviderOptionCurrentValue(descriptor); + + if (currentValue === input.nextValue) { + return { action: "ignore" }; + } + + if (input.optionChangeBlocked) { + return { action: "warn", toast: LOCKED_PROVIDER_OPTION_TOAST }; + } + + return { + action: "apply", + nextDescriptors: replaceDescriptorCurrentValue( + input.descriptors, + input.descriptorId, + input.nextValue, + ), + }; +} + export interface TraitsMenuContentProps { provider: ProviderDriverKind; instanceId?: ProviderInstanceId; @@ -214,6 +271,8 @@ export interface TraitsMenuContentProps { onPromptChange: (prompt: string) => void; modelOptions?: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; + /** Started-thread lock: clicking a session-bound option warns instead of applying. */ + optionChangeBlocked?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; } @@ -227,8 +286,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ onPromptChange, modelOptions, allowPromptInjectedEffort = true, + optionChangeBlocked, ...persistence }: TraitsMenuContentProps & TraitsPersistence) { + const optionsLocked = optionChangeBlocked === true; + const lockedOptionWarningToastIdRef = useRef(null); const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const updateModelOptions = useCallback( (nextOptions: ProviderOptions | undefined) => { @@ -268,11 +330,34 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); }; + const showLockedOptionWarning = (toast: typeof LOCKED_PROVIDER_OPTION_TOAST) => { + if (lockedOptionWarningToastIdRef.current !== null) { + toastManager.close(lockedOptionWarningToastIdRef.current); + } + lockedOptionWarningToastIdRef.current = toastManager.add(toast); + }; + const handleSelectChange = ( descriptor: Extract, value: string, ) => { if (!value) return; + const effectiveCurrent = + ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id + ? "ultrathink" + : (getDescriptorStringValue(descriptor) ?? ""); + const resolution = resolveProviderOptionChange({ + descriptors, + descriptorId: descriptor.id, + nextValue: value, + optionChangeBlocked: optionsLocked, + currentValue: effectiveCurrent, + }); + if (resolution.action === "ignore") return; + if (resolution.action === "warn") { + showLockedOptionWarning(resolution.toast); + return; + } if (descriptor.promptInjectedValues?.includes(value)) { const nextPrompt = prompt.trim().length === 0 @@ -286,7 +371,25 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); onPromptChange(stripped); } - updateDescriptors(replaceDescriptorCurrentValue(descriptors, descriptor.id, value)); + updateDescriptors(resolution.nextDescriptors); + }; + + const handleBooleanChange = ( + descriptor: Extract, + value: string, + ) => { + const resolution = resolveProviderOptionChange({ + descriptors, + descriptorId: descriptor.id, + nextValue: value === "on", + optionChangeBlocked: optionsLocked, + }); + if (resolution.action === "ignore") return; + if (resolution.action === "warn") { + showLockedOptionWarning(resolution.toast); + return; + } + updateDescriptors(resolution.nextDescriptors); }; if (!hasAnyControls) { @@ -355,11 +458,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ { - updateDescriptors( - replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"), - ); - }} + onValueChange={(value) => handleBooleanChange(descriptor, value)} > {(["on", "off"] as const).map((value) => ( @@ -441,6 +540,7 @@ export const TraitsPicker = memo(function TraitsPicker({ onPromptChange, modelOptions, allowPromptInjectedEffort = true, + optionChangeBlocked, triggerVariant, triggerClassName, ...persistence @@ -527,6 +627,7 @@ export const TraitsPicker = memo(function TraitsPicker({ onPromptChange={onPromptChange} modelOptions={modelOptions} allowPromptInjectedEffort={allowPromptInjectedEffort} + {...(optionChangeBlocked !== undefined ? { optionChangeBlocked } : {})} {...persistence} /> diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 067e71ef1bf..72c2a85018f 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -1,3 +1,4 @@ +import { isValidElement } from "react"; import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, @@ -5,6 +6,7 @@ import { type ProviderOptionSelection, type ServerProviderModel, } from "@t3tools/contracts"; +import { DraftId } from "../../composerDraftStore"; import { getComposerPromptInjectionState, getComposerProviderState, @@ -245,4 +247,58 @@ describe("provider traits render guards", () => { expect(renderProviderTraitsPicker(args)).toBeNull(); expect(renderProviderTraitsMenuContent(args)).toBeNull(); }); + + it("forwards optionChangeBlocked through the composerProviderState seam", () => { + // ChatComposer passes traitsOptionChangeBlocked into these render helpers; + // prove the seam keeps the prop on the traits element so locked rows can + // disable without a toast path. + const models = modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + ]); + const args = { + provider: PROVIDER, + draftId: DraftId.make("draft-wiring"), + model: MODEL, + models, + modelOptions: selections(["effort", "low"]), + optionChangeBlocked: true, + prompt: "", + onPromptChange: () => {}, + }; + const optionChangeBlockedProp = ( + node: ReturnType, + ): boolean | undefined => { + if (!isValidElement(node)) { + return undefined; + } + return (node.props as { optionChangeBlocked?: boolean }).optionChangeBlocked; + }; + + const picker = renderProviderTraitsPicker(args); + const menu = renderProviderTraitsMenuContent(args); + expect(isValidElement(picker)).toBe(true); + expect(isValidElement(menu)).toBe(true); + expect(optionChangeBlockedProp(picker)).toBe(true); + expect(optionChangeBlockedProp(menu)).toBe(true); + + const unlockedPicker = renderProviderTraitsPicker({ + ...args, + optionChangeBlocked: false, + }); + expect(isValidElement(unlockedPicker)).toBe(true); + expect(optionChangeBlockedProp(unlockedPicker)).toBe(false); + + // Omitting the prop leaves it undefined so traits stay editable by default. + const omittedPicker = renderProviderTraitsPicker({ + provider: PROVIDER, + draftId: DraftId.make("draft-wiring"), + model: MODEL, + models, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }); + expect(isValidElement(omittedPicker)).toBe(true); + expect(optionChangeBlockedProp(omittedPicker)).toBeUndefined(); + }); }); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 1349e2509b7..f4df8bb36f8 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -44,6 +44,7 @@ type TraitsRenderInput = { model: string; models: ReadonlyArray; modelOptions: ReadonlyArray | undefined; + optionChangeBlocked?: boolean; prompt: string; onPromptChange: (prompt: string) => void; }; @@ -92,6 +93,7 @@ function renderTraitsControl( model, models, modelOptions, + optionChangeBlocked, prompt, onPromptChange, } = input; @@ -111,6 +113,7 @@ function renderTraitsControl( {...(draftId ? { draftId } : {})} model={model} modelOptions={modelOptions} + {...(optionChangeBlocked !== undefined ? { optionChangeBlocked } : {})} prompt={prompt} onPromptChange={onPromptChange} /> diff --git a/docs/README.md b/docs/README.md index bc359826a04..ee305ddf321 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [Grok](./user/providers-grok.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e..9c4d3426a28 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -75,6 +75,7 @@ authenticated shows its status in **Settings** and fails at session start with t to run. For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For Grok reasoning controls, see [Grok Build](./providers-grok.md). ## Next Steps diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md new file mode 100644 index 00000000000..555a055c683 --- /dev/null +++ b/docs/user/providers-grok.md @@ -0,0 +1,14 @@ +# Grok Build + +T3 Code discovers Grok models and their available reasoning-effort choices from +the Grok Build CLI. Models that report reasoning support get a Reasoning menu. +Grok 4.5 offers Low, Medium, and High, with High as the default. T3 Code keeps +that known Grok 4.5 menu available when older CLI metadata omits it. + +Reasoning effort is fixed when a Grok conversation starts. To use a different +effort or model after sending the first message, start a new chat and choose the +new value before sending. + +Install and authenticate the CLI as described in [Install](./install.md). The +Grok process runs on the machine hosting the T3 Code server, including when you +control it from another browser or the mobile app.