1import {
2 isCompactionCommandMessage,
3 type ModelSelection,
4 type OrchestrationV2ThreadProjection,
5 type OrchestrationV2TurnItem,
6} from "@t3tools/contracts";
7import { modelSelectionsEqual } from "@t3tools/shared/model";
8import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
9
10/**
11 * The model or effort change a new run brings, measured from the latest run the timeline shows
12 * through its message or its handoff row, so consecutive dividers always chain. `baseline` stands
13 * in when there is none, such as a fork's source run. Callers skip starts with a context handoff;
14 * the handoff item already shows those.
15 */
16export function modelSwitchSince(
17 projection: Pick<OrchestrationV2ThreadProjection, "runs" | "turnItems">,
18 to: ModelSelection,
19 baseline?: ModelSelection,
20): Pick<Extract<OrchestrationV2TurnItem, { type: "user_message" }>, "modelSwitch"> {
21 // Clients hide pending queued messages, fold async answers into their question, and replace
22 // `/compact` with a compaction row.
23 const shown = new Set(
24 projection.turnItems.flatMap((item) =>
25 item.type === "user_message" &&
26 !(item.inputIntent === "queued_turn" && item.status === "pending") &&
27 !item.messageId.startsWith("async-answer:") &&
28 !isCompactionCommandMessage({ role: "user", ...item })
29 ? [item.messageId]
30 : [],
31 ),
32 );
33 let previous: OrchestrationV2ThreadProjection["runs"][number] | undefined;
34 for (const run of projection.runs) {
35 if (run.status === "rolled_back" || run.status === "queued") continue;
36 // A wake shows as a notification, but its context handoff row still shows the swap.
37 if (!shown.has(run.userMessageId) && run.contextHandoffId === null) continue;
38 if (previous === undefined || runRanAfter(run, previous)) previous = run;
39 }
40 const from = previous?.modelSelection ?? baseline;
41 return from === undefined || modelSelectionsEqual(from, to) ? {} : { modelSwitch: { from, to } };
42}