From 62af66a74fb9b01bbcea1fe5369ccd24c56085ff Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 4 Jul 2026 10:58:56 -0400 Subject: [PATCH] fix(tui): distinguish variant switch notices (#35315) --- AGENTS.md | 1 + .../client/src/promise/generated/types.ts | 3 ++ packages/core/src/session.ts | 6 ++-- packages/core/src/session/message-updater.ts | 32 +++++++++++++------ packages/core/src/session/projector.ts | 14 +++++++- packages/core/test/session-create.test.ts | 6 ++-- packages/core/test/session-projector.test.ts | 3 ++ packages/schema/src/session-message.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 2 ++ packages/tui/src/context/data.tsx | 11 +++++++ packages/tui/src/routes/session/index.tsx | 3 +- packages/tui/src/util/model.ts | 5 +-- packages/tui/test/cli/tui/data.test.tsx | 20 ++++++++++-- packages/tui/test/util/model.test.ts | 12 +++++++ 14 files changed, 98 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8c72fe65a8..0ea4055ce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,7 @@ const table = sqliteTable("session", { ## V2 Session Core +- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views. - Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries. - Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry. - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op. diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index ff06963036..151f0ff03f 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -928,6 +928,7 @@ export type SessionContextOutput = { readonly time: { readonly created: number } readonly type: "model-switched" readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string } } | { readonly id: string @@ -1621,6 +1622,7 @@ export type SessionMessageOutput = { readonly time: { readonly created: number } readonly type: "model-switched" readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string } } | { readonly id: string @@ -1820,6 +1822,7 @@ export type MessageListOutput = { readonly time: { readonly created: number } readonly type: "model-switched" readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string } } | { readonly id: string diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e8928a7520..05d64c8efb 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -540,7 +540,8 @@ const layer = Layer.effect( const started = yield* Effect.gen(function* () { const shell = yield* Shell.Service return yield* shell.create({ command: input.command, cwd: session.location.directory }) - }).pipe(Effect.provide(locations.get(session.location))) + }) + .pipe(Effect.provide(locations.get(session.location))) yield* events.publish( SessionEvent.Shell.Started, { @@ -563,8 +564,7 @@ const layer = Layer.effect( .pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput()))) : missingShellOutput() return { shell: terminal.info, output } - }) - .pipe(Effect.provide(locations.get(session.location))) + }).pipe(Effect.provide(locations.get(session.location))) yield* events.publish(SessionEvent.Shell.Ended, { sessionID: input.sessionID, shell: completed.shell, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 3658e67c0f..2a8ade7b1a 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -8,6 +8,7 @@ export type MemoryState = { } export interface Adapter { + readonly getModel: () => Effect.Effect readonly getCurrentAssistant: () => Effect.Effect readonly getAssistant: ( messageID: SessionMessage.ID, @@ -29,6 +30,15 @@ export function memory(state: MemoryState): Adapter { const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") return { + getModel() { + return Effect.sync( + () => + state.messages.findLast( + (message): message is SessionMessage.ModelSelected | SessionMessage.Assistant => + message.type === "model-switched" || message.type === "assistant", + )?.model, + ) + }, getCurrentAssistant() { return Effect.sync(() => { const index = latestAssistantIndex() @@ -115,15 +125,19 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }, "session.model.selected": (event) => { - return adapter.appendMessage( - SessionMessage.ModelSelected.make({ - id: SessionMessage.ID.fromEvent(event.id), - type: "model-switched", - metadata: event.metadata, - model: event.data.model, - time: { created: event.created }, - }), - ) + return Effect.gen(function* () { + const previous = yield* adapter.getModel() + yield* adapter.appendMessage( + SessionMessage.ModelSelected.make({ + id: SessionMessage.ID.fromEvent(event.id), + type: "model-switched", + metadata: event.metadata, + model: event.data.model, + previous, + time: { created: event.created }, + }), + ) + }) }, "session.moved": () => Effect.void, "session.renamed": () => Effect.void, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 746e448a3b..f265aa6fc9 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" import { makeGlobalNode } from "../effect/app-node" +import { ModelV2 } from "../model" import { SessionEvent } from "./event" import { SessionV1 } from "../v1/session" import { WorkspaceTable } from "../control-plane/workspace.sql" @@ -356,6 +357,17 @@ function run(db: DatabaseService, event: MessageEvent) { } const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message) const adapter: SessionMessageUpdater.Adapter = { + getModel() { + return db + .select({ model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, event.data.sessionID)) + .get() + .pipe( + Effect.orDie, + Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(ModelV2.Ref)(row.model) : undefined)), + ) + }, getCurrentAssistant() { return Effect.gen(function* () { // A newer step supersedes stale incomplete rows; never resume an older assistant projection. @@ -570,13 +582,13 @@ const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.ModelSelected, (event) => Effect.gen(function* () { + yield* run(db, event) yield* db .update(SessionTable) .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - yield* run(db, event) }), ) yield* events.project(SessionEvent.Renamed, (event) => diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index d18c2c8e81..5408030c1a 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -562,9 +562,9 @@ describe("SessionV2.create", () => { yield* session.switchModel({ sessionID: created.id, model }) expect(yield* session.get(created.id)).toMatchObject({ model }) - expect( - Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ type: "session.model.selected", data: { model } }]) + const events = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)) + expect(events).toMatchObject([{ type: "session.model.selected" }]) + expect(events[0]?.data).toEqual({ sessionID: created.id, model }) }), ) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index c17b979076..1d77303d43 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -34,6 +34,7 @@ const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.no const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } +const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") } const encodeMessage = Schema.encodeSync(SessionMessage.Message) const assistantRow = ( @@ -238,6 +239,7 @@ describe("SessionProjector", () => { directory: "/project", title: "test", version: "test", + model: previousModel, }) .run() .pipe(Effect.orDie) @@ -337,6 +339,7 @@ describe("SessionProjector", () => { text: "synthetic context", metadata: { source: "projector-test" }, }) + expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel }) expect(messages.find((message) => message.type === "shell")).toMatchObject({ shell: { command: "pwd", status: "exited", exit: 0 }, output: { output: "/project", truncated: false }, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index e3d81a31d7..c9f39193a5 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -44,6 +44,7 @@ export const ModelSelected = Schema.Struct({ ...Base, type: Schema.Literal("model-switched"), model: Model.Ref, + previous: Model.Ref.pipe(optional), }).annotate({ identifier: "Session.Message.ModelSelected" }) export interface User extends Schema.Schema.Type {} diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 524d7ea854..2b6e1d031f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4310,6 +4310,7 @@ export type SessionMessageModelSelected = { } type: "model-switched" model: ModelRef + previous?: ModelRef } export type SessionMessageUser = { @@ -7985,6 +7986,7 @@ export type SessionMessageModelSelected2 = { } type: "model-switched" model: ModelRef2 + previous?: ModelRef2 } export type SessionMessageUser2 = { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 0ed464e561..5815bc2f20 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -235,6 +235,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.model.selected": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "model", event.data.model) + if (!store.session.message[event.data.sessionID]) break message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: messageIDFromEvent(event.id), @@ -243,6 +244,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ time: { created: event.created }, }) }) + void sdk.api.session + .message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) }) + .then((item) => { + message.update(event.data.sessionID, (draft, index) => { + const position = index.get(item.id) + if (position === undefined) return message.append(draft, index, mutable(item)) + draft[position] = mutable(item) + }) + }) + .catch((error) => console.error("Failed to load projected model switch message", error)) break case "session.renamed": if (store.session.info[event.data.sessionID]) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b968f13cd3..604cd95e89 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1231,7 +1231,8 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) { const { theme } = useTheme() const text = () => { if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}` - if (props.message.type === "model-switched") return switchLabel(props.message.model, ctx.models()) + if (props.message.type === "model-switched") + return switchLabel(props.message.model, ctx.models(), props.message.previous) return "" } return {text()} diff --git a/packages/tui/src/util/model.ts b/packages/tui/src/util/model.ts index 64d8636406..275112d33d 100644 --- a/packages/tui/src/util/model.ts +++ b/packages/tui/src/util/model.ts @@ -34,11 +34,12 @@ export function formatRef(model: { providerID: string; id: string; variant?: str export function switchLabel( model: { providerID: string; id: string; variant?: string }, models?: readonly { providerID: string; id: string; name: string }[], + previous?: { providerID: string; id: string; variant?: string }, ) { + if (previous?.providerID === model.providerID && previous.id === model.id) + return `Switched variant to ${model.variant ?? "default"}` const display = models?.find((item) => item.providerID === model.providerID && item.id === model.id)?.name if (display === undefined) return `Switched model to ${formatRef(model)}` - // Variant-only switches publish the same model id; without the variant the - // notice would look like a redundant model switch. const variant = model.variant && model.variant !== "default" ? ` (${model.variant})` : "" return `Switched model to ${display}${variant}` } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 0587662dc3..1bc975b9bc 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -767,7 +767,18 @@ test("adds and dismisses question requests from live events", async () => { test("settles pending tools when a live failure arrives", async () => { const events = createEventStream() - const calls = createFetch(undefined, events) + const calls = createFetch((url) => { + if (url.pathname === "/api/session/session-1/message/msg_model_1") + return json({ + data: { + id: "msg_model_1", + type: "model-switched", + previous: { id: "model-1", providerID: "provider-1", variant: "medium" }, + model: { id: "model-1", providerID: "provider-1", variant: "high" }, + time: { created: 0 }, + }, + }) + }, events) let sync!: ReturnType let ready!: () => void const mounted = new Promise((resolve) => { @@ -808,7 +819,7 @@ test("settles pending tools when a live failure arrives", async () => { durable: durable("session-1", 1), data: { sessionID: "session-1", - model: { id: "model-1", providerID: "provider-1" }, + model: { id: "model-1", providerID: "provider-1", variant: "high" }, }, }) emitEvent(events, { @@ -895,6 +906,11 @@ test("settles pending tools when a live failure arrives", async () => { "model-switched", "assistant", ]) + expect(sync.session.message.get("session-1", "msg_model_1")).toMatchObject({ + type: "model-switched", + previous: { id: "model-1", providerID: "provider-1", variant: "medium" }, + model: { id: "model-1", providerID: "provider-1", variant: "high" }, + }) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/util/model.test.ts b/packages/tui/test/util/model.test.ts index ad4ea87c88..888b102013 100644 --- a/packages/tui/test/util/model.test.ts +++ b/packages/tui/test/util/model.test.ts @@ -34,4 +34,16 @@ describe("util.model", () => { "Switched model to removed/gone/high", ) }) + + test("distinguishes variant-only switches from model switches", () => { + const previous = { providerID: "openai", id: "gpt-5.5", variant: "medium" } + + expect(switchLabel({ ...previous, variant: "high" }, undefined, previous)).toBe("Switched variant to high") + expect(switchLabel({ providerID: "openai", id: "gpt-5.5" }, undefined, previous)).toBe( + "Switched variant to default", + ) + expect(switchLabel({ providerID: "anthropic", id: "sonnet", variant: "high" }, undefined, previous)).toBe( + "Switched model to anthropic/sonnet/high", + ) + }) })