From e4d8bc07e7ecf052a8366f7e3320e9c22feb2f3a Mon Sep 17 00:00:00 2001 From: Zach Backas <52803336+zachback64@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:22:25 -0700 Subject: [PATCH] feat: hand a thread off to another provider mid-conversation Picking a model from another driver or continuation group on a started thread used to fail with "Thread is bound to driver X and cannot switch". Now the turn stops the old provider session, starts a fresh session on the new provider with no resume cursor, and seeds that first turn with a transcript handoff built from T3's own projection: user and assistant messages, a compact trail of completed tool calls, and the latest unimplemented plan. The handoff is sized to a share of the target context window and always fits the provider turn input limit, keeping the newest history and the opening request. Nothing is read from the previous provider, so the switch also works when that provider is unavailable. The timeline marks the switch with a divider ("Switched from X to Y, context handed off") on web and mobile. The composer no longer locks the model picker to the thread's driver; fallbacks still stay on it. A switch sent while a turn is running queues instead of steering across providers. Manual compaction keeps rejecting incompatible switches. Refs #3797 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 6 +- apps/mobile/src/lib/threadActivity.ts | 9 +- .../Layers/ProviderCommandReactor.test.ts | 188 ++++++++++------ .../Layers/ProviderCommandReactor.ts | 188 ++++++++++++++-- .../src/orchestration/providerHandoff.test.ts | 159 ++++++++++++++ .../src/orchestration/providerHandoff.ts | 206 ++++++++++++++++++ .../provider/Layers/ProviderService.test.ts | 31 +++ .../src/provider/Layers/ProviderService.ts | 5 +- .../web/src/components/ChatView.logic.test.ts | 34 +++ apps/web/src/components/ChatView.logic.ts | 12 + apps/web/src/components/ChatView.tsx | 35 +-- apps/web/src/components/chat/ChatComposer.tsx | 13 +- .../chat/MessagesTimeline.logic.test.ts | 36 ++- .../components/chat/MessagesTimeline.logic.ts | 24 +- .../src/components/chat/MessagesTimeline.tsx | 12 +- packages/contracts/src/provider.ts | 3 + 16 files changed, 825 insertions(+), 136 deletions(-) create mode 100644 apps/server/src/orchestration/providerHandoff.test.ts create mode 100644 apps/server/src/orchestration/providerHandoff.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 83b0bef9c022..e8cfde09c6a6 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1470,7 +1470,11 @@ function renderFeedEntry( (); +/** Activities that render as a labeled rule across the feed. */ +function isDividerActivityKind(kind: string | undefined): boolean { + return kind === "context-compaction" || kind === "provider.handoff"; +} + export function isContextCompactionActivityGroup( entry: Extract, ): boolean { return ( entry.activities.length === 1 && - entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction" + isDividerActivityKind(entry.activities[0]?.workEntry.sourceActivityKind) ); } @@ -1583,7 +1588,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th } const isStandalone = - entry.activity.workEntry.sourceActivityKind === "context-compaction" || + isDividerActivityKind(entry.activity.workEntry.sourceActivityKind) || entry.activity.workEntry.questionAnswer !== undefined; if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) { flushGroup(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 87f9bd03d46c..7e6bae5611b2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -350,10 +350,15 @@ describe("ProviderCommandReactor", () => { const providerSnapshots = [ { instanceId: modelSelection.instanceId, + models: [{ slug: modelSelection.model, name: "GPT-5 Codex" }], ...(input?.requiresNewThreadForModelChange === true ? { requiresNewThreadForModelChange: true } : {}), }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + models: [{ slug: "claude-opus-4-6", name: "Claude Opus 4.6" }], + }, ]; const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; @@ -3538,7 +3543,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("full-access"); }); - it("rejects provider changes after a thread is already bound to a session provider", async () => { + it("hands a started thread off to another driver with a fresh session and transcript", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -3583,75 +3588,140 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(async () => { - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.length).toBe(1); + expect(harness.startSession.mock.calls.length).toBe(2); + const handoffStart = harness.startSession.mock.calls[1]?.[1]; + expect(handoffStart).toMatchObject({ + provider: "claudeAgent", + providerInstanceId: "claudeAgent", }); + expect(handoffStart).not.toHaveProperty("resumeCursor"); - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.sendTurn.mock.calls.length).toBe(1); - expect(harness.stopSession.mock.calls.length).toBe(0); + const handoffTurn = harness.sendTurn.mock.calls[1]?.[0] as { input?: string } | undefined; + expect(handoffTurn?.input).toMatch(/^/); + expect(handoffTurn?.input).toContain("### User\nfirst"); + expect(handoffTurn?.input).not.toContain("### User\nsecond"); + expect(handoffTurn?.input?.endsWith("\n\nsecond")).toBe(true); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.providerName).toBe("codex"); - expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect(thread?.session?.providerName).toBe("claudeAgent"); expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed"), + ).toBe(false); + expect( + thread?.activities.find((activity) => activity.kind === "provider.handoff"), ).toMatchObject({ - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, + summary: "Switched from GPT-5 Codex to Claude Opus 4.6, context handed off", + payload: { fromInstanceId: "codex", toInstanceId: "claudeAgent", includedMessages: 1 }, }); }); - it("rejects cross-driver provider changes after the existing thread session has stopped", async () => { + it("hands off to another driver after the existing thread session has stopped", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-stopped-provider-switch"), - threadId: ThreadId.make("thread-1"), - session: { + harness.engine + .dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-stopped-provider-switch"), threadId: ThreadId.make("thread-1"), - status: "stopped", - providerName: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - createdAt: now, - }), + session: { + threadId: ThreadId.make("thread-1"), + status: "stopped", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }) + .pipe( + Effect.andThen( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-stopped-provider-switch"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-stopped-provider-switch"), + role: "user", + text: "continue with claude", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ), + ), ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + providerInstanceId: "claudeAgent", + }); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("resumeCursor"); + const turn = harness.sendTurn.mock.calls[0]?.[0] as { input?: string } | undefined; + expect(turn?.input).toMatch(/^/); + expect(turn?.input?.endsWith("\n\ncontinue with claude")).toBe(true); + }); + + it("keeps rejecting incompatible provider changes for manual compaction", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-stopped-provider-switch"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-stopped-provider-switch"), - role: "user", - text: "continue with claude", - attachments: [], - }, - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), + harness.engine + .dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-compact-switch-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-compact-switch-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }) + .pipe( + Effect.andThen( + Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)), + ), + Effect.andThen( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-compact-switch-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-compact-switch-2"), + role: "user", + text: "/compact", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ), + ), ); await waitFor(async () => { @@ -3662,18 +3732,8 @@ describe("ProviderCommandReactor", () => { false ); }); - - expect(harness.startSession.mock.calls.length).toBe(0); - expect(harness.sendTurn.mock.calls.length).toBe(0); - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.compactThread.mock.calls.length).toBe(0); }); it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 77022a8518d7..7496b3d25782 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -3,10 +3,13 @@ import { type ChatAttachment, CommandId, EventId, + type MessageId, type ModelSelection, type OrchestrationEvent, ProviderDriverKind, + type ProviderInstanceId, type ProjectId, + type ServerProvider, type OrchestrationSession, ThreadId, type ProviderSession, @@ -58,6 +61,11 @@ import { type ThreadTitleMessage, } from "../../textGeneration/ThreadTitleContext.ts"; import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; +import { + buildProviderHandoff, + PROVIDER_HANDOFF_ACTIVITY_KIND, + withProviderHandoff, +} from "../providerHandoff.ts"; import { resolveSourceControlWriterModelSelection, ServerSettingsService, @@ -122,6 +130,12 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; +/** The provider a thread left when its next turn needs a transcript handoff. */ +interface ProviderHandoffSource { + readonly instanceId: ProviderInstanceId; + readonly model?: string; +} + function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : "unknown"; @@ -578,6 +592,9 @@ const make = Effect.gen(function* () { // First-turn prompt seed. A manual title that still equals this seed was // written by the client's auto-title, not a user rename. readonly titleSeed?: string; + // Turn starts may move a started thread to a provider that cannot resume + // its native session. The caller then sends a transcript handoff. + readonly allowProviderHandoff?: boolean; }, ) { const thread = yield* resolveThreadShell(threadId); @@ -617,20 +634,9 @@ const make = Effect.gen(function* () { : thread.modelSelection.instanceId; const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; const desiredInstanceId = desiredModelSelection.instanceId; - const currentInfo = yield* providerService.getInstanceInfo(currentInstanceId).pipe( - Effect.mapError( - () => - new ProviderAdapterRequestError({ - provider: providerErrorLabelFromInstanceHint({ - instanceId: String(currentInstanceId), - modelSelectionInstanceId: String(thread.modelSelection.instanceId), - sessionProvider: thread.session?.providerName ?? undefined, - }), - method: "thread.turn.start", - detail: `Thread '${threadId}' references unknown provider instance '${currentInstanceId}'. The instance is not configured in this build.`, - }), - ), - ); + const currentInfoOption = yield* providerService + .getInstanceInfo(currentInstanceId) + .pipe(Effect.option); const desiredInfo = yield* providerService.getInstanceInfo(desiredInstanceId).pipe( Effect.mapError( () => @@ -652,6 +658,36 @@ const make = Effect.gen(function* () { }); } const preferredProvider: ProviderDriverKind = desiredDriverKind; + // The instance holding the thread's native history, even after its session stopped. + const historyInstanceId = + activeSession?.providerInstanceId ?? thread.session?.providerInstanceId ?? currentInstanceId; + const historyInfo = + historyInstanceId === currentInstanceId + ? currentInfoOption + : yield* providerService.getInstanceInfo(historyInstanceId).pipe(Effect.option); + // A started thread moving to a provider that cannot resume its native + // session starts fresh and receives T3's transcript instead. This also + // covers a previous instance that is no longer configured. + const requiresProviderHandoff = + options?.allowProviderHandoff === true && + thread.session !== null && + requestedModelSelection !== undefined && + requestedModelSelection.instanceId !== historyInstanceId && + (Option.isNone(historyInfo) || + historyInfo.value.driverKind !== desiredInfo.driverKind || + historyInfo.value.continuationIdentity.continuationKey !== + desiredInfo.continuationIdentity.continuationKey); + if (Option.isNone(currentInfoOption) && !requiresProviderHandoff) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(currentInstanceId), + modelSelectionInstanceId: String(thread.modelSelection.instanceId), + sessionProvider: thread.session?.providerName ?? undefined, + }), + method: "thread.turn.start", + detail: `Thread '${threadId}' references unknown provider instance '${currentInstanceId}'. The instance is not configured in this build.`, + }); + } if (options?.pendingTurnStart === true && thread.session?.status !== "running") { yield* setThreadSession({ threadId, @@ -683,10 +719,13 @@ const make = Effect.gen(function* () { }); } if ( + !requiresProviderHandoff && + Option.isSome(currentInfoOption) && thread.session !== null && requestedModelSelection !== undefined && requestedModelSelection.instanceId !== currentInstanceId ) { + const currentInfo = currentInfoOption.value; if (currentInfo.driverKind !== desiredInfo.driverKind) { return yield* new ProviderAdapterRequestError({ provider: preferredProvider, @@ -728,6 +767,7 @@ const make = Effect.gen(function* () { const startProviderSession = (input?: { readonly resumeCursor?: unknown; readonly provider?: ProviderDriverKind; + readonly transcriptHandoff?: boolean; }) => providerService .startSession(threadId, { @@ -738,6 +778,7 @@ const make = Effect.gen(function* () { ...(sessionTitle ? { title: sessionTitle } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + ...(input?.transcriptHandoff === true ? { transcriptHandoff: true } : {}), runtimeMode: desiredRuntimeMode, }) .pipe(Effect.tap(() => refreshWorkspaceSnapshot)); @@ -771,6 +812,38 @@ const make = Effect.gen(function* () { }); }); + if (requiresProviderHandoff) { + // Stop the old native session before the new provider starts, so two + // agents never run in the same workspace. A failed stop is not fatal: + // startSession also stops stale sessions for this thread. + if (activeSession !== undefined) { + yield* providerService.stopSession({ threadId }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to stop session before handoff", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ); + } + yield* Effect.logInfo("provider command reactor handing thread off to another provider", { + threadId, + fromInstanceId: historyInstanceId, + toInstanceId: desiredInstanceId, + }); + const handoffSession = yield* startProviderSession({ transcriptHandoff: true }); + yield* bindSessionToThread(handoffSession); + const previousModel = + activeSession?.model ?? + (thread.modelSelection.instanceId === historyInstanceId + ? thread.modelSelection.model + : undefined); + return { + instanceId: historyInstanceId, + ...(previousModel !== undefined ? { model: previousModel } : {}), + } satisfies ProviderHandoffSource; + } + const existingSessionThreadId = thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { @@ -799,7 +872,7 @@ const make = Effect.gen(function* () { !shouldRestartForModelSelectionChange ) { yield* refreshWorkspaceSnapshot; - return existingSessionThreadId; + return null; } const resumeCursor = shouldRestartForModelChange @@ -836,16 +909,81 @@ const make = Effect.gen(function* () { cwd: restartedSession.cwd, }); yield* bindSessionToThread(restartedSession); - return restartedSession.threadId; + return null; } const startedSession = yield* startProviderSession(undefined); yield* bindSessionToThread(startedSession); - return startedSession.threadId; + return null; + }); + + const describeModelSelection = ( + providers: ReadonlyArray, + selection: { readonly instanceId: ProviderInstanceId; readonly model?: string | undefined }, + ) => { + const snapshot = providers.find((provider) => provider.instanceId === selection.instanceId); + const modelName = + selection.model === undefined + ? undefined + : snapshot?.models.find((model) => model.slug === selection.model)?.name; + return modelName ?? selection.model ?? snapshot?.displayName ?? String(selection.instanceId); + }; + + /** + * Prepends T3's transcript to the first turn on a provider that could not + * resume the thread, and marks the switch in the timeline. + */ + const prepareProviderHandoff = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly messageText: string; + readonly from: ProviderHandoffSource; + readonly to: ModelSelection; + }) { + const detail = yield* projectionSnapshotQuery + .getThreadDetailById(input.threadId, { activityKinds: ["tool.completed"] }) + .pipe(Effect.map(Option.getOrUndefined)); + const providers = yield* providerRegistry.getProviders; + const fromLabel = describeModelSelection(providers, input.from); + const toLabel = describeModelSelection(providers, input.to); + const handoff = buildProviderHandoff({ + fromLabel, + toLabel, + messages: detail?.messages ?? [], + activities: detail?.activities ?? [], + proposedPlans: detail?.proposedPlans ?? [], + currentMessageId: input.messageId, + currentMessageChars: input.messageText.length, + }); + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: yield* serverCommandId("provider-handoff"), + threadId: input.threadId, + activity: { + id: yield* serverEventId(), + tone: "info", + kind: PROVIDER_HANDOFF_ACTIVITY_KIND, + summary: `Switched from ${fromLabel} to ${toLabel}, context handed off`, + payload: { + fromInstanceId: input.from.instanceId, + toInstanceId: input.to.instanceId, + includedMessages: handoff.includedMessages, + omittedMessages: handoff.omittedMessages, + toolCalls: handoff.toolCalls, + handoffChars: handoff.text.length, + }, + turnId: null, + createdAt, + }, + createdAt, + }); + return withProviderHandoff(handoff, input.messageText); }); const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; + readonly messageId?: MessageId; readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; @@ -859,15 +997,26 @@ const make = Effect.gen(function* () { new Error(`Thread '${input.threadId}' was not found in read model.`), ); } - yield* ensureSessionForThread(input.threadId, input.createdAt, { + const handoffFrom = yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), ...(input.titleSeed !== undefined ? { titleSeed: input.titleSeed } : {}), pendingTurnStart: true, + allowProviderHandoff: input.messageId !== undefined, }); if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } - const normalizedInput = toNonEmptyProviderInput(input.messageText); + const messageText = + handoffFrom !== null && input.messageId !== undefined && input.modelSelection !== undefined + ? yield* prepareProviderHandoff({ + threadId: input.threadId, + messageId: input.messageId, + messageText: input.messageText, + from: handoffFrom, + to: input.modelSelection, + }) + : input.messageText; + const normalizedInput = toNonEmptyProviderInput(messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService .listSessions() @@ -1492,6 +1641,7 @@ const make = Effect.gen(function* () { } const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, + messageId: message.id, messageText: projectComposerContextForProvider({ text: message.text, records: message.context?.records ?? [], diff --git a/apps/server/src/orchestration/providerHandoff.test.ts b/apps/server/src/orchestration/providerHandoff.test.ts new file mode 100644 index 000000000000..c235e024a86c --- /dev/null +++ b/apps/server/src/orchestration/providerHandoff.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + EventId, + MessageId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + TurnId, + type OrchestrationMessage, + type OrchestrationProposedPlan, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; + +import { + buildProviderHandoff, + resolveHandoffBudgetChars, + withProviderHandoff, +} from "./providerHandoff.ts"; + +const at = (minute: number) => `2026-01-01T00:${String(minute).padStart(2, "0")}:00.000Z`; + +const message = ( + id: string, + role: OrchestrationMessage["role"], + text: string, + minute: number, + turnId: string | null = null, +): OrchestrationMessage => ({ + id: MessageId.make(id), + role, + text, + turnId: turnId === null ? null : TurnId.make(turnId), + streaming: false, + createdAt: at(minute), + updatedAt: at(minute), +}); + +const toolCompleted = ( + id: string, + turnId: string, + detail: string, + minute: number, + status = "completed", +): OrchestrationThreadActivity => ({ + id: EventId.make(id), + tone: "tool", + kind: "tool.completed", + summary: "Command run", + payload: { itemType: "command_execution", status, title: "Command run", detail }, + turnId: TurnId.make(turnId), + createdAt: at(minute), +}); + +const base = { + fromLabel: "Claude Opus 4.6", + toLabel: "GPT-5.4", + activities: [] as OrchestrationThreadActivity[], + proposedPlans: [] as OrchestrationProposedPlan[], + currentMessageId: MessageId.make("current"), + currentMessageChars: 20, +}; + +describe("buildProviderHandoff", () => { + it("renders messages and a tool trail in order, excluding the turn being sent", () => { + const handoff = buildProviderHandoff({ + ...base, + messages: [ + message("m1", "user", "Fix the flaky test", 1), + message("m1r", "reasoning", "thinking out loud", 2, "t1"), + message("m2", "assistant", "Fixed it by awaiting the receipt.", 4, "t1"), + message("current", "user", "Now open a PR", 5), + ], + activities: [ + toolCompleted("a1", "t1", "Bash: vp test run foo.test.ts", 3), + toolCompleted("a2", "t1", "Bash: vp test run bar.test.ts", 3, "failed"), + ], + }); + + expect(handoff.includedMessages).toBe(2); + expect(handoff.omittedMessages).toBe(0); + expect(handoff.toolCalls).toBe(2); + expect(handoff.text).toContain("started with Claude Opus 4.6 and continues with you (GPT-5.4)"); + expect(handoff.text).not.toContain("thinking out loud"); + expect(handoff.text).not.toContain("Now open a PR"); + const user = handoff.text.indexOf("### User\nFix the flaky test"); + const tools = handoff.text.indexOf("Tool calls (2):"); + const assistant = handoff.text.indexOf("### Assistant\nFixed it"); + expect(user).toBeGreaterThan(-1); + expect(tools).toBeGreaterThan(user); + expect(assistant).toBeGreaterThan(tools); + expect(handoff.text).toContain("- Bash: vp test run bar.test.ts (failed)"); + }); + + it("includes the latest unimplemented plan", () => { + const plan = (id: string, markdown: string, minute: number, implemented: boolean) => ({ + id, + turnId: null, + planMarkdown: markdown, + implementedAt: implemented ? at(minute) : null, + implementationThreadId: null, + createdAt: at(minute), + updatedAt: at(minute), + }); + const handoff = buildProviderHandoff({ + ...base, + messages: [message("m1", "user", "Plan it", 1)], + proposedPlans: [ + plan("p1", "1. old plan", 1, false), + plan("p2", "1. shipped plan", 3, true), + plan("p3", "1. current plan", 2, false), + ], + }); + expect(handoff.text).toContain("## Current plan\n1. current plan"); + expect(handoff.text).not.toContain("old plan"); + expect(handoff.text).not.toContain("shipped plan"); + }); + + it("keeps the newest history and the opening request when the budget is tight", () => { + const filler = "x".repeat(9_000); + const messages = [message("m0", "user", "Original task statement", 0)]; + for (let index = 1; index <= 40; index += 1) { + messages.push( + message(`m${index}`, index % 2 === 0 ? "user" : "assistant", `${index} ${filler}`, index), + ); + } + const handoff = buildProviderHandoff({ ...base, messages }); + + expect(handoff.text.length).toBeLessThanOrEqual( + resolveHandoffBudgetChars({ currentMessageChars: base.currentMessageChars }), + ); + expect(handoff.omittedMessages).toBeGreaterThan(0); + expect(handoff.includedMessages + handoff.omittedMessages).toBe(41); + expect(handoff.text).toContain("### User\nOriginal task statement"); + expect(handoff.text).toContain(`### User\n40 ${filler}`); + expect(handoff.text).toContain(`[${handoff.omittedMessages} earlier messages omitted`); + expect(handoff.text).not.toContain(`### Assistant\n1 ${filler}`); + }); + + it("sizes the budget to the target context window and the turn input limit", () => { + expect(resolveHandoffBudgetChars({ contextWindowTokens: 32_000, currentMessageChars: 0 })).toBe( + 28_000, + ); + expect( + resolveHandoffBudgetChars({ contextWindowTokens: 1_000_000, currentMessageChars: 10_000 }), + ).toBeLessThanOrEqual(PROVIDER_SEND_TURN_MAX_INPUT_CHARS - 10_000); + }); + + it("fits under the provider input limit with the user's message", () => { + const text = "y".repeat(50_000); + const handoff = buildProviderHandoff({ + ...base, + currentMessageChars: text.length, + messages: Array.from({ length: 30 }, (_, index) => + message(`m${index}`, "assistant", "z".repeat(11_000), index), + ), + }); + expect(withProviderHandoff(handoff, text).length).toBeLessThanOrEqual( + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + ); + }); +}); diff --git a/apps/server/src/orchestration/providerHandoff.ts b/apps/server/src/orchestration/providerHandoff.ts new file mode 100644 index 000000000000..c52a310317f9 --- /dev/null +++ b/apps/server/src/orchestration/providerHandoff.ts @@ -0,0 +1,206 @@ +/** + * Transcript handoff for switching a started thread to a provider that cannot + * resume the previous provider's native session (another driver, or another + * continuation group of the same driver). + * + * The new session starts fresh, so the first turn carries T3's own projected + * history as text: messages, a compact trail of completed tool calls, and the + * latest proposed plan. Nothing here reads provider-native state, so a switch + * works even when the previous provider is unavailable or out of quota. + */ +import { + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type MessageId, + type OrchestrationMessage, + type OrchestrationProposedPlan, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; + +/** Assumed when the target model's window is unknown: the smallest window T3 ships. */ +export const DEFAULT_HANDOFF_CONTEXT_WINDOW_TOKENS = 128_000; +/** Share of the target context window the handoff may use. */ +const HANDOFF_CONTEXT_SHARE = 0.25; +/** Conservative chars-per-token estimate for mixed prose and code. */ +const CHARS_PER_TOKEN = 3.5; +const MAX_MESSAGE_CHARS = 12_000; +const MAX_PLAN_SHARE = 0.25; +const MAX_TOOL_LINES_PER_TURN = 12; +const MAX_TOOL_LINE_CHARS = 180; +const ENVELOPE_RESERVE_CHARS = 1_024; + +export const PROVIDER_HANDOFF_ACTIVITY_KIND = "provider.handoff"; + +export interface ProviderHandoffInput { + readonly fromLabel: string; + readonly toLabel: string; + readonly messages: ReadonlyArray; + readonly activities: ReadonlyArray; + readonly proposedPlans: ReadonlyArray; + /** The message that starts the new turn. It is sent after the handoff, not inside it. */ + readonly currentMessageId: MessageId; + readonly currentMessageChars: number; + readonly contextWindowTokens?: number | undefined; +} + +export interface ProviderHandoff { + readonly text: string; + readonly includedMessages: number; + readonly omittedMessages: number; + readonly toolCalls: number; +} + +interface HandoffItem { + readonly createdAt: string; + readonly text: string; + readonly isMessage: boolean; +} + +export function resolveHandoffBudgetChars(input: { + readonly contextWindowTokens?: number | undefined; + readonly currentMessageChars: number; +}): number { + const windowTokens = + input.contextWindowTokens !== undefined && input.contextWindowTokens > 0 + ? input.contextWindowTokens + : DEFAULT_HANDOFF_CONTEXT_WINDOW_TOKENS; + const windowBudget = Math.floor(windowTokens * HANDOFF_CONTEXT_SHARE * CHARS_PER_TOKEN); + const inputBudget = + PROVIDER_SEND_TURN_MAX_INPUT_CHARS - input.currentMessageChars - ENVELOPE_RESERVE_CHARS; + return Math.max(0, Math.min(windowBudget, inputBudget)); +} + +function clip(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const marker = `\n[... ${text.length - maxChars} characters omitted ...]\n`; + const keep = Math.max(0, maxChars - marker.length); + const head = Math.ceil(keep * 0.6); + return `${text.slice(0, head)}${marker}${text.slice(text.length - (keep - head))}`; +} + +function oneLine(text: string, maxChars: number): string { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length <= maxChars ? flat : `${flat.slice(0, maxChars - 3)}...`; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" ? (value as Record) : null; +} + +function describeToolActivity(activity: OrchestrationThreadActivity): string { + const payload = asRecord(activity.payload); + const detail = typeof payload?.detail === "string" ? payload.detail : null; + const title = typeof payload?.title === "string" ? payload.title : activity.summary; + const status = typeof payload?.status === "string" ? payload.status : null; + const body = detail ?? title; + const failed = status !== null && status !== "completed"; + return `- ${oneLine(body, MAX_TOOL_LINE_CHARS)}${failed ? ` (${status})` : ""}`; +} + +function toolItems(activities: ReadonlyArray): { + readonly items: HandoffItem[]; + readonly toolCalls: number; +} { + const byTurn = new Map(); + let toolCalls = 0; + for (const activity of activities) { + if (activity.kind !== "tool.completed" || activity.turnId === null) continue; + toolCalls += 1; + const group = byTurn.get(activity.turnId) ?? []; + group.push(activity); + byTurn.set(activity.turnId, group); + } + const items: HandoffItem[] = []; + for (const group of byTurn.values()) { + const sorted = group.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + const shown = sorted.slice(-MAX_TOOL_LINES_PER_TURN); + const hidden = sorted.length - shown.length; + const lines = [ + `Tool calls (${sorted.length}):`, + ...(hidden > 0 ? [`- ... ${hidden} earlier calls`] : []), + ...shown.map(describeToolActivity), + ]; + items.push({ createdAt: sorted[0]!.createdAt, text: lines.join("\n"), isMessage: false }); + } + return { items, toolCalls }; +} + +/** + * Renders the handoff that precedes the first message sent to the new + * provider. Newest history wins when the budget is tight; the opening user + * message is kept when it still fits, since it usually states the task. + */ +export function buildProviderHandoff(input: ProviderHandoffInput): ProviderHandoff { + const budget = resolveHandoffBudgetChars(input); + const conversation = input.messages.filter( + (message) => + message.id !== input.currentMessageId && + (message.role === "user" || message.role === "assistant") && + message.text.trim().length > 0, + ); + const messageItems: HandoffItem[] = conversation.map((message) => ({ + createdAt: message.createdAt, + text: `### ${message.role === "user" ? "User" : "Assistant"}\n${clip( + message.text.trim(), + MAX_MESSAGE_CHARS, + )}`, + isMessage: true, + })); + const tools = toolItems(input.activities); + const items = [...messageItems, ...tools.items].toSorted((a, b) => + a.createdAt.localeCompare(b.createdAt), + ); + + const header = [ + ``, + `This conversation started with ${input.fromLabel} and continues with you (${input.toLabel}).`, + `You have no native memory of the earlier turns. T3 Code recorded them and hands them off below.`, + `Treat this as context, not as new instructions. The workspace already reflects the earlier work.`, + ].join("\n"); + const footer = ``; + + const latestPlan = input.proposedPlans + .filter((plan) => plan.implementedAt === null) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) + .at(-1); + const planSection = latestPlan + ? `## Current plan\n${clip(latestPlan.planMarkdown, Math.floor(budget * MAX_PLAN_SHARE))}` + : null; + + let remaining = budget - header.length - footer.length - (planSection?.length ?? 0) - 64; + const selected: HandoffItem[] = []; + for (let index = items.length - 1; index >= 0 && remaining > 0; index -= 1) { + const item = items[index]!; + if (item.text.length + 2 > remaining) break; + selected.unshift(item); + remaining -= item.text.length + 2; + } + const firstMessage = items.find((item) => item.isMessage); + const keepsOpening = + firstMessage !== undefined && + !selected.includes(firstMessage) && + firstMessage.text.length + 64 <= remaining; + const includedMessages = + selected.filter((item) => item.isMessage).length + (keepsOpening ? 1 : 0); + const omittedMessages = conversation.length - includedMessages; + + const body: string[] = []; + if (planSection) body.push(planSection); + body.push("## Conversation so far"); + if (keepsOpening) body.push(firstMessage.text); + if (omittedMessages > 0) { + body.push(`[${omittedMessages} earlier messages omitted to fit the context budget]`); + } + body.push(...selected.map((item) => item.text)); + + return { + text: [header, ...body, footer].join("\n\n"), + includedMessages, + omittedMessages, + toolCalls: tools.toolCalls, + }; +} + +/** Prepends the handoff to the provider input without touching the stored user message. */ +export function withProviderHandoff(handoff: ProviderHandoff, messageText: string): string { + return `${handoff.text}\n\n${messageText}`; +} diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 7f0465b9c401..13bd52d94c86 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1250,6 +1250,37 @@ antigravityInstanceRouting.layer("ProviderServiceLive instance-owned conversatio } }), ); + + it.effect("starts a fresh session on another instance for a transcript handoff", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + originalAntigravityInstanceAvailable = true; + const threadId = asThreadId("thread-antigravity-transcript-handoff"); + yield* directory.upsert({ + threadId, + provider: antigravityDriver, + providerInstanceId: originalAntigravityInstanceId, + status: "stopped", + runtimeMode: "approval-required", + resumeCursor: { sessionId: "native-session" }, + }); + replacementAntigravity.startSession.mockClear(); + + yield* provider.startSession(threadId, { + providerInstanceId: replacementAntigravityInstanceId, + threadId, + runtimeMode: "approval-required", + transcriptHandoff: true, + }); + + assert.equal(replacementAntigravity.startSession.mock.calls.length, 1); + assert.equal(replacementAntigravity.startSession.mock.calls[0]?.[0].resumeCursor, undefined); + const binding = Option.getOrThrow(yield* directory.getBinding(threadId)); + assert.equal(binding.providerInstanceId, replacementAntigravityInstanceId); + yield* provider.stopSession({ threadId }); + }), + ); }); const unsupportedRollback = makeProviderServiceLayer({ supportsConversationRollback: false }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f4e7b0b39bdb..052aab80c357 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1443,10 +1443,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + // A transcript handoff deliberately replaces the native conversation, + // so only a cursor carried across instances needs a compatible resume state. if ( persistedBinding?.provider === resolvedProvider && persistedBinding.providerInstanceId !== resolvedInstanceId && - (input.resumeCursor != null || persistedBinding.resumeCursor != null) + (input.resumeCursor != null || + (persistedBinding.resumeCursor != null && input.transcriptHandoff !== true)) ) { const previousInstanceId = yield* requireBindingInstanceId( "ProviderService.startSession", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a2708de8bda3..ace550b7a985 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1208,6 +1208,40 @@ describe("resolveComposerProviderSelection", () => { ).toBeNull(); }); + it("lets a picked model leave the locked driver but keeps fallbacks on it", () => { + const codexEntry = entry("codex"); + const claudeEntry = entry("claudeAgent"); + const disabledClaudeEntry = entry("claudeAgent", "claude_work", { enabled: false }); + const locked = { + lockedProvider: ProviderDriverKind.make("codex"), + lockedInstanceId: codexEntry.instanceId, + }; + + expect( + resolveComposerProviderSelection({ + entries: [codexEntry, claudeEntry], + explicitInstanceId: claudeEntry.instanceId, + candidateInstanceIds: [claudeEntry.instanceId, codexEntry.instanceId], + ...locked, + }).selectedProviderEntry?.instanceId, + ).toBe(claudeEntry.instanceId); + expect( + resolveComposerProviderSelection({ + entries: [claudeEntry, codexEntry], + candidateInstanceIds: [null, claudeEntry.instanceId], + ...locked, + }).selectedProviderEntry?.instanceId, + ).toBe(codexEntry.instanceId); + expect( + resolveComposerProviderSelection({ + entries: [codexEntry, disabledClaudeEntry], + explicitInstanceId: disabledClaudeEntry.instanceId, + candidateInstanceIds: [disabledClaudeEntry.instanceId, codexEntry.instanceId], + ...locked, + }).selectedProviderEntry?.instanceId, + ).toBe(codexEntry.instanceId); + }); + it("uses the custom instance's capability instead of the default instance", () => { const defaultEntry = entry("antigravity", "antigravity", { showInteractionModeToggle: true, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 528024de4393..921ea288c14c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -542,6 +542,8 @@ export function buildThreadTurnInterruptInput(thread: Pick; candidateInstanceIds: ReadonlyArray; + /** The instance the user picked in the composer, if any. */ + explicitInstanceId?: ProviderInstanceId | null | undefined; lockedProvider: ProviderDriverKind | null; lockedInstanceId: ProviderInstanceId | null | undefined; }) { @@ -568,7 +570,17 @@ export function resolveComposerProviderSelection(input: { (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey) && (!requiresExactInstance || entry.instanceId === input.lockedInstanceId), ); + // A model the user picked may leave the locked driver: the server hands the + // conversation off to the new provider. Fallbacks stay on the locked driver. + const explicitEntry = + input.explicitInstanceId == null + ? undefined + : input.entries.find( + (entry) => + entry.instanceId === input.explicitInstanceId && entry.enabled && entry.isAvailable, + ); const selectedProviderEntry = + explicitEntry ?? input.candidateInstanceIds .map((candidate) => compatibleEntries.find( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 61f1770c4bef..4a9d7a9bcbe2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2849,6 +2849,7 @@ export default function ChatView(props: ChatViewProps) { () => resolveComposerProviderSelection({ entries: providerInstanceEntries, + explicitInstanceId: selectedProviderByThreadId, candidateInstanceIds: [ selectedProviderByThreadId, activeThread?.session?.providerInstanceId, @@ -7662,12 +7663,18 @@ export default function ChatView(props: ChatViewProps) { (useQueuedMessageStore.getState().queuesByThreadKey[activeThreadKey] ?? []).some( (message) => message.sending !== undefined || !message.holdUntilUserAction, ); + // Steering cannot cross providers: a switch waits for the running turn, + // then starts the new provider with a transcript handoff. + const switchesRunningProvider = + activeThread.session?.providerInstanceId !== undefined && + activeThread.session.providerInstanceId !== ctxSelectedModelSelection.instanceId; if ( !directAnnotation && activeThreadKey && (queueStillSending || (phase === "running" && - (settings.followUpBehavior === "queue") !== (submissionIntent === "alternate"))) + (switchesRunningProvider || + (settings.followUpBehavior === "queue") !== (submissionIntent === "alternate")))) ) { const sendSettings = readComposerSendSettings(sendCtx); if ( @@ -9225,29 +9232,8 @@ export default function ChatView(props: ChatViewProps) { // Look up the configured instance so model normalization and custom // model lookup stay scoped to that exact instance. Unknown instance ids // are rejected by returning early; the server remains authoritative too. - const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); - const resolvedDriverKind = entry?.driver ?? null; - if ( - lockedProvider !== null && - resolvedDriverKind !== null && - resolvedDriverKind !== lockedProvider - ) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - if (lockedProvider !== null && activeThread.session?.providerInstanceId) { - const currentEntry = providerStatuses.find( - (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, - ); - if ( - currentEntry?.continuation?.groupKey && - entry?.continuation?.groupKey && - currentEntry.continuation.groupKey !== entry.continuation.groupKey - ) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - } + // Another driver or continuation group is allowed on a started thread: + // the server starts a fresh session there and hands the transcript off. const resolvedModel = resolveAppModelSelectionForInstance( instanceId, settings, @@ -9288,7 +9274,6 @@ export default function ChatView(props: ChatViewProps) { }, [ activeThread, - lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d168c6db4e5f..ecc36ee0e97d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1870,15 +1870,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [providerStatuses, settings], ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; - const { - selectedProviderEntry, - requestedDriverKind, - lockedContinuationGroupKey, - unavailableProviderInstanceId, - } = useMemo( + const { selectedProviderEntry, requestedDriverKind, unavailableProviderInstanceId } = useMemo( () => resolveComposerProviderSelection({ entries: providerInstanceEntries, + explicitInstanceId: selectedProviderByThreadId, candidateInstanceIds: [ selectedProviderByThreadId, activeThread?.session?.providerInstanceId, @@ -5064,8 +5060,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? (activeThreadModelSelection?.model ?? selectedModelForPickerWithCustomFallback) : selectedModelForPickerWithCustomFallback } - lockedProvider={lockedProvider} - lockedContinuationGroupKey={lockedContinuationGroupKey} + // Started threads can switch to any provider; the server hands off context. + lockedProvider={null} + lockedContinuationGroupKey={null} instanceEntries={providerInstanceEntries} keybindings={keybindings} modelOptionsByInstance={modelOptionsByInstance} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d95e22517630..9e737629f7c0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1354,10 +1354,44 @@ describe("deriveMessagesTimelineRows", () => { expect(rows).toEqual([ { - kind: "context-compaction", + kind: "divider", id: "compaction-entry", createdAt: "2026-01-01T00:00:00Z", label: "Compacted context 899K → 19K tokens", + icon: "compaction", + }, + ]); + }); + + it("renders a provider handoff as a divider", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "handoff-entry", + kind: "work", + createdAt: "2026-01-01T00:00:00Z", + entry: { + id: "handoff", + createdAt: "2026-01-01T00:00:00Z", + label: "Switched from Claude Opus 4.6 to GPT-5.4, context handed off", + tone: "info", + sourceActivityKind: "provider.handoff", + }, + }, + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows).toEqual([ + { + kind: "divider", + id: "handoff-entry", + createdAt: "2026-01-01T00:00:00Z", + label: "Switched from Claude Opus 4.6 to GPT-5.4, context handed off", + icon: "handoff", }, ]); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 21508a9663ae..56bed8d30cc0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -335,6 +335,11 @@ export type TimelineLatestTurn = Pick< const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; +/** Activities that render as a labeled rule across the timeline. */ +function isDividerActivityKind(kind: string | undefined): boolean { + return kind === "context-compaction" || kind === "provider.handoff"; +} + type ActivityEntry = Extract; function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry { @@ -343,7 +348,7 @@ function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry { : entry.kind === "work" && entry.entry.agentSpawn === undefined && entry.entry.questionAnswer === undefined && - entry.entry.sourceActivityKind !== "context-compaction" && + !isDividerActivityKind(entry.entry.sourceActivityKind) && entry.entry.tone !== "error"; } @@ -400,10 +405,11 @@ export type MessagesTimelineRow = expanded: boolean; } | { - kind: "context-compaction"; + kind: "divider"; id: string; createdAt: string; label: string; + icon: "compaction" | "handoff"; } | { kind: "message"; @@ -1040,7 +1046,7 @@ export function deriveMessagesTimelineRows(input: { !entryBelongsToActiveTurn(entry, index) || entry.kind !== "work" || entry.entry.questionAnswer !== undefined || - entry.entry.sourceActivityKind === "context-compaction" || + isDividerActivityKind(entry.entry.sourceActivityKind) || entry.entry.tone === "error" ) { break; @@ -1206,13 +1212,15 @@ export function deriveMessagesTimelineRows(input: { if ( timelineEntry.kind === "work" && - timelineEntry.entry.sourceActivityKind === "context-compaction" + isDividerActivityKind(timelineEntry.entry.sourceActivityKind) ) { nextRows.push({ - kind: "context-compaction", + kind: "divider", id: timelineEntry.id, createdAt: timelineEntry.createdAt, label: timelineEntry.entry.label, + icon: + timelineEntry.entry.sourceActivityKind === "provider.handoff" ? "handoff" : "compaction", }); continue; } @@ -1247,7 +1255,7 @@ export function deriveMessagesTimelineRows(input: { nextEntry.kind !== "work" || nextEntry.entry.agentSpawn !== undefined || nextEntry.entry.questionAnswer !== undefined || - nextEntry.entry.sourceActivityKind === "context-compaction" || + isDividerActivityKind(nextEntry.entry.sourceActivityKind) || nextEntry.entry.tone === "error" || activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || @@ -1630,9 +1638,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; } - case "context-compaction": { + case "divider": { const bc = b as typeof a; - return a.createdAt === bc.createdAt && a.label === bc.label; + return a.createdAt === bc.createdAt && a.label === bc.label && a.icon === bc.icon; } case "proposed-plan": diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bf7faa735fed..cd23cfd5cd7b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -107,6 +107,7 @@ import remarkGfm from "remark-gfm"; import type { Root, RootContent } from "mdast"; import { T3Wordmark } from "../T3Wordmark"; import { + ArrowRightLeftIcon, BotIcon, BrainIcon, CheckIcon, @@ -1731,7 +1732,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "activity-group" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} - {row.kind === "context-compaction" ? : null} + {row.kind === "divider" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} {row.kind === "message" && row.message.role === "assistant" ? ( @@ -1877,11 +1878,8 @@ function QueuedMessageTimelineRow({ ); } -function ContextCompactionTimelineRow({ - row, -}: { - row: Extract; -}) { +function DividerTimelineRow({ row }: { row: Extract }) { + const Icon = row.icon === "handoff" ? ArrowRightLeftIcon : Minimize2Icon; return (
- diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 8753ac2cf8e8..52ba2484817b 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -60,6 +60,9 @@ export const ProviderSessionStartInput = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), + /** Internal: the orchestrator is replacing the thread's native conversation + with a fresh session seeded by a transcript handoff. */ + transcriptHandoff: Schema.optional(Schema.Boolean), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), runtimeMode: RuntimeMode,