| 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,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 @@ |
| 1 | import { useThreadReportedModelSelection } from "../../state/entities"; | ||
| 1 | import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; | 2 | import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | 3 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import type { WorktreeSetupCardProps } from "./worktree-setup-card"; | 4 | import type { WorktreeSetupCardProps } from "./worktree-setup-card"; |
| ... | @@ -303,6 +304,10 @@ const USER_INPUT_TOGGLE_TIMING = { | ... | @@ -303,6 +304,10 @@ const USER_INPUT_TOGGLE_TIMING = { |
| 303 | 304 | ||
| 304 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { | 305 | export 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 | <ThreadComposer | 1301 | <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 | <DisclosureRow | 751 | <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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { deriveReportedModelSelection } from "@t3tools/client-runtime/state/thread-execution"; | ||
| 2 | 3 | ||
| 3 | import { appAtomRegistry } from "./atom-registry"; | 4 | import { appAtomRegistry } from "./atom-registry"; |
| 4 | import type { | 5 | import 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"; |
| 8 | import type { | 10 | import type { |
| ... | @@ -15,7 +17,7 @@ import { Atom } from "effect/unstable/reactivity"; | ... | @@ -15,7 +17,7 @@ import { Atom } from "effect/unstable/reactivity"; |
| 15 | 17 | ||
| 16 | import { environmentProjects } from "./projects"; | 18 | import { environmentProjects } from "./projects"; |
| 17 | import { environmentServerConfigsAtom, serverEnvironment } from "./server"; | 19 | import { environmentServerConfigsAtom, serverEnvironment } from "./server"; |
| 18 | import { environmentThreadShells } from "./threads"; | 20 | import { environmentThreadDetails, environmentThreadShells } from "./threads"; |
| 19 | 21 | ||
| 20 | const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe( | 22 | const 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( |
| 87 | export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> { | 89 | export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> { |
| 88 | return useAtomValue(environmentServerConfigsAtom); | 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,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 | |||
| 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,7 +78,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 78 | import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; | 78 | import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; |
| 79 | import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; | 79 | import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; |
| 80 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; | 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 | import { causeErrorTag } from "@t3tools/shared/observability"; | 82 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 83 | 83 | ||
| 84 | import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; | 84 | import { 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 | }); |
| 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 | * Gives a subagent call its session once both are known: OpenCode names | 1410 | * 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. The | 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,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 of | 1503 | // 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 the | 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,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 |
| 88 | import { useThreadActions } from "../hooks/useThreadActions"; | 88 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 89 | import { | 89 | import { |
| 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 === undefined | 4078 | activeThread === undefined |
| 4075 | ? null | 4079 | ? 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 | <ChatComposer | 10828 | <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; | ||
| 1584 | 1585 | ||
| 1585 | // Context window | 1586 | // 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker"; | 7 | import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker"; |
| 4 | 8 | ||
| 5 | function selectDescriptor( | 9 | function selectDescriptor( |
| ... | @@ -232,3 +236,34 @@ describe("buildUnavailableModelOptionDescriptors", () => { | ... | @@ -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,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 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( |
| 122 | 123 | ||
| 123 | function getDescriptorStringValue( | 124 | function 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 | } |
| 132 | 135 | ||
| ... | @@ -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 | ...persistence | 298 | ...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?.id | 398 | ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id |
| 388 | ? "ultrathink" | 399 | ? "ultrathink" |
| 389 | : (getDescriptorStringValue(descriptor) ?? ""); | 400 | : (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? ""); |
| 390 | 401 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 15 | import { v2Projection } from "./orchestrationV2TestFixtures.ts"; | 18 | import { v2Projection } from "./orchestrationV2TestFixtures.ts"; |
| 16 | import { | 19 | import { |
| 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 | |||
| 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,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 | // 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). |
| 146 | const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const; | 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,6 +167,7 @@ const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", " |
| 155 | export function formatModelSelectionEffort( | 167 | export 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 |
| 796 | 796 | ||
| 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. */ |
| 798 | export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({ | 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 | 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 | |||
| 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,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 | export function getProviderOptionCurrentValue( | 221 | export 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( |
| 219 | 242 | ||
| 220 | export function getProviderOptionCurrentLabel( | 243 | export 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 | } |
| 239 | 268 | ||
| 240 | export function buildProviderOptionSelectionsFromDescriptors( | 269 | export function buildProviderOptionSelectionsFromDescriptors( |