From f4e2dfb2cd15e0a876ea872762d1094e6dbcc5c1 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sat, 1 Aug 2026 17:31:09 -0400 Subject: [PATCH] feat(orchestrator): Surface Grok reasoning effort options Advertise Grok reasoning effort from ACP model metadata, with a known grok-4.5 fallback when metadata is absent, and apply the selected CLI-safe value through the agent spawn flag. Treat reasoning effort as a spawn-bound ACP option so active sessions reject changes, runtime restore paths preserve the applied value, and generic session configuration ignores it. Keep web and mobile selections aligned with the committed session, including durable mobile outbox delivery, while allowing cross-provider handoffs. --- README.md | 2 +- .../src/features/threads/ThreadComposer.tsx | 39 +- .../features/threads/ThreadDetailScreen.tsx | 4 + .../features/threads/ThreadRouteScreen.tsx | 2 + apps/mobile/src/lib/modelOptions.test.ts | 478 ++++++++++++- apps/mobile/src/lib/modelOptions.ts | 174 +++++ apps/mobile/src/lib/providerOptions.test.ts | 118 +++- apps/mobile/src/lib/providerOptions.ts | 121 +++- .../environment-server-configs-access.test.ts | 58 ++ .../environment-server-configs-access.ts | 36 + .../src/state/use-thread-composer-state.ts | 70 +- .../src/state/use-thread-outbox-drain.ts | 68 +- .../Adapters/AcpAdapterV2.test.ts | 662 ++++++++++++++++++ .../orchestration-v2/Adapters/AcpAdapterV2.ts | 183 ++++- .../Adapters/GrokAdapterV2.test.ts | 62 ++ .../Adapters/GrokAdapterV2.ts | 105 ++- .../orchestration-v2/ProviderAdapterDriver.ts | 4 + .../ProviderSelectionTransition.test.ts | 166 +++++ .../ProviderSelectionTransition.ts | 33 + .../ProviderSwitchService.test.ts | 97 +++ .../server/src/provider/Drivers/GrokDriver.ts | 50 +- .../src/provider/Layers/GrokProvider.test.ts | 232 +++++- .../src/provider/Layers/GrokProvider.ts | 168 ++++- .../src/provider/acp/GrokAcpSupport.test.ts | 190 +++++ .../server/src/provider/acp/GrokAcpSupport.ts | 139 +++- .../src/textGeneration/GrokTextGeneration.ts | 2 + .../web/src/components/ChatView.logic.test.ts | 381 ++++++++++ apps/web/src/components/ChatView.logic.ts | 130 ++++ apps/web/src/components/ChatView.tsx | 45 +- apps/web/src/components/chat/ChatComposer.tsx | 75 +- .../src/components/chat/TraitsPicker.test.ts | 94 ++- apps/web/src/components/chat/TraitsPicker.tsx | 115 ++- .../chat/composerProviderState.test.tsx | 56 ++ .../components/chat/composerProviderState.tsx | 3 + docs/README.md | 2 +- docs/user/install.md | 1 + docs/user/providers-grok.md | 14 + 37 files changed, 4055 insertions(+), 124 deletions(-) create mode 100644 apps/mobile/src/state/environment-server-configs-access.test.ts create mode 100644 apps/mobile/src/state/environment-server-configs-access.ts create mode 100644 docs/user/providers-grok.md 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.