authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-03 00:05:53-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 00:05:53-05:00
log63f74e209dc27df1f8733535d6fd4a79d442add8
treef64306708d999157bc61339c7639f9a242378579
parentca4f84e702428656665d53635e16251112091657
signature Signed by PGP key B5690EEEBB952194

fix(opencode): fill blank reasoning labels (#14978)


16 files changed, 397 insertions(+), 21 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+3
...@@ -150,6 +150,7 @@ export interface ThreadComposerProps {...@@ -150,6 +150,7 @@ export interface ThreadComposerProps {
150 */150 */
151 readonly threadSyncPhase?: "loading" | "syncing" | null;151 readonly threadSyncPhase?: "loading" | "syncing" | null;
152 readonly selectedThread: EnvironmentThreadShell;152 readonly selectedThread: EnvironmentThreadShell;
153 readonly reportedModelSelection?: ModelSelection | null;
153 readonly hasCompactableConversation: boolean;154 readonly hasCompactableConversation: boolean;
154 readonly serverConfig: T3ServerConfig | null;155 readonly serverConfig: T3ServerConfig | null;
155 readonly queueCount: number;156 readonly queueCount: number;
...@@ -667,6 +668,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -667,6 +668,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
667 providerInstanceId: currentModelSelection.instanceId,668 providerInstanceId: currentModelSelection.instanceId,
668 providerGroups: threadProviderGroups,669 providerGroups: threadProviderGroups,
669 selectedModel: currentModelSelection,670 selectedModel: currentModelSelection,
671 reportedModelSelection: props.reportedModelSelection,
670 onSelectModel: (option) =>672 onSelectModel: (option) =>
671 props.onUpdateModelSelection(withRememberedModelOptions(option.selection)),673 props.onUpdateModelSelection(withRememberedModelOptions(option.selection)),
672 optionDescriptors: providerOptionDescriptors,674 optionDescriptors: providerOptionDescriptors,
...@@ -683,6 +685,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -683,6 +685,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
683 }),685 }),
684 [686 [
685 currentModelSelection,687 currentModelSelection,
688 props.reportedModelSelection,
686 currentRuntimeMode,689 currentRuntimeMode,
687 props.onUpdateModelSelection,690 props.onUpdateModelSelection,
688 props.onUpdateRuntimeMode,691 props.onUpdateRuntimeMode,
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+7
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useThreadReportedModelSelection } from "../../state/entities";
1import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";2import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
2import { useNavigation } from "@react-navigation/native";3import { useNavigation } from "@react-navigation/native";
3import type { WorktreeSetupCardProps } from "./worktree-setup-card";4import type { WorktreeSetupCardProps } from "./worktree-setup-card";
...@@ -303,6 +304,10 @@ const USER_INPUT_TOGGLE_TIMING = {...@@ -303,6 +304,10 @@ const USER_INPUT_TOGGLE_TIMING = {
303304
304export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {305export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
305 const navigation = useNavigation();306 const navigation = useNavigation();
307 const reportedModelSelection = useThreadReportedModelSelection({
308 environmentId: props.environmentId,
309 threadId: props.selectedThread.id,
310 });
306 const deviceState = useEnvironmentQuery(311 const deviceState = useEnvironmentQuery(
307 deviceEnvironment.state({ environmentId: props.environmentId, input: {} }),312 deviceEnvironment.state({ environmentId: props.environmentId, input: {} }),
308 );313 );
...@@ -1277,6 +1282,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1277,6 +1282,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1277 effortLabel={formatModelSelectionEffort(1282 effortLabel={formatModelSelectionEffort(
1278 props.selectedThread.modelSelection,1283 props.selectedThread.modelSelection,
1279 providerSubagentProvider?.models,1284 providerSubagentProvider?.models,
1285 reportedModelSelection,
1280 )}1286 )}
1281 status={props.providerSubagentStatus ?? null}1287 status={props.providerSubagentStatus ?? null}
1282 onOpenParent={1288 onOpenParent={
...@@ -1293,6 +1299,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1293,6 +1299,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1293 ) : (1299 ) : (
1294 <>1300 <>
1295 <ThreadComposer1301 <ThreadComposer
1302 reportedModelSelection={reportedModelSelection}
1296 editorRef={composerEditorRef}1303 editorRef={composerEditorRef}
1297 draftMessage={props.draftMessage}1304 draftMessage={props.draftMessage}
1298 draftAttachments={props.draftAttachments}1305 draftAttachments={props.draftAttachments}
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+19-2
...@@ -245,6 +245,7 @@ type ThreadSettingsSessionProps = {...@@ -245,6 +245,7 @@ type ThreadSettingsSessionProps = {
245 readonly providerInstanceId?: ProviderInstanceId;245 readonly providerInstanceId?: ProviderInstanceId;
246 readonly providerGroups: ReadonlyArray<ProviderGroup>;246 readonly providerGroups: ReadonlyArray<ProviderGroup>;
247 readonly selectedModel: ModelSelection | null;247 readonly selectedModel: ModelSelection | null;
248 readonly reportedModelSelection?: ModelSelection | null;
248 readonly onSelectModel: (option: ModelOption) => void;249 readonly onSelectModel: (option: ModelOption) => void;
249 readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;250 readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
250 readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void;251 readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void;
...@@ -304,6 +305,8 @@ type ThreadSettingsSessionValue = {...@@ -304,6 +305,8 @@ type ThreadSettingsSessionValue = {
304 readonly runtimeModeChoices: ReturnType<typeof runtimeModeChoicesForSupportedModes>;305 readonly runtimeModeChoices: ReturnType<typeof runtimeModeChoicesForSupportedModes>;
305 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;306 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
306 readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>;307 readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
308 readonly displayedModelSelection: ModelSelection | null;
309 readonly reportedModelSelection: ModelSelection | null;
307 readonly providerExpansionOverrides: ReadonlySet<string>;310 readonly providerExpansionOverrides: ReadonlySet<string>;
308 readonly hasLegacyModels: boolean;311 readonly hasLegacyModels: boolean;
309 readonly pendingModel: ModelOption | null;312 readonly pendingModel: ModelOption | null;
...@@ -476,6 +479,8 @@ function ThreadSettingsSessionProvider(...@@ -476,6 +479,8 @@ function ThreadSettingsSessionProvider(
476 runtimeModeChoices,479 runtimeModeChoices,
477 onUpdateRuntimeMode: props.onUpdateRuntimeMode,480 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
478 displayedDescriptors,481 displayedDescriptors,
482 displayedModelSelection: pendingModel?.selection ?? props.selectedModel,
483 reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null),
479 favoriteKeys,484 favoriteKeys,
480 favoritesLoaded,485 favoritesLoaded,
481 providerExpansionOverrides,486 providerExpansionOverrides,
...@@ -507,6 +512,8 @@ function ThreadSettingsSessionProvider(...@@ -507,6 +512,8 @@ function ThreadSettingsSessionProvider(
507 isApplied,512 isApplied,
508 isDisplayed,513 isDisplayed,
509 props.environmentId,514 props.environmentId,
515 props.selectedModel,
516 props.reportedModelSelection,
510 props.providerInstanceId,517 props.providerInstanceId,
511 pendingModel,518 pendingModel,
512 pressModel,519 pressModel,
...@@ -743,7 +750,11 @@ function ThreadSettingsOptionsItem(props: {...@@ -743,7 +750,11 @@ function ThreadSettingsOptionsItem(props: {
743 >750 >
744 <DisclosureRow751 <DisclosureRow
745 label={descriptor.label}752 label={descriptor.label}
746 value={getProviderOptionCurrentLabel(descriptor)}753 value={getProviderOptionCurrentLabel(
754 descriptor,
755 session.displayedModelSelection,
756 session.reportedModelSelection,
757 )}
747 onPress={() => props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })}758 onPress={() => props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })}
748 />759 />
749 </Animated.View>760 </Animated.View>
...@@ -997,7 +1008,13 @@ function ThreadSettingsChoiceContent(props: {...@@ -997,7 +1008,13 @@ function ThreadSettingsChoiceContent(props: {
997 id: choice.id,1008 id: choice.id,
998 label: choice.label,1009 label: choice.label,
999 description: undefined,1010 description: undefined,
1000 selected: choice.id === getProviderOptionCurrentValue(activeDescriptor),1011 selected:
1012 choice.id ===
1013 getProviderOptionCurrentValue(
1014 activeDescriptor,
1015 session.displayedModelSelection,
1016 session.reportedModelSelection,
1017 ),
1001 onPress: () => {1018 onPress: () => {
1002 void Haptics.selectionAsync();1019 void Haptics.selectionAsync();
1003 session.applyOptionChange(activeDescriptor.id, choice.id);1020 session.applyOptionChange(activeDescriptor.id, choice.id);
apps/mobile/src/state/entities.ts+10-1
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { deriveReportedModelSelection } from "@t3tools/client-runtime/state/thread-execution";
23
3import { appAtomRegistry } from "./atom-registry";4import { appAtomRegistry } from "./atom-registry";
4import type {5import type {
5 EnvironmentProject,6 EnvironmentProject,
7 EnvironmentThread,
6 EnvironmentThreadShell,8 EnvironmentThreadShell,
7} from "@t3tools/client-runtime/state/shell";9} from "@t3tools/client-runtime/state/shell";
8import type {10import type {
...@@ -15,7 +17,7 @@ import { Atom } from "effect/unstable/reactivity";...@@ -15,7 +17,7 @@ import { Atom } from "effect/unstable/reactivity";
1517
16import { environmentProjects } from "./projects";18import { environmentProjects } from "./projects";
17import { environmentServerConfigsAtom, serverEnvironment } from "./server";19import { environmentServerConfigsAtom, serverEnvironment } from "./server";
18import { environmentThreadShells } from "./threads";20import { environmentThreadDetails, environmentThreadShells } from "./threads";
1921
20const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(22const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
21 Atom.withLabel("mobile-project:empty"),23 Atom.withLabel("mobile-project:empty"),
...@@ -87,3 +89,10 @@ export function useEnvironmentServerConfig(...@@ -87,3 +89,10 @@ export function useEnvironmentServerConfig(
87export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {89export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
88 return useAtomValue(environmentServerConfigsAtom);90 return useAtomValue(environmentServerConfigsAtom);
89}91}
92
93const selectReportedModelSelection = (thread: EnvironmentThread | null) =>
94 thread === null ? null : deriveReportedModelSelection(thread.projection);
95
96export function useThreadReportedModelSelection(ref: ScopedThreadRef) {
97 return useAtomValue(environmentThreadDetails.threadAtom(ref), selectReportedModelSelection);
98}
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+50
...@@ -410,6 +410,11 @@ describe("OpenCode2 adapter", () => {...@@ -410,6 +410,11 @@ describe("OpenCode2 adapter", () => {
410 promptAccepted,410 promptAccepted,
411 event("session.execution.succeeded", { sessionID: SESSION }),411 event("session.execution.succeeded", { sessionID: SESSION }),
412 ]);412 ]);
413 assert.deepEqual(thread.nativeMetadata?.modelSelection, {
414 ...bigPickle,
415 options: [{ id: "variant", value: "default" }],
416 });
417 assert.equal(bigPickle.options, undefined);
413 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);418 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
414 yield* runtime.startTurn(419 yield* runtime.startTurn(
415 turnInput(thread, {420 turnInput(thread, {
...@@ -2833,6 +2838,10 @@ describe("OpenCode2 adapter", () => {...@@ -2833,6 +2838,10 @@ describe("OpenCode2 adapter", () => {
2833 modelSelection: bigPickle,2838 modelSelection: bigPickle,
2834 runtimePolicy: customPolicy,2839 runtimePolicy: customPolicy,
2835 });2840 });
2841 assert.deepEqual(thread.nativeMetadata?.modelSelection, {
2842 ...bigPickle,
2843 options: [{ id: "variant", value: "default" }],
2844 });
2836 // Each directory keeps its own limit, whichever was read last.2845 // Each directory keeps its own limit, whichever was read last.
2837 assert.equal(runtime.getModelContextWindow?.(bigPickle, WORK), 160000);2846 assert.equal(runtime.getModelContextWindow?.(bigPickle, WORK), 160000);
2838 assert.equal(runtime.getModelContextWindow?.(bigPickle, custom), 48000);2847 assert.equal(runtime.getModelContextWindow?.(bigPickle, custom), 48000);
...@@ -3869,3 +3878,44 @@ const providerTurnId = Effect.gen(function* () {...@@ -3869,3 +3878,44 @@ const providerTurnId = Effect.gen(function* () {
3869 nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`,3878 nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`,
3870 });3879 });
3871}).pipe(Effect.provide(IdAllocator.layer));3880}).pipe(Effect.provide(IdAllocator.layer));
3881
3882describe("OpenCode reported model variants", () => {
3883 it.effect(
3884 "updates reported variants from selected-model and step events without duplicate updates",
3885 () =>
3886 Effect.gen(function* () {
3887 const model = { providerID: "opencode", id: "big-pickle", variant: "thinking" };
3888 const step = {
3889 sessionID: SESSION,
3890 assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z",
3891 agent: "build",
3892 model,
3893 started: 1,
3894 };
3895 const { runtime, thread } = yield* resumed([
3896 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
3897 promptAccepted,
3898 event("session.execution.started", { sessionID: SESSION }),
3899 event("session.model.selected", { sessionID: SESSION, model }),
3900 event("session.step.started", step),
3901 event("session.step.started", { ...step, model: { ...model, variant: "none" } }),
3902 event("session.execution.succeeded", { sessionID: SESSION }),
3903 ]);
3904 const collected = yield* runtime.events.pipe(
3905 Stream.takeUntil((event) => event.type === "turn.terminal"),
3906 Stream.runCollect,
3907 Effect.forkScoped,
3908 );
3909 yield* runtime.startTurn(turnInput(thread));
3910 const updates = (yield* Fiber.join(collected)).filter(
3911 (event) => event.type === "provider_thread.updated",
3912 );
3913 assert.deepEqual(
3914 updates.map(
3915 (event) => event.providerThread.nativeMetadata?.modelSelection?.options?.[0]?.value,
3916 ),
3917 ["default", "thinking", "none", "none"],
3918 );
3919 }).pipe(Effect.scoped),
3920 );
3921});
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts+45-6
...@@ -78,7 +78,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts";...@@ -78,7 +78,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
78import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts";78import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts";
79import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts";79import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts";
80import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";80import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
81import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";81import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
82import { causeErrorTag } from "@t3tools/shared/observability";82import { causeErrorTag } from "@t3tools/shared/observability";
8383
84import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts";84import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts";
...@@ -1385,6 +1385,27 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -1385,6 +1385,27 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
1385 });1385 });
1386 });1386 });
13871387
1388 const withReportedModel = (
1389 providerThread: OrchestrationV2ProviderThread,
1390 model: ModelRef | undefined,
1391 ): OrchestrationV2ProviderThread => {
1392 if (model === undefined) return providerThread;
1393 const modelSelection: ModelSelection = {
1394 instanceId,
1395 model: `${model.providerID}/${model.id}`,
1396 options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }],
1397 };
1398 if (
1399 providerThread.nativeMetadata?.modelSelection !== undefined &&
1400 modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection)
1401 )
1402 return providerThread;
1403 return {
1404 ...providerThread,
1405 nativeMetadata: { ...providerThread.nativeMetadata, modelSelection },
1406 };
1407 };
1408
1388 /**1409 /**
1389 * Gives a subagent call its session once both are known: OpenCode names1410 * Gives a subagent call its session once both are known: OpenCode names
1390 * the session on the call's progress, and announces it just before. The1411 * the session on the call's progress, and announces it just before. The
...@@ -1463,7 +1484,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -1463,7 +1484,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
1463 const child =1484 const child =
1464 previous ?? newThreadState(childId, providerThread, call.state.directory, subagent);1485 previous ?? newThreadState(childId, providerThread, call.state.directory, subagent);
1465 child.subagent = subagent;1486 child.subagent = subagent;
1466 child.providerThread = providerThread;1487 child.model = info?.model ?? child.model;
1488 child.providerThread = withReportedModel(providerThread, child.model);
1467 child.directory = call.state.directory;1489 child.directory = call.state.directory;
1468 child.agent = info?.agent ?? call.agent ?? child.agent;1490 child.agent = info?.agent ?? call.agent ?? child.agent;
1469 // OpenCode gives a new session its parent's rules, which are the thread's.1491 // OpenCode gives a new session its parent's rules, which are the thread's.
...@@ -1472,7 +1494,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -1472,7 +1494,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
1472 childOwners.set(childId, rootOf(call.state));1494 childOwners.set(childId, rootOf(call.state));
1473 call.child = child;1495 call.child = child;
1474 yield* emit({ type: "app_thread.created", driver, appThread });1496 yield* emit({ type: "app_thread.created", driver, appThread });
1475 yield* emit({ type: "provider_thread.updated", driver, providerThread });1497 yield* emit({
1498 type: "provider_thread.updated",
1499 driver,
1500 providerThread: child.providerThread,
1501 });
1476 yield* emitSubagent(call);1502 yield* emitSubagent(call);
1477 // A session called again was not made now, so it may hold the rules of1503 // A session called again was not made now, so it may hold the rules of
1478 // a mode the thread has left. OpenCode applies a rules change to the1504 // a mode the thread has left. OpenCode applies a rules change to the
...@@ -2520,6 +2546,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -2520,6 +2546,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
2520 if (sessionId === undefined) return;2546 if (sessionId === undefined) return;
2521 const state = threads.get(sessionId);2547 const state = threads.get(sessionId);
2522 if (state === undefined) return;2548 if (state === undefined) return;
2549 if (event.type === "session.model.selected" || event.type === "session.step.started") {
2550 if (event.type === "session.model.selected") state.model = event.data.model;
2551 const providerThread = withReportedModel(state.providerThread, event.data.model);
2552 if (providerThread !== state.providerThread) {
2553 state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now };
2554 yield* emit({
2555 type: "provider_thread.updated",
2556 driver,
2557 providerThread: state.providerThread,
2558 });
2559 }
2560 }
2523 if (2561 if (
2524 event.type === "session.inbox.enqueued" &&2562 event.type === "session.inbox.enqueued" &&
2525 state.subagent !== undefined &&2563 state.subagent !== undefined &&
...@@ -2962,6 +3000,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -2962,6 +3000,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
2962 },3000 },
2963 directory: string,3001 directory: string,
2964 ) => {3002 ) => {
3003 providerThread = withReportedModel(providerThread, native.model);
2965 const existing = threads.get(native.id);3004 const existing = threads.get(native.id);
2966 if (existing !== undefined) {3005 if (existing !== undefined) {
2967 existing.providerThread = providerThread;3006 existing.providerThread = providerThread;
...@@ -3582,7 +3621,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -3582,7 +3621,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
3582 directory,3621 directory,
3583 );3622 );
3584 state.policy = policy;3623 state.policy = policy;
3585 return providerThread;3624 return state.providerThread;
3586 }).pipe(3625 }).pipe(
3587 Effect.mapError((cause) =>3626 Effect.mapError((cause) =>
3588 isProviderAdapterError(cause)3627 isProviderAdapterError(cause)
...@@ -3625,7 +3664,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -3625,7 +3664,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
3625 directory: AbsolutePath.make(cwd),3664 directory: AbsolutePath.make(cwd),
3626 });3665 });
3627 }3666 }
3628 return providerThread;3667 return state.providerThread;
3629 }).pipe(3668 }).pipe(
3630 Effect.mapError((cause) =>3669 Effect.mapError((cause) =>
3631 isProviderAdapterError(cause)3670 isProviderAdapterError(cause)
...@@ -4183,7 +4222,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -4183,7 +4222,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
4183 directory: AbsolutePath.make(cwd),4222 directory: AbsolutePath.make(cwd),
4184 });4223 });
4185 }4224 }
4186 return providerThread;4225 return state.providerThread;
4187 }).pipe(4226 }).pipe(
4188 exclusive(forkInput.sourceProviderThread),4227 exclusive(forkInput.sourceProviderThread),
4189 Effect.mapError((cause) =>4228 Effect.mapError((cause) =>
apps/web/src/components/ChatView.tsx+10-1
...@@ -88,6 +88,7 @@ import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/th...@@ -88,6 +88,7 @@ import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/th
88import { useThreadActions } from "../hooks/useThreadActions";88import { useThreadActions } from "../hooks/useThreadActions";
89import {89import {
90 deriveProviderSubagentStatus,90 deriveProviderSubagentStatus,
91 deriveReportedModelSelection,
91 formatModelSelectionEffort,92 formatModelSelectionEffort,
92 deriveRunlessWorkStartedAt,93 deriveRunlessWorkStartedAt,
93 deriveThreadActivityRun,94 deriveThreadActivityRun,
...@@ -1626,6 +1627,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -1626,6 +1627,9 @@ export default function ChatView(props: ChatViewProps) {
1626 });1627 });
1627 const serverThreadProjection = useThreadProjection(routeThreadDetailRef);1628 const serverThreadProjection = useThreadProjection(routeThreadDetailRef);
1628 const serverProjection = serverThreadProjection?.projection ?? null;1629 const serverProjection = serverThreadProjection?.projection ?? null;
1630 const reportedModelSelection = serverProjection
1631 ? deriveReportedModelSelection(serverProjection)
1632 : null;
1629 const threadStatus = useThreadStatus(routeThreadDetailRef);1633 const threadStatus = useThreadStatus(routeThreadDetailRef);
1630 const threadSyncPhase = resolveThreadSyncPhase({1634 const threadSyncPhase = resolveThreadSyncPhase({
1631 detailExists: serverProjection !== null,1635 detailExists: serverProjection !== null,
...@@ -4073,7 +4077,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -4073,7 +4077,11 @@ export default function ChatView(props: ChatViewProps) {
4073 const providerSubagentEffortLabel =4077 const providerSubagentEffortLabel =
4074 activeThread === undefined4078 activeThread === undefined
4075 ? null4079 ? null
4076 : formatModelSelectionEffort(activeThread.modelSelection, providerSubagentModels);4080 : formatModelSelectionEffort(
4081 activeThread.modelSelection,
4082 providerSubagentModels,
4083 reportedModelSelection,
4084 );
4077 const mountComposerContextStrip = shouldShowComposerContextStrip({4085 const mountComposerContextStrip = shouldShowComposerContextStrip({
4078 isDraftHeroState,4086 isDraftHeroState,
4079 persistInActiveThreads: settings.persistComposerContextStrip,4087 persistInActiveThreads: settings.persistComposerContextStrip,
...@@ -10818,6 +10826,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -10818,6 +10826,7 @@ export default function ChatView(props: ChatViewProps) {
10818 ) : null}10826 ) : null}
10819 {!composerMounted ? null : (10827 {!composerMounted ? null : (
10820 <ChatComposer10828 <ChatComposer
10829 reportedModelSelection={reportedModelSelection}
10821 multipleModelSelections={multipleModelSelections}10830 multipleModelSelections={multipleModelSelections}
10822 supportsMultipleModels={10831 supportsMultipleModels={
10823 serverConfig?.environment.capabilities.requiredWorktreeBootstrap ===10832 serverConfig?.environment.capabilities.requiredWorktreeBootstrap ===
apps/web/src/components/chat/ChatComposer.tsx+4
...@@ -1581,6 +1581,7 @@ export interface ChatComposerProps {...@@ -1581,6 +1581,7 @@ export interface ChatComposerProps {
1581 providerCatalogKnown: boolean;1581 providerCatalogKnown: boolean;
1582 activeProjectDefaultModelSelection: ModelSelection | null | undefined;1582 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
1583 activeThreadModelSelection: ModelSelection | null | undefined;1583 activeThreadModelSelection: ModelSelection | null | undefined;
1584 reportedModelSelection?: ModelSelection | null;
15841585
1585 // Context window1586 // Context window
1586 activeContextWindow: ContextWindowSnapshot | null;1587 activeContextWindow: ContextWindowSnapshot | null;
...@@ -1728,6 +1729,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1728,6 +1729,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1728 providerCatalogKnown,1729 providerCatalogKnown,
1729 activeProjectDefaultModelSelection,1730 activeProjectDefaultModelSelection,
1730 activeThreadModelSelection,1731 activeThreadModelSelection,
1732 reportedModelSelection,
1731 activeContextWindow,1733 activeContextWindow,
1732 compactThreadUnavailable,1734 compactThreadUnavailable,
1733 compactDisabled,1735 compactDisabled,
...@@ -2877,6 +2879,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2877,6 +2879,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2877 model: selectedModel,2879 model: selectedModel,
2878 models: selectedProviderModels,2880 models: selectedProviderModels,
2879 modelOptions: composerModelOptions?.[selectedInstanceId],2881 modelOptions: composerModelOptions?.[selectedInstanceId],
2882 reportedModelSelection,
2880 prompt,2883 prompt,
2881 onPromptChange: setPromptFromTraits,2884 onPromptChange: setPromptFromTraits,
2882 planModeEnabled: settings.planModeEnabled,2885 planModeEnabled: settings.planModeEnabled,
...@@ -2889,6 +2892,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2889,6 +2892,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2889 model: selectedModel,2892 model: selectedModel,
2890 models: selectedProviderModels,2893 models: selectedProviderModels,
2891 modelOptions: composerModelOptions?.[selectedInstanceId],2894 modelOptions: composerModelOptions?.[selectedInstanceId],
2895 reportedModelSelection,
2892 prompt,2896 prompt,
2893 onPromptChange: setPromptFromTraits,2897 onPromptChange: setPromptFromTraits,
2894 planModeEnabled: settings.planModeEnabled,2898 planModeEnabled: settings.planModeEnabled,
apps/web/src/components/chat/TraitsPicker.test.ts+36-1
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts";2import {
3 ProviderInstanceId,
4 ProviderDriverKind,
5 type ProviderOptionDescriptor,
6} from "@t3tools/contracts";
3import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker";7import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker";
48
5function selectDescriptor(9function selectDescriptor(
...@@ -232,3 +236,34 @@ describe("buildUnavailableModelOptionDescriptors", () => {...@@ -232,3 +236,34 @@ describe("buildUnavailableModelOptionDescriptors", () => {
232 ]);236 ]);
233 });237 });
234});238});
239
240it("shows Unknown until a matching provider report provides Default", () => {
241 const selection = {
242 instanceId: ProviderInstanceId.make("opencode"),
243 model: "ling",
244 options: [],
245 };
246 const input = {
247 provider: ProviderDriverKind.make("opencode"),
248 descriptors: [
249 selectDescriptor(
250 "variant",
251 [
252 { id: "none", label: "None" },
253 { id: "thinking", label: "Thinking" },
254 ],
255 "",
256 ),
257 ],
258 primarySelectDescriptorId: "variant",
259 ultrathinkPromptControlled: false,
260 modelSelection: selection,
261 };
262 expect(buildTraitsTriggerDisplay(input).label).toBe("Unknown");
263 expect(
264 buildTraitsTriggerDisplay({
265 ...input,
266 reportedModelSelection: { ...selection, options: [{ id: "variant", value: "default" }] },
267 }).label,
268 ).toBe("Default");
269});
apps/web/src/components/chat/TraitsPicker.tsx+25-4
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type ModelSelection,
2 type ProviderDriverKind,3 type ProviderDriverKind,
3 type ProviderInstanceId,4 type ProviderInstanceId,
4 type ProviderOptionDescriptor,5 type ProviderOptionDescriptor,
...@@ -122,11 +123,13 @@ function replaceDescriptorCurrentValue(...@@ -122,11 +123,13 @@ function replaceDescriptorCurrentValue(
122123
123function getDescriptorStringValue(124function getDescriptorStringValue(
124 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }> | null,125 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }> | null,
126 selection?: ModelSelection | null,
127 reportedSelection?: ModelSelection | null,
125): string | null {128): string | null {
126 if (!descriptor) {129 if (!descriptor) {
127 return null;130 return null;
128 }131 }
129 const value = getProviderOptionCurrentValue(descriptor);132 const value = getProviderOptionCurrentValue(descriptor, selection, reportedSelection);
130 return typeof value === "string" ? value : null;133 return typeof value === "string" ? value : null;
131}134}
132135
...@@ -274,6 +277,7 @@ export interface TraitsMenuContentProps {...@@ -274,6 +277,7 @@ export interface TraitsMenuContentProps {
274 prompt: string;277 prompt: string;
275 onPromptChange: (prompt: string) => void;278 onPromptChange: (prompt: string) => void;
276 modelOptions?: ProviderOptions | null | undefined;279 modelOptions?: ProviderOptions | null | undefined;
280 reportedModelSelection?: ModelSelection | null | undefined;
277 allowPromptInjectedEffort?: boolean;281 allowPromptInjectedEffort?: boolean;
278 planModeEnabled: boolean;282 planModeEnabled: boolean;
279 triggerClassName?: string;283 triggerClassName?: string;
...@@ -288,10 +292,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -288,10 +292,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
288 prompt,292 prompt,
289 onPromptChange,293 onPromptChange,
290 modelOptions,294 modelOptions,
295 reportedModelSelection,
291 allowPromptInjectedEffort = true,296 allowPromptInjectedEffort = true,
292 planModeEnabled,297 planModeEnabled,
293 ...persistence298 ...persistence
294}: TraitsMenuContentProps & TraitsPersistence) {299}: TraitsMenuContentProps & TraitsPersistence) {
300 const modelSelection =
301 instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null;
295 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);302 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
296 const updateModelOptions = useCallback(303 const updateModelOptions = useCallback(
297 (nextOptions: ProviderOptions | undefined) => {304 (nextOptions: ProviderOptions | undefined) => {
...@@ -362,7 +369,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -362,7 +369,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
362 return (369 return (
363 <>370 <>
364 {descriptors.map((descriptor, index) => {371 {descriptors.map((descriptor, index) => {
365 const value = getProviderOptionCurrentLabel(descriptor);372 const value = getProviderOptionCurrentLabel(
373 descriptor,
374 modelSelection,
375 reportedModelSelection,
376 );
366 if (!value) return null;377 if (!value) return null;
367 return (378 return (
368 <div key={descriptor.id}>379 <div key={descriptor.id}>
...@@ -386,7 +397,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -386,7 +397,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
386 const selectedValue =397 const selectedValue =
387 ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id398 ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id
388 ? "ultrathink"399 ? "ultrathink"
389 : (getDescriptorStringValue(descriptor) ?? "");400 : (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? "");
390401
391 return (402 return (
392 <div key={descriptor.id}>403 <div key={descriptor.id}>
...@@ -483,6 +494,8 @@ export function buildTraitsTriggerDisplay(input: {...@@ -483,6 +494,8 @@ export function buildTraitsTriggerDisplay(input: {
483 descriptors: ReadonlyArray<ProviderOptionDescriptor>;494 descriptors: ReadonlyArray<ProviderOptionDescriptor>;
484 primarySelectDescriptorId: string | null;495 primarySelectDescriptorId: string | null;
485 ultrathinkPromptControlled: boolean;496 ultrathinkPromptControlled: boolean;
497 modelSelection?: ModelSelection | null;
498 reportedModelSelection?: ModelSelection | null | undefined;
486}): { label: string; speedIcon: "fast" | "ultrafast" | null } {499}): { label: string; speedIcon: "fast" | "ultrafast" | null } {
487 let fastModeFallbackLabel: string | null = null;500 let fastModeFallbackLabel: string | null = null;
488 let speedIcon: "fast" | "ultrafast" | null = null;501 let speedIcon: "fast" | "ultrafast" | null = null;
...@@ -522,7 +535,11 @@ export function buildTraitsTriggerDisplay(input: {...@@ -522,7 +535,11 @@ export function buildTraitsTriggerDisplay(input: {
522 ? "Ultrathink"535 ? "Ultrathink"
523 : descriptor.type === "boolean"536 : descriptor.type === "boolean"
524 ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`537 ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`
525 : getProviderOptionCurrentLabel(descriptor);538 : getProviderOptionCurrentLabel(
539 descriptor,
540 input.modelSelection,
541 input.reportedModelSelection,
542 );
526 if (typeof label === "string" && label.length > 0) {543 if (typeof label === "string" && label.length > 0) {
527 labels.push(label);544 labels.push(label);
528 }545 }
...@@ -545,6 +562,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -545,6 +562,7 @@ export const TraitsPicker = memo(function TraitsPicker({
545 prompt,562 prompt,
546 onPromptChange,563 onPromptChange,
547 modelOptions,564 modelOptions,
565 reportedModelSelection,
548 allowPromptInjectedEffort = true,566 allowPromptInjectedEffort = true,
549 planModeEnabled,567 planModeEnabled,
550 triggerClassName,568 triggerClassName,
...@@ -588,6 +606,8 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -588,6 +606,8 @@ export const TraitsPicker = memo(function TraitsPicker({
588 descriptors,606 descriptors,
589 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,607 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
590 ultrathinkPromptControlled,608 ultrathinkPromptControlled,
609 modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null,
610 reportedModelSelection,
591 });611 });
592 const speedLabel = speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on";612 const speedLabel = speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on";
593 const accessibleLabel = speedIcon ? `${triggerLabel}, ${speedLabel}` : triggerLabel;613 const accessibleLabel = speedIcon ? `${triggerLabel}, ${speedLabel}` : triggerLabel;
...@@ -686,6 +706,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -686,6 +706,7 @@ export const TraitsPicker = memo(function TraitsPicker({
686 prompt={prompt}706 prompt={prompt}
687 onPromptChange={onPromptChange}707 onPromptChange={onPromptChange}
688 modelOptions={modelOptions}708 modelOptions={modelOptions}
709 reportedModelSelection={reportedModelSelection}
689 allowPromptInjectedEffort={allowPromptInjectedEffort}710 allowPromptInjectedEffort={allowPromptInjectedEffort}
690 planModeEnabled={planModeEnabled}711 planModeEnabled={planModeEnabled}
691 {...persistence}712 {...persistence}
apps/web/src/components/chat/composerProviderState.tsx+4
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 type ModelCapabilities,2 type ModelCapabilities,
3 type ModelSelection,
3 type ProviderDriverKind,4 type ProviderDriverKind,
4 type ProviderInstanceId,5 type ProviderInstanceId,
5 type ProviderOptionSelection,6 type ProviderOptionSelection,
...@@ -48,6 +49,7 @@ type TraitsRenderInput = {...@@ -48,6 +49,7 @@ type TraitsRenderInput = {
48 model: string;49 model: string;
49 models: ReadonlyArray<ServerProviderModel>;50 models: ReadonlyArray<ServerProviderModel>;
50 modelOptions: ReadonlyArray<ProviderOptionSelection> | undefined;51 modelOptions: ReadonlyArray<ProviderOptionSelection> | undefined;
52 reportedModelSelection?: ModelSelection | null | undefined;
51 prompt: string;53 prompt: string;
52 onPromptChange: (prompt: string) => void;54 onPromptChange: (prompt: string) => void;
53 planModeEnabled: boolean;55 planModeEnabled: boolean;
...@@ -170,6 +172,7 @@ function renderTraitsControl(...@@ -170,6 +172,7 @@ function renderTraitsControl(
170 model,172 model,
171 models,173 models,
172 modelOptions,174 modelOptions,
175 reportedModelSelection,
173 prompt,176 prompt,
174 onPromptChange,177 onPromptChange,
175 planModeEnabled,178 planModeEnabled,
...@@ -208,6 +211,7 @@ function renderTraitsControl(...@@ -208,6 +211,7 @@ function renderTraitsControl(
208 {...(draftId ? { draftId } : {})}211 {...(draftId ? { draftId } : {})}
209 model={model}212 model={model}
210 modelOptions={resolvedModelOptions}213 modelOptions={resolvedModelOptions}
214 reportedModelSelection={reportedModelSelection}
211 prompt={prompt}215 prompt={prompt}
212 onPromptChange={onPromptChange}216 onPromptChange={onPromptChange}
213 planModeEnabled={planModeEnabled}217 planModeEnabled={planModeEnabled}
packages/client-runtime/src/state/threadExecution.test.ts+73
...@@ -3,6 +3,9 @@ import {...@@ -3,6 +3,9 @@ import {
3 NodeId,3 NodeId,
4 MessageId,4 MessageId,
5 ProviderInstanceId,5 ProviderInstanceId,
6 ProviderThreadId,
7 ProviderSessionId,
8 ProviderDriverKind,
6 RunId,9 RunId,
7 ThreadId,10 ThreadId,
8 type OrchestrationV2ExecutionNode,11 type OrchestrationV2ExecutionNode,
...@@ -15,6 +18,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -15,6 +18,7 @@ import { describe, expect, it } from "vite-plus/test";
15import { v2Projection } from "./orchestrationV2TestFixtures.ts";18import { v2Projection } from "./orchestrationV2TestFixtures.ts";
16import {19import {
17 presentPendingBackgroundWork,20 presentPendingBackgroundWork,
21 deriveReportedModelSelection,
18 deriveLatestThreadRun,22 deriveLatestThreadRun,
19 deriveProviderSubagentStatus,23 deriveProviderSubagentStatus,
20 formatModelSelectionEffort,24 formatModelSelectionEffort,
...@@ -578,3 +582,72 @@ describe("presentPendingBackgroundWork", () => {...@@ -578,3 +582,72 @@ describe("presentPendingBackgroundWork", () => {
578 );582 );
579 });583 });
580});584});
585
586describe("provider-reported model selection", () => {
587 const selected = v2Projection.thread.modelSelection;
588 const reported = { ...selected, options: [{ id: "reasoningEffort", value: "default" }] };
589 const providerThread = {
590 id: ProviderThreadId.make("active"),
591 driver: ProviderDriverKind.make("codex"),
592 providerInstanceId: selected.instanceId,
593 providerSessionId: ProviderSessionId.make("session"),
594 appThreadId: v2Projection.thread.id,
595 ownerNodeId: null,
596 nativeThreadRef: null,
597 nativeConversationHeadRef: null,
598 status: "idle" as const,
599 firstRunOrdinal: null,
600 lastRunOrdinal: null,
601 handoffIds: [],
602 forkedFrom: null,
603 createdAt: now,
604 updatedAt: now,
605 nativeMetadata: { modelSelection: reported },
606 };
607 const projection = {
608 ...v2Projection,
609 thread: { ...v2Projection.thread, activeProviderThreadId: providerThread.id },
610 providerThreads: [providerThread],
611 };
612
613 it("reads only the active provider thread's reported selection", () => {
614 expect(deriveReportedModelSelection(projection)).toBe(reported);
615 expect(
616 deriveReportedModelSelection({
617 ...projection,
618 thread: { ...projection.thread, activeProviderThreadId: null },
619 }),
620 ).toBeNull();
621 expect(
622 deriveReportedModelSelection({
623 ...projection,
624 providerThreads: [
625 { ...providerThread, providerInstanceId: ProviderInstanceId.make("other") },
626 ],
627 }),
628 ).toBeNull();
629 });
630
631 it("shows the reported default in a subagent's effort label", () => {
632 const models = [
633 {
634 slug: selected.model,
635 name: selected.model,
636 isCustom: false,
637 capabilities: {
638 optionDescriptors: [
639 {
640 id: "variant",
641 label: "Reasoning",
642 type: "select" as const,
643 options: [{ id: "high", label: "High" }],
644 },
645 ],
646 },
647 },
648 ];
649 const variantReport = { ...selected, options: [{ id: "variant", value: "default" }] };
650 expect(formatModelSelectionEffort(selected, models, variantReport)).toBe("Default");
651 expect(formatModelSelectionEffort(selected, models)).toBe("Unknown");
652 });
653});
packages/client-runtime/src/state/threadExecution.ts+14-1
...@@ -142,6 +142,18 @@ export function deriveProviderSubagentStatus(...@@ -142,6 +142,18 @@ export function deriveProviderSubagentStatus(
142 };142 };
143}143}
144144
145/** The observed selection belongs to the active provider thread, never a previous handoff. */
146export function deriveReportedModelSelection(
147 projection: OrchestrationV2ThreadProjection,
148): ModelSelection | null {
149 const providerThread = projection.providerThreads.find(
150 (candidate) =>
151 candidate.id === projection.thread.activeProviderThreadId &&
152 candidate.providerInstanceId === projection.thread.modelSelection.instanceId,
153 );
154 return providerThread?.nativeMetadata?.modelSelection ?? null;
155}
156
145// Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode).157// Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode).
146const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const;158const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const;
147159
...@@ -155,6 +167,7 @@ const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "...@@ -155,6 +167,7 @@ const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "
155export function formatModelSelectionEffort(167export function formatModelSelectionEffort(
156 selection: ModelSelection,168 selection: ModelSelection,
157 models: ReadonlyArray<ServerProviderModel> = [],169 models: ReadonlyArray<ServerProviderModel> = [],
170 reportedSelection?: ModelSelection | null,
158): string | null {171): string | null {
159 const caps = models.find((model) => model.slug === selection.model)?.capabilities;172 const caps = models.find((model) => model.slug === selection.model)?.capabilities;
160 if (!caps) return null;173 if (!caps) return null;
...@@ -162,7 +175,7 @@ export function formatModelSelectionEffort(...@@ -162,7 +175,7 @@ export function formatModelSelectionEffort(
162 for (const id of REASONING_EFFORT_OPTION_IDS) {175 for (const id of REASONING_EFFORT_OPTION_IDS) {
163 const descriptor = descriptors.find((candidate) => candidate.id === id);176 const descriptor = descriptors.find((candidate) => candidate.id === id);
164 if (descriptor?.type !== "select") continue;177 if (descriptor?.type !== "select") continue;
165 const label = getProviderOptionCurrentLabel(descriptor);178 const label = getProviderOptionCurrentLabel(descriptor, selection, reportedSelection);
166 if (label) return label;179 if (label) return label;
167 }180 }
168 return null;181 return null;
packages/contracts/src/orchestrationV2.ts+2
...@@ -796,6 +796,8 @@ export type OrchestrationV2PendingBackgroundTask = typeof OrchestrationV2Pending...@@ -796,6 +796,8 @@ export type OrchestrationV2PendingBackgroundTask = typeof OrchestrationV2Pending
796796
797/** Provider and adapter metadata that should not overwrite the app thread's title. */797/** Provider and adapter metadata that should not overwrite the app thread's title. */
798export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({798export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({
799 /** Provider-reported selection for display, separate from the app's saved preferences. */
800 modelSelection: Schema.optional(ModelSelection),
799 title: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),801 title: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
800 updatedAt: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),802 updatedAt: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
801 /** Version 2 scopes provider-derived item ids by provider instance. */803 /** Version 2 scopes provider-derived item ids by provider instance. */
packages/shared/src/model.test.ts+61
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 createModelSelection,9 createModelSelection,
10 formatCodexModelName,10 formatCodexModelName,
11 formatModelSlugName,11 formatModelSlugName,
12 getProviderOptionCurrentLabel,
12 getModelSelectionBooleanOptionValue,13 getModelSelectionBooleanOptionValue,
13 getModelSelectionStringOptionValue,14 getModelSelectionStringOptionValue,
14 getProviderOptionDescriptors,15 getProviderOptionDescriptors,
...@@ -302,3 +303,63 @@ describe("readCustomModelEntries", () => {...@@ -302,3 +303,63 @@ describe("readCustomModelEntries", () => {
302 });303 });
303 });304 });
304});305});
306
307describe("provider-reported option display", () => {
308 const selection = createModelSelection(ProviderInstanceId.make("opencode"), "ling");
309 const reported = { ...selection, options: [{ id: "variant", value: "default" }] };
310 const descriptor = {
311 id: "variant",
312 label: "Reasoning",
313 type: "select" as const,
314 options: [
315 { id: "none", label: "None" },
316 { id: "thinking", label: "Thinking" },
317 ],
318 };
319
320 it("shows explicit reports without adding a choice or a dispatch option", () => {
321 expect(getProviderOptionCurrentLabel(descriptor, selection, reported)).toBe("Default");
322 expect(
323 getProviderOptionCurrentLabel(descriptor, selection, {
324 ...reported,
325 options: [{ id: "variant", value: "thinking" }],
326 }),
327 ).toBe("Thinking");
328 expect(
329 getProviderOptionCurrentLabel(
330 { ...descriptor, currentValue: "none" },
331 { ...selection, options: [{ id: "variant", value: "none" }] },
332 reported,
333 ),
334 ).toBe("None");
335 expect(descriptor.options.map((option) => option.id)).toEqual(["none", "thinking"]);
336 expect(buildProviderOptionSelectionsFromDescriptors([descriptor])).toBeUndefined();
337 expect(getProviderOptionCurrentLabel(descriptor, selection)).toBe("Unknown");
338 const effortDescriptor = { ...descriptor, id: "effort", currentValue: "default" };
339 expect(getProviderOptionCurrentLabel(effortDescriptor, selection)).toBeUndefined();
340 expect(
341 getProviderOptionCurrentLabel(effortDescriptor, selection, {
342 ...reported,
343 model: "other",
344 options: [{ id: "effort", value: "default" }],
345 }),
346 ).toBeUndefined();
347 expect(
348 getProviderOptionCurrentLabel(effortDescriptor, selection, {
349 ...reported,
350 options: [{ id: "effort", value: "default" }],
351 }),
352 ).toBe("Default");
353 expect(
354 getProviderOptionCurrentLabel({ ...descriptor, currentValue: "thinking" }, selection),
355 ).toBe("Unknown");
356 });
357
358 it.each([
359 { ...selection, model: "other" },
360 { ...selection, instanceId: ProviderInstanceId.make("other") },
361 { ...selection, options: [{ id: "variant", value: "none" }] },
362 ])("ignores reports after changing the model, instance, or option: %j", (selected) => {
363 expect(getProviderOptionCurrentLabel(descriptor, selected, reported)).toBe("Unknown");
364 });
365});
packages/shared/src/model.ts+34-5
...@@ -202,12 +202,35 @@ export function getProviderOptionDescriptors(input: {...@@ -202,12 +202,35 @@ export function getProviderOptionDescriptors(input: {
202 );202 );
203}203}
204204
205function getReportedOptionValue(
206 id: string,
207 selection?: ModelSelection | null,
208 reportedSelection?: ModelSelection | null,
209) {
210 if (
211 !selection ||
212 !reportedSelection ||
213 selection.instanceId !== reportedSelection.instanceId ||
214 selection.model !== reportedSelection.model ||
215 selection.options?.some((option) => option.id === id)
216 )
217 return undefined;
218 return getRawSelectionValueById(reportedSelection.options, id);
219}
220
205export function getProviderOptionCurrentValue(221export function getProviderOptionCurrentValue(
206 descriptor: ProviderOptionDescriptor | null | undefined,222 descriptor: ProviderOptionDescriptor | null | undefined,
223 selection?: ModelSelection | null,
224 reportedSelection?: ModelSelection | null,
207): string | boolean | undefined {225): string | boolean | undefined {
208 if (!descriptor) {226 if (!descriptor) {
209 return undefined;227 return undefined;
210 }228 }
229 const hasExplicitOption = selection?.options?.some((option) => option.id === descriptor.id);
230 // Reported values are display-only; callers that build dispatch options omit this context.
231 const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection);
232 if (reportedValue !== undefined) return reportedValue;
233 if (descriptor.id === "variant" && selection && !hasExplicitOption) return undefined;
211 if (descriptor.type === "boolean") {234 if (descriptor.type === "boolean") {
212 return descriptor.currentValue;235 return descriptor.currentValue;
213 }236 }
...@@ -219,6 +242,8 @@ export function getProviderOptionCurrentValue(...@@ -219,6 +242,8 @@ export function getProviderOptionCurrentValue(
219242
220export function getProviderOptionCurrentLabel(243export function getProviderOptionCurrentLabel(
221 descriptor: ProviderOptionDescriptor | null | undefined,244 descriptor: ProviderOptionDescriptor | null | undefined,
245 selection?: ModelSelection | null,
246 reportedSelection?: ModelSelection | null,
222): string | undefined {247): string | undefined {
223 if (!descriptor) {248 if (!descriptor) {
224 return undefined;249 return undefined;
...@@ -230,11 +255,15 @@ export function getProviderOptionCurrentLabel(...@@ -230,11 +255,15 @@ export function getProviderOptionCurrentLabel(
230 : "Off"255 : "Off"
231 : undefined;256 : undefined;
232 }257 }
233 const currentValue = getProviderOptionCurrentValue(descriptor);258 const currentValue = getProviderOptionCurrentValue(descriptor, selection, reportedSelection);
234 if (typeof currentValue !== "string") {259 return (
235 return undefined;260 descriptor.options.find((option) => option.id === currentValue)?.label ??
236 }261 (getReportedOptionValue(descriptor.id, selection, reportedSelection) === "default"
237 return descriptor.options.find((option) => option.id === currentValue)?.label;262 ? "Default"
263 : descriptor.id === "variant"
264 ? "Unknown"
265 : undefined)
266 );
238}267}
239268
240export function buildProviderOptionSelectionsFromDescriptors(269export function buildProviderOptionSelectionsFromDescriptors(