diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b43e246fc9c4f..3eeda330ca0a6 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -15,7 +15,11 @@ import { enrichedSkillReference, } from "../../lib/enrichedLinkPresentation"; import { ThreadContextDivider } from "./thread-context-divider"; -import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row"; +import { + ThreadHandoffDivider, + ThreadHandoffRow, + ThreadModelSwitchDivider, +} from "./thread-handoff-row"; import { GuestBubbleTint, GuestMessageName } from "./guestMessageStyle"; import { ThreadGuestPendingMessage, useThreadGuestShare } from "./ThreadGuestShareBar"; import { LinkRequestCard } from "./LinkRequestCard"; @@ -3590,6 +3594,16 @@ export const ThreadFeed = memo(function ThreadFeed({ workspaceRoot={props.workspaceRoot} > + {info.item.type === "message" && + !isCompactionCommandFeedEntry(info.item) && + info.item.message.projectedItem?.item.type === "user_message" && + info.item.message.projectedItem.item.modelSwitch ? ( + + ) : null} {renderFeedEntry(info, { userMessageGrouping: info.item.userMessageGrouping, environmentId: props.environmentId, diff --git a/apps/mobile/src/features/threads/thread-context-divider.tsx b/apps/mobile/src/features/threads/thread-context-divider.tsx index 082f655d11955..19c99f6e63e45 100644 --- a/apps/mobile/src/features/threads/thread-context-divider.tsx +++ b/apps/mobile/src/features/threads/thread-context-divider.tsx @@ -6,7 +6,7 @@ import { ShimmeringWorkContent } from "./thread-work-log"; export function ThreadContextDivider(props: { label: string; - icon: AppSymbolName; + icon?: AppSymbolName; iconColor: ColorValue; active?: boolean; failed?: boolean; @@ -16,7 +16,9 @@ export function ThreadContextDivider(props: { - + {props.icon ? ( + + ) : null} {props.active ? ( { + const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === selection.model); + const modelName = + (model ? resolveModelShortName(model, provider?.driver) : undefined) ?? + model?.name ?? + selection.model; + return [change.modelChanged ? modelName : null, effort].filter(Boolean).join(" "); + }; + const { from, to } = props.modelSwitch; + return ( + + ); +} + export function ThreadHandoffRow(props: { environmentId: EnvironmentId; projectedItem: OrchestrationV2ProjectedTurnItem; diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 0363f6862448f..61e69a6d9c7f9 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -269,7 +269,7 @@ vi.mock("./openExternalUrl", () => ({})); vi.mock("./attachmentDownload", () => ({})); vi.mock("./uuid", () => ({})); vi.mock("../features/threads/thread-context-divider", () => ({})); -vi.mock("../features/threads/thread-handoff-row", () => ({})); +vi.mock("../features/threads/thread-handoff-row", () => ({ ThreadModelSwitchDivider: () => null })); vi.mock("../features/threads/ThreadGuestShareBar", () => ({ useThreadGuestShare: () => null })); vi.mock("../features/threads/SecretRequestCard", () => ({})); vi.mock("../features/threads/LinkRequestCard", () => ({ LinkRequestCard: () => null })); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 2dc0a5a66d5b0..906cda379913e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -48,6 +48,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type OrchestrationV2RemoteTaskChild, + type OrchestrationV2ModelSwitch, type OrchestrationV2Run, type OrchestrationV2RunAttempt, type OrchestrationV2ThreadShell, @@ -75,6 +76,7 @@ import { managedRuntimeModeCeilingMessage, } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { modelSwitchSince } from "./modelSwitch.ts"; import { recordThreadOpen } from "@t3tools/shared/paletteSearch"; import { backgroundWorkHoldsCompletion, @@ -2055,6 +2057,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio startedAt: now, completedAt: now, updatedAt: now, + ...(activeHandoff === null ? modelSwitchSince(projection, queuedRun.modelSelection) : {}), }; const handoffTurnItem: OrchestrationV2TurnItem | null = activeHandoff === null @@ -4796,6 +4799,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly providerTurnId: typeof providerTurn.id | null; readonly providerThreadId: OrchestrationV2ProviderThread["id"]; readonly providerInstanceId: ProviderInstanceId; + readonly modelSwitch?: OrchestrationV2ModelSwitch | undefined; }) { const message: OrchestrationV2ConversationMessage = { createdBy: input.createdBy, @@ -4851,6 +4855,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: input.text, attachments: input.attachments, ...(input.context ? { context: input.context } : {}), + ...(messageInput.modelSwitch === undefined + ? {} + : { modelSwitch: messageInput.modelSwitch }), }; yield* emitEvent({ type: "message.updated", @@ -5311,6 +5318,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerTurnId: null, providerThreadId: restartProviderThread.id, providerInstanceId: input.modelSelection.instanceId, + // Measured on the pre-restart projection, so the rewritten run is not its own baseline. + ...(restartHandoff === null ? modelSwitchSince(input.projection, input.modelSelection) : {}), }); const interruptedAttemptId = targetRun.activeAttemptId; if (interruptedAttemptId === null) { @@ -6424,6 +6433,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: dispatchText, ...(command.context ? { context: command.context } : {}), attachments: command.attachments, + ...(run.contextHandoffId === null ? modelSwitchSince(projection, modelSelection) : {}), }; const preparationTurnItem: OrchestrationV2TurnItem | null = dispatchMode.type === "defer_start" @@ -7175,6 +7185,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: dispatchText, ...(command.context ? { context: command.context } : {}), attachments: command.attachments, + ...(run.contextHandoffId === null + ? modelSwitchSince(projection, modelSelection, sourceRun?.modelSelection) + : {}), }; const activeHandoff = portableForkHandoff ?? mergeBackHandoff ?? providerSwitchHandoff; const handoffSourceRuns = @@ -9349,7 +9362,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio "runtimeRequests", "subagents", ], - { turnItemTypes: [], messageRoles: ["user"] }, + // User messages measure a restart's model switch. + { turnItemTypes: ["user_message"], messageRoles: ["user"] }, ); if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) { return yield* new OrchestratorDispatchError({ diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index d059c4c20e9d7..4352d8b2d6477 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -5,6 +5,7 @@ import { MessageId, type ModelSelection, type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ThreadProjection, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, ProjectId, @@ -47,6 +48,10 @@ const seedSelection = { instanceId: providerInstanceId, model: "seed-model", } satisfies ModelSelection; +const modelSwitches = (projection: OrchestrationV2ThreadProjection) => + projection.turnItems.flatMap((item) => + item.type === "user_message" && item.modelSwitch !== undefined ? [item.modelSwitch] : [], + ); const pooledCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; const exclusiveCapabilities: OrchestrationV2ProviderCapabilities = { ...CodexProviderCapabilitiesV2, @@ -407,6 +412,9 @@ it.live("retries a failed selection restart session open before completing", () "selection restart supersede must not project hard-Stop interrupt items", ); assert.equal(projection.runs.at(-1)?.modelSelection.model, replacementSelection.model); + assert.deepEqual(modelSwitches(projection), [ + { from: initialSelection, to: replacementSelection }, + ]); assert.equal(projection.runs.at(-1)?.status, "completed"); assert.isTrue(captured.failedReplacementOpen); assert.equal(captured.closedSessionCount, 0); @@ -690,6 +698,9 @@ it.live( const { projection, captured } = result; assert.lengthOf(projection.runs, 2); assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model); + assert.deepEqual(modelSwitches(projection), [ + { from: seedSelection, to: replacementSelection }, + ]); assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]); assert.equal(captured.closedSessionCount, 1); assert.deepEqual( diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 3297119852231..840a24a3da18a 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1175,6 +1175,55 @@ it.effect("steers a promoted queued message with the selection it was queued wit ), ); +it.effect("marks the model switch on a promoted message that restarts the turn", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "promote-restart-switch", + true, + false, + ); + const otherSelection = { ...runSelection, model: "other-model" }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queued"), + threadId, + messageId: MessageId.make("message:queued"), + text: "queued", + attachments: [], + modelSelection: otherSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + const promoted = (yield* orchestrator.getThreadProjection(threadId)).turnItems.find( + (item) => item.type === "user_message" && item.messageId === "message:queued", + ); + assert.deepEqual(promoted?.type === "user_message" ? promoted.modelSwitch : undefined, { + from: runSelection, + to: otherSelection, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("keeps a queued message queued when promoted into a run that Stop reached", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/modelSwitch.test.ts b/apps/server/src/orchestration-v2/modelSwitch.test.ts new file mode 100644 index 0000000000000..940340074c1f2 --- /dev/null +++ b/apps/server/src/orchestration-v2/modelSwitch.test.ts @@ -0,0 +1,107 @@ +import { + ContextHandoffId, + MessageId, + ProviderInstanceId, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { modelSwitchSince } from "./modelSwitch.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const selection = (model: string) => ({ instanceId, model }); +const run = ( + message: string, + ordinal: number, + model: string, + status: OrchestrationV2ThreadProjection["runs"][number]["status"] = "completed", +) => + ({ + ordinal, + status, + completedAt: null, + contextHandoffId: null, + userMessageId: MessageId.make(message), + modelSelection: selection(model), + }) as OrchestrationV2ThreadProjection["runs"][number]; +const shown = (...messages: ReadonlyArray) => + messages.map((message) => ({ + type: "user_message", + messageId: MessageId.make(message), + inputIntent: "turn_start", + status: "completed", + text: message, + attachments: [], + })) as unknown as OrchestrationV2ThreadProjection["turnItems"]; + +describe("modelSwitchSince", () => { + it("measures from the latest run whose message is in the timeline", () => { + // B failed before it started; its message shows "a -> b", so the next one must show "b -> a". + expect( + modelSwitchSince( + { + runs: [ + run("first", 1, "a"), + run("failed", 2, "b", "failed"), + run("queued", 3, "c", "queued"), + run("undone", 4, "d", "rolled_back"), + ], + turnItems: shown("first", "failed", "queued", "undone"), + }, + selection("a"), + ), + ).toEqual({ modelSwitch: { from: selection("b"), to: selection("a") } }); + }); + + it("skips messages clients hide", () => { + const [first, pending, compact, answer] = shown( + "first", + "pending", + "/compact", + "async-answer:question", + ); + expect( + modelSwitchSince( + { + runs: [ + run("first", 1, "a"), + run("pending", 2, "b", "cancelled"), + run("/compact", 3, "c"), + run("async-answer:question", 4, "d"), + ], + turnItems: [ + first!, + { ...pending!, inputIntent: "queued_turn", status: "pending" }, + compact!, + answer!, + ] as OrchestrationV2ThreadProjection["turnItems"], + }, + selection("c"), + ), + ).toEqual({ modelSwitch: { from: selection("a"), to: selection("c") } }); + }); + + it("counts a wake whose handoff row shows the swap", () => { + const wake = { + ...run("wake", 2, "other"), + contextHandoffId: ContextHandoffId.make("handoff"), + }; + expect( + modelSwitchSince( + { runs: [run("first", 1, "a"), wake], turnItems: shown("first") }, + selection("other"), + ), + ).toEqual({}); + }); + + it("records nothing for a first run or an unchanged selection", () => { + expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"))).toEqual({}); + // A fork's first message compares with the run it forked from. + expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"), selection("b"))).toEqual({ + modelSwitch: { from: selection("b"), to: selection("a") }, + }); + expect( + modelSwitchSince({ runs: [run("first", 1, "a")], turnItems: shown("first") }, selection("a")), + ).toEqual({}); + }); +}); diff --git a/apps/server/src/orchestration-v2/modelSwitch.ts b/apps/server/src/orchestration-v2/modelSwitch.ts new file mode 100644 index 0000000000000..5b893cd0026d2 --- /dev/null +++ b/apps/server/src/orchestration-v2/modelSwitch.ts @@ -0,0 +1,42 @@ +import { + isCompactionCommandMessage, + type ModelSelection, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; + +/** + * The model or effort change a new run brings, measured from the latest run the timeline shows + * through its message or its handoff row, so consecutive dividers always chain. `baseline` stands + * in when there is none, such as a fork's source run. Callers skip starts with a context handoff; + * the handoff item already shows those. + */ +export function modelSwitchSince( + projection: Pick, + to: ModelSelection, + baseline?: ModelSelection, +): Pick, "modelSwitch"> { + // Clients hide pending queued messages, fold async answers into their question, and replace + // `/compact` with a compaction row. + const shown = new Set( + projection.turnItems.flatMap((item) => + item.type === "user_message" && + !(item.inputIntent === "queued_turn" && item.status === "pending") && + !item.messageId.startsWith("async-answer:") && + !isCompactionCommandMessage({ role: "user", ...item }) + ? [item.messageId] + : [], + ), + ); + let previous: OrchestrationV2ThreadProjection["runs"][number] | undefined; + for (const run of projection.runs) { + if (run.status === "rolled_back" || run.status === "queued") continue; + // A wake shows as a notification, but its context handoff row still shows the swap. + if (!shown.has(run.userMessageId) && run.contextHandoffId === null) continue; + if (previous === undefined || runRanAfter(run, previous)) previous = run; + } + const from = previous?.modelSelection ?? baseline; + return from === undefined || modelSelectionsEqual(from, to) ? {} : { modelSwitch: { from, to } }; +} diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index dfda2a18938ee..bf52aad538e18 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -38,6 +38,7 @@ import { } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; +import { ModelSwitchRow } from "./V2LifecycleRow"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { AttachmentImage } from "../media/AttachmentImage"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; @@ -246,6 +247,20 @@ function UserVideoAttachment({ } export function UserMessageRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const item = row.projectedItem?.item; + const modelSwitch = item?.type === "user_message" ? item.modelSwitch : undefined; + return ( + <> + {modelSwitch ? ( + + ) : null} + + + ); +} + +function UserMessageContent({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); return ctx.workflowOpening?.messageId === row.message.id ? ( ; } +/** A same-harness model or effort change. Shows only what changed. */ +export function ModelSwitchRow(props: { + readonly environmentId: EnvironmentId; + readonly modelSwitch: OrchestrationV2ModelSwitch; +}) { + const providers = + useAtomValue(serverEnvironment.providersValueAtom(props.environmentId)) ?? + EMPTY_SERVER_PROVIDERS; + const change = resolveModelSwitchChange(props.modelSwitch, providers); + if (change === null) return null; + const describe = (selection: ModelSelection, effort: string | undefined) => { + const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === selection.model); + const modelName = model ? getTriggerDisplayModelName(model, provider?.driver) : selection.model; + return [change.modelChanged ? modelName : null, effort].filter(Boolean).join(" "); + }; + const { from, to } = props.modelSwitch; + return ( + + ); +} + export function HandoffRow(props: { readonly environmentId: EnvironmentId; readonly endpoints: HandoffEndpoints; diff --git a/packages/client-runtime/src/handoff.test.ts b/packages/client-runtime/src/handoff.test.ts index 508a2f5694d02..d2f5fb4e78903 100644 --- a/packages/client-runtime/src/handoff.test.ts +++ b/packages/client-runtime/src/handoff.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { resolveHandoffEndpoints, + resolveModelSwitchChange, resolvePendingHandoff, type HandoffTimelineRun, } from "./handoff.ts"; @@ -173,3 +174,99 @@ describe("pending context handoff", () => { ).toBeNull(); }); }); + +describe("model switch change", () => { + const selection = (model: string, options?: Record) => ({ + instanceId: from, + model, + ...(options === undefined + ? {} + : { options: Object.entries(options).map(([id, value]) => ({ id, value })) }), + }); + const providers = [ + { + instanceId: from, + models: ["gpt", "mini"].map((slug) => ({ + slug, + capabilities: { + optionDescriptors: [ + { id: "serviceTier", type: "select", options: [{ id: "fast", label: "Fast" }] }, + { + id: "thought_level", + type: "select", + role: "reasoning", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + }, + ], + }, + })), + }, + ] as unknown as ServerProvider[]; + + it("shows only what changed, reading an unset effort as the default", () => { + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { thought_level: "high" }) }, + providers, + ), + ).toEqual({ modelChanged: false, effort: { from: "Medium", to: "High" } }); + expect( + resolveModelSwitchChange({ from: selection("gpt"), to: selection("mini") }, providers), + ).toEqual({ modelChanged: true, effort: null }); + }); + + it("ignores changes the user cannot see", () => { + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { thought_level: "medium" }) }, + providers, + ), + ).toBeNull(); + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { serviceTier: "fast" }) }, + providers, + ), + ).toBeNull(); + // A cataloged model without an effort control ignores stale effort options. + expect( + resolveModelSwitchChange( + { + from: selection("plain", { reasoningEffort: "low" }), + to: selection("plain", { reasoningEffort: "high" }), + }, + [{ instanceId: from, models: [{ slug: "plain" }] }] as unknown as ServerProvider[], + ), + ).toBeNull(); + }); + + it("falls back to stated efforts without a catalog", () => { + expect( + resolveModelSwitchChange( + { + from: selection("gpt", { reasoningEffort: "low" }), + to: selection("gpt", { reasoningEffort: "high" }), + }, + [], + ), + ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } }); + expect( + resolveModelSwitchChange( + { + from: selection("pi", { thinking: "low" }), + to: selection("pi", { thinking: "high" }), + }, + [], + ), + ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } }); + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "high" }) }, + [], + ), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts index a26c018ad9cdf..c4e7b09595284 100644 --- a/packages/client-runtime/src/handoff.ts +++ b/packages/client-runtime/src/handoff.ts @@ -1,12 +1,20 @@ import type { ModelSelection, + OrchestrationV2ModelSwitch, OrchestrationV2Run, OrchestrationV2TurnItem, ProviderInstanceId, ServerProvider, + ServerProviderModel, } from "@t3tools/contracts"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, +} from "@t3tools/shared/model"; +import { isReasoningSelect } from "./modelTraits.ts"; import type { ThreadStructure } from "./state/models.ts"; +import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts"; export type HandoffTimelineRun = Pick< OrchestrationV2Run, @@ -80,3 +88,51 @@ function latestRunModelBefore( } return latest?.modelSelection.model; } + +export interface ModelSwitchChange { + readonly modelChanged: boolean; + /** Effort labels, set only when the effort changed. */ + readonly effort: { readonly from: string; readonly to: string } | null; +} + +/** + * What a recorded model switch visibly changed, or null when it changed neither the model nor the + * effort. The composer omits an untouched effort, so an unset effort reads as the model's default. + */ +export function resolveModelSwitchChange( + { from, to }: OrchestrationV2ModelSwitch, + providers: ReadonlyArray, +): ModelSwitchChange | null { + const modelsOf = (selection: ModelSelection) => + providers.find((provider) => provider.instanceId === selection.instanceId)?.models; + const fromEffort = resolveEffort(from, modelsOf(from)); + const toEffort = resolveEffort(to, modelsOf(to)); + const modelChanged = from.model !== to.model; + const effort = + fromEffort && toEffort && fromEffort.value !== toEffort.value + ? { from: fromEffort.label, to: toEffort.label } + : null; + return modelChanged || effort ? { modelChanged, effort } : null; +} + +/** The composer's reasoning control for the model; stated values only when the catalog lacks it. */ +function resolveEffort( + selection: ModelSelection, + models: ReadonlyArray | undefined, +): { readonly value: string; readonly label: string } | undefined { + const model = models?.find((candidate) => candidate.slug === selection.model); + if (model === undefined) { + // Pi states its effort as `thinking`; the catalog would otherwise mark it a reasoning control. + const value = [...REASONING_EFFORT_OPTION_IDS, "thinking"] + .map((id) => getModelSelectionStringOptionValue(selection, id)) + .find((candidate) => candidate !== undefined); + return value === undefined ? undefined : { value, label: value }; + } + const descriptor = model.capabilities?.optionDescriptors?.find(isReasoningSelect); + if (descriptor === undefined) return undefined; + const value = + getModelSelectionStringOptionValue(selection, descriptor.id) ?? + getProviderOptionCurrentValue(descriptor, selection); + if (typeof value !== "string") return undefined; + return { value, label: descriptor.options.find((option) => option.id === value)?.label ?? value }; +} diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 1c80128f8bbc5..6734e9d55f272 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1480,6 +1480,13 @@ export const OrchestrationV2UserMessageInputIntent = Schema.Literals([ export type OrchestrationV2UserMessageInputIntent = typeof OrchestrationV2UserMessageInputIntent.Type; +/** A same-harness model or effort change that took effect with this message. */ +export const OrchestrationV2ModelSwitch = Schema.Struct({ + from: ModelSelection, + to: ModelSelection, +}); +export type OrchestrationV2ModelSwitch = typeof OrchestrationV2ModelSwitch.Type; + const OrchestrationV2TurnItemBaseFields = { /** Wire-only cursor for collapsed work; persisted items keep their complete history. */ deferredWork: Schema.optionalKey( @@ -1601,6 +1608,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ textOmitted: DedupedBodyMarker, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(ChatAttachment), + modelSwitch: Schema.optional(OrchestrationV2ModelSwitch), }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -2431,6 +2439,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ text: Schema.String, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(ChatAttachment), + modelSwitch: Schema.optional(OrchestrationV2ModelSwitch), }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, diff --git a/packages/provider-core/src/server/notification.ts b/packages/provider-core/src/server/notification.ts index 30f6826278c7a..85cac547cfa37 100644 --- a/packages/provider-core/src/server/notification.ts +++ b/packages/provider-core/src/server/notification.ts @@ -217,6 +217,7 @@ export function notificationTurnItem( createdBy: _createdBy, creationSource: _creationSource, scheduledTaskId: _scheduledTaskId, + modelSwitch: _modelSwitch, ...base } = item; return { ...base, type: "notification", ...notification };