| author | |
| committer | |
| log | c616e16c8fd1da1953fd5d0039fffa9e9b146560 |
| tree | 697ae70a800fd50c0e31cabb21f778b6d00e147b |
| parent | 380bba368a9071828616b1b03bde1876cc0af4c8 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>9 files changed, 217 insertions(+), 43 deletions(-)
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3| ... | ... | @@ -6,6 +6,7 @@ import type { WorktreeSetupCardProps } from "./worktree-setup-card"; |
| 6 | 6 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; |
| 7 | 7 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 8 | 8 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 9 | import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; | |
| 9 | 10 | import type { |
| 10 | 11 | CodexFeedbackSubmission, |
| 11 | 12 | EnvironmentThreadStatus, |
| ... | ... | @@ -162,6 +163,7 @@ export interface ThreadDetailScreenProps { |
| 162 | 163 | readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>; |
| 163 | 164 | readonly onDismissFeedback: (id: MessageId) => void; |
| 164 | 165 | readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; |
| 166 | readonly pendingHandoff?: HandoffEndpoints | null; | |
| 165 | 167 | readonly activityRun: ThreadFeedLatestRun | null; |
| 166 | 168 | readonly activeWorkStartedAt: string | null; |
| 167 | 169 | /** The live work is a provider-native subagent's runless root turn. */ |
| ... | ... | @@ -1159,6 +1161,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1159 | 1161 | threadId={props.selectedThread.id} |
| 1160 | 1162 | workspaceRoot={props.threadCwd} |
| 1161 | 1163 | feed={props.selectedThreadFeed} |
| 1164 | pendingHandoff={props.pendingHandoff} | |
| 1162 | 1165 | worktreeSetup={props.worktreeSetup} |
| 1163 | 1166 | setupWorkingStartedAt={props.setupWorkingStartedAt} |
| 1164 | 1167 | queuedMessages={props.queuedMessages} |
apps/mobile/src/features/threads/ThreadFeed.tsx+13-1| ... | ... | @@ -3,7 +3,7 @@ import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdown |
| 3 | 3 | import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; |
| 4 | 4 | import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation"; |
| 5 | 5 | import { ThreadContextDivider } from "./thread-context-divider"; |
| 6 | import { ThreadHandoffRow } from "./thread-handoff-row"; | |
| 6 | import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row"; | |
| 7 | 7 | import { SecretRequestCard } from "./SecretRequestCard"; |
| 8 | 8 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 9 | 9 | import { |
| ... | ... | @@ -15,6 +15,7 @@ import * as Haptics from "expo-haptics"; |
| 15 | 15 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 16 | 16 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; |
| 17 | 17 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 18 | import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; | |
| 18 | 19 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; |
| 19 | 20 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 20 | 21 | import { |
| ... | ... | @@ -292,6 +293,7 @@ export interface ThreadFeedProps { |
| 292 | 293 | readonly threadTitle: string; |
| 293 | 294 | readonly workspaceRoot?: string | null; |
| 294 | 295 | readonly feed: ReadonlyArray<ThreadFeedEntry>; |
| 296 | readonly pendingHandoff?: HandoffEndpoints | null; | |
| 295 | 297 | readonly contentPresentation: ThreadContentPresentation; |
| 296 | 298 | readonly agentLabel: string; |
| 297 | 299 | readonly latestRun: ThreadFeedLatestRun | null; |
| ... | ... | @@ -2756,6 +2758,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2756 | 2758 | ) : null} |
| 2757 | 2759 | </> |
| 2758 | 2760 | } |
| 2761 | ListFooterComponent={ | |
| 2762 | props.pendingHandoff ? ( | |
| 2763 | <ThreadHandoffDivider | |
| 2764 | environmentId={props.environmentId} | |
| 2765 | endpoints={props.pendingHandoff} | |
| 2766 | iconColor={iconSubtleColor} | |
| 2767 | label="Context handoff pending" | |
| 2768 | /> | |
| 2769 | ) : null | |
| 2770 | } | |
| 2759 | 2771 | contentContainerStyle={{ |
| 2760 | 2772 | paddingTop: 12, |
| 2761 | 2773 | paddingLeft: contentHorizontalPadding + contentLeftInset, |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1| ... | ... | @@ -995,6 +995,7 @@ function ThreadRouteContent( |
| 995 | 995 | feedbackSubmissions={composer.feedbackSubmissions} |
| 996 | 996 | onDismissFeedback={composer.dismissFeedback} |
| 997 | 997 | selectedThreadFeed={composer.selectedThreadFeed} |
| 998 | pendingHandoff={composer.pendingHandoff} | |
| 998 | 999 | activityRun={composer.selectedThreadActivityRun} |
| 999 | 1000 | activeWorkStartedAt={ |
| 1000 | 1001 | creationState?.kind === "preparing" || |
apps/mobile/src/features/threads/thread-handoff-row.tsx+25-6| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { resolveHandoffEndpoints } from "@t3tools/client-runtime/handoff"; | |
| 3 | import { resolveHandoffEndpoints, type HandoffEndpoints } from "@t3tools/client-runtime/handoff"; | |
| 4 | 4 | import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; |
| 5 | 5 | import type { |
| 6 | 6 | EnvironmentId, |
| ... | ... | @@ -45,15 +45,34 @@ export function ThreadHandoffRow(props: { |
| 45 | 45 | [props.environmentId, props.projectedItem], |
| 46 | 46 | ); |
| 47 | 47 | const endpoints = useAtomValue(endpointsAtom); |
| 48 | const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); | |
| 49 | 48 | if (item.type !== "handoff" || endpoints === null) return null; |
| 50 | 49 | const color = item.status === "failed" ? "#e11d48" : props.iconColor; |
| 51 | 50 | return ( |
| 52 | <ThreadContextDivider | |
| 53 | label="Context handoff" | |
| 54 | icon="arrow.left.arrow.right" | |
| 51 | <ThreadHandoffDivider | |
| 52 | environmentId={props.environmentId} | |
| 53 | endpoints={endpoints} | |
| 55 | 54 | iconColor={color} |
| 55 | label="Context handoff" | |
| 56 | 56 | failed={item.status === "failed"} |
| 57 | /> | |
| 58 | ); | |
| 59 | } | |
| 60 | ||
| 61 | export function ThreadHandoffDivider(props: { | |
| 62 | environmentId: EnvironmentId; | |
| 63 | endpoints: HandoffEndpoints; | |
| 64 | iconColor: ColorValue; | |
| 65 | label: string; | |
| 66 | failed?: boolean; | |
| 67 | }) { | |
| 68 | const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); | |
| 69 | const { endpoints } = props; | |
| 70 | return ( | |
| 71 | <ThreadContextDivider | |
| 72 | label={props.label} | |
| 73 | icon="arrow.left.arrow.right" | |
| 74 | iconColor={props.iconColor} | |
| 75 | failed={props.failed} | |
| 57 | 76 | > |
| 58 | 77 | <View className="flex-row flex-wrap items-center justify-center gap-1.5"> |
| 59 | 78 | {endpoints.from.map((endpoint, index) => ( |
| ... | ... | @@ -67,7 +86,7 @@ export function ThreadHandoffRow(props: { |
| 67 | 86 | </Fragment> |
| 68 | 87 | ))} |
| 69 | 88 | {endpoints.from.length > 0 ? ( |
| 70 | <SymbolView name="arrow.right" size={12} tintColor={color} /> | |
| 89 | <SymbolView name="arrow.right" size={12} tintColor={props.iconColor} /> | |
| 71 | 90 | ) : null} |
| 72 | 91 | <HandoffEndpoint {...endpoints.to} providers={config?.providers ?? []} /> |
| 73 | 92 | </View> |
apps/mobile/src/state/use-thread-composer-state.ts+11| ... | ... | @@ -3,6 +3,7 @@ import { readEnvironmentScope } from "./session"; |
| 3 | 3 | import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; |
| 4 | 4 | import { useAtomValue } from "@effect/atom-react"; |
| 5 | 5 | import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; |
| 6 | import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; | |
| 6 | 7 | import { |
| 7 | 8 | deriveProviderSubagentStatus, |
| 8 | 9 | deriveRunlessWorkStartedAt, |
| ... | ... | @@ -334,6 +335,15 @@ export function useThreadComposerState() { |
| 334 | 335 | const selectedThreadQueueCount = selectedThreadQueuedMessages.length; |
| 335 | 336 | const selectedThread = selectedThreadShell; |
| 336 | 337 | const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; |
| 338 | const pendingHandoff = useMemo( | |
| 339 | () => | |
| 340 | resolvePendingHandoff( | |
| 341 | selectedThreadProjection?.projection, | |
| 342 | modelSelection, | |
| 343 | selectedEnvironmentRuntime?.serverConfig?.providers ?? [], | |
| 344 | ), | |
| 345 | [modelSelection, selectedThreadProjection, selectedEnvironmentRuntime?.serverConfig?.providers], | |
| 346 | ); | |
| 337 | 347 | const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; |
| 338 | 348 | const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find( |
| 339 | 349 | (provider) => provider.instanceId === modelSelection?.instanceId, |
| ... | ... | @@ -1091,6 +1101,7 @@ export function useThreadComposerState() { |
| 1091 | 1101 | cancelQueuedRunEdit, |
| 1092 | 1102 | onRemoveQueuedEditAttachment, |
| 1093 | 1103 | modelSelection, |
| 1104 | pendingHandoff, | |
| 1094 | 1105 | canSwitchThreadProvider, |
| 1095 | 1106 | runtimeMode, |
| 1096 | 1107 | interactionMode, |
apps/web/src/components/ChatView.tsx+38-1| ... | ... | @@ -108,6 +108,8 @@ import { |
| 108 | 108 | presentProviderGoal, |
| 109 | 109 | } from "@t3tools/client-runtime/state/thread-execution"; |
| 110 | 110 | import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; |
| 111 | import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; | |
| 112 | import { HandoffRow } from "./chat/V2LifecycleRow"; | |
| 111 | 113 | import { |
| 112 | 114 | codexFeedbackMessage, |
| 113 | 115 | parseCodexFeedbackCommand, |
| ... | ... | @@ -1802,6 +1804,12 @@ export default function ChatView(props: ChatViewProps) { |
| 1802 | 1804 | const composerActiveProvider = useComposerDraftStore( |
| 1803 | 1805 | (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, |
| 1804 | 1806 | ); |
| 1807 | const composerSelectedModelSelection = useComposerDraftStore((store) => { | |
| 1808 | const draft = store.getComposerDraft(composerDraftTarget); | |
| 1809 | return draft?.activeProvider | |
| 1810 | ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null) | |
| 1811 | : null; | |
| 1812 | }); | |
| 1805 | 1813 | const composerHasAttachments = useComposerDraftStore((store) => { |
| 1806 | 1814 | const draft = store.getComposerDraft(composerDraftTarget); |
| 1807 | 1815 | return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; |
| ... | ... | @@ -7733,6 +7741,20 @@ export default function ChatView(props: ChatViewProps) { |
| 7733 | 7741 | item.type === "user_message" && |
| 7734 | 7742 | (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0), |
| 7735 | 7743 | ); |
| 7744 | const pendingHandoff = useMemo( | |
| 7745 | () => | |
| 7746 | resolvePendingHandoff( | |
| 7747 | serverProjection, | |
| 7748 | composerSelectedModelSelection ?? activeThread?.modelSelection, | |
| 7749 | providerStatuses, | |
| 7750 | ), | |
| 7751 | [ | |
| 7752 | serverProjection, | |
| 7753 | composerSelectedModelSelection, | |
| 7754 | activeThread?.modelSelection, | |
| 7755 | providerStatuses, | |
| 7756 | ], | |
| 7757 | ); | |
| 7736 | 7758 | const compactThreadUnavailable = |
| 7737 | 7759 | !canOperateThread || |
| 7738 | 7760 | !activeThread || |
| ... | ... | @@ -11563,7 +11585,22 @@ export default function ChatView(props: ChatViewProps) { |
| 11563 | 11585 | {...(draftId ? { onWorktreeSetupWorkLocally } : {})} |
| 11564 | 11586 | {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} |
| 11565 | 11587 | isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} |
| 11566 | footer={paintOnlyDisplayedTimeline ? null : threadStatusLine} | |
| 11588 | footer={ | |
| 11589 | paintOnlyDisplayedTimeline ? null : pendingHandoff === null ? ( | |
| 11590 | threadStatusLine | |
| 11591 | ) : ( | |
| 11592 | <> | |
| 11593 | <div className="chat-content-lane"> | |
| 11594 | <HandoffRow | |
| 11595 | endpoints={pendingHandoff} | |
| 11596 | providers={providerStatuses} | |
| 11597 | pending | |
| 11598 | /> | |
| 11599 | </div> | |
| 11600 | {threadStatusLine} | |
| 11601 | </> | |
| 11602 | ) | |
| 11603 | } | |
| 11567 | 11604 | listRef={legendListRef} |
| 11568 | 11605 | timelineEntries={displayedTimeline.entries} |
| 11569 | 11606 | providerStatuses={ |
apps/web/src/components/chat/V2LifecycleRow.tsx+52-33| ... | ... | @@ -10,7 +10,11 @@ import { environmentThreadDetails } from "../../state/threads"; |
| 10 | 10 | import { MiddleTruncate } from "../ui/middle-truncate"; |
| 11 | 11 | import * as DateTime from "effect/DateTime"; |
| 12 | 12 | import { WorkLogRow } from "./WorkLog"; |
| 13 | import { resolveHandoffEndpoints, type HandoffTimelineRun } from "@t3tools/client-runtime/handoff"; | |
| 13 | import { | |
| 14 | resolveHandoffEndpoints, | |
| 15 | type HandoffEndpoints, | |
| 16 | type HandoffTimelineRun, | |
| 17 | } from "@t3tools/client-runtime/handoff"; | |
| 14 | 18 | import { Fragment } from "react"; |
| 15 | 19 | import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; |
| 16 | 20 | import { |
| ... | ... | @@ -121,39 +125,11 @@ export function V2LifecycleRow(props: { |
| 121 | 125 | ); |
| 122 | 126 | } |
| 123 | 127 | if (item.type === "handoff") { |
| 124 | const { from: fromEndpoints, to } = resolveHandoffEndpoints(item, props.runs); | |
| 125 | 128 | return ( |
| 126 | <TimelineSystemDivider | |
| 127 | label="Context handoff" | |
| 128 | icon={ArrowRightLeftIcon} | |
| 129 | showDetailSeparator={false} | |
| 130 | tone={item.status === "failed" ? "danger" : "neutral"} | |
| 131 | detail={ | |
| 132 | <span className="inline-flex min-w-0 flex-wrap items-center justify-center gap-1.5"> | |
| 133 | {fromEndpoints.map((endpoint, index) => ( | |
| 134 | <Fragment key={`${endpoint.instanceId}:${endpoint.model ?? ""}`}> | |
| 135 | {index > 0 ? ( | |
| 136 | <span aria-hidden="true" className="-ml-1"> | |
| 137 | , | |
| 138 | </span> | |
| 139 | ) : null} | |
| 140 | <HandoffEndpoint | |
| 141 | providers={props.providerStatuses} | |
| 142 | instanceId={endpoint.instanceId} | |
| 143 | model={endpoint.model} | |
| 144 | /> | |
| 145 | </Fragment> | |
| 146 | ))} | |
| 147 | {fromEndpoints.length > 0 ? ( | |
| 148 | <ArrowRightIcon aria-hidden="true" className="size-3 shrink-0" /> | |
| 149 | ) : null} | |
| 150 | <HandoffEndpoint | |
| 151 | providers={props.providerStatuses} | |
| 152 | instanceId={item.toProviderInstanceId} | |
| 153 | model={to.model} | |
| 154 | /> | |
| 155 | </span> | |
| 156 | } | |
| 129 | <HandoffRow | |
| 130 | endpoints={resolveHandoffEndpoints(item, props.runs)} | |
| 131 | providers={props.providerStatuses} | |
| 132 | failed={item.status === "failed"} | |
| 157 | 133 | /> |
| 158 | 134 | ); |
| 159 | 135 | } |
| ... | ... | @@ -570,6 +546,49 @@ function SubagentTimelineTooltip( |
| 570 | 546 | ); |
| 571 | 547 | } |
| 572 | 548 | |
| 549 | export function HandoffRow(props: { | |
| 550 | readonly endpoints: HandoffEndpoints; | |
| 551 | readonly providers: ReadonlyArray<ServerProvider>; | |
| 552 | readonly pending?: boolean; | |
| 553 | readonly failed?: boolean; | |
| 554 | }) { | |
| 555 | const { from: fromEndpoints, to } = props.endpoints; | |
| 556 | return ( | |
| 557 | <TimelineSystemDivider | |
| 558 | label={props.pending ? "Context handoff pending" : "Context handoff"} | |
| 559 | icon={ArrowRightLeftIcon} | |
| 560 | showDetailSeparator={false} | |
| 561 | tone={props.failed ? "danger" : "neutral"} | |
| 562 | detail={ | |
| 563 | <span className="inline-flex min-w-0 flex-wrap items-center justify-center gap-1.5"> | |
| 564 | {fromEndpoints.map((endpoint, index) => ( | |
| 565 | <Fragment key={`${endpoint.instanceId}:${endpoint.model ?? ""}`}> | |
| 566 | {index > 0 ? ( | |
| 567 | <span aria-hidden="true" className="-ml-1"> | |
| 568 | , | |
| 569 | </span> | |
| 570 | ) : null} | |
| 571 | <HandoffEndpoint | |
| 572 | providers={props.providers} | |
| 573 | instanceId={endpoint.instanceId} | |
| 574 | model={endpoint.model} | |
| 575 | /> | |
| 576 | </Fragment> | |
| 577 | ))} | |
| 578 | {fromEndpoints.length > 0 ? ( | |
| 579 | <ArrowRightIcon aria-hidden="true" className="size-3 shrink-0" /> | |
| 580 | ) : null} | |
| 581 | <HandoffEndpoint | |
| 582 | providers={props.providers} | |
| 583 | instanceId={to.instanceId} | |
| 584 | model={to.model} | |
| 585 | /> | |
| 586 | </span> | |
| 587 | } | |
| 588 | /> | |
| 589 | ); | |
| 590 | } | |
| 591 | ||
| 573 | 592 | function HandoffEndpoint(props: { |
| 574 | 593 | readonly providers: ReadonlyArray<ServerProvider>; |
| 575 | 594 | readonly instanceId: ProviderInstanceId; |
packages/client-runtime/src/handoff.test.ts+47-2| ... | ... | @@ -1,6 +1,15 @@ |
| 1 | import { ProviderInstanceId, RunId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | ProviderInstanceId, | |
| 3 | RunId, | |
| 4 | type OrchestrationV2ThreadProjection, | |
| 5 | type ServerProvider, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { resolveHandoffEndpoints, type HandoffTimelineRun } from "./handoff.ts"; | |
| 8 | import { | |
| 9 | resolveHandoffEndpoints, | |
| 10 | resolvePendingHandoff, | |
| 11 | type HandoffTimelineRun, | |
| 12 | } from "./handoff.ts"; | |
| 4 | 13 | |
| 5 | 14 | const from = ProviderInstanceId.make("codex_personal"); |
| 6 | 15 | const to = ProviderInstanceId.make("claudeAgent"); |
| ... | ... | @@ -63,3 +72,39 @@ describe("handoff endpoints shared by web and mobile", () => { |
| 63 | 72 | ).toBeUndefined(); |
| 64 | 73 | }); |
| 65 | 74 | }); |
| 75 | ||
| 76 | const projection = { | |
| 77 | runs: [{ ...run("source", 1, from, "source"), startedAt: "2026-10-11T00:00:00Z" }], | |
| 78 | } as unknown as OrchestrationV2ThreadProjection; | |
| 79 | ||
| 80 | describe("pending context handoff", () => { | |
| 81 | it("appears on selection, updates the destination, and disappears on returning", () => { | |
| 82 | expect(resolvePendingHandoff(projection, { instanceId: to, model: "first" }, [])).toEqual({ | |
| 83 | from: [{ instanceId: from, model: "source" }], | |
| 84 | to: { instanceId: to, model: "first" }, | |
| 85 | }); | |
| 86 | expect( | |
| 87 | resolvePendingHandoff(projection, { instanceId: to, model: "second" }, [])?.to.model, | |
| 88 | ).toBe("second"); | |
| 89 | expect(resolvePendingHandoff(projection, { instanceId: from, model: "source" }, [])).toBeNull(); | |
| 90 | expect(resolvePendingHandoff(projection, { instanceId: from, model: "other" }, [])).toBeNull(); | |
| 91 | }); | |
| 92 | it("only previews a same-provider model change when it needs a fresh native thread", () => { | |
| 93 | const providers = [ | |
| 94 | { instanceId: from, requiresNewThreadForModelChange: true }, | |
| 95 | ] as unknown as ServerProvider[]; | |
| 96 | expect( | |
| 97 | resolvePendingHandoff(projection, { instanceId: from, model: "other" }, providers)?.to.model, | |
| 98 | ).toBe("other"); | |
| 99 | }); | |
| 100 | it("resumes compatible account overlays without showing a handoff", () => { | |
| 101 | const providers = [from, to].map((instanceId) => ({ | |
| 102 | instanceId, | |
| 103 | driver: "codex", | |
| 104 | continuation: { groupKey: "shared" }, | |
| 105 | })) as unknown as ServerProvider[]; | |
| 106 | expect( | |
| 107 | resolvePendingHandoff(projection, { instanceId: to, model: "target" }, providers), | |
| 108 | ).toBeNull(); | |
| 109 | }); | |
| 110 | }); |
packages/client-runtime/src/handoff.ts+27| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | import type { |
| 2 | ModelSelection, | |
| 2 | 3 | OrchestrationV2Run, |
| 4 | OrchestrationV2ThreadProjection, | |
| 3 | 5 | OrchestrationV2TurnItem, |
| 4 | 6 | ProviderInstanceId, |
| 7 | ServerProvider, | |
| 5 | 8 | } from "@t3tools/contracts"; |
| 6 | 9 | |
| 7 | 10 | /** |
| ... | ... | @@ -14,6 +17,30 @@ export type HandoffTimelineRun = Pick< |
| 14 | 17 | "id" | "ordinal" | "providerInstanceId" | "modelSelection" |
| 15 | 18 | >; |
| 16 | 19 | |
| 20 | export type HandoffEndpoints = ReturnType<typeof resolveHandoffEndpoints>; | |
| 21 | ||
| 22 | /** Preview the next send's handoff without adding anything to durable history. */ | |
| 23 | export function resolvePendingHandoff( | |
| 24 | projection: OrchestrationV2ThreadProjection | null | undefined, | |
| 25 | selection: ModelSelection | null | undefined, | |
| 26 | providers: ReadonlyArray<ServerProvider>, | |
| 27 | ): HandoffEndpoints | null { | |
| 28 | const from = projection?.runs.findLast( | |
| 29 | (run) => run.startedAt !== null && run.status !== "rolled_back", | |
| 30 | )?.modelSelection; | |
| 31 | if (from === undefined || selection == null) return null; | |
| 32 | const source = providers.find((provider) => provider.instanceId === from.instanceId); | |
| 33 | const target = providers.find((provider) => provider.instanceId === selection.instanceId); | |
| 34 | const needsHandoff = | |
| 35 | from.instanceId === selection.instanceId | |
| 36 | ? from.model !== selection.model && target?.requiresNewThreadForModelChange === true | |
| 37 | : // Accounts sharing a continuation group resume the same native thread. | |
| 38 | source?.continuation?.groupKey === undefined || | |
| 39 | source.driver !== target?.driver || | |
| 40 | source.continuation.groupKey !== target.continuation?.groupKey; | |
| 41 | return needsHandoff ? { from: [from], to: selection } : null; | |
| 42 | } | |
| 43 | ||
| 17 | 44 | export function resolveHandoffEndpoints( |
| 18 | 45 | item: Pick< |
| 19 | 46 | Extract<OrchestrationV2TurnItem, { type: "handoff" }>, |