From e2e22daa012b890a66c13c12bbe62cc137dc467f Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 11 Nov 2026 14:59:45 -0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(server):=20stage=20Claude=E2=86=94Code?= =?UTF-8?q?x=20seat=20switches=20with=20richer=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow create_with_handoff provider switches while a turn is still running without detaching the live session, and frame the portable handoff with durable seat identity (title, place, open queue) so the successor keeps the seat across harness changes. Co-authored-by: Cursor --- .../ContextHandoffService.test.ts | 74 +++++++++++++++++ .../orchestration-v2/ContextHandoffService.ts | 82 ++++++++++++++++++- .../Orchestrator.control-reads.test.ts | 16 +++- .../src/orchestration-v2/Orchestrator.ts | 24 ++++++ .../ProviderSwitch.integration.test.ts | 15 +++- 5 files changed, 207 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts index 78958c2e317d5..0172e4cac5281 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 runtime seat switch with durable identity and place", () => { + const threadId = ThreadId.make("thread:seat-switch"); + const coverage = ContextHandoffService.makeRuntimeSeatSwitchCoverage({ + threadId, + coveredRunOrdinals: { from: 1, to: 3 }, + items: [importedItem({ role: "user", id: "seat-user", text: "Keep going", ordinal: 1 })], + seat: ContextHandoffService.runtimeSeatSwitchContext({ + thread: { + title: "Ship the handoff", + worktreePath: "/repo/worktrees/seat", + branch: "agent/seat-switch", + }, + fromProviderInstanceId: ProviderInstanceId.make("codex"), + toProviderInstanceId: ProviderInstanceId.make("claude"), + runs: [{ status: "queued" }, { status: "completed" }, { status: "queued" }], + reason: "Codex usage window", + }), + }); + + assert.include(coverage, "Runtime seat switch."); + assert.include(coverage, `Seat thread: ${threadId}.`); + assert.include(coverage, "Title: Ship the handoff."); + assert.include(coverage, "Place: /repo/worktrees/seat (branch agent/seat-switch)."); + assert.include(coverage, "Harness: codex → claude."); + assert.include(coverage, "Open queued runs on this seat: 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 seat handoff", (it) => { + it.effect("embeds seat framing in the provider handoff summary", () => + Effect.gen(function* () { + const service = yield* ContextHandoffService.ContextHandoffServiceV2; + const handoff = yield* service.prepareProviderHandoff({ + threadId: ThreadId.make("thread:seat-handoff"), + targetRunId: RunId.make("run:seat-handoff"), + transferId: ContextTransferId.make("transfer:seat-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: "seat-handoff-user", + text: "Switch harnesses without losing the seat", + ordinal: 1, + }), + ], + seat: ContextHandoffService.runtimeSeatSwitchContext({ + thread: { + title: "Seat 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, "Runtime seat switch."); + assert.include(handoff.summaryText, "Title: Seat handoff."); + assert.include(handoff.summaryText, "Place: /repo (branch main)."); + assert.include(handoff.summaryText, "Switch harnesses without losing the seat"); + 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..dd68d4ea4f066 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 seat?: RuntimeSeatSwitchContext; readonly createdAt: DateTime.Utc; }) => Effect.Effect; /** Parent activity after the fork point, prepended to a side chat's first provider turn. */ @@ -114,6 +115,76 @@ function compactText(text: string, maxLength = 240): string { return `${compacted.slice(0, last >= 0xd800 && last <= 0xdbff ? end - 1 : end)}...`; } +/** Durable seat fields carried across Claude↔Codex (and other) harness switches. */ +export type RuntimeSeatSwitchContext = { + 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 runtimeSeatSwitchContext(input: { + readonly thread: Pick; + readonly fromProviderInstanceId: ProviderInstanceId; + readonly toProviderInstanceId: ProviderInstanceId; + readonly runs?: ReadonlyArray>; + readonly reason?: string | null; +}): RuntimeSeatSwitchContext { + 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 }), + }; +} + +/** + * OpenRig `rig seat set-runtime` framing: same seat address/place/open work; fresh native + * conversation; only the handover carries prior context. + */ +export function makeRuntimeSeatSwitchCoverage(input: { + readonly threadId: ThreadId; + readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; + readonly items: ReadonlyArray; + readonly seat: RuntimeSeatSwitchContext; +}): string { + const place = + input.seat.worktreePath === null + ? "unresolved workspace" + : input.seat.branch === null || input.seat.branch === "" + ? input.seat.worktreePath + : `${input.seat.worktreePath} (branch ${input.seat.branch})`; + const queued = + input.seat.openQueuedRunCount !== undefined && input.seat.openQueuedRunCount > 0 + ? `Open queued runs on this seat: ${input.seat.openQueuedRunCount}.` + : "No open queued runs on this seat."; + const reason = + input.seat.reason !== undefined && input.seat.reason !== null && input.seat.reason !== "" + ? `Switch reason: ${compactText(input.seat.reason)}.` + : null; + return [ + "Runtime seat switch. You are taking over the same durable seat; keep its name, place, and open work.", + `Seat thread: ${input.threadId}.`, + `Title: ${input.seat.title ?? "(untitled)"}.`, + `Place: ${place}.`, + `Harness: ${input.seat.fromProviderInstanceId} → ${input.seat.toProviderInstanceId}.`, + queued, + ...(reason === null ? [] : [reason]), + "Native conversation starts fresh; only this handover carries prior context. The predecessor's recent 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 +571,7 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi "delta_since_target_last_seen" | "full_thread_summary" >; readonly items: ReadonlyArray; + readonly seat?: RuntimeSeatSwitchContext; readonly createdAt: DateTime.Utc; }) { const handoffId = yield* idAllocator.allocate @@ -521,7 +593,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.seat === undefined + ? handoffCoverage(input) + : makeRuntimeSeatSwitchCoverage({ + threadId: input.threadId, + coveredRunOrdinals: input.coveredRunOrdinals, + items: input.items, + seat: input.seat, + }); 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..21e9146674c2c 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,20 @@ 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 the seat while the turn keeps running. + assert.ok(Result.isSuccess(outcome)); + assert.deepEqual(after.thread.modelSelection, target); + assert.equal(after.thread.providerInstanceId, target.instanceId); + 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 seat switch must not detach the live provider session mid-turn", + ); + assert.isAbove(yield* orchestrator.getThreadEventSequence(threadId), sequence); } 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..0b439f55ed27d 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, + runtimeSeatSwitchContext, visibleConversationItems, visibleDeltaRunOrdinals, } from "./ContextHandoffService.ts"; @@ -2398,6 +2399,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio coveredRuns.map((run) => run.id), )), ], + seat: runtimeSeatSwitchContext({ + thread: projection.thread, + fromProviderInstanceId: latestHandoffRun.providerInstanceId, + toProviderInstanceId: queuedRun.providerInstanceId, + runs: projection.runs, + }), createdAt: now, }) .pipe( @@ -3970,6 +3977,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio turnItemTypes: [], }); if (projection.runs.some(isActiveRun)) { + // OpenRig set-runtime: stage a cross-harness seat switch without cutting the + // live occupant. Selection moves now; session detach waits for the handoff turn. + if (plan.transition.type === "create_with_handoff") { + return { ...plan, releaseProviderSessionIds: [] }; + } return yield* new OrchestratorProviderSelectionBusyError({ commandId: command.commandId, threadId: command.threadId, @@ -5800,6 +5812,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .map((run) => run.id), ), runs: input.projection.runs, + seat: runtimeSeatSwitchContext({ + thread: input.projection.thread, + fromProviderInstanceId: targetRun.providerInstanceId, + toProviderInstanceId: input.modelSelection.instanceId, + runs: input.projection.runs, + }), createdAt: now, }) .pipe(mapDispatchError(input.command)); @@ -7822,6 +7840,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : "delta_since_target_last_seen", items: providerSwitchItems, runs: projection.runs, + seat: runtimeSeatSwitchContext({ + thread: projection.thread, + fromProviderInstanceId: latestHandoffRun.providerInstanceId, + toProviderInstanceId: modelSelection.instanceId, + runs: projection.runs, + }), createdAt: now, }) .pipe( 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..f98fa139ebf46 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 ?? "", "Runtime seat switch."); + assert.include(handoff?.summaryText ?? "", "Title: Interrupted provider handoff."); + assert.include( + handoff?.summaryText ?? "", + `Place: ${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, "Runtime seat switch."); if (native) { assert.equal(delivered.text, "Continue"); assert.notInclude(deliveredHistory, '"text":"Continue"'); From a212835d4a9a90b2802e7bab09c0547792e84b9a Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 11 Nov 2026 15:57:55 -0800 Subject: [PATCH 2/2] feat: show current vs next-turn provider switch with cancel Keep the live modelSelection as current while staging a cross-provider pick on nextTurnModelSelection. Timeline notices record staged/cancelled outcomes; the composer shows next-turn with cancel. Drop human-facing "seat" naming from handoff framing. Co-authored-by: Cursor --- .../src/state/thread-model-selection.ts | 11 +- .../src/state/use-thread-composer-state.ts | 30 +++-- .../ContextHandoffService.test.ts | 48 ++++---- .../orchestration-v2/ContextHandoffService.ts | 58 ++++----- .../Orchestrator.control-reads.test.ts | 14 ++- .../src/orchestration-v2/Orchestrator.ts | 114 ++++++++++++++++-- .../src/orchestration-v2/ProjectionStore.ts | 2 + .../ProviderSwitch.integration.test.ts | 6 +- apps/web/src/components/ChatView.tsx | 56 +++++++-- .../src/components/chat/V2LifecycleRow.tsx | 12 +- .../chat/useApplyThreadModelPick.ts | 4 +- packages/client-runtime/src/handoff.test.ts | 30 +++++ packages/client-runtime/src/handoff.ts | 16 +++ packages/client-runtime/src/state/models.ts | 2 + .../src/state/threadCommands.ts | 20 ++- packages/contracts/src/orchestrationV2.ts | 6 + 16 files changed, 324 insertions(+), 105 deletions(-) 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 0172e4cac5281..8884f13b7e911 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts @@ -258,17 +258,17 @@ it("covers the run range of a very long delta", () => { ); }); -it("frames a runtime seat switch with durable identity and place", () => { - const threadId = ThreadId.make("thread:seat-switch"); - const coverage = ContextHandoffService.makeRuntimeSeatSwitchCoverage({ +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: "seat-user", text: "Keep going", ordinal: 1 })], - seat: ContextHandoffService.runtimeSeatSwitchContext({ + items: [importedItem({ role: "user", id: "switch-user", text: "Keep going", ordinal: 1 })], + identity: ContextHandoffService.providerHandoffIdentity({ thread: { title: "Ship the handoff", - worktreePath: "/repo/worktrees/seat", - branch: "agent/seat-switch", + worktreePath: "/repo/worktrees/switch", + branch: "agent/provider-switch", }, fromProviderInstanceId: ProviderInstanceId.make("codex"), toProviderInstanceId: ProviderInstanceId.make("claude"), @@ -277,25 +277,25 @@ it("frames a runtime seat switch with durable identity and place", () => { }), }); - assert.include(coverage, "Runtime seat switch."); - assert.include(coverage, `Seat thread: ${threadId}.`); + assert.include(coverage, "Provider switch."); + assert.include(coverage, `Thread: ${threadId}.`); assert.include(coverage, "Title: Ship the handoff."); - assert.include(coverage, "Place: /repo/worktrees/seat (branch agent/seat-switch)."); + 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 seat: 2."); + 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 seat handoff", (it) => { - it.effect("embeds seat framing in the provider handoff summary", () => +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:seat-handoff"), - targetRunId: RunId.make("run:seat-handoff"), - transferId: ContextTransferId.make("transfer:seat-handoff"), + 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"), @@ -305,14 +305,14 @@ it.layer(layerTest)("ContextHandoffService provider seat handoff", (it) => { items: [ importedItem({ role: "user", - id: "seat-handoff-user", - text: "Switch harnesses without losing the seat", + id: "identity-handoff-user", + text: "Switch harnesses without losing the thread", ordinal: 1, }), ], - seat: ContextHandoffService.runtimeSeatSwitchContext({ + identity: ContextHandoffService.providerHandoffIdentity({ thread: { - title: "Seat handoff", + title: "Identity handoff", worktreePath: "/repo", branch: "main", }, @@ -323,10 +323,10 @@ it.layer(layerTest)("ContextHandoffService provider seat handoff", (it) => { }); assert.equal(handoff.strategy, "full_thread_summary"); - assert.include(handoff.summaryText, "Runtime seat switch."); - assert.include(handoff.summaryText, "Title: Seat handoff."); - assert.include(handoff.summaryText, "Place: /repo (branch main)."); - assert.include(handoff.summaryText, "Switch harnesses without losing the seat"); + 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."); }), ); diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index dd68d4ea4f066..87a104be3b715 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -82,7 +82,7 @@ export interface ContextHandoffServiceV2Shape { "delta_since_target_last_seen" | "full_thread_summary" >; readonly items: ReadonlyArray; - readonly seat?: RuntimeSeatSwitchContext; + readonly identity?: ProviderHandoffIdentity; readonly createdAt: DateTime.Utc; }) => Effect.Effect; /** Parent activity after the fork point, prepended to a side chat's first provider turn. */ @@ -115,8 +115,8 @@ function compactText(text: string, maxLength = 240): string { return `${compacted.slice(0, last >= 0xd800 && last <= 0xdbff ? end - 1 : end)}...`; } -/** Durable seat fields carried across Claude↔Codex (and other) harness switches. */ -export type RuntimeSeatSwitchContext = { +/** 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; @@ -126,13 +126,13 @@ export type RuntimeSeatSwitchContext = { readonly reason?: string | null; }; -export function runtimeSeatSwitchContext(input: { +export function providerHandoffIdentity(input: { readonly thread: Pick; readonly fromProviderInstanceId: ProviderInstanceId; readonly toProviderInstanceId: ProviderInstanceId; readonly runs?: ReadonlyArray>; readonly reason?: string | null; -}): RuntimeSeatSwitchContext { +}): ProviderHandoffIdentity { return { title: input.thread.title, worktreePath: input.thread.worktreePath, @@ -145,38 +145,40 @@ export function runtimeSeatSwitchContext(input: { } /** - * OpenRig `rig seat set-runtime` framing: same seat address/place/open work; fresh native - * conversation; only the handover carries prior context. + * 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 makeRuntimeSeatSwitchCoverage(input: { +export function makeProviderHandoffIdentityCoverage(input: { readonly threadId: ThreadId; readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; readonly items: ReadonlyArray; - readonly seat: RuntimeSeatSwitchContext; + readonly identity: ProviderHandoffIdentity; }): string { const place = - input.seat.worktreePath === null + input.identity.worktreePath === null ? "unresolved workspace" - : input.seat.branch === null || input.seat.branch === "" - ? input.seat.worktreePath - : `${input.seat.worktreePath} (branch ${input.seat.branch})`; + : input.identity.branch === null || input.identity.branch === "" + ? input.identity.worktreePath + : `${input.identity.worktreePath} (branch ${input.identity.branch})`; const queued = - input.seat.openQueuedRunCount !== undefined && input.seat.openQueuedRunCount > 0 - ? `Open queued runs on this seat: ${input.seat.openQueuedRunCount}.` - : "No open queued runs on this seat."; + 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.seat.reason !== undefined && input.seat.reason !== null && input.seat.reason !== "" - ? `Switch reason: ${compactText(input.seat.reason)}.` + input.identity.reason !== undefined && + input.identity.reason !== null && + input.identity.reason !== "" + ? `Switch reason: ${compactText(input.identity.reason)}.` : null; return [ - "Runtime seat switch. You are taking over the same durable seat; keep its name, place, and open work.", - `Seat thread: ${input.threadId}.`, - `Title: ${input.seat.title ?? "(untitled)"}.`, - `Place: ${place}.`, - `Harness: ${input.seat.fromProviderInstanceId} → ${input.seat.toProviderInstanceId}.`, + "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. The predecessor's recent activity is summarized below.", + "Native conversation starts fresh; only this handover carries prior context. Earlier activity is summarized below.", handoffCoverage({ threadId: input.threadId, coveredRunOrdinals: input.coveredRunOrdinals, @@ -571,7 +573,7 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi "delta_since_target_last_seen" | "full_thread_summary" >; readonly items: ReadonlyArray; - readonly seat?: RuntimeSeatSwitchContext; + readonly identity?: ProviderHandoffIdentity; readonly createdAt: DateTime.Utc; }) { const handoffId = yield* idAllocator.allocate @@ -594,13 +596,13 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi ); const runStatuses = new Map(input.runs?.map((run) => [run.id, run.status])); const coverage = - input.seat === undefined + input.identity === undefined ? handoffCoverage(input) - : makeRuntimeSeatSwitchCoverage({ + : makeProviderHandoffIdentityCoverage({ threadId: input.threadId, coveredRunOrdinals: input.coveredRunOrdinals, items: input.items, - seat: input.seat, + identity: input.identity, }); const selected = selectHistory({ messages: input.items.flatMap((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 21e9146674c2c..d87007c3340b2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -217,19 +217,25 @@ it.effect.each( assert.ok(Result.isFailure(outcome)); assert.instanceOf(outcome.failure, Orchestrator.OrchestratorProviderSelectionBusyError); } else if (active && selectionChange === "instance") { - // Cross-harness / create_with_handoff stages the seat while the turn keeps running. + // Cross-harness / create_with_handoff stages next-turn while the turn keeps running. assert.ok(Result.isSuccess(outcome)); - assert.deepEqual(after.thread.modelSelection, target); - assert.equal(after.thread.providerInstanceId, target.instanceId); + 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 seat switch must not detach the live provider session mid-turn", + "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 0b439f55ed27d..e61162a794547 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -116,7 +116,7 @@ import { ContextHandoffServiceV2, readForkHistoryItems, readInheritedHandoffItems, - runtimeSeatSwitchContext, + providerHandoffIdentity, visibleConversationItems, visibleDeltaRunOrdinals, } from "./ContextHandoffService.ts"; @@ -2399,7 +2399,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio coveredRuns.map((run) => run.id), )), ], - seat: runtimeSeatSwitchContext({ + identity: providerHandoffIdentity({ thread: projection.thread, fromProviderInstanceId: latestHandoffRun.providerInstanceId, toProviderInstanceId: queuedRun.providerInstanceId, @@ -3956,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) => @@ -3977,10 +3991,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio turnItemTypes: [], }); if (projection.runs.some(isActiveRun)) { - // OpenRig set-runtime: stage a cross-harness seat switch without cutting the - // live occupant. Selection moves now; session detach waits for the handoff turn. + // 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: [] }; + return { + ...plan, + releaseProviderSessionIds: [], + stageNextTurn: true, + clearNextTurn: false, + }; } return yield* new OrchestratorProviderSelectionBusyError({ commandId: command.commandId, @@ -3988,7 +4007,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } } - return plan; + return { ...plan, stageNextTurn: false, clearNextTurn: false }; }) : null; @@ -4546,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, }; } @@ -4616,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( @@ -4633,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); @@ -5812,7 +5897,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .map((run) => run.id), ), runs: input.projection.runs, - seat: runtimeSeatSwitchContext({ + identity: providerHandoffIdentity({ thread: input.projection.thread, fromProviderInstanceId: targetRun.providerInstanceId, toProviderInstanceId: input.modelSelection.instanceId, @@ -6312,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, @@ -7035,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, @@ -7052,6 +7141,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...projection.thread, providerInstanceId: modelSelection.instanceId, modelSelection, + nextTurnModelSelection: null, updatedAt: now, }, }); @@ -7840,7 +7930,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : "delta_since_target_last_seen", items: providerSwitchItems, runs: projection.runs, - seat: runtimeSeatSwitchContext({ + identity: providerHandoffIdentity({ thread: projection.thread, fromProviderInstanceId: latestHandoffRun.providerInstanceId, toProviderInstanceId: modelSelection.instanceId, 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 f98fa139ebf46..a02b3a652e339 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -1746,11 +1746,11 @@ describe("orchestration v2 provider switching", () => { from: sourceOrdinal, to: sourceOrdinal, }); - assert.include(handoff?.summaryText ?? "", "Runtime seat switch."); + assert.include(handoff?.summaryText ?? "", "Provider switch."); assert.include(handoff?.summaryText ?? "", "Title: Interrupted provider handoff."); assert.include( handoff?.summaryText ?? "", - `Place: ${cwd} (branch agent/runtime-seat-switch).`, + `Workspace: ${cwd} (branch agent/runtime-seat-switch).`, ); const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; const history = yield* Ref.get(injectedHistory); @@ -1758,7 +1758,7 @@ describe("orchestration v2 provider switching", () => { assert.include(deliveredHistory, originalPrompt); assert.include(deliveredHistory, partialResponse); assert.include(deliveredHistory, `run-status=${status}`); - assert.include(deliveredHistory, "Runtime seat switch."); + 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),