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 {
150150 */
151151 readonly threadSyncPhase?: "loading" | "syncing" | null;
152152 readonly selectedThread: EnvironmentThreadShell;
153 readonly reportedModelSelection?: ModelSelection | null;
153154 readonly hasCompactableConversation: boolean;
154155 readonly serverConfig: T3ServerConfig | null;
155156 readonly queueCount: number;
......@@ -667,6 +668,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
667668 providerInstanceId: currentModelSelection.instanceId,
668669 providerGroups: threadProviderGroups,
669670 selectedModel: currentModelSelection,
671 reportedModelSelection: props.reportedModelSelection,
670672 onSelectModel: (option) =>
671673 props.onUpdateModelSelection(withRememberedModelOptions(option.selection)),
672674 optionDescriptors: providerOptionDescriptors,
......@@ -683,6 +685,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
683685 }),
684686 [
685687 currentModelSelection,
688 props.reportedModelSelection,
686689 currentRuntimeMode,
687690 props.onUpdateModelSelection,
688691 props.onUpdateRuntimeMode,
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+7
......@@ -1,3 +1,4 @@
1import { useThreadReportedModelSelection } from "../../state/entities";
12import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
23import { useNavigation } from "@react-navigation/native";
34import type { WorktreeSetupCardProps } from "./worktree-setup-card";
......@@ -303,6 +304,10 @@ const USER_INPUT_TOGGLE_TIMING = {
303304
304305export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
305306 const navigation = useNavigation();
307 const reportedModelSelection = useThreadReportedModelSelection({
308 environmentId: props.environmentId,
309 threadId: props.selectedThread.id,
310 });
306311 const deviceState = useEnvironmentQuery(
307312 deviceEnvironment.state({ environmentId: props.environmentId, input: {} }),
308313 );
......@@ -1277,6 +1282,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
12771282 effortLabel={formatModelSelectionEffort(
12781283 props.selectedThread.modelSelection,
12791284 providerSubagentProvider?.models,
1285 reportedModelSelection,
12801286 )}
12811287 status={props.providerSubagentStatus ?? null}
12821288 onOpenParent={
......@@ -1293,6 +1299,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
12931299 ) : (
12941300 <>
12951301 <ThreadComposer
1302 reportedModelSelection={reportedModelSelection}
12961303 editorRef={composerEditorRef}
12971304 draftMessage={props.draftMessage}
12981305 draftAttachments={props.draftAttachments}
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+19-2
......@@ -245,6 +245,7 @@ type ThreadSettingsSessionProps = {
245245 readonly providerInstanceId?: ProviderInstanceId;
246246 readonly providerGroups: ReadonlyArray<ProviderGroup>;
247247 readonly selectedModel: ModelSelection | null;
248 readonly reportedModelSelection?: ModelSelection | null;
248249 readonly onSelectModel: (option: ModelOption) => void;
249250 readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
250251 readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void;
......@@ -304,6 +305,8 @@ type ThreadSettingsSessionValue = {
304305 readonly runtimeModeChoices: ReturnType<typeof runtimeModeChoicesForSupportedModes>;
305306 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
306307 readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
308 readonly displayedModelSelection: ModelSelection | null;
309 readonly reportedModelSelection: ModelSelection | null;
307310 readonly providerExpansionOverrides: ReadonlySet<string>;
308311 readonly hasLegacyModels: boolean;
309312 readonly pendingModel: ModelOption | null;
......@@ -476,6 +479,8 @@ function ThreadSettingsSessionProvider(
476479 runtimeModeChoices,
477480 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
478481 displayedDescriptors,
482 displayedModelSelection: pendingModel?.selection ?? props.selectedModel,
483 reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null),
479484 favoriteKeys,
480485 favoritesLoaded,
481486 providerExpansionOverrides,
......@@ -507,6 +512,8 @@ function ThreadSettingsSessionProvider(
507512 isApplied,
508513 isDisplayed,
509514 props.environmentId,
515 props.selectedModel,
516 props.reportedModelSelection,
510517 props.providerInstanceId,
511518 pendingModel,
512519 pressModel,
......@@ -743,7 +750,11 @@ function ThreadSettingsOptionsItem(props: {
743750 >
744751 <DisclosureRow
745752 label={descriptor.label}
746 value={getProviderOptionCurrentLabel(descriptor)}
753 value={getProviderOptionCurrentLabel(
754 descriptor,
755 session.displayedModelSelection,
756 session.reportedModelSelection,
757 )}
747758 onPress={() => props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })}
748759 />
749760 </Animated.View>
......@@ -997,7 +1008,13 @@ function ThreadSettingsChoiceContent(props: {
9971008 id: choice.id,
9981009 label: choice.label,
9991010 description: undefined,
1000 selected: choice.id === getProviderOptionCurrentValue(activeDescriptor),
1011 selected:
1012 choice.id ===
1013 getProviderOptionCurrentValue(
1014 activeDescriptor,
1015 session.displayedModelSelection,
1016 session.reportedModelSelection,
1017 ),
10011018 onPress: () => {
10021019 void Haptics.selectionAsync();
10031020 session.applyOptionChange(activeDescriptor.id, choice.id);
apps/mobile/src/state/entities.ts+10-1
......@@ -1,8 +1,10 @@
11import { useAtomValue } from "@effect/atom-react";
2import { deriveReportedModelSelection } from "@t3tools/client-runtime/state/thread-execution";
23
34import { appAtomRegistry } from "./atom-registry";
45import type {
56 EnvironmentProject,
7 EnvironmentThread,
68 EnvironmentThreadShell,
79} from "@t3tools/client-runtime/state/shell";
810import type {
......@@ -15,7 +17,7 @@ import { Atom } from "effect/unstable/reactivity";
1517
1618import { environmentProjects } from "./projects";
1719import { environmentServerConfigsAtom, serverEnvironment } from "./server";
18import { environmentThreadShells } from "./threads";
20import { environmentThreadDetails, environmentThreadShells } from "./threads";
1921
2022const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
2123 Atom.withLabel("mobile-project:empty"),
......@@ -87,3 +89,10 @@ export function useEnvironmentServerConfig(
8789export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
8890 return useAtomValue(environmentServerConfigsAtom);
8991}
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", () => {
410410 promptAccepted,
411411 event("session.execution.succeeded", { sessionID: SESSION }),
412412 ]);
413 assert.deepEqual(thread.nativeMetadata?.modelSelection, {
414 ...bigPickle,
415 options: [{ id: "variant", value: "default" }],
416 });
417 assert.equal(bigPickle.options, undefined);
413418 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
414419 yield* runtime.startTurn(
415420 turnInput(thread, {
......@@ -2833,6 +2838,10 @@ describe("OpenCode2 adapter", () => {
28332838 modelSelection: bigPickle,
28342839 runtimePolicy: customPolicy,
28352840 });
2841 assert.deepEqual(thread.nativeMetadata?.modelSelection, {
2842 ...bigPickle,
2843 options: [{ id: "variant", value: "default" }],
2844 });
28362845 // Each directory keeps its own limit, whichever was read last.
28372846 assert.equal(runtime.getModelContextWindow?.(bigPickle, WORK), 160000);
28382847 assert.equal(runtime.getModelContextWindow?.(bigPickle, custom), 48000);
......@@ -3869,3 +3878,44 @@ const providerTurnId = Effect.gen(function* () {
38693878 nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`,
38703879 });
38713880}).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";
7878import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts";
7979import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts";
8080import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
81import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
81import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
8282import { causeErrorTag } from "@t3tools/shared/observability";
8383
8484import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts";
......@@ -1385,6 +1385,27 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
13851385 });
13861386 });
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
13881409 /**
13891410 * Gives a subagent call its session once both are known: OpenCode names
13901411 * 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
14631484 const child =
14641485 previous ?? newThreadState(childId, providerThread, call.state.directory, subagent);
14651486 child.subagent = subagent;
1466 child.providerThread = providerThread;
1487 child.model = info?.model ?? child.model;
1488 child.providerThread = withReportedModel(providerThread, child.model);
14671489 child.directory = call.state.directory;
14681490 child.agent = info?.agent ?? call.agent ?? child.agent;
14691491 // 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
14721494 childOwners.set(childId, rootOf(call.state));
14731495 call.child = child;
14741496 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 });
14761502 yield* emitSubagent(call);
14771503 // A session called again was not made now, so it may hold the rules of
14781504 // 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
25202546 if (sessionId === undefined) return;
25212547 const state = threads.get(sessionId);
25222548 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 }
25232561 if (
25242562 event.type === "session.inbox.enqueued" &&
25252563 state.subagent !== undefined &&
......@@ -2962,6 +3000,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
29623000 },
29633001 directory: string,
29643002 ) => {
3003 providerThread = withReportedModel(providerThread, native.model);
29653004 const existing = threads.get(native.id);
29663005 if (existing !== undefined) {
29673006 existing.providerThread = providerThread;
......@@ -3582,7 +3621,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
35823621 directory,
35833622 );
35843623 state.policy = policy;
3585 return providerThread;
3624 return state.providerThread;
35863625 }).pipe(
35873626 Effect.mapError((cause) =>
35883627 isProviderAdapterError(cause)
......@@ -3625,7 +3664,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
36253664 directory: AbsolutePath.make(cwd),
36263665 });
36273666 }
3628 return providerThread;
3667 return state.providerThread;
36293668 }).pipe(
36303669 Effect.mapError((cause) =>
36313670 isProviderAdapterError(cause)
......@@ -4183,7 +4222,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
41834222 directory: AbsolutePath.make(cwd),
41844223 });
41854224 }
4186 return providerThread;
4225 return state.providerThread;
41874226 }).pipe(
41884227 exclusive(forkInput.sourceProviderThread),
41894228 Effect.mapError((cause) =>
apps/web/src/components/ChatView.tsx+10-1
......@@ -88,6 +88,7 @@ import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/th
8888import { useThreadActions } from "../hooks/useThreadActions";
8989import {
9090 deriveProviderSubagentStatus,
91 deriveReportedModelSelection,
9192 formatModelSelectionEffort,
9293 deriveRunlessWorkStartedAt,
9394 deriveThreadActivityRun,
......@@ -1626,6 +1627,9 @@ export default function ChatView(props: ChatViewProps) {
16261627 });
16271628 const serverThreadProjection = useThreadProjection(routeThreadDetailRef);
16281629 const serverProjection = serverThreadProjection?.projection ?? null;
1630 const reportedModelSelection = serverProjection
1631 ? deriveReportedModelSelection(serverProjection)
1632 : null;
16291633 const threadStatus = useThreadStatus(routeThreadDetailRef);
16301634 const threadSyncPhase = resolveThreadSyncPhase({
16311635 detailExists: serverProjection !== null,
......@@ -4073,7 +4077,11 @@ export default function ChatView(props: ChatViewProps) {
40734077 const providerSubagentEffortLabel =
40744078 activeThread === undefined
40754079 ? null
4076 : formatModelSelectionEffort(activeThread.modelSelection, providerSubagentModels);
4080 : formatModelSelectionEffort(
4081 activeThread.modelSelection,
4082 providerSubagentModels,
4083 reportedModelSelection,
4084 );
40774085 const mountComposerContextStrip = shouldShowComposerContextStrip({
40784086 isDraftHeroState,
40794087 persistInActiveThreads: settings.persistComposerContextStrip,
......@@ -10818,6 +10826,7 @@ export default function ChatView(props: ChatViewProps) {
1081810826 ) : null}
1081910827 {!composerMounted ? null : (
1082010828 <ChatComposer
10829 reportedModelSelection={reportedModelSelection}
1082110830 multipleModelSelections={multipleModelSelections}
1082210831 supportsMultipleModels={
1082310832 serverConfig?.environment.capabilities.requiredWorktreeBootstrap ===
apps/web/src/components/chat/ChatComposer.tsx+4
......@@ -1581,6 +1581,7 @@ export interface ChatComposerProps {
15811581 providerCatalogKnown: boolean;
15821582 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
15831583 activeThreadModelSelection: ModelSelection | null | undefined;
1584 reportedModelSelection?: ModelSelection | null;
15841585
15851586 // Context window
15861587 activeContextWindow: ContextWindowSnapshot | null;
......@@ -1728,6 +1729,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17281729 providerCatalogKnown,
17291730 activeProjectDefaultModelSelection,
17301731 activeThreadModelSelection,
1732 reportedModelSelection,
17311733 activeContextWindow,
17321734 compactThreadUnavailable,
17331735 compactDisabled,
......@@ -2877,6 +2879,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28772879 model: selectedModel,
28782880 models: selectedProviderModels,
28792881 modelOptions: composerModelOptions?.[selectedInstanceId],
2882 reportedModelSelection,
28802883 prompt,
28812884 onPromptChange: setPromptFromTraits,
28822885 planModeEnabled: settings.planModeEnabled,
......@@ -2889,6 +2892,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28892892 model: selectedModel,
28902893 models: selectedProviderModels,
28912894 modelOptions: composerModelOptions?.[selectedInstanceId],
2895 reportedModelSelection,
28922896 prompt,
28932897 onPromptChange: setPromptFromTraits,
28942898 planModeEnabled: settings.planModeEnabled,
apps/web/src/components/chat/TraitsPicker.test.ts+36-1
......@@ -1,5 +1,9 @@
11import { 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";
37import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker";
48
59function selectDescriptor(
......@@ -232,3 +236,34 @@ describe("buildUnavailableModelOptionDescriptors", () => {
232236 ]);
233237 });
234238});
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 @@
11import {
2 type ModelSelection,
23 type ProviderDriverKind,
34 type ProviderInstanceId,
45 type ProviderOptionDescriptor,
......@@ -122,11 +123,13 @@ function replaceDescriptorCurrentValue(
122123
123124function getDescriptorStringValue(
124125 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }> | null,
126 selection?: ModelSelection | null,
127 reportedSelection?: ModelSelection | null,
125128): string | null {
126129 if (!descriptor) {
127130 return null;
128131 }
129 const value = getProviderOptionCurrentValue(descriptor);
132 const value = getProviderOptionCurrentValue(descriptor, selection, reportedSelection);
130133 return typeof value === "string" ? value : null;
131134}
132135
......@@ -274,6 +277,7 @@ export interface TraitsMenuContentProps {
274277 prompt: string;
275278 onPromptChange: (prompt: string) => void;
276279 modelOptions?: ProviderOptions | null | undefined;
280 reportedModelSelection?: ModelSelection | null | undefined;
277281 allowPromptInjectedEffort?: boolean;
278282 planModeEnabled: boolean;
279283 triggerClassName?: string;
......@@ -288,10 +292,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
288292 prompt,
289293 onPromptChange,
290294 modelOptions,
295 reportedModelSelection,
291296 allowPromptInjectedEffort = true,
292297 planModeEnabled,
293298 ...persistence
294299}: TraitsMenuContentProps & TraitsPersistence) {
300 const modelSelection =
301 instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null;
295302 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
296303 const updateModelOptions = useCallback(
297304 (nextOptions: ProviderOptions | undefined) => {
......@@ -362,7 +369,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
362369 return (
363370 <>
364371 {descriptors.map((descriptor, index) => {
365 const value = getProviderOptionCurrentLabel(descriptor);
372 const value = getProviderOptionCurrentLabel(
373 descriptor,
374 modelSelection,
375 reportedModelSelection,
376 );
366377 if (!value) return null;
367378 return (
368379 <div key={descriptor.id}>
......@@ -386,7 +397,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
386397 const selectedValue =
387398 ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id
388399 ? "ultrathink"
389 : (getDescriptorStringValue(descriptor) ?? "");
400 : (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? "");
390401
391402 return (
392403 <div key={descriptor.id}>
......@@ -483,6 +494,8 @@ export function buildTraitsTriggerDisplay(input: {
483494 descriptors: ReadonlyArray<ProviderOptionDescriptor>;
484495 primarySelectDescriptorId: string | null;
485496 ultrathinkPromptControlled: boolean;
497 modelSelection?: ModelSelection | null;
498 reportedModelSelection?: ModelSelection | null | undefined;
486499}): { label: string; speedIcon: "fast" | "ultrafast" | null } {
487500 let fastModeFallbackLabel: string | null = null;
488501 let speedIcon: "fast" | "ultrafast" | null = null;
......@@ -522,7 +535,11 @@ export function buildTraitsTriggerDisplay(input: {
522535 ? "Ultrathink"
523536 : descriptor.type === "boolean"
524537 ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`
525 : getProviderOptionCurrentLabel(descriptor);
538 : getProviderOptionCurrentLabel(
539 descriptor,
540 input.modelSelection,
541 input.reportedModelSelection,
542 );
526543 if (typeof label === "string" && label.length > 0) {
527544 labels.push(label);
528545 }
......@@ -545,6 +562,7 @@ export const TraitsPicker = memo(function TraitsPicker({
545562 prompt,
546563 onPromptChange,
547564 modelOptions,
565 reportedModelSelection,
548566 allowPromptInjectedEffort = true,
549567 planModeEnabled,
550568 triggerClassName,
......@@ -588,6 +606,8 @@ export const TraitsPicker = memo(function TraitsPicker({
588606 descriptors,
589607 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
590608 ultrathinkPromptControlled,
609 modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null,
610 reportedModelSelection,
591611 });
592612 const speedLabel = speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on";
593613 const accessibleLabel = speedIcon ? `${triggerLabel}, ${speedLabel}` : triggerLabel;
......@@ -686,6 +706,7 @@ export const TraitsPicker = memo(function TraitsPicker({
686706 prompt={prompt}
687707 onPromptChange={onPromptChange}
688708 modelOptions={modelOptions}
709 reportedModelSelection={reportedModelSelection}
689710 allowPromptInjectedEffort={allowPromptInjectedEffort}
690711 planModeEnabled={planModeEnabled}
691712 {...persistence}
apps/web/src/components/chat/composerProviderState.tsx+4
......@@ -1,5 +1,6 @@
11import {
22 type ModelCapabilities,
3 type ModelSelection,
34 type ProviderDriverKind,
45 type ProviderInstanceId,
56 type ProviderOptionSelection,
......@@ -48,6 +49,7 @@ type TraitsRenderInput = {
4849 model: string;
4950 models: ReadonlyArray<ServerProviderModel>;
5051 modelOptions: ReadonlyArray<ProviderOptionSelection> | undefined;
52 reportedModelSelection?: ModelSelection | null | undefined;
5153 prompt: string;
5254 onPromptChange: (prompt: string) => void;
5355 planModeEnabled: boolean;
......@@ -170,6 +172,7 @@ function renderTraitsControl(
170172 model,
171173 models,
172174 modelOptions,
175 reportedModelSelection,
173176 prompt,
174177 onPromptChange,
175178 planModeEnabled,
......@@ -208,6 +211,7 @@ function renderTraitsControl(
208211 {...(draftId ? { draftId } : {})}
209212 model={model}
210213 modelOptions={resolvedModelOptions}
214 reportedModelSelection={reportedModelSelection}
211215 prompt={prompt}
212216 onPromptChange={onPromptChange}
213217 planModeEnabled={planModeEnabled}
packages/client-runtime/src/state/threadExecution.test.ts+73
......@@ -3,6 +3,9 @@ import {
33 NodeId,
44 MessageId,
55 ProviderInstanceId,
6 ProviderThreadId,
7 ProviderSessionId,
8 ProviderDriverKind,
69 RunId,
710 ThreadId,
811 type OrchestrationV2ExecutionNode,
......@@ -15,6 +18,7 @@ import { describe, expect, it } from "vite-plus/test";
1518import { v2Projection } from "./orchestrationV2TestFixtures.ts";
1619import {
1720 presentPendingBackgroundWork,
21 deriveReportedModelSelection,
1822 deriveLatestThreadRun,
1923 deriveProviderSubagentStatus,
2024 formatModelSelectionEffort,
......@@ -578,3 +582,72 @@ describe("presentPendingBackgroundWork", () => {
578582 );
579583 });
580584});
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(
142142 };
143143}
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
145157// Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode).
146158const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const;
147159
......@@ -155,6 +167,7 @@ const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "
155167export function formatModelSelectionEffort(
156168 selection: ModelSelection,
157169 models: ReadonlyArray<ServerProviderModel> = [],
170 reportedSelection?: ModelSelection | null,
158171): string | null {
159172 const caps = models.find((model) => model.slug === selection.model)?.capabilities;
160173 if (!caps) return null;
......@@ -162,7 +175,7 @@ export function formatModelSelectionEffort(
162175 for (const id of REASONING_EFFORT_OPTION_IDS) {
163176 const descriptor = descriptors.find((candidate) => candidate.id === id);
164177 if (descriptor?.type !== "select") continue;
165 const label = getProviderOptionCurrentLabel(descriptor);
178 const label = getProviderOptionCurrentLabel(descriptor, selection, reportedSelection);
166179 if (label) return label;
167180 }
168181 return null;
packages/contracts/src/orchestrationV2.ts+2
......@@ -796,6 +796,8 @@ export type OrchestrationV2PendingBackgroundTask = typeof OrchestrationV2Pending
796796
797797/** Provider and adapter metadata that should not overwrite the app thread's title. */
798798export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({
799 /** Provider-reported selection for display, separate from the app's saved preferences. */
800 modelSelection: Schema.optional(ModelSelection),
799801 title: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
800802 updatedAt: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
801803 /** Version 2 scopes provider-derived item ids by provider instance. */
packages/shared/src/model.test.ts+61
......@@ -9,6 +9,7 @@ import {
99 createModelSelection,
1010 formatCodexModelName,
1111 formatModelSlugName,
12 getProviderOptionCurrentLabel,
1213 getModelSelectionBooleanOptionValue,
1314 getModelSelectionStringOptionValue,
1415 getProviderOptionDescriptors,
......@@ -302,3 +303,63 @@ describe("readCustomModelEntries", () => {
302303 });
303304 });
304305});
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: {
202202 );
203203}
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
205221export function getProviderOptionCurrentValue(
206222 descriptor: ProviderOptionDescriptor | null | undefined,
223 selection?: ModelSelection | null,
224 reportedSelection?: ModelSelection | null,
207225): string | boolean | undefined {
208226 if (!descriptor) {
209227 return undefined;
210228 }
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;
211234 if (descriptor.type === "boolean") {
212235 return descriptor.currentValue;
213236 }
......@@ -219,6 +242,8 @@ export function getProviderOptionCurrentValue(
219242
220243export function getProviderOptionCurrentLabel(
221244 descriptor: ProviderOptionDescriptor | null | undefined,
245 selection?: ModelSelection | null,
246 reportedSelection?: ModelSelection | null,
222247): string | undefined {
223248 if (!descriptor) {
224249 return undefined;
......@@ -230,11 +255,15 @@ export function getProviderOptionCurrentLabel(
230255 : "Off"
231256 : undefined;
232257 }
233 const currentValue = getProviderOptionCurrentValue(descriptor);
234 if (typeof currentValue !== "string") {
235 return undefined;
236 }
237 return descriptor.options.find((option) => option.id === currentValue)?.label;
258 const currentValue = getProviderOptionCurrentValue(descriptor, selection, reportedSelection);
259 return (
260 descriptor.options.find((option) => option.id === currentValue)?.label ??
261 (getReportedOptionValue(descriptor.id, selection, reportedSelection) === "default"
262 ? "Default"
263 : descriptor.id === "variant"
264 ? "Unknown"
265 : undefined)
266 );
238267}
239268
240269export function buildProviderOptionSelectionsFromDescriptors(