| author | |
| committer | |
| log | 63f74e209dc27df1f8733535d6fd4a79d442add8 |
| tree | f64306708d999157bc61339c7639f9a242378579 |
| parent | ca4f84e702428656665d53635e16251112091657 |
| signature | Signed by PGP key B5690EEEBB952194 |
16 files changed, 397 insertions(+), 21 deletions(-)
apps/mobile/src/features/threads/ThreadComposer.tsx+3| ... | ... | @@ -150,6 +150,7 @@ export interface ThreadComposerProps { |
| 150 | 150 | */ |
| 151 | 151 | readonly threadSyncPhase?: "loading" | "syncing" | null; |
| 152 | 152 | readonly selectedThread: EnvironmentThreadShell; |
| 153 | readonly reportedModelSelection?: ModelSelection | null; | |
| 153 | 154 | readonly hasCompactableConversation: boolean; |
| 154 | 155 | readonly serverConfig: T3ServerConfig | null; |
| 155 | 156 | readonly queueCount: number; |
| ... | ... | @@ -667,6 +668,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 667 | 668 | providerInstanceId: currentModelSelection.instanceId, |
| 668 | 669 | providerGroups: threadProviderGroups, |
| 669 | 670 | selectedModel: currentModelSelection, |
| 671 | reportedModelSelection: props.reportedModelSelection, | |
| 670 | 672 | onSelectModel: (option) => |
| 671 | 673 | props.onUpdateModelSelection(withRememberedModelOptions(option.selection)), |
| 672 | 674 | optionDescriptors: providerOptionDescriptors, |
| ... | ... | @@ -683,6 +685,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 683 | 685 | }), |
| 684 | 686 | [ |
| 685 | 687 | currentModelSelection, |
| 688 | props.reportedModelSelection, | |
| 686 | 689 | currentRuntimeMode, |
| 687 | 690 | props.onUpdateModelSelection, |
| 688 | 691 | props.onUpdateRuntimeMode, |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+7| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useThreadReportedModelSelection } from "../../state/entities"; | |
| 1 | 2 | import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; |
| 2 | 3 | import { useNavigation } from "@react-navigation/native"; |
| 3 | 4 | import type { WorktreeSetupCardProps } from "./worktree-setup-card"; |
| ... | ... | @@ -303,6 +304,10 @@ const USER_INPUT_TOGGLE_TIMING = { |
| 303 | 304 | |
| 304 | 305 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 305 | 306 | const navigation = useNavigation(); |
| 307 | const reportedModelSelection = useThreadReportedModelSelection({ | |
| 308 | environmentId: props.environmentId, | |
| 309 | threadId: props.selectedThread.id, | |
| 310 | }); | |
| 306 | 311 | const deviceState = useEnvironmentQuery( |
| 307 | 312 | deviceEnvironment.state({ environmentId: props.environmentId, input: {} }), |
| 308 | 313 | ); |
| ... | ... | @@ -1277,6 +1282,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1277 | 1282 | effortLabel={formatModelSelectionEffort( |
| 1278 | 1283 | props.selectedThread.modelSelection, |
| 1279 | 1284 | providerSubagentProvider?.models, |
| 1285 | reportedModelSelection, | |
| 1280 | 1286 | )} |
| 1281 | 1287 | status={props.providerSubagentStatus ?? null} |
| 1282 | 1288 | onOpenParent={ |
| ... | ... | @@ -1293,6 +1299,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1293 | 1299 | ) : ( |
| 1294 | 1300 | <> |
| 1295 | 1301 | <ThreadComposer |
| 1302 | reportedModelSelection={reportedModelSelection} | |
| 1296 | 1303 | editorRef={composerEditorRef} |
| 1297 | 1304 | draftMessage={props.draftMessage} |
| 1298 | 1305 | draftAttachments={props.draftAttachments} |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+19-2| ... | ... | @@ -245,6 +245,7 @@ type ThreadSettingsSessionProps = { |
| 245 | 245 | readonly providerInstanceId?: ProviderInstanceId; |
| 246 | 246 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 247 | 247 | readonly selectedModel: ModelSelection | null; |
| 248 | readonly reportedModelSelection?: ModelSelection | null; | |
| 248 | 249 | readonly onSelectModel: (option: ModelOption) => void; |
| 249 | 250 | readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; |
| 250 | 251 | readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void; |
| ... | ... | @@ -304,6 +305,8 @@ type ThreadSettingsSessionValue = { |
| 304 | 305 | readonly runtimeModeChoices: ReturnType<typeof runtimeModeChoicesForSupportedModes>; |
| 305 | 306 | readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; |
| 306 | 307 | readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>; |
| 308 | readonly displayedModelSelection: ModelSelection | null; | |
| 309 | readonly reportedModelSelection: ModelSelection | null; | |
| 307 | 310 | readonly providerExpansionOverrides: ReadonlySet<string>; |
| 308 | 311 | readonly hasLegacyModels: boolean; |
| 309 | 312 | readonly pendingModel: ModelOption | null; |
| ... | ... | @@ -476,6 +479,8 @@ function ThreadSettingsSessionProvider( |
| 476 | 479 | runtimeModeChoices, |
| 477 | 480 | onUpdateRuntimeMode: props.onUpdateRuntimeMode, |
| 478 | 481 | displayedDescriptors, |
| 482 | displayedModelSelection: pendingModel?.selection ?? props.selectedModel, | |
| 483 | reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null), | |
| 479 | 484 | favoriteKeys, |
| 480 | 485 | favoritesLoaded, |
| 481 | 486 | providerExpansionOverrides, |
| ... | ... | @@ -507,6 +512,8 @@ function ThreadSettingsSessionProvider( |
| 507 | 512 | isApplied, |
| 508 | 513 | isDisplayed, |
| 509 | 514 | props.environmentId, |
| 515 | props.selectedModel, | |
| 516 | props.reportedModelSelection, | |
| 510 | 517 | props.providerInstanceId, |
| 511 | 518 | pendingModel, |
| 512 | 519 | pressModel, |
| ... | ... | @@ -743,7 +750,11 @@ function ThreadSettingsOptionsItem(props: { |
| 743 | 750 | > |
| 744 | 751 | <DisclosureRow |
| 745 | 752 | label={descriptor.label} |
| 746 | value={getProviderOptionCurrentLabel(descriptor)} | |
| 753 | value={getProviderOptionCurrentLabel( | |
| 754 | descriptor, | |
| 755 | session.displayedModelSelection, | |
| 756 | session.reportedModelSelection, | |
| 757 | )} | |
| 747 | 758 | onPress={() => props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })} |
| 748 | 759 | /> |
| 749 | 760 | </Animated.View> |
| ... | ... | @@ -997,7 +1008,13 @@ function ThreadSettingsChoiceContent(props: { |
| 997 | 1008 | id: choice.id, |
| 998 | 1009 | label: choice.label, |
| 999 | 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 | 1018 | onPress: () => { |
| 1002 | 1019 | void Haptics.selectionAsync(); |
| 1003 | 1020 | session.applyOptionChange(activeDescriptor.id, choice.id); |
apps/mobile/src/state/entities.ts+10-1| ... | ... | @@ -1,8 +1,10 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { deriveReportedModelSelection } from "@t3tools/client-runtime/state/thread-execution"; | |
| 2 | 3 | |
| 3 | 4 | import { appAtomRegistry } from "./atom-registry"; |
| 4 | 5 | import type { |
| 5 | 6 | EnvironmentProject, |
| 7 | EnvironmentThread, | |
| 6 | 8 | EnvironmentThreadShell, |
| 7 | 9 | } from "@t3tools/client-runtime/state/shell"; |
| 8 | 10 | import type { |
| ... | ... | @@ -15,7 +17,7 @@ import { Atom } from "effect/unstable/reactivity"; |
| 15 | 17 | |
| 16 | 18 | import { environmentProjects } from "./projects"; |
| 17 | 19 | import { environmentServerConfigsAtom, serverEnvironment } from "./server"; |
| 18 | import { environmentThreadShells } from "./threads"; | |
| 20 | import { environmentThreadDetails, environmentThreadShells } from "./threads"; | |
| 19 | 21 | |
| 20 | 22 | const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe( |
| 21 | 23 | Atom.withLabel("mobile-project:empty"), |
| ... | ... | @@ -87,3 +89,10 @@ export function useEnvironmentServerConfig( |
| 87 | 89 | export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> { |
| 88 | 90 | return useAtomValue(environmentServerConfigsAtom); |
| 89 | 91 | } |
| 92 | ||
| 93 | const selectReportedModelSelection = (thread: EnvironmentThread | null) => | |
| 94 | thread === null ? null : deriveReportedModelSelection(thread.projection); | |
| 95 | ||
| 96 | export 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 | 410 | promptAccepted, |
| 411 | 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 | 418 | const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); |
| 414 | 419 | yield* runtime.startTurn( |
| 415 | 420 | turnInput(thread, { |
| ... | ... | @@ -2833,6 +2838,10 @@ describe("OpenCode2 adapter", () => { |
| 2833 | 2838 | modelSelection: bigPickle, |
| 2834 | 2839 | runtimePolicy: customPolicy, |
| 2835 | 2840 | }); |
| 2841 | assert.deepEqual(thread.nativeMetadata?.modelSelection, { | |
| 2842 | ...bigPickle, | |
| 2843 | options: [{ id: "variant", value: "default" }], | |
| 2844 | }); | |
| 2836 | 2845 | // Each directory keeps its own limit, whichever was read last. |
| 2837 | 2846 | assert.equal(runtime.getModelContextWindow?.(bigPickle, WORK), 160000); |
| 2838 | 2847 | assert.equal(runtime.getModelContextWindow?.(bigPickle, custom), 48000); |
| ... | ... | @@ -3869,3 +3878,44 @@ const providerTurnId = Effect.gen(function* () { |
| 3869 | 3878 | nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`, |
| 3870 | 3879 | }); |
| 3871 | 3880 | }).pipe(Effect.provide(IdAllocator.layer)); |
| 3881 | ||
| 3882 | describe("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 | 78 | import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; |
| 79 | 79 | import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; |
| 80 | 80 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; |
| 81 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 81 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; | |
| 82 | 82 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 83 | 83 | |
| 84 | 84 | import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; |
| ... | ... | @@ -1385,6 +1385,27 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr |
| 1385 | 1385 | }); |
| 1386 | 1386 | }); |
| 1387 | 1387 | |
| 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 | 1410 | * Gives a subagent call its session once both are known: OpenCode names |
| 1390 | 1411 | * 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 | 1484 | const child = |
| 1464 | 1485 | previous ?? newThreadState(childId, providerThread, call.state.directory, subagent); |
| 1465 | 1486 | child.subagent = subagent; |
| 1466 | child.providerThread = providerThread; | |
| 1487 | child.model = info?.model ?? child.model; | |
| 1488 | child.providerThread = withReportedModel(providerThread, child.model); | |
| 1467 | 1489 | child.directory = call.state.directory; |
| 1468 | 1490 | child.agent = info?.agent ?? call.agent ?? child.agent; |
| 1469 | 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 | 1494 | childOwners.set(childId, rootOf(call.state)); |
| 1473 | 1495 | call.child = child; |
| 1474 | 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 | 1502 | yield* emitSubagent(call); |
| 1477 | 1503 | // A session called again was not made now, so it may hold the rules of |
| 1478 | 1504 | // 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 | 2546 | if (sessionId === undefined) return; |
| 2521 | 2547 | const state = threads.get(sessionId); |
| 2522 | 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 | 2561 | if ( |
| 2524 | 2562 | event.type === "session.inbox.enqueued" && |
| 2525 | 2563 | state.subagent !== undefined && |
| ... | ... | @@ -2962,6 +3000,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr |
| 2962 | 3000 | }, |
| 2963 | 3001 | directory: string, |
| 2964 | 3002 | ) => { |
| 3003 | providerThread = withReportedModel(providerThread, native.model); | |
| 2965 | 3004 | const existing = threads.get(native.id); |
| 2966 | 3005 | if (existing !== undefined) { |
| 2967 | 3006 | existing.providerThread = providerThread; |
| ... | ... | @@ -3582,7 +3621,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr |
| 3582 | 3621 | directory, |
| 3583 | 3622 | ); |
| 3584 | 3623 | state.policy = policy; |
| 3585 | return providerThread; | |
| 3624 | return state.providerThread; | |
| 3586 | 3625 | }).pipe( |
| 3587 | 3626 | Effect.mapError((cause) => |
| 3588 | 3627 | isProviderAdapterError(cause) |
| ... | ... | @@ -3625,7 +3664,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr |
| 3625 | 3664 | directory: AbsolutePath.make(cwd), |
| 3626 | 3665 | }); |
| 3627 | 3666 | } |
| 3628 | return providerThread; | |
| 3667 | return state.providerThread; | |
| 3629 | 3668 | }).pipe( |
| 3630 | 3669 | Effect.mapError((cause) => |
| 3631 | 3670 | isProviderAdapterError(cause) |
| ... | ... | @@ -4183,7 +4222,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr |
| 4183 | 4222 | directory: AbsolutePath.make(cwd), |
| 4184 | 4223 | }); |
| 4185 | 4224 | } |
| 4186 | return providerThread; | |
| 4225 | return state.providerThread; | |
| 4187 | 4226 | }).pipe( |
| 4188 | 4227 | exclusive(forkInput.sourceProviderThread), |
| 4189 | 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 | 88 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 89 | 89 | import { |
| 90 | 90 | deriveProviderSubagentStatus, |
| 91 | deriveReportedModelSelection, | |
| 91 | 92 | formatModelSelectionEffort, |
| 92 | 93 | deriveRunlessWorkStartedAt, |
| 93 | 94 | deriveThreadActivityRun, |
| ... | ... | @@ -1626,6 +1627,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1626 | 1627 | }); |
| 1627 | 1628 | const serverThreadProjection = useThreadProjection(routeThreadDetailRef); |
| 1628 | 1629 | const serverProjection = serverThreadProjection?.projection ?? null; |
| 1630 | const reportedModelSelection = serverProjection | |
| 1631 | ? deriveReportedModelSelection(serverProjection) | |
| 1632 | : null; | |
| 1629 | 1633 | const threadStatus = useThreadStatus(routeThreadDetailRef); |
| 1630 | 1634 | const threadSyncPhase = resolveThreadSyncPhase({ |
| 1631 | 1635 | detailExists: serverProjection !== null, |
| ... | ... | @@ -4073,7 +4077,11 @@ export default function ChatView(props: ChatViewProps) { |
| 4073 | 4077 | const providerSubagentEffortLabel = |
| 4074 | 4078 | activeThread === undefined |
| 4075 | 4079 | ? null |
| 4076 | : formatModelSelectionEffort(activeThread.modelSelection, providerSubagentModels); | |
| 4080 | : formatModelSelectionEffort( | |
| 4081 | activeThread.modelSelection, | |
| 4082 | providerSubagentModels, | |
| 4083 | reportedModelSelection, | |
| 4084 | ); | |
| 4077 | 4085 | const mountComposerContextStrip = shouldShowComposerContextStrip({ |
| 4078 | 4086 | isDraftHeroState, |
| 4079 | 4087 | persistInActiveThreads: settings.persistComposerContextStrip, |
| ... | ... | @@ -10818,6 +10826,7 @@ export default function ChatView(props: ChatViewProps) { |
| 10818 | 10826 | ) : null} |
| 10819 | 10827 | {!composerMounted ? null : ( |
| 10820 | 10828 | <ChatComposer |
| 10829 | reportedModelSelection={reportedModelSelection} | |
| 10821 | 10830 | multipleModelSelections={multipleModelSelections} |
| 10822 | 10831 | supportsMultipleModels={ |
| 10823 | 10832 | serverConfig?.environment.capabilities.requiredWorktreeBootstrap === |
apps/web/src/components/chat/ChatComposer.tsx+4| ... | ... | @@ -1581,6 +1581,7 @@ export interface ChatComposerProps { |
| 1581 | 1581 | providerCatalogKnown: boolean; |
| 1582 | 1582 | activeProjectDefaultModelSelection: ModelSelection | null | undefined; |
| 1583 | 1583 | activeThreadModelSelection: ModelSelection | null | undefined; |
| 1584 | reportedModelSelection?: ModelSelection | null; | |
| 1584 | 1585 | |
| 1585 | 1586 | // Context window |
| 1586 | 1587 | activeContextWindow: ContextWindowSnapshot | null; |
| ... | ... | @@ -1728,6 +1729,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1728 | 1729 | providerCatalogKnown, |
| 1729 | 1730 | activeProjectDefaultModelSelection, |
| 1730 | 1731 | activeThreadModelSelection, |
| 1732 | reportedModelSelection, | |
| 1731 | 1733 | activeContextWindow, |
| 1732 | 1734 | compactThreadUnavailable, |
| 1733 | 1735 | compactDisabled, |
| ... | ... | @@ -2877,6 +2879,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2877 | 2879 | model: selectedModel, |
| 2878 | 2880 | models: selectedProviderModels, |
| 2879 | 2881 | modelOptions: composerModelOptions?.[selectedInstanceId], |
| 2882 | reportedModelSelection, | |
| 2880 | 2883 | prompt, |
| 2881 | 2884 | onPromptChange: setPromptFromTraits, |
| 2882 | 2885 | planModeEnabled: settings.planModeEnabled, |
| ... | ... | @@ -2889,6 +2892,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2889 | 2892 | model: selectedModel, |
| 2890 | 2893 | models: selectedProviderModels, |
| 2891 | 2894 | modelOptions: composerModelOptions?.[selectedInstanceId], |
| 2895 | reportedModelSelection, | |
| 2892 | 2896 | prompt, |
| 2893 | 2897 | onPromptChange: setPromptFromTraits, |
| 2894 | 2898 | planModeEnabled: settings.planModeEnabled, |
apps/web/src/components/chat/TraitsPicker.test.ts+36-1| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | ProviderInstanceId, | |
| 4 | ProviderDriverKind, | |
| 5 | type ProviderOptionDescriptor, | |
| 6 | } from "@t3tools/contracts"; | |
| 3 | 7 | import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker"; |
| 4 | 8 | |
| 5 | 9 | function selectDescriptor( |
| ... | ... | @@ -232,3 +236,34 @@ describe("buildUnavailableModelOptionDescriptors", () => { |
| 232 | 236 | ]); |
| 233 | 237 | }); |
| 234 | 238 | }); |
| 239 | ||
| 240 | it("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 | 1 | import { |
| 2 | type ModelSelection, | |
| 2 | 3 | type ProviderDriverKind, |
| 3 | 4 | type ProviderInstanceId, |
| 4 | 5 | type ProviderOptionDescriptor, |
| ... | ... | @@ -122,11 +123,13 @@ function replaceDescriptorCurrentValue( |
| 122 | 123 | |
| 123 | 124 | function getDescriptorStringValue( |
| 124 | 125 | descriptor: Extract<ProviderOptionDescriptor, { type: "select" }> | null, |
| 126 | selection?: ModelSelection | null, | |
| 127 | reportedSelection?: ModelSelection | null, | |
| 125 | 128 | ): string | null { |
| 126 | 129 | if (!descriptor) { |
| 127 | 130 | return null; |
| 128 | 131 | } |
| 129 | const value = getProviderOptionCurrentValue(descriptor); | |
| 132 | const value = getProviderOptionCurrentValue(descriptor, selection, reportedSelection); | |
| 130 | 133 | return typeof value === "string" ? value : null; |
| 131 | 134 | } |
| 132 | 135 | |
| ... | ... | @@ -274,6 +277,7 @@ export interface TraitsMenuContentProps { |
| 274 | 277 | prompt: string; |
| 275 | 278 | onPromptChange: (prompt: string) => void; |
| 276 | 279 | modelOptions?: ProviderOptions | null | undefined; |
| 280 | reportedModelSelection?: ModelSelection | null | undefined; | |
| 277 | 281 | allowPromptInjectedEffort?: boolean; |
| 278 | 282 | planModeEnabled: boolean; |
| 279 | 283 | triggerClassName?: string; |
| ... | ... | @@ -288,10 +292,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 288 | 292 | prompt, |
| 289 | 293 | onPromptChange, |
| 290 | 294 | modelOptions, |
| 295 | reportedModelSelection, | |
| 291 | 296 | allowPromptInjectedEffort = true, |
| 292 | 297 | planModeEnabled, |
| 293 | 298 | ...persistence |
| 294 | 299 | }: TraitsMenuContentProps & TraitsPersistence) { |
| 300 | const modelSelection = | |
| 301 | instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null; | |
| 295 | 302 | const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); |
| 296 | 303 | const updateModelOptions = useCallback( |
| 297 | 304 | (nextOptions: ProviderOptions | undefined) => { |
| ... | ... | @@ -362,7 +369,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 362 | 369 | return ( |
| 363 | 370 | <> |
| 364 | 371 | {descriptors.map((descriptor, index) => { |
| 365 | const value = getProviderOptionCurrentLabel(descriptor); | |
| 372 | const value = getProviderOptionCurrentLabel( | |
| 373 | descriptor, | |
| 374 | modelSelection, | |
| 375 | reportedModelSelection, | |
| 376 | ); | |
| 366 | 377 | if (!value) return null; |
| 367 | 378 | return ( |
| 368 | 379 | <div key={descriptor.id}> |
| ... | ... | @@ -386,7 +397,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 386 | 397 | const selectedValue = |
| 387 | 398 | ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id |
| 388 | 399 | ? "ultrathink" |
| 389 | : (getDescriptorStringValue(descriptor) ?? ""); | |
| 400 | : (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? ""); | |
| 390 | 401 | |
| 391 | 402 | return ( |
| 392 | 403 | <div key={descriptor.id}> |
| ... | ... | @@ -483,6 +494,8 @@ export function buildTraitsTriggerDisplay(input: { |
| 483 | 494 | descriptors: ReadonlyArray<ProviderOptionDescriptor>; |
| 484 | 495 | primarySelectDescriptorId: string | null; |
| 485 | 496 | ultrathinkPromptControlled: boolean; |
| 497 | modelSelection?: ModelSelection | null; | |
| 498 | reportedModelSelection?: ModelSelection | null | undefined; | |
| 486 | 499 | }): { label: string; speedIcon: "fast" | "ultrafast" | null } { |
| 487 | 500 | let fastModeFallbackLabel: string | null = null; |
| 488 | 501 | let speedIcon: "fast" | "ultrafast" | null = null; |
| ... | ... | @@ -522,7 +535,11 @@ export function buildTraitsTriggerDisplay(input: { |
| 522 | 535 | ? "Ultrathink" |
| 523 | 536 | : descriptor.type === "boolean" |
| 524 | 537 | ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` |
| 525 | : getProviderOptionCurrentLabel(descriptor); | |
| 538 | : getProviderOptionCurrentLabel( | |
| 539 | descriptor, | |
| 540 | input.modelSelection, | |
| 541 | input.reportedModelSelection, | |
| 542 | ); | |
| 526 | 543 | if (typeof label === "string" && label.length > 0) { |
| 527 | 544 | labels.push(label); |
| 528 | 545 | } |
| ... | ... | @@ -545,6 +562,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 545 | 562 | prompt, |
| 546 | 563 | onPromptChange, |
| 547 | 564 | modelOptions, |
| 565 | reportedModelSelection, | |
| 548 | 566 | allowPromptInjectedEffort = true, |
| 549 | 567 | planModeEnabled, |
| 550 | 568 | triggerClassName, |
| ... | ... | @@ -588,6 +606,8 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 588 | 606 | descriptors, |
| 589 | 607 | primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, |
| 590 | 608 | ultrathinkPromptControlled, |
| 609 | modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null, | |
| 610 | reportedModelSelection, | |
| 591 | 611 | }); |
| 592 | 612 | const speedLabel = speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on"; |
| 593 | 613 | const accessibleLabel = speedIcon ? `${triggerLabel}, ${speedLabel}` : triggerLabel; |
| ... | ... | @@ -686,6 +706,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 686 | 706 | prompt={prompt} |
| 687 | 707 | onPromptChange={onPromptChange} |
| 688 | 708 | modelOptions={modelOptions} |
| 709 | reportedModelSelection={reportedModelSelection} | |
| 689 | 710 | allowPromptInjectedEffort={allowPromptInjectedEffort} |
| 690 | 711 | planModeEnabled={planModeEnabled} |
| 691 | 712 | {...persistence} |
apps/web/src/components/chat/composerProviderState.tsx+4| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | type ModelCapabilities, |
| 3 | type ModelSelection, | |
| 3 | 4 | type ProviderDriverKind, |
| 4 | 5 | type ProviderInstanceId, |
| 5 | 6 | type ProviderOptionSelection, |
| ... | ... | @@ -48,6 +49,7 @@ type TraitsRenderInput = { |
| 48 | 49 | model: string; |
| 49 | 50 | models: ReadonlyArray<ServerProviderModel>; |
| 50 | 51 | modelOptions: ReadonlyArray<ProviderOptionSelection> | undefined; |
| 52 | reportedModelSelection?: ModelSelection | null | undefined; | |
| 51 | 53 | prompt: string; |
| 52 | 54 | onPromptChange: (prompt: string) => void; |
| 53 | 55 | planModeEnabled: boolean; |
| ... | ... | @@ -170,6 +172,7 @@ function renderTraitsControl( |
| 170 | 172 | model, |
| 171 | 173 | models, |
| 172 | 174 | modelOptions, |
| 175 | reportedModelSelection, | |
| 173 | 176 | prompt, |
| 174 | 177 | onPromptChange, |
| 175 | 178 | planModeEnabled, |
| ... | ... | @@ -208,6 +211,7 @@ function renderTraitsControl( |
| 208 | 211 | {...(draftId ? { draftId } : {})} |
| 209 | 212 | model={model} |
| 210 | 213 | modelOptions={resolvedModelOptions} |
| 214 | reportedModelSelection={reportedModelSelection} | |
| 211 | 215 | prompt={prompt} |
| 212 | 216 | onPromptChange={onPromptChange} |
| 213 | 217 | planModeEnabled={planModeEnabled} |
packages/client-runtime/src/state/threadExecution.test.ts+73| ... | ... | @@ -3,6 +3,9 @@ import { |
| 3 | 3 | NodeId, |
| 4 | 4 | MessageId, |
| 5 | 5 | ProviderInstanceId, |
| 6 | ProviderThreadId, | |
| 7 | ProviderSessionId, | |
| 8 | ProviderDriverKind, | |
| 6 | 9 | RunId, |
| 7 | 10 | ThreadId, |
| 8 | 11 | type OrchestrationV2ExecutionNode, |
| ... | ... | @@ -15,6 +18,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 15 | 18 | import { v2Projection } from "./orchestrationV2TestFixtures.ts"; |
| 16 | 19 | import { |
| 17 | 20 | presentPendingBackgroundWork, |
| 21 | deriveReportedModelSelection, | |
| 18 | 22 | deriveLatestThreadRun, |
| 19 | 23 | deriveProviderSubagentStatus, |
| 20 | 24 | formatModelSelectionEffort, |
| ... | ... | @@ -578,3 +582,72 @@ describe("presentPendingBackgroundWork", () => { |
| 578 | 582 | ); |
| 579 | 583 | }); |
| 580 | 584 | }); |
| 585 | ||
| 586 | describe("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 | 142 | }; |
| 143 | 143 | } |
| 144 | 144 | |
| 145 | /** The observed selection belongs to the active provider thread, never a previous handoff. */ | |
| 146 | export 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 | 157 | // Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode). |
| 146 | 158 | const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const; |
| 147 | 159 | |
| ... | ... | @@ -155,6 +167,7 @@ const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", " |
| 155 | 167 | export function formatModelSelectionEffort( |
| 156 | 168 | selection: ModelSelection, |
| 157 | 169 | models: ReadonlyArray<ServerProviderModel> = [], |
| 170 | reportedSelection?: ModelSelection | null, | |
| 158 | 171 | ): string | null { |
| 159 | 172 | const caps = models.find((model) => model.slug === selection.model)?.capabilities; |
| 160 | 173 | if (!caps) return null; |
| ... | ... | @@ -162,7 +175,7 @@ export function formatModelSelectionEffort( |
| 162 | 175 | for (const id of REASONING_EFFORT_OPTION_IDS) { |
| 163 | 176 | const descriptor = descriptors.find((candidate) => candidate.id === id); |
| 164 | 177 | if (descriptor?.type !== "select") continue; |
| 165 | const label = getProviderOptionCurrentLabel(descriptor); | |
| 178 | const label = getProviderOptionCurrentLabel(descriptor, selection, reportedSelection); | |
| 166 | 179 | if (label) return label; |
| 167 | 180 | } |
| 168 | 181 | return null; |
packages/contracts/src/orchestrationV2.ts+2| ... | ... | @@ -796,6 +796,8 @@ export type OrchestrationV2PendingBackgroundTask = typeof OrchestrationV2Pending |
| 796 | 796 | |
| 797 | 797 | /** Provider and adapter metadata that should not overwrite the app thread's title. */ |
| 798 | 798 | export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({ |
| 799 | /** Provider-reported selection for display, separate from the app's saved preferences. */ | |
| 800 | modelSelection: Schema.optional(ModelSelection), | |
| 799 | 801 | title: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 800 | 802 | updatedAt: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 801 | 803 | /** Version 2 scopes provider-derived item ids by provider instance. */ |
packages/shared/src/model.test.ts+61| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | createModelSelection, |
| 10 | 10 | formatCodexModelName, |
| 11 | 11 | formatModelSlugName, |
| 12 | getProviderOptionCurrentLabel, | |
| 12 | 13 | getModelSelectionBooleanOptionValue, |
| 13 | 14 | getModelSelectionStringOptionValue, |
| 14 | 15 | getProviderOptionDescriptors, |
| ... | ... | @@ -302,3 +303,63 @@ describe("readCustomModelEntries", () => { |
| 302 | 303 | }); |
| 303 | 304 | }); |
| 304 | 305 | }); |
| 306 | ||
| 307 | describe("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 | 202 | ); |
| 203 | 203 | } |
| 204 | 204 | |
| 205 | function 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 | ||
| 205 | 221 | export function getProviderOptionCurrentValue( |
| 206 | 222 | descriptor: ProviderOptionDescriptor | null | undefined, |
| 223 | selection?: ModelSelection | null, | |
| 224 | reportedSelection?: ModelSelection | null, | |
| 207 | 225 | ): string | boolean | undefined { |
| 208 | 226 | if (!descriptor) { |
| 209 | 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 | 234 | if (descriptor.type === "boolean") { |
| 212 | 235 | return descriptor.currentValue; |
| 213 | 236 | } |
| ... | ... | @@ -219,6 +242,8 @@ export function getProviderOptionCurrentValue( |
| 219 | 242 | |
| 220 | 243 | export function getProviderOptionCurrentLabel( |
| 221 | 244 | descriptor: ProviderOptionDescriptor | null | undefined, |
| 245 | selection?: ModelSelection | null, | |
| 246 | reportedSelection?: ModelSelection | null, | |
| 222 | 247 | ): string | undefined { |
| 223 | 248 | if (!descriptor) { |
| 224 | 249 | return undefined; |
| ... | ... | @@ -230,11 +255,15 @@ export function getProviderOptionCurrentLabel( |
| 230 | 255 | : "Off" |
| 231 | 256 | : undefined; |
| 232 | 257 | } |
| 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 | ); | |
| 238 | 267 | } |
| 239 | 268 | |
| 240 | 269 | export function buildProviderOptionSelectionsFromDescriptors( |