Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8400a85
feat(web,mobile): show model and effort changes in the thread
t3dotgg Nov 2, 2026
5ee9b51
fix: resolve default effort, skip unrun runs, share switch label
t3dotgg Nov 2, 2026
ddf398a
fix: skip unstarted failed runs, share endpoint effort label
t3dotgg Nov 2, 2026
a189d5a
fix: follow the composer's reasoning control, skip any unstarted run
t3dotgg Nov 2, 2026
4a2fca6
refactor(mobile): render the switch divider directly
t3dotgg Nov 2, 2026
1255025
test(mobile): mock the model switch atom in the feed preview test
t3dotgg Nov 2, 2026
cdf5bdf
fix: steering restarts keep their model and effort dividers
t3dotgg Nov 2, 2026
55f027b
fix: minimal switch dividers, per-start handoffs, skip stopped restarts
t3dotgg Nov 2, 2026
c9a4413
refactor: record model switches on the user message item
t3dotgg Nov 2, 2026
24c1669
fix(server): chain model switches from the last visible run
t3dotgg Nov 2, 2026
93f77ed
fix(server): order switches by execution, compare a fork's first turn…
t3dotgg Nov 2, 2026
782d477
fix: drop switch stamps from wakes, skip pending queues, trust the ca…
t3dotgg Nov 2, 2026
789b07f
fix(server): count a wake's handoff row as the previous switch point
t3dotgg Nov 2, 2026
0764d27
fix: skip hidden messages, measure restarts by the same rule, mount m…
t3dotgg Nov 2, 2026
79c6e3e
fix(server): load user messages when promoting a queued message to a …
t3dotgg Nov 2, 2026
d82c0ad
fix(server): skip folded async answers when chaining model switches
t3dotgg Nov 2, 2026
61e1c57
fix: hide the mobile divider on /compact rows, read Pi thinking witho…
t3dotgg Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {
enrichedSkillReference,
} from "../../lib/enrichedLinkPresentation";
import { ThreadContextDivider } from "./thread-context-divider";
import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row";
import {
ThreadHandoffDivider,
ThreadHandoffRow,
ThreadModelSwitchDivider,
} from "./thread-handoff-row";
import { GuestBubbleTint, GuestMessageName } from "./guestMessageStyle";
import { ThreadGuestPendingMessage, useThreadGuestShare } from "./ThreadGuestShareBar";
import { LinkRequestCard } from "./LinkRequestCard";
Expand Down Expand Up @@ -3590,6 +3594,16 @@ export const ThreadFeed = memo(function ThreadFeed({
workspaceRoot={props.workspaceRoot}
>
<ThreadMediaVisibility>
{info.item.type === "message" &&
!isCompactionCommandFeedEntry(info.item) &&
info.item.message.projectedItem?.item.type === "user_message" &&
info.item.message.projectedItem.item.modelSwitch ? (
<ThreadModelSwitchDivider
environmentId={props.environmentId}
modelSwitch={info.item.message.projectedItem.item.modelSwitch}
iconColor={iconSubtleColor}
/>
) : null}
{renderFeedEntry(info, {
userMessageGrouping: info.item.userMessageGrouping,
environmentId: props.environmentId,
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/features/threads/thread-context-divider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ShimmeringWorkContent } from "./thread-work-log";

export function ThreadContextDivider(props: {
label: string;
icon: AppSymbolName;
icon?: AppSymbolName;
iconColor: ColorValue;
active?: boolean;
failed?: boolean;
Expand All @@ -16,7 +16,9 @@ export function ThreadContextDivider(props: {
<View className="mb-3 flex-row items-center gap-3 px-1 py-1">
<View className="h-px min-w-2 flex-1 bg-separator" />
<View className="shrink flex-row flex-wrap items-center justify-center gap-1.5">
<SymbolView name={props.icon} size={12} tintColor={props.iconColor} type="monochrome" />
{props.icon ? (
<SymbolView name={props.icon} size={12} tintColor={props.iconColor} type="monochrome" />
) : null}
{props.active ? (
<ShimmeringWorkContent
className="flex-none"
Expand Down
35 changes: 34 additions & 1 deletion apps/mobile/src/features/threads/thread-handoff-row.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import * as Dialog from "../../components/ui/Dialog";
import { useAtomValue } from "@effect/atom-react";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { resolveHandoffEndpoints, type HandoffEndpoints } from "@t3tools/client-runtime/handoff";
import {
resolveHandoffEndpoints,
resolveModelSwitchChange,
type HandoffEndpoints,
} from "@t3tools/client-runtime/handoff";
import { resolveModelShortName } from "@t3tools/client-runtime/provider-clients";
import {
normalizeProviderAccentColor,
Expand All @@ -10,6 +14,8 @@ import {
} from "@t3tools/client-runtime/state/provider-instance-display";
import type {
EnvironmentId,
ModelSelection,
OrchestrationV2ModelSwitch,
OrchestrationV2ProjectedTurnItem,
ProviderInstanceId,
ServerProvider,
Expand Down Expand Up @@ -45,6 +51,33 @@ function handoffEndpointsAtom(
});
}

/** A same-harness model or effort change. Shows only what changed. */
export function ThreadModelSwitchDivider(props: {
environmentId: EnvironmentId;
modelSwitch: OrchestrationV2ModelSwitch;
iconColor: ColorValue;
}) {
const providers = useAtomValue(serverEnvironment.providersValueAtom(props.environmentId)) ?? [];
const change = resolveModelSwitchChange(props.modelSwitch, providers);
if (change === null) return null;
const describe = (selection: ModelSelection, effort: string | undefined) => {
const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId);
const model = provider?.models.find((candidate) => candidate.slug === selection.model);
const modelName =
(model ? resolveModelShortName(model, provider?.driver) : undefined) ??
model?.name ??
selection.model;
return [change.modelChanged ? modelName : null, effort].filter(Boolean).join(" ");
};
const { from, to } = props.modelSwitch;
return (
<ThreadContextDivider
label={`${describe(from, change.effort?.from)} → ${describe(to, change.effort?.to)}`}
iconColor={props.iconColor}
/>
);
}

export function ThreadHandoffRow(props: {
environmentId: EnvironmentId;
projectedItem: OrchestrationV2ProjectedTurnItem;
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/filePreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ vi.mock("./openExternalUrl", () => ({}));
vi.mock("./attachmentDownload", () => ({}));
vi.mock("./uuid", () => ({}));
vi.mock("../features/threads/thread-context-divider", () => ({}));
vi.mock("../features/threads/thread-handoff-row", () => ({}));
vi.mock("../features/threads/thread-handoff-row", () => ({ ThreadModelSwitchDivider: () => null }));
vi.mock("../features/threads/ThreadGuestShareBar", () => ({ useThreadGuestShare: () => null }));
vi.mock("../features/threads/SecretRequestCard", () => ({}));
vi.mock("../features/threads/LinkRequestCard", () => ({ LinkRequestCard: () => null }));
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
type OrchestrationV2ProviderThread,
type OrchestrationV2ProviderTurn,
type OrchestrationV2RemoteTaskChild,
type OrchestrationV2ModelSwitch,
type OrchestrationV2Run,
type OrchestrationV2RunAttempt,
type OrchestrationV2ThreadShell,
Expand Down Expand Up @@ -75,6 +76,7 @@ import {
managedRuntimeModeCeilingMessage,
} from "@t3tools/contracts";
import { modelSelectionsEqual } from "@t3tools/shared/model";
import { modelSwitchSince } from "./modelSwitch.ts";
import { recordThreadOpen } from "@t3tools/shared/paletteSearch";
import {
backgroundWorkHoldsCompletion,
Expand Down Expand Up @@ -2055,6 +2057,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
startedAt: now,
completedAt: now,
updatedAt: now,
...(activeHandoff === null ? modelSwitchSince(projection, queuedRun.modelSelection) : {}),
};
const handoffTurnItem: OrchestrationV2TurnItem | null =
activeHandoff === null
Expand Down Expand Up @@ -4796,6 +4799,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
readonly providerTurnId: typeof providerTurn.id | null;
readonly providerThreadId: OrchestrationV2ProviderThread["id"];
readonly providerInstanceId: ProviderInstanceId;
readonly modelSwitch?: OrchestrationV2ModelSwitch | undefined;
}) {
const message: OrchestrationV2ConversationMessage = {
createdBy: input.createdBy,
Expand Down Expand Up @@ -4851,6 +4855,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
text: input.text,
attachments: input.attachments,
...(input.context ? { context: input.context } : {}),
...(messageInput.modelSwitch === undefined
? {}
: { modelSwitch: messageInput.modelSwitch }),
};
yield* emitEvent({
type: "message.updated",
Expand Down Expand Up @@ -5311,6 +5318,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
providerTurnId: null,
providerThreadId: restartProviderThread.id,
providerInstanceId: input.modelSelection.instanceId,
// Measured on the pre-restart projection, so the rewritten run is not its own baseline.
...(restartHandoff === null ? modelSwitchSince(input.projection, input.modelSelection) : {}),
});
const interruptedAttemptId = targetRun.activeAttemptId;
if (interruptedAttemptId === null) {
Expand Down Expand Up @@ -6424,6 +6433,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
text: dispatchText,
...(command.context ? { context: command.context } : {}),
attachments: command.attachments,
...(run.contextHandoffId === null ? modelSwitchSince(projection, modelSelection) : {}),
};
const preparationTurnItem: OrchestrationV2TurnItem | null =
dispatchMode.type === "defer_start"
Expand Down Expand Up @@ -7175,6 +7185,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
text: dispatchText,
...(command.context ? { context: command.context } : {}),
attachments: command.attachments,
...(run.contextHandoffId === null
? modelSwitchSince(projection, modelSelection, sourceRun?.modelSelection)
: {}),
};
const activeHandoff = portableForkHandoff ?? mergeBackHandoff ?? providerSwitchHandoff;
const handoffSourceRuns =
Expand Down Expand Up @@ -9349,7 +9362,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
"runtimeRequests",
"subagents",
],
{ turnItemTypes: [], messageRoles: ["user"] },
// User messages measure a restart's model switch.
{ turnItemTypes: ["user_message"], messageRoles: ["user"] },
);
if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) {
return yield* new OrchestratorDispatchError({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MessageId,
type ModelSelection,
type OrchestrationV2ProviderCapabilities,
type OrchestrationV2ThreadProjection,
type OrchestrationV2ProviderSession,
type OrchestrationV2ProviderThread,
ProjectId,
Expand Down Expand Up @@ -47,6 +48,10 @@ const seedSelection = {
instanceId: providerInstanceId,
model: "seed-model",
} satisfies ModelSelection;
const modelSwitches = (projection: OrchestrationV2ThreadProjection) =>
projection.turnItems.flatMap((item) =>
item.type === "user_message" && item.modelSwitch !== undefined ? [item.modelSwitch] : [],
);
const pooledCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2;
const exclusiveCapabilities: OrchestrationV2ProviderCapabilities = {
...CodexProviderCapabilitiesV2,
Expand Down Expand Up @@ -407,6 +412,9 @@ it.live("retries a failed selection restart session open before completing", ()
"selection restart supersede must not project hard-Stop interrupt items",
);
assert.equal(projection.runs.at(-1)?.modelSelection.model, replacementSelection.model);
assert.deepEqual(modelSwitches(projection), [
{ from: initialSelection, to: replacementSelection },
]);
assert.equal(projection.runs.at(-1)?.status, "completed");
assert.isTrue(captured.failedReplacementOpen);
assert.equal(captured.closedSessionCount, 0);
Expand Down Expand Up @@ -690,6 +698,9 @@ it.live(
const { projection, captured } = result;
assert.lengthOf(projection.runs, 2);
assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model);
assert.deepEqual(modelSwitches(projection), [
{ from: seedSelection, to: replacementSelection },
]);
assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]);
assert.equal(captured.closedSessionCount, 1);
assert.deepEqual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,55 @@ it.effect("steers a promoted queued message with the selection it was queued wit
),
);

it.effect("marks the model switch on a promoted message that restarts the turn", () =>
Effect.scoped(
Effect.gen(function* () {
const { started, layer, startFirstTurn } = yield* nextTurnSelectionHarness(
"promote-restart-switch",
true,
false,
);
const otherSelection = { ...runSelection, model: "other-model" };
yield* Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
const threadId = yield* startFirstTurn;
yield* orchestrator.dispatch({
type: "message.dispatch",
commandId: CommandId.make("queued"),
threadId,
messageId: MessageId.make("message:queued"),
text: "queued",
attachments: [],
modelSelection: otherSelection,
dispatchMode: { type: "queue_after_active" },
createdBy: "user",
creationSource: "web",
});
const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find(
(run) => run.status === "queued",
)!;
yield* orchestrator.dispatch({
type: "queued-message.promote-to-steer",
commandId: CommandId.make("promote"),
threadId,
queuedRunId: queuedRun.id,
targetRunId: started[0]!.runId,
});
yield* worker.drain();

const promoted = (yield* orchestrator.getThreadProjection(threadId)).turnItems.find(
(item) => item.type === "user_message" && item.messageId === "message:queued",
);
assert.deepEqual(promoted?.type === "user_message" ? promoted.modelSwitch : undefined, {
from: runSelection,
to: otherSelection,
});
}).pipe(Effect.provide(layer));
}),
),
);

it.effect("keeps a queued message queued when promoted into a run that Stop reached", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
107 changes: 107 additions & 0 deletions apps/server/src/orchestration-v2/modelSwitch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
ContextHandoffId,
MessageId,
ProviderInstanceId,
type OrchestrationV2ThreadProjection,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { modelSwitchSince } from "./modelSwitch.ts";

const instanceId = ProviderInstanceId.make("codex");
const selection = (model: string) => ({ instanceId, model });
const run = (
message: string,
ordinal: number,
model: string,
status: OrchestrationV2ThreadProjection["runs"][number]["status"] = "completed",
) =>
({
ordinal,
status,
completedAt: null,
contextHandoffId: null,
userMessageId: MessageId.make(message),
modelSelection: selection(model),
}) as OrchestrationV2ThreadProjection["runs"][number];
const shown = (...messages: ReadonlyArray<string>) =>
messages.map((message) => ({
type: "user_message",
messageId: MessageId.make(message),
inputIntent: "turn_start",
status: "completed",
text: message,
attachments: [],
})) as unknown as OrchestrationV2ThreadProjection["turnItems"];

describe("modelSwitchSince", () => {
it("measures from the latest run whose message is in the timeline", () => {
// B failed before it started; its message shows "a -> b", so the next one must show "b -> a".
expect(
modelSwitchSince(
{
runs: [
run("first", 1, "a"),
run("failed", 2, "b", "failed"),
run("queued", 3, "c", "queued"),
run("undone", 4, "d", "rolled_back"),
],
turnItems: shown("first", "failed", "queued", "undone"),
},
selection("a"),
),
).toEqual({ modelSwitch: { from: selection("b"), to: selection("a") } });
});

it("skips messages clients hide", () => {
const [first, pending, compact, answer] = shown(
"first",
"pending",
"/compact",
"async-answer:question",
);
expect(
modelSwitchSince(
{
runs: [
run("first", 1, "a"),
run("pending", 2, "b", "cancelled"),
run("/compact", 3, "c"),
run("async-answer:question", 4, "d"),
],
turnItems: [
first!,
{ ...pending!, inputIntent: "queued_turn", status: "pending" },
compact!,
answer!,
] as OrchestrationV2ThreadProjection["turnItems"],
},
selection("c"),
),
).toEqual({ modelSwitch: { from: selection("a"), to: selection("c") } });
});

it("counts a wake whose handoff row shows the swap", () => {
const wake = {
...run("wake", 2, "other"),
contextHandoffId: ContextHandoffId.make("handoff"),
};
expect(
modelSwitchSince(
{ runs: [run("first", 1, "a"), wake], turnItems: shown("first") },
selection("other"),
),
).toEqual({});
});

it("records nothing for a first run or an unchanged selection", () => {
expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"))).toEqual({});
// A fork's first message compares with the run it forked from.
expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"), selection("b"))).toEqual({
modelSwitch: { from: selection("b"), to: selection("a") },
});
expect(
modelSwitchSince({ runs: [run("first", 1, "a")], turnItems: shown("first") }, selection("a")),
).toEqual({});
});
});
Loading
Loading