fix(tui): distinguish variant switch notices (#35315)

This commit is contained in:
Kit Langton 2026-07-04 10:58:56 -04:00 committed by GitHub
commit 62af66a74f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 98 additions and 21 deletions

View file

@ -151,6 +151,7 @@ const table = sqliteTable("session", {
## V2 Session Core ## 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. - 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. - 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. - 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.

View file

@ -928,6 +928,7 @@ export type SessionContextOutput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
readonly type: "model-switched" readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } 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 readonly id: string
@ -1621,6 +1622,7 @@ export type SessionMessageOutput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
readonly type: "model-switched" readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } 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 readonly id: string
@ -1820,6 +1822,7 @@ export type MessageListOutput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
readonly type: "model-switched" readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } 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 readonly id: string

View file

@ -540,7 +540,8 @@ const layer = Layer.effect(
const started = yield* Effect.gen(function* () { const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service const shell = yield* Shell.Service
return yield* shell.create({ command: input.command, cwd: session.location.directory }) 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( yield* events.publish(
SessionEvent.Shell.Started, SessionEvent.Shell.Started,
{ {
@ -563,8 +564,7 @@ const layer = Layer.effect(
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput()))) .pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
: missingShellOutput() : missingShellOutput()
return { shell: terminal.info, output } 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, { yield* events.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
shell: completed.shell, shell: completed.shell,

View file

@ -8,6 +8,7 @@ export type MemoryState = {
} }
export interface Adapter { export interface Adapter {
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never> readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: ( readonly getAssistant: (
messageID: SessionMessage.ID, messageID: SessionMessage.ID,
@ -29,6 +30,15 @@ export function memory(state: MemoryState): Adapter {
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
return { return {
getModel() {
return Effect.sync(
() =>
state.messages.findLast(
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
message.type === "model-switched" || message.type === "assistant",
)?.model,
)
},
getCurrentAssistant() { getCurrentAssistant() {
return Effect.sync(() => { return Effect.sync(() => {
const index = latestAssistantIndex() const index = latestAssistantIndex()
@ -115,15 +125,19 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
) )
}, },
"session.model.selected": (event) => { "session.model.selected": (event) => {
return adapter.appendMessage( return Effect.gen(function* () {
SessionMessage.ModelSelected.make({ const previous = yield* adapter.getModel()
id: SessionMessage.ID.fromEvent(event.id), yield* adapter.appendMessage(
type: "model-switched", SessionMessage.ModelSelected.make({
metadata: event.metadata, id: SessionMessage.ID.fromEvent(event.id),
model: event.data.model, type: "model-switched",
time: { created: event.created }, metadata: event.metadata,
}), model: event.data.model,
) previous,
time: { created: event.created },
}),
)
})
}, },
"session.moved": () => Effect.void, "session.moved": () => Effect.void,
"session.renamed": () => Effect.void, "session.renamed": () => Effect.void,

View file

@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database" import { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { makeGlobalNode } from "../effect/app-node" import { makeGlobalNode } from "../effect/app-node"
import { ModelV2 } from "../model"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionV1 } from "../v1/session" import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql" 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 appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = { 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() { getCurrentAssistant() {
return Effect.gen(function* () { return Effect.gen(function* () {
// A newer step supersedes stale incomplete rows; never resume an older assistant projection. // 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) => yield* events.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* run(db, event)
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) }) .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* run(db, event)
}), }),
) )
yield* events.project(SessionEvent.Renamed, (event) => yield* events.project(SessionEvent.Renamed, (event) =>

View file

@ -562,9 +562,9 @@ describe("SessionV2.create", () => {
yield* session.switchModel({ sessionID: created.id, model }) yield* session.switchModel({ sessionID: created.id, model })
expect(yield* session.get(created.id)).toMatchObject({ model }) expect(yield* session.get(created.id)).toMatchObject({ model })
expect( const events = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect))
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), expect(events).toMatchObject([{ type: "session.model.selected" }])
).toMatchObject([{ type: "session.model.selected", data: { model } }]) expect(events[0]?.data).toEqual({ sessionID: created.id, model })
}), }),
) )

View file

@ -34,6 +34,7 @@ const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.no
const sessionID = SessionV2.ID.make("ses_projector_test") const sessionID = SessionV2.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0) const created = DateTime.makeUnsafe(0)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } 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 encodeMessage = Schema.encodeSync(SessionMessage.Message)
const assistantRow = ( const assistantRow = (
@ -238,6 +239,7 @@ describe("SessionProjector", () => {
directory: "/project", directory: "/project",
title: "test", title: "test",
version: "test", version: "test",
model: previousModel,
}) })
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@ -337,6 +339,7 @@ describe("SessionProjector", () => {
text: "synthetic context", text: "synthetic context",
metadata: { source: "projector-test" }, metadata: { source: "projector-test" },
}) })
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({ expect(messages.find((message) => message.type === "shell")).toMatchObject({
shell: { command: "pwd", status: "exited", exit: 0 }, shell: { command: "pwd", status: "exited", exit: 0 },
output: { output: "/project", truncated: false }, output: { output: "/project", truncated: false },

View file

@ -44,6 +44,7 @@ export const ModelSelected = Schema.Struct({
...Base, ...Base,
type: Schema.Literal("model-switched"), type: Schema.Literal("model-switched"),
model: Model.Ref, model: Model.Ref,
previous: Model.Ref.pipe(optional),
}).annotate({ identifier: "Session.Message.ModelSelected" }) }).annotate({ identifier: "Session.Message.ModelSelected" })
export interface User extends Schema.Schema.Type<typeof User> {} export interface User extends Schema.Schema.Type<typeof User> {}

View file

@ -4310,6 +4310,7 @@ export type SessionMessageModelSelected = {
} }
type: "model-switched" type: "model-switched"
model: ModelRef model: ModelRef
previous?: ModelRef
} }
export type SessionMessageUser = { export type SessionMessageUser = {
@ -7985,6 +7986,7 @@ export type SessionMessageModelSelected2 = {
} }
type: "model-switched" type: "model-switched"
model: ModelRef2 model: ModelRef2
previous?: ModelRef2
} }
export type SessionMessageUser2 = { export type SessionMessageUser2 = {

View file

@ -235,6 +235,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.model.selected": case "session.model.selected":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model) 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.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, { message.append(draft, index, {
id: messageIDFromEvent(event.id), id: messageIDFromEvent(event.id),
@ -243,6 +244,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
time: { created: event.created }, 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 break
case "session.renamed": case "session.renamed":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])

View file

@ -1231,7 +1231,8 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
const { theme } = useTheme() const { theme } = useTheme()
const text = () => { const text = () => {
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}` 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 ""
} }
return <text fg={theme.textMuted}>{text()}</text> return <text fg={theme.textMuted}>{text()}</text>

View file

@ -34,11 +34,12 @@ export function formatRef(model: { providerID: string; id: string; variant?: str
export function switchLabel( export function switchLabel(
model: { providerID: string; id: string; variant?: string }, model: { providerID: string; id: string; variant?: string },
models?: readonly { providerID: string; id: string; name: 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 const display = models?.find((item) => item.providerID === model.providerID && item.id === model.id)?.name
if (display === undefined) return `Switched model to ${formatRef(model)}` 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})` : "" const variant = model.variant && model.variant !== "default" ? ` (${model.variant})` : ""
return `Switched model to ${display}${variant}` return `Switched model to ${display}${variant}`
} }

View file

@ -767,7 +767,18 @@ test("adds and dismisses question requests from live events", async () => {
test("settles pending tools when a live failure arrives", async () => { test("settles pending tools when a live failure arrives", async () => {
const events = createEventStream() 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<typeof useData> let sync!: ReturnType<typeof useData>
let ready!: () => void let ready!: () => void
const mounted = new Promise<void>((resolve) => { const mounted = new Promise<void>((resolve) => {
@ -808,7 +819,7 @@ test("settles pending tools when a live failure arrives", async () => {
durable: durable("session-1", 1), durable: durable("session-1", 1),
data: { data: {
sessionID: "session-1", sessionID: "session-1",
model: { id: "model-1", providerID: "provider-1" }, model: { id: "model-1", providerID: "provider-1", variant: "high" },
}, },
}) })
emitEvent(events, { emitEvent(events, {
@ -895,6 +906,11 @@ test("settles pending tools when a live failure arrives", async () => {
"model-switched", "model-switched",
"assistant", "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 { } finally {
app.renderer.destroy() app.renderer.destroy()
} }

View file

@ -34,4 +34,16 @@ describe("util.model", () => {
"Switched model to removed/gone/high", "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",
)
})
}) })