From 76f205d260f5b8680ed0103b700ca5cea45f5121 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Jul 2026 11:35:12 -0400 Subject: [PATCH] fix(tui): restore compaction model marker --- .../client/src/promise/generated/types.ts | 25 ++++++++++--------- packages/core/src/session/compaction.ts | 5 ++++ packages/core/src/session/message-updater.ts | 2 ++ packages/core/src/session/runner/llm.ts | 6 ++--- packages/core/test/session-compaction.test.ts | 8 +++++- packages/schema/src/session-event.ts | 1 + packages/schema/src/session-message.ts | 1 + packages/schema/test/session-message.test.ts | 21 ++++++++++++++++ packages/tui/src/context/data.tsx | 2 ++ packages/tui/src/routes/session/index.tsx | 16 +++++++++++- packages/tui/src/util/model.ts | 12 +++++++++ packages/tui/test/cli/tui/data.test.tsx | 9 ++++++- packages/tui/test/util/model.test.ts | 16 +++++++++++- 13 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 packages/schema/test/session-message.test.ts diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 255beaa1bb..a4b1bd5ed8 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -117,17 +117,6 @@ export type SessionMessageCompactionRunning = { recent: string } -export type SessionMessageCompactionCompleted = { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "completed" - reason: "auto" | "manual" - summary: string - recent: string -} - export type InstructionEntryKey = string export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } } @@ -507,6 +496,18 @@ export type SessionMessageModelSelected = { previous?: ModelRef } +export type SessionMessageCompactionCompleted = { + type: "compaction" + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + status: "completed" + reason: "auto" | "manual" + model?: ModelRef + summary: string + recent: string +} + export type CommandInfo = { name: string template: string @@ -749,7 +750,7 @@ export type SessionCompactionEnded = { type: "session.compaction.ended" durable: { aggregateID: string; seq: number; version: 1 } location?: LocationRef - data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string } + data: { sessionID: string; reason: "auto" | "manual"; model?: ModelRef; text: string; recent: string } } export type SessionRevertCleared = { diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 19ba3c9053..83106d3ec4 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -69,6 +69,7 @@ export type AutoInput = { readonly sessionID: SessionSchema.ID readonly messages: readonly SessionMessage.Info[] readonly model: Model + readonly ref: SessionMessage.Assistant["model"] } export type ManualInput = { @@ -80,6 +81,7 @@ export type ManualInput = { type Plan = { readonly sessionID: SessionSchema.ID readonly model: Model + readonly ref: SessionMessage.Assistant["model"] readonly reason: SessionMessage.Compaction["reason"] readonly prompt: string readonly recent: string @@ -291,6 +293,7 @@ const make = (dependencies: Dependencies) => { yield* dependencies.events.publish(SessionEvent.Compaction.Ended, { sessionID: plan.sessionID, reason: plan.reason, + model: plan.ref, text: summary, recent: plan.recent, }) @@ -302,6 +305,7 @@ const make = (dependencies: Dependencies) => { return yield* execute({ sessionID: input.sessionID, model: input.model, + ref: input.ref, reason: "auto", ...content, }) @@ -350,6 +354,7 @@ const make = (dependencies: Dependencies) => { return yield* execute({ sessionID: input.session.id, model: resolved.model, + ref: resolved.ref, reason: "manual", inputID: input.inputID, ...content, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 2f7f1d3d12..ba53c66c1c 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -470,6 +470,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ...current, status: "completed", reason: event.data.reason, + model: event.data.model, summary: event.data.text, recent: event.data.recent, }) @@ -482,6 +483,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { status: "completed", metadata: event.metadata, reason: event.data.reason, + model: event.data.model, summary: event.data.text, recent: event.data.recent, time: { created: event.created }, diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6a15137c07..56ed0e2f47 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -187,7 +187,7 @@ const layer = Layer.effect( const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const history = yield* SessionHistory.entriesForRunner(db, session.id, instructions) const context = history.entries.map((entry) => entry.message) - const compactionInput = { sessionID: session.id, messages: context, model } + const compactionInput = { sessionID: session.id, messages: context, model, ref: resolved.ref } if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { const compacted = yield* compaction.compact(compactionInput) if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const @@ -331,8 +331,8 @@ const layer = Layer.effect( recoverOverflow && !publisher.hasRetryEvidence() && isContextOverflowFailure(overflowFailure ?? streamFailure) && - (yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status === - "completed" + (yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model, ref: resolved.ref }))) + .status === "completed" ) return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index dc57b46f44..e3dc0770a1 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -154,7 +154,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () => expect(requests[0]?.generation).toBeUndefined() expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") expect(yield* store.context(sessionID)).toMatchObject([ - { type: "compaction", reason: "manual", summary: "manual summary", recent: "" }, + { + type: "compaction", + reason: "manual", + model: { id: "summary-model", providerID: "test" }, + summary: "manual summary", + recent: "", + }, ]) expect( yield* db diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 61341168ce..83caa2d7f7 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -488,6 +488,7 @@ export namespace Compaction { schema: { ...Base, reason: Started.data.fields.reason, + model: Model.Ref.pipe(optional), text: Schema.String, recent: Schema.String, }, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 3d45e283e6..fe394f7a70 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -222,6 +222,7 @@ export const CompactionCompleted = Schema.Struct({ ...CompactionBase, status: Schema.tag("completed"), reason: Schema.Literals(["auto", "manual"]), + model: Model.Ref.pipe(optional), summary: Schema.String, recent: Schema.String, }).annotate({ identifier: "Session.Message.Compaction.Completed" }) diff --git a/packages/schema/test/session-message.test.ts b/packages/schema/test/session-message.test.ts new file mode 100644 index 0000000000..fb460a8e1c --- /dev/null +++ b/packages/schema/test/session-message.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { SessionMessage } from "../src/session-message.js" + +describe("session message", () => { + test("decodes completed compactions recorded before model attribution", () => { + const encoded = { + type: "compaction" as const, + id: "msg_compaction", + time: { created: 0 }, + status: "completed" as const, + reason: "manual" as const, + summary: "summary", + recent: "", + } + const message = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted)(encoded) + + expect(message.model).toBeUndefined() + expect(Schema.encodeSync(SessionMessage.CompactionCompleted)(message)).toEqual(encoded) + }) +}) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 114da13a1f..0e4c50b70a 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -732,6 +732,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ Object.assign(current, { status: "completed", reason: event.data.reason, + model: event.data.model, summary: event.data.text, recent: event.data.recent, }) @@ -742,6 +743,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ type: "compaction", status: "completed", reason: event.data.reason, + model: event.data.model, summary: event.data.text, recent: event.data.recent, time: { created: event.created }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f5dbb56408..3173d4f545 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -73,7 +73,7 @@ import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keyma import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows" -import { switchLabel } from "../../util/model" +import { compactionMarker, switchLabel } from "../../util/model" addDefaultParsers(parsers.parsers) @@ -1306,10 +1306,14 @@ function SessionSkillMessage(props: { message: Extract }) { const ctx = use() + const local = useLocal() const { theme, syntax } = useTheme() const status = () => props.message.status const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary) const content = createMemo(() => text().trim()) + const marker = createMemo(() => + compactionMarker(props.message.status === "completed" ? props.message.model : undefined, ctx.models()), + ) const color = () => (status() === "failed" ? theme.error : theme.textMuted) return ( @@ -1344,6 +1348,16 @@ function CompactionMessage(props: { message: Extract + + {(value) => ( + + + {Locale.titlecase(value().agent)} + ยท {value().model} + + + )} + ) } diff --git a/packages/tui/src/util/model.ts b/packages/tui/src/util/model.ts index 215602845f..6093cfccd6 100644 --- a/packages/tui/src/util/model.ts +++ b/packages/tui/src/util/model.ts @@ -7,6 +7,18 @@ export function formatRef(model: { providerID: string; id: string; variant?: str return [model.providerID, model.id, model.variant].filter((value) => value !== undefined).join("/") } +export function compactionMarker( + model: { providerID: string; id: string; variant?: string } | undefined, + models?: readonly { providerID: string; id: string; name: string }[], +) { + if (!model) return + return { + agent: "compaction", + model: + models?.find((item) => item.providerID === model.providerID && item.id === model.id)?.name ?? formatRef(model), + } +} + export function switchLabel( model: { providerID: string; id: string; variant?: string }, models?: readonly { providerID: string; id: string; name: string }[], diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 2671ba1e22..21317a0559 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1126,7 +1126,13 @@ test("tracks session status from active sessions and execution events", async () created: 0, type: "session.compaction.ended", durable: durable("session-live", 5), - data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" }, + data: { + sessionID: "session-live", + reason: "auto", + model: { providerID: "anthropic", id: "claude-sonnet" }, + text: "Live summary", + recent: "recent", + }, }) await wait(() => { const message = data.session.message.get("session-live", "msg_compaction_started") @@ -1135,6 +1141,7 @@ test("tracks session status from active sessions and execution events", async () expect(data.session.message.get("session-live", "msg_compaction_started")).toMatchObject({ type: "compaction", status: "completed", + model: { providerID: "anthropic", id: "claude-sonnet" }, summary: "Live summary", }) expect(rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe( diff --git a/packages/tui/test/util/model.test.ts b/packages/tui/test/util/model.test.ts index 888b102013..9e54d757ed 100644 --- a/packages/tui/test/util/model.test.ts +++ b/packages/tui/test/util/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { formatRef, parse, switchLabel } from "../../src/util/model" +import { compactionMarker, formatRef, parse, switchLabel } from "../../src/util/model" describe("util.model", () => { test("splits provider from a nested model identifier", () => { @@ -12,6 +12,20 @@ describe("util.model", () => { expect(formatRef({ providerID: "anthropic", id: "sonnet" })).toBe("anthropic/sonnet") }) + test("labels completed compactions with their actual model", () => { + const models = [{ providerID: "anthropic", id: "sonnet", name: "Claude Sonnet" }] + + expect(compactionMarker({ providerID: "anthropic", id: "sonnet" }, models)).toEqual({ + agent: "compaction", + model: "Claude Sonnet", + }) + expect(compactionMarker({ providerID: "removed", id: "gone", variant: "high" }, models)).toEqual({ + agent: "compaction", + model: "removed/gone/high", + }) + expect(compactionMarker(undefined, models)).toBeUndefined() + }) + test("includes the selected variant in model switch notices", () => { expect(switchLabel({ providerID: "anthropic", id: "sonnet", variant: "thinking" })).toBe( "Switched model to anthropic/sonnet/thinking",