diff --git a/apps/mobile/src/state/thread-model-selection.ts b/apps/mobile/src/state/thread-model-selection.ts index 1a31364413c74..25397c3f77260 100644 --- a/apps/mobile/src/state/thread-model-selection.ts +++ b/apps/mobile/src/state/thread-model-selection.ts @@ -10,7 +10,7 @@ import { scopedThreadKey } from "../lib/scopedEntities"; import * as Dialog from "../components/ui/Dialog"; import { appAtomRegistry } from "./atom-registry"; import { readEnvironmentScope } from "./session"; -import { environmentThreadShells, threadEnvironment } from "./threads"; +import { threadEnvironment } from "./threads"; import { releaseComposerDraftModelSelection, updateComposerDraftSettings, @@ -25,13 +25,8 @@ export function selectThreadModel( // Its own object, so an older reply for an equal pick cannot release this one. const modelSelection = { ...value }; updateComposerDraftSettings(threadKey, { modelSelection }); - const thread = appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); - // Another instance replaces the live session, which may hold an open question; that pick - // still waits for a sent message. - if ( - thread?.modelSelection.instanceId !== modelSelection.instanceId || - !readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope) - ) { + // Cross-instance may stage for the next turn while the current turn keeps running. + if (!readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope)) { return; } void runAtomCommand( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 896adc53f5203..a2cc76e971199 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -313,15 +313,29 @@ export function useThreadComposerState(input: { const selectedDraft = useComposerDraftSettings(selectedThreadKey); const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadShell; - const modelSelection = selectedDraft.modelSelection ?? selectedThread?.modelSelection ?? null; + const modelSelection = + selectedDraft.modelSelection ?? + selectedThread?.nextTurnModelSelection ?? + selectedThread?.modelSelection ?? + null; const serverConfig = selectedEnvironmentRuntime?.serverConfig; - const pendingHandoff = resolvePendingHandoff( - selectedThreadProjection?.projection, - isModelSelectionUnavailable(serverConfig, modelSelection, selectedThread?.projectId ?? null) - ? null - : modelSelection, - serverConfig?.providers ?? [], - ); + const stagedProviderSwitch = + selectedThread?.nextTurnModelSelection != null && + selectedThread.nextTurnModelSelection.instanceId !== selectedThread.modelSelection.instanceId + ? { + from: [selectedThread.modelSelection], + to: selectedThread.nextTurnModelSelection, + } + : null; + const pendingHandoff = + stagedProviderSwitch ?? + resolvePendingHandoff( + selectedThreadProjection?.projection, + isModelSelectionUnavailable(serverConfig, modelSelection, selectedThread?.projectId ?? null) + ? null + : modelSelection, + serverConfig?.providers ?? [], + ); const runtimeMode = selectedDraft.runtimeMode ?? selectedThread?.runtimeMode ?? null; // Plan mode is not offered to users; every send runs in build mode. const interactionMode = selectedThread ? DEFAULT_PROVIDER_INTERACTION_MODE : null; diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts index 78958c2e317d5..8884f13b7e911 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts @@ -258,6 +258,80 @@ it("covers the run range of a very long delta", () => { ); }); +it("frames a provider switch with durable thread identity and place", () => { + const threadId = ThreadId.make("thread:provider-switch"); + const coverage = ContextHandoffService.makeProviderHandoffIdentityCoverage({ + threadId, + coveredRunOrdinals: { from: 1, to: 3 }, + items: [importedItem({ role: "user", id: "switch-user", text: "Keep going", ordinal: 1 })], + identity: ContextHandoffService.providerHandoffIdentity({ + thread: { + title: "Ship the handoff", + worktreePath: "/repo/worktrees/switch", + branch: "agent/provider-switch", + }, + fromProviderInstanceId: ProviderInstanceId.make("codex"), + toProviderInstanceId: ProviderInstanceId.make("claude"), + runs: [{ status: "queued" }, { status: "completed" }, { status: "queued" }], + reason: "Codex usage window", + }), + }); + + assert.include(coverage, "Provider switch."); + assert.include(coverage, `Thread: ${threadId}.`); + assert.include(coverage, "Title: Ship the handoff."); + assert.include(coverage, "Workspace: /repo/worktrees/switch (branch agent/provider-switch)."); + assert.include(coverage, "Harness: codex → claude."); + assert.include(coverage, "Open queued runs on this thread: 2."); + assert.include(coverage, "Switch reason: Codex usage window."); + assert.include(coverage, "Native conversation starts fresh"); + assert.include(coverage, "Provider context handoff."); +}); + +it.layer(layerTest)("ContextHandoffService provider identity handoff", (it) => { + it.effect("embeds thread identity framing in the provider handoff summary", () => + Effect.gen(function* () { + const service = yield* ContextHandoffService.ContextHandoffServiceV2; + const handoff = yield* service.prepareProviderHandoff({ + threadId: ThreadId.make("thread:identity-handoff"), + targetRunId: RunId.make("run:identity-handoff"), + transferId: ContextTransferId.make("transfer:identity-handoff"), + fromProviderThreadIds: [ProviderThreadId.make("provider-thread:codex")], + toProviderThreadId: ProviderThreadId.make("provider-thread:claude"), + fromProviderInstanceId: ProviderInstanceId.make("codex"), + toProviderInstanceId: ProviderInstanceId.make("claude"), + coveredRunOrdinals: { from: 1, to: 1 }, + strategy: "full_thread_summary", + items: [ + importedItem({ + role: "user", + id: "identity-handoff-user", + text: "Switch harnesses without losing the thread", + ordinal: 1, + }), + ], + identity: ContextHandoffService.providerHandoffIdentity({ + thread: { + title: "Identity handoff", + worktreePath: "/repo", + branch: "main", + }, + fromProviderInstanceId: ProviderInstanceId.make("codex"), + toProviderInstanceId: ProviderInstanceId.make("claude"), + }), + createdAt: DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"), + }); + + assert.equal(handoff.strategy, "full_thread_summary"); + assert.include(handoff.summaryText, "Provider switch."); + assert.include(handoff.summaryText, "Title: Identity handoff."); + assert.include(handoff.summaryText, "Workspace: /repo (branch main)."); + assert.include(handoff.summaryText, "Switch harnesses without losing the thread"); + assert.include(handoff.history?.coverage ?? "", "Harness: codex → claude."); + }), + ); +}); + it.effect("inherited history stops at a missing fork ancestor", () => Effect.gen(function* () { const now = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 10f2701446268..87a104be3b715 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -82,6 +82,7 @@ export interface ContextHandoffServiceV2Shape { "delta_since_target_last_seen" | "full_thread_summary" >; readonly items: ReadonlyArray; + readonly identity?: ProviderHandoffIdentity; readonly createdAt: DateTime.Utc; }) => Effect.Effect; /** Parent activity after the fork point, prepended to a side chat's first provider turn. */ @@ -114,6 +115,78 @@ function compactText(text: string, maxLength = 240): string { return `${compacted.slice(0, last >= 0xd800 && last <= 0xdbff ? end - 1 : end)}...`; } +/** Durable thread identity carried across Claude↔Codex (and other) harness switches. */ +export type ProviderHandoffIdentity = { + readonly title: string | null; + readonly worktreePath: string | null; + readonly branch: string | null; + readonly fromProviderInstanceId: ProviderInstanceId; + readonly toProviderInstanceId: ProviderInstanceId; + readonly openQueuedRunCount?: number; + readonly reason?: string | null; +}; + +export function providerHandoffIdentity(input: { + readonly thread: Pick; + readonly fromProviderInstanceId: ProviderInstanceId; + readonly toProviderInstanceId: ProviderInstanceId; + readonly runs?: ReadonlyArray>; + readonly reason?: string | null; +}): ProviderHandoffIdentity { + return { + title: input.thread.title, + worktreePath: input.thread.worktreePath, + branch: input.thread.branch, + fromProviderInstanceId: input.fromProviderInstanceId, + toProviderInstanceId: input.toProviderInstanceId, + openQueuedRunCount: input.runs?.filter((run) => run.status === "queued").length ?? 0, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }; +} + +/** + * Cross-harness switch framing (OpenRig set-runtime-inspired): same thread name/place/open + * work; fresh native conversation; only the handover carries prior context. + */ +export function makeProviderHandoffIdentityCoverage(input: { + readonly threadId: ThreadId; + readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; + readonly items: ReadonlyArray; + readonly identity: ProviderHandoffIdentity; +}): string { + const place = + input.identity.worktreePath === null + ? "unresolved workspace" + : input.identity.branch === null || input.identity.branch === "" + ? input.identity.worktreePath + : `${input.identity.worktreePath} (branch ${input.identity.branch})`; + const queued = + input.identity.openQueuedRunCount !== undefined && input.identity.openQueuedRunCount > 0 + ? `Open queued runs on this thread: ${input.identity.openQueuedRunCount}.` + : "No open queued runs on this thread."; + const reason = + input.identity.reason !== undefined && + input.identity.reason !== null && + input.identity.reason !== "" + ? `Switch reason: ${compactText(input.identity.reason)}.` + : null; + return [ + "Provider switch. You continue the same thread; keep its name, workspace, and open work.", + `Thread: ${input.threadId}.`, + `Title: ${input.identity.title ?? "(untitled)"}.`, + `Workspace: ${place}.`, + `Harness: ${input.identity.fromProviderInstanceId} → ${input.identity.toProviderInstanceId}.`, + queued, + ...(reason === null ? [] : [reason]), + "Native conversation starts fresh; only this handover carries prior context. Earlier activity is summarized below.", + handoffCoverage({ + threadId: input.threadId, + coveredRunOrdinals: input.coveredRunOrdinals, + items: input.items, + }), + ].join("\n"); +} + export function visibleDeltaRunOrdinals( projection: { readonly runs: ReadonlyArray> }, items: ReadonlyArray>, @@ -500,6 +573,7 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi "delta_since_target_last_seen" | "full_thread_summary" >; readonly items: ReadonlyArray; + readonly identity?: ProviderHandoffIdentity; readonly createdAt: DateTime.Utc; }) { const handoffId = yield* idAllocator.allocate @@ -521,7 +595,15 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi ), ); const runStatuses = new Map(input.runs?.map((run) => [run.id, run.status])); - const coverage = handoffCoverage(input); + const coverage = + input.identity === undefined + ? handoffCoverage(input) + : makeProviderHandoffIdentityCoverage({ + threadId: input.threadId, + coveredRunOrdinals: input.coveredRunOrdinals, + items: input.items, + identity: input.identity, + }); const selected = selectHistory({ messages: input.items.flatMap((item) => { const message = historicalMessage(item); diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index d79d1a2d9ca97..d87007c3340b2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -202,7 +202,7 @@ it.effect.each( }) .pipe(Effect.result); const after = yield* projections.getThreadProjection(threadId); - if (active && selectionChange !== "next-turn") { + if (active && selectionChange === "restart") { assert.deepEqual( after, before, @@ -216,6 +216,26 @@ it.effect.each( assert.equal(yield* orchestrator.getThreadEventSequence(threadId), sequence); assert.ok(Result.isFailure(outcome)); assert.instanceOf(outcome.failure, Orchestrator.OrchestratorProviderSelectionBusyError); + } else if (active && selectionChange === "instance") { + // Cross-harness / create_with_handoff stages next-turn while the turn keeps running. + assert.ok(Result.isSuccess(outcome)); + assert.deepEqual(after.thread.modelSelection, before.thread.modelSelection); + assert.equal(after.thread.providerInstanceId, before.thread.providerInstanceId); + assert.deepEqual(after.thread.nextTurnModelSelection, target); + assert.deepEqual(after.runs, before.runs); + assert.deepEqual(after.providerTurns, before.providerTurns); + assert.deepEqual(after.providerSessions, before.providerSessions); + assert.deepEqual( + yield* outbox.listByCommandId(commandId), + [], + "a staged next-turn switch must not detach the live provider session mid-turn", + ); + assert.isAbove(yield* orchestrator.getThreadEventSequence(threadId), sequence); + assert.isTrue( + after.turnItems.some( + (item) => item.type === "system_notice" && item.title === "Provider switch staged", + ), + ); } else { assert.ok(Result.isSuccess(outcome)); assert.deepEqual(after.thread.modelSelection, target); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 748d8a79d37aa..e61162a794547 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -116,6 +116,7 @@ import { ContextHandoffServiceV2, readForkHistoryItems, readInheritedHandoffItems, + providerHandoffIdentity, visibleConversationItems, visibleDeltaRunOrdinals, } from "./ContextHandoffService.ts"; @@ -2398,6 +2399,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio coveredRuns.map((run) => run.id), )), ], + identity: providerHandoffIdentity({ + thread: projection.thread, + fromProviderInstanceId: latestHandoffRun.providerInstanceId, + toProviderInstanceId: queuedRun.providerInstanceId, + runs: projection.runs, + }), createdAt: now, }) .pipe( @@ -3949,6 +3956,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const providerSwitchPlan = command.type === "thread.model-selection.set" || command.type === "provider.switch" ? yield* Effect.gen(function* () { + const currentThread = providerContext!.thread; + // Restoring the live selection cancels a staged next-turn switch. + if ( + modelSelectionsEqual(command.modelSelection, currentThread.modelSelection) && + currentThread.nextTurnModelSelection != null + ) { + return { + instanceChanged: false, + releaseProviderSessionIds: [] as ReadonlyArray, + transition: { type: "reuse" as const }, + stageNextTurn: false, + clearNextTurn: true, + }; + } yield* providerAdapters.get(command.modelSelection.instanceId).pipe( Effect.mapError( (cause) => @@ -3970,13 +3991,23 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio turnItemTypes: [], }); if (projection.runs.some(isActiveRun)) { + // Cross-harness switch: stage next-turn selection; keep the live occupant. + // Session detach waits until the handoff turn applies it. + if (plan.transition.type === "create_with_handoff") { + return { + ...plan, + releaseProviderSessionIds: [], + stageNextTurn: true, + clearNextTurn: false, + }; + } return yield* new OrchestratorProviderSelectionBusyError({ commandId: command.commandId, threadId: command.threadId, }); } } - return plan; + return { ...plan, stageNextTurn: false, clearNextTurn: false }; }) : null; @@ -4534,10 +4565,21 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { ...thread, interactionMode: command.interactionMode, updatedAt: now }; case "thread.model-selection.set": case "provider.switch": + if (providerSwitchPlan?.clearNextTurn === true) { + return { ...thread, nextTurnModelSelection: null, updatedAt: now }; + } + if (providerSwitchPlan?.stageNextTurn === true) { + return { + ...thread, + nextTurnModelSelection: command.modelSelection, + updatedAt: now, + }; + } return { ...thread, providerInstanceId: command.modelSelection.instanceId, modelSelection: command.modelSelection, + nextTurnModelSelection: null, updatedAt: now, }; } @@ -4604,11 +4646,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.interaction-mode.set": return "thread.interaction-mode-updated" as const; case "thread.model-selection.set": - return thread.providerInstanceId === command.modelSelection.instanceId + return providerSwitchPlan?.stageNextTurn === true || + providerSwitchPlan?.clearNextTurn === true || + thread.providerInstanceId === command.modelSelection.instanceId ? ("thread.model-selection-updated" as const) : ("thread.provider-switched" as const); case "provider.switch": - return "thread.provider-switched" as const; + return providerSwitchPlan?.stageNextTurn === true || + providerSwitchPlan?.clearNextTurn === true + ? ("thread.model-selection-updated" as const) + : ("thread.provider-switched" as const); } })(); yield* emit( @@ -4621,6 +4668,56 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: updatedThread, }); + if ( + (command.type === "thread.model-selection.set" || command.type === "provider.switch") && + (providerSwitchPlan?.stageNextTurn === true || providerSwitchPlan?.clearNextTurn === true) + ) { + const noticeProjection = yield* loadProjectionForCommand(command, ["runs"], { + turnItemTypes: [], + }); + const activeRun = noticeProjection.runs.findLast(isActiveRun); + if (activeRun !== undefined) { + const from = thread.modelSelection; + const to = command.modelSelection; + const staged = providerSwitchPlan.stageNextTurn === true; + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + runId: activeRun.id, + ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }), + providerInstanceId: activeRun.providerInstanceId, + occurredAt: now, + payload: { + id: idAllocator.derive.runSignalTurnItem({ + runId: activeRun.id, + signal: staged + ? `provider-switch-staged:${to.instanceId}:${to.model}` + : `provider-switch-cancelled:${from.instanceId}:${from.model}`, + }), + threadId: command.threadId, + runId: activeRun.id, + nodeId: activeRun.rootNodeId, + providerThreadId: activeRun.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: staged ? "Provider switch staged" : "Provider switch cancelled", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "system_notice", + message: staged + ? `Next turn will switch from ${from.instanceId} (${from.model}) to ${to.instanceId} (${to.model}). The current turn keeps running.` + : `Cancelled the staged switch. Staying on ${from.instanceId} (${from.model}).`, + }, + }); + } + } // Archiving ends the guest share, so its waiting files go too. yield* releaseGuestAttachments(command, thread.guestShare, updatedThread.guestShare, effects); @@ -5800,6 +5897,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .map((run) => run.id), ), runs: input.projection.runs, + identity: providerHandoffIdentity({ + thread: input.projection.thread, + fromProviderInstanceId: targetRun.providerInstanceId, + toProviderInstanceId: input.modelSelection.instanceId, + runs: input.projection.runs, + }), createdAt: now, }) .pipe(mapDispatchError(input.command)); @@ -6294,7 +6397,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); projection = applyToProjection(projection, event); } - let modelSelection = command.modelSelection ?? projection.thread.modelSelection; + let modelSelection = + command.modelSelection ?? + projection.thread.nextTurnModelSelection ?? + projection.thread.modelSelection; let dispatchMode = resolveMessageDispatchIntent( projection, command.dispatchMode, @@ -7017,7 +7123,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const now = yield* DateTime.now; if ( !modelSelectionsEqual(projection.thread.modelSelection, modelSelection) || - projection.thread.providerInstanceId !== modelSelection.instanceId + projection.thread.providerInstanceId !== modelSelection.instanceId || + projection.thread.nextTurnModelSelection != null ) { yield* emit( events, @@ -7034,6 +7141,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...projection.thread, providerInstanceId: modelSelection.instanceId, modelSelection, + nextTurnModelSelection: null, updatedAt: now, }, }); @@ -7822,6 +7930,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : "delta_since_target_last_seen", items: providerSwitchItems, runs: projection.runs, + identity: providerHandoffIdentity({ + thread: projection.thread, + fromProviderInstanceId: latestHandoffRun.providerInstanceId, + toProviderInstanceId: modelSelection.instanceId, + runs: projection.runs, + }), createdAt: now, }) .pipe( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 9e27210350642..dee1333152b19 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1637,6 +1637,7 @@ export function threadShellFromProjection( title: projection.thread.title, providerInstanceId: projection.thread.providerInstanceId, modelSelection: projection.thread.modelSelection, + nextTurnModelSelection: projection.thread.nextTurnModelSelection ?? null, runtimeMode: projection.thread.runtimeMode, interactionMode: projection.thread.interactionMode, branch: projection.thread.branch, @@ -1940,6 +1941,7 @@ function shellFromState(input: { title: input.state.thread.title, providerInstanceId: input.state.thread.providerInstanceId, modelSelection: input.state.thread.modelSelection, + nextTurnModelSelection: input.state.thread.nextTurnModelSelection ?? null, runtimeMode: input.state.thread.runtimeMode, interactionMode: input.state.thread.interactionMode, branch: input.state.thread.branch, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 728e4f2189c26..a02b3a652e339 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -1706,8 +1706,8 @@ describe("orchestration v2 provider switching", () => { modelSelection: CODEX_MODEL_SELECTION, runtimeMode: "full-access", interactionMode: "default", - branch: null, - worktreePath: null, + branch: "agent/runtime-seat-switch", + worktreePath: cwd, }); if (returning) { yield* dispatch("initial", "Earlier successful request.", CODEX_MODEL_SELECTION); @@ -1729,6 +1729,10 @@ describe("orchestration v2 provider switching", () => { } yield* waitForRun(sourceOrdinal + 1, "completed"); const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal(projection.thread.id, threadId); + assert.equal(projection.thread.title, "Interrupted provider handoff"); + assert.equal(projection.thread.worktreePath, cwd); + assert.equal(projection.thread.branch, "agent/runtime-seat-switch"); const targetRun = projection.runs.at(-1)!; const handoff = projection.contextHandoffs.find( (candidate) => candidate.id === targetRun.contextHandoffId, @@ -1742,12 +1746,19 @@ describe("orchestration v2 provider switching", () => { from: sourceOrdinal, to: sourceOrdinal, }); + assert.include(handoff?.summaryText ?? "", "Provider switch."); + assert.include(handoff?.summaryText ?? "", "Title: Interrupted provider handoff."); + assert.include( + handoff?.summaryText ?? "", + `Workspace: ${cwd} (branch agent/runtime-seat-switch).`, + ); const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; const history = yield* Ref.get(injectedHistory); const deliveredHistory = native ? yield* encodeJson(history) : delivered.text; assert.include(deliveredHistory, originalPrompt); assert.include(deliveredHistory, partialResponse); assert.include(deliveredHistory, `run-status=${status}`); + assert.include(deliveredHistory, "Provider switch."); if (native) { assert.equal(delivered.text, "Continue"); assert.notInclude(deliveredHistory, '"text":"Continue"'); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 36d4e7b63b548..2db5686124518 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -95,7 +95,10 @@ import { threadSupportsProviderHandoff, } from "@t3tools/client-runtime/state/thread-workflows"; import { compactionBlocksSteering } from "@t3tools/client-runtime/state/composer-dispatch"; -import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; +import { + resolvePendingHandoff, + resolveStagedProviderSwitch, +} from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { codexFeedbackMessage, @@ -445,6 +448,9 @@ export default function ChatView(props: ChatViewProps) { const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); + const setThreadModelSelection = useOrchestrationCommand(threadEnvironment.setModelSelection, { + label: "change model", + }); const setThreadInteractionMode = useOrchestrationCommand(threadEnvironment.setInteractionMode, { reportFailure: false, }); @@ -2438,18 +2444,33 @@ export default function ChatView(props: ChatViewProps) { item.type === "user_message" && (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0), ); - const pendingHandoffSelection = composerSelectedModelSelection ?? activeThread?.modelSelection; + const stagedProviderSwitch = resolveStagedProviderSwitch( + activeThread === undefined + ? null + : { + modelSelection: activeThread.modelSelection, + nextTurnModelSelection: activeThread.nextTurnModelSelection, + }, + ); + const pendingHandoffSelection = + stagedProviderSwitch?.next ?? composerSelectedModelSelection ?? activeThread?.modelSelection; const pendingHandoffSelectionUsable = providerInstanceEntries.some( (entry) => entry.instanceId === pendingHandoffSelection?.instanceId && entry.enabled && entry.isAvailable, ); - const pendingHandoff = resolvePendingHandoff( - serverProjection, - pendingHandoffSelectionUsable ? pendingHandoffSelection : null, - providerStatuses, - ); + const pendingHandoff = + stagedProviderSwitch !== null + ? { + from: [stagedProviderSwitch.current], + to: stagedProviderSwitch.next, + } + : resolvePendingHandoff( + serverProjection, + pendingHandoffSelectionUsable ? pendingHandoffSelection : null, + providerStatuses, + ); const compactThreadUnavailable = !canOperateThread || !activeThread || @@ -3101,7 +3122,21 @@ export default function ChatView(props: ChatViewProps) { environmentId={environmentId} endpoints={pendingHandoff} providers={providerStatuses} - pending + pending={stagedProviderSwitch === null} + nextTurn={stagedProviderSwitch !== null} + {...(stagedProviderSwitch === null || activeThread === undefined + ? {} + : { + onCancel: () => { + void setThreadModelSelection({ + environmentId, + input: { + threadId: activeThread.id, + modelSelection: stagedProviderSwitch.current, + }, + }); + }, + })} /> )} @@ -3661,7 +3696,10 @@ export default function ChatView(props: ChatViewProps) { activeProjectDefaultModelSelection={ activeProjectDefaultModelSelection } - activeThreadModelSelection={activeThread?.modelSelection} + activeThreadModelSelection={ + activeThread?.nextTurnModelSelection ?? + activeThread?.modelSelection + } activeProjectId={activeProject?.id ?? null} projectName={activeProject?.title} compactThreadUnavailable={compactThreadUnavailable} diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 40fffaf608412..c12724370dbf5 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -632,7 +632,10 @@ export function HandoffRow(props: { readonly endpoints: HandoffEndpoints; readonly providers: ReadonlyArray; readonly pending?: boolean; + /** Staged for the next turn while the current turn keeps the from endpoint. */ + readonly nextTurn?: boolean; readonly failed?: boolean; + readonly onCancel?: () => void; }) { const { from: fromEndpoints, to } = props.endpoints; const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); @@ -640,10 +643,17 @@ export function HandoffRow(props: { const entries = settings ? applyProviderInstanceSettings(derived, settings, null) : derived; return ( {fromEndpoints.map((endpoint, index) => ( diff --git a/apps/web/src/components/chat/useApplyThreadModelPick.ts b/apps/web/src/components/chat/useApplyThreadModelPick.ts index 7730273768232..499406771fe49 100644 --- a/apps/web/src/components/chat/useApplyThreadModelPick.ts +++ b/apps/web/src/components/chat/useApplyThreadModelPick.ts @@ -25,9 +25,7 @@ export function useApplyThreadModelPick() { const pick = draft.modelSelectionByProvider[draft.activeProvider]; const thread = registry.get(environmentThreadShells.threadShellAtom(target)); if (!pick || !thread || store.draftThreadsByThreadKey[scopedThreadKey(target)]) return; - // Another instance replaces the live session, which may hold an open question; that pick - // still waits for a sent message. - if (pick.instanceId !== thread.modelSelection.instanceId) return; + // Cross-instance may stage for the next turn while the current turn keeps running. const result = await setModelSelection({ environmentId: target.environmentId, input: { threadId: target.threadId, modelSelection: pick }, diff --git a/packages/client-runtime/src/handoff.test.ts b/packages/client-runtime/src/handoff.test.ts index d2f5fb4e78903..69ea34a9be963 100644 --- a/packages/client-runtime/src/handoff.test.ts +++ b/packages/client-runtime/src/handoff.test.ts @@ -10,6 +10,7 @@ import { resolveHandoffEndpoints, resolveModelSwitchChange, resolvePendingHandoff, + resolveStagedProviderSwitch, type HandoffTimelineRun, } from "./handoff.ts"; @@ -175,6 +176,35 @@ describe("pending context handoff", () => { }); }); +describe("staged next-turn provider switch", () => { + it("exposes current vs next when a cross-provider pick is staged", () => { + expect( + resolveStagedProviderSwitch({ + modelSelection: { instanceId: from, model: "source" }, + nextTurnModelSelection: { instanceId: to, model: "target" }, + }), + ).toEqual({ + current: { instanceId: from, model: "source" }, + next: { instanceId: to, model: "target" }, + }); + }); + + it("hides staging when next-turn matches the live selection or is absent", () => { + expect( + resolveStagedProviderSwitch({ + modelSelection: { instanceId: from, model: "source" }, + nextTurnModelSelection: { instanceId: from, model: "other" }, + }), + ).toBeNull(); + expect( + resolveStagedProviderSwitch({ + modelSelection: { instanceId: from, model: "source" }, + nextTurnModelSelection: null, + }), + ).toBeNull(); + }); +}); + describe("model switch change", () => { const selection = (model: string, options?: Record) => ({ instanceId: from, diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts index 48a1227429312..6aba9b68e8e55 100644 --- a/packages/client-runtime/src/handoff.ts +++ b/packages/client-runtime/src/handoff.ts @@ -23,6 +23,22 @@ export type HandoffTimelineRun = Pick< export type HandoffEndpoints = ReturnType; +/** Staged cross-provider pick for the next turn, while the live selection stays current. */ +export function resolveStagedProviderSwitch( + thread: + | { + readonly modelSelection: ModelSelection; + readonly nextTurnModelSelection?: ModelSelection | null | undefined; + } + | null + | undefined, +): { readonly current: ModelSelection; readonly next: ModelSelection } | null { + const next = thread?.nextTurnModelSelection; + if (thread == null || next == null) return null; + if (next.instanceId === thread.modelSelection.instanceId) return null; + return { current: thread.modelSelection, next }; +} + export function resolvePendingHandoff( projection: Pick | null | undefined, selection: ModelSelection | null | undefined, diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 098b73141e3c9..f8b7833a3e6cb 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -141,6 +141,7 @@ export interface EnvironmentThreadShell { readonly title: string; readonly providerInstanceId: ProviderInstanceId; readonly modelSelection: OrchestrationV2ThreadShell["modelSelection"]; + readonly nextTurnModelSelection?: OrchestrationV2ThreadShell["nextTurnModelSelection"]; readonly runtimeMode: OrchestrationV2ThreadShell["runtimeMode"]; readonly interactionMode: OrchestrationV2ThreadShell["interactionMode"]; readonly branch: string | null; @@ -288,6 +289,7 @@ export function presentThreadShell( : thread.title, providerInstanceId: thread.providerInstanceId, modelSelection: thread.modelSelection, + nextTurnModelSelection: thread.nextTurnModelSelection ?? null, runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, branch: thread.branch, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 3761d0fa6e715..fa0fa34d1ce4b 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -460,10 +460,20 @@ export function createThreadEnvironmentAtoms( ...thread, activeOrderKey: input.orderKey, })), - // Shown at once, so a composer that releases its accepted pick keeps showing it. - setModelSelection: optimistic.wrap(commands.setModelSelection, (thread, input) => ({ - ...thread, - modelSelection: input.modelSelection, - })), + // Same-instance applies now. Cross-instance stages next-turn while a turn may still + // be running; restoring the live selection clears a staged switch. + setModelSelection: optimistic.wrap(commands.setModelSelection, (thread, input) => { + if (input.modelSelection.instanceId === thread.modelSelection.instanceId) { + return { + ...thread, + modelSelection: input.modelSelection, + nextTurnModelSelection: null, + }; + } + return { + ...thread, + nextTurnModelSelection: input.modelSelection, + }; + }), }; } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 3189bb3eb7e65..aa57d296a3ab2 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -451,6 +451,11 @@ export const OrchestrationV2AppThread = Schema.Struct({ title: TrimmedNonEmptyString, providerInstanceId: ProviderInstanceId, modelSelection: ModelSelection, + /** + * Cross-provider pick staged for the next turn while the current turn keeps running. + * Absent/null means no staged switch. `modelSelection` stays the live occupant. + */ + nextTurnModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), @@ -2124,6 +2129,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ title: Schema.String, providerInstanceId: ProviderInstanceId, modelSelection: ModelSelection, + nextTurnModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString),