From 26a5ec6198530714e94aef31ac219a18d07c55f8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 2 Sep 2026 21:05:26 -0500 Subject: [PATCH 1/3] feat(core): persist compaction model and provider state --- .../session-timeline-notices.spec.ts | 1 + packages/client/src/effect/api/api.ts | 27 +++++- .../client/src/promise/generated/types.ts | 93 ++++++++++++++---- packages/client/src/solid/data.ts | 4 + packages/client/test/solid-compaction.test.ts | 31 +++++- packages/core/src/database/migration.gen.ts | 2 + .../20260902000000_compaction_model.ts | 59 ++++++++++++ .../core/src/database/v1-migration.bun.ts | 6 +- packages/core/src/session/compaction.ts | 8 ++ packages/core/src/session/message-updater.ts | 4 + packages/core/src/session/transfer.ts | 30 +++++- packages/core/test/database-migration.test.ts | 95 +++++++++++++++++++ packages/core/test/session-compaction.test.ts | 1 + packages/core/test/session-create.test.ts | 81 ++++++++++++++++ .../core/test/session-instructions.test.ts | 8 +- packages/core/test/session-projector.test.ts | 23 +++++ .../core/test/session-runner-message.test.ts | 2 + packages/core/test/session-runner.test.ts | 52 ++++++++++ packages/core/test/v1-migration.test.ts | 11 ++- packages/protocol/src/groups/session.ts | 2 +- packages/schema/src/session-event.ts | 2 + packages/schema/src/session-message.ts | 3 + packages/schema/src/session-transfer.ts | 17 ++++ packages/schema/test/compaction-state.test.ts | 49 ++++++++++ .../src/storybook/current-session-fixtures.ts | 1 + packages/tui/test/cli/tui/data.test.tsx | 24 ++++- .../tui/test/cli/tui/session-rows.test.ts | 1 + .../tui/test/mini/stream-v2.transport.test.ts | 10 +- packages/tui/test/util/session.test.ts | 1 + 29 files changed, 611 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/database/migration/20260902000000_compaction_model.ts create mode 100644 packages/schema/test/compaction-state.test.ts diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index 4183fc3c666a..710571f36d0d 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -110,6 +110,7 @@ test("renders a compaction summary while it streams and after completion", async compactionEnded({ sessionID, reason: "manual", + model: { id: "model", providerID: "provider" }, text: "## Checkpoint\n\nFinal implementation details.", recent: "", }), diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 45ce1d7359ae..44ebf480726e 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -215,7 +215,30 @@ export type SessionCreateOperation = (input?: SessionCreateInput) => export type SessionImportInput = { readonly info: Session.Info - readonly messages: ReadonlyArray + readonly messages: ReadonlyArray< + | SessionMessage.AgentSelected + | SessionMessage.ModelSelected + | SessionMessage.LocationSwitched + | SessionMessage.User + | SessionMessage.Synthetic + | SessionMessage.System + | SessionMessage.Skill + | SessionMessage.Shell + | SessionMessage.Assistant + | SessionMessage.Compaction + | { + readonly type: "compaction" + readonly id: SessionMessage.ID + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly time: { readonly created: DateTime.Utc } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly model?: Model.Ref | undefined + readonly providerState?: SessionMessage.ProviderState | undefined + readonly summary: string + readonly recent: string + } + > readonly location?: Location.Ref | undefined } export type SessionImportOutput = Session.Info @@ -965,6 +988,8 @@ export type SessionLogOutput = readonly data: { readonly sessionID: Session.ID readonly reason: "auto" | "manual" + readonly model: Model.Ref + readonly providerState?: SessionMessage.ProviderState | undefined readonly text: string readonly recent: string } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e8036b9f5417..899479c78bef 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -138,17 +138,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 SessionActive = { type: "running" } export type SessionInboxDelivery = "steer" | "queue" @@ -521,6 +510,19 @@ export type SessionMessageAssistantReasoning = { time?: { created: number; completed?: number } } +export type SessionMessageCompactionCompleted = { + type: "compaction" + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + status: "completed" + reason: "auto" | "manual" + model: ModelRef + providerState?: SessionMessageProviderState + summary: string + recent: string +} + export type ToolContent = ToolTextContent | ToolFileContent export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError } @@ -809,16 +811,6 @@ export type SessionCompactionStarted = { data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string } } -export type SessionCompactionEnded = { - id: string - created: number - metadata?: { [x: string]: any } - type: "session.compaction.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: LocationRef - data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string } -} - export type SessionCompactionFailed = { id: string created: number @@ -1351,6 +1343,23 @@ export type SessionToolCalled = { } } +export type SessionCompactionEnded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.compaction.ended" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + reason: "auto" | "manual" + model: ModelRef + providerState?: SessionMessageProviderState1 + text: string + recent: string + } +} + export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 } export type SessionMessageAssistantReasoning1 = { @@ -3063,6 +3072,8 @@ export type SessionImportInput = { readonly time: { readonly created: number } readonly status: "completed" readonly reason: "auto" | "manual" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerState?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string } @@ -3076,6 +3087,18 @@ export type SessionImportInput = { readonly error: { readonly type: string; readonly message: string; readonly status?: number } } ) + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerState?: { readonly [x: string]: JsonValue } + readonly summary: string + readonly recent: string + } > readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["info"] @@ -3340,6 +3363,8 @@ export type SessionImportInput = { readonly time: { readonly created: number } readonly status: "completed" readonly reason: "auto" | "manual" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerState?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string } @@ -3353,6 +3378,18 @@ export type SessionImportInput = { readonly error: { readonly type: string; readonly message: string; readonly status?: number } } ) + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerState?: { readonly [x: string]: JsonValue } + readonly summary: string + readonly recent: string + } > readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["messages"] @@ -3617,6 +3654,8 @@ export type SessionImportInput = { readonly time: { readonly created: number } readonly status: "completed" readonly reason: "auto" | "manual" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerState?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string } @@ -3630,6 +3669,18 @@ export type SessionImportInput = { readonly error: { readonly type: string; readonly message: string; readonly status?: number } } ) + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerState?: { readonly [x: string]: JsonValue } + readonly summary: string + readonly recent: string + } > readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["location"] diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index d18de9071266..9afebba3024c 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1038,6 +1038,8 @@ export function createData(config: CreateDataInput) { Object.assign(current, { status: "completed", reason: event.data.reason, + model: event.data.model, + providerState: event.data.providerState, summary: event.data.text, recent: event.data.recent, }) @@ -1048,6 +1050,8 @@ export function createData(config: CreateDataInput) { type: "compaction", status: "completed", reason: event.data.reason, + model: event.data.model, + providerState: event.data.providerState, summary: event.data.text, recent: event.data.recent, time: { created: event.created }, diff --git a/packages/client/test/solid-compaction.test.ts b/packages/client/test/solid-compaction.test.ts index 85848a08ae34..45a74d96e898 100644 --- a/packages/client/test/solid-compaction.test.ts +++ b/packages/client/test/solid-compaction.test.ts @@ -3,6 +3,20 @@ import { createRoot } from "solid-js" import { createData, type CreateDataInput } from "../src/solid" import { OpenCode, type OpenCodeEvent, type SessionInboxCompaction, type SessionInboxInfo } from "../src/promise" +test("projects model and provider state when the compaction start was not observed", () => { + using fixture = setup() + const model = { providerID: "demo", id: "model", variant: "variant" } + const providerState = { responseId: "summary-response" } + fixture.emit({ + ...event, + type: "session.compaction.ended", + data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "" }, + }) + expect(fixture.data.session.message.list(sessionID)).toMatchObject([ + { type: "compaction", status: "completed", summary: "Summary", model, providerState }, + ]) +}) + test("admits compaction before model setup and serializes the following prompt", async () => { using fixture = setup() const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } }) @@ -101,10 +115,23 @@ test.each(["started", "cancelled", "failed"])( fixture.emit({ ...event, type: "session.compaction.ended", - data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" }, + data: { + sessionID, + reason: "manual", + model: { providerID: "demo", id: "model" }, + providerState: { responseId: "summary-response" }, + text: "Summary", + recent: "Recent", + }, }) expect(fixture.data.session.message.list(sessionID)).toMatchObject([ - { type: "compaction", status: "completed", summary: "Summary" }, + { + type: "compaction", + status: "completed", + summary: "Summary", + model: { providerID: "demo", id: "model" }, + providerState: { responseId: "summary-response" }, + }, ]) } }, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 382849b4ce5b..d3d3da8ae68a 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js" import m43 from "./migration/20260812213948_worktree.js" import m44 from "./migration/20260819222447_session_viewed_state.js" import m45 from "./migration/20260823191254_nullable_workspace_binding.js" +import m46 from "./migration/20260902000000_compaction_model.js" export const migrations = [ m00, @@ -93,4 +94,5 @@ export const migrations = [ m43, m44, m45, + m46, ] satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260902000000_compaction_model.ts b/packages/core/src/database/migration/20260902000000_compaction_model.ts new file mode 100644 index 000000000000..d63346dd3521 --- /dev/null +++ b/packages/core/src/database/migration/20260902000000_compaction_model.ts @@ -0,0 +1,59 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration.js" + +const migration: DatabaseMigration.Migration = { + id: "20260902000000_compaction_model", + up(tx) { + return Effect.gen(function* () { + // Old checkpoints have no provider state. Infer display provenance without + // treating a nearby model as proof that it produced replayable provider data. + for (const table of ["session_message", "event"] as const) { + const session = table === "session_message" ? "session_id" : "aggregate_id" + yield* tx.run(` + UPDATE ${table} AS checkpoint + SET data = json_set(checkpoint.data, '$.model', json(coalesce( + ( + SELECT json_extract(source.data, '$.model') FROM session_message AS source + WHERE source.session_id = checkpoint.${session} + AND source.type IN ('assistant', 'model-switched') + AND json_type(source.data, '$.model') = 'object' + AND source.seq < checkpoint.seq + ORDER BY source.seq DESC LIMIT 1 + ), + ( + SELECT json_extract(source.data, '$.model') FROM event AS source + WHERE source.aggregate_id = checkpoint.${session} + AND source.type IN ('session.step.started.1', 'session.model.selected.1', 'session.created.1') + AND json_type(source.data, '$.model') = 'object' + AND source.seq < checkpoint.seq + ORDER BY source.seq DESC LIMIT 1 + ), + ( + SELECT json_extract(source.data, '$.model') FROM session_message AS source + WHERE source.session_id = checkpoint.${session} + AND source.type IN ('assistant', 'model-switched') + AND json_type(source.data, '$.model') = 'object' + AND source.seq > checkpoint.seq + ORDER BY source.seq ASC LIMIT 1 + ), + ( + SELECT json_extract(source.data, '$.model') FROM event AS source + WHERE source.aggregate_id = checkpoint.${session} + AND source.type IN ('session.step.started.1', 'session.model.selected.1', 'session.created.1') + AND json_type(source.data, '$.model') = 'object' + AND source.seq > checkpoint.seq + ORDER BY source.seq ASC LIMIT 1 + ), + (SELECT model FROM session_v2 WHERE id = checkpoint.${session}), + '{"id":"unknown","providerID":"unknown"}' + ))) + WHERE checkpoint.type = '${table === "session_message" ? "compaction" : "session.compaction.ended.1"}' + ${table === "session_message" ? "AND json_extract(checkpoint.data, '$.status') = 'completed'" : ""} + AND json_type(checkpoint.data, '$.model') IS NULL + `) + } + }) + }, +} + +export default migration diff --git a/packages/core/src/database/v1-migration.bun.ts b/packages/core/src/database/v1-migration.bun.ts index 193d454831dc..57cab6f9a66c 100644 --- a/packages/core/src/database/v1-migration.bun.ts +++ b/packages/core/src/database/v1-migration.bun.ts @@ -14,6 +14,7 @@ import { existsSync } from "node:fs" import path from "node:path" import type { Database as SQLiteDatabase } from "bun:sqlite" import { Project } from "@opencode-ai/schema/project" +import compactionModelMigration from "./migration/20260902000000_compaction_model.js" export type SourceMessage = { readonly id: string @@ -305,6 +306,7 @@ export function transformSession(input: TransformInput): TransformResult { type: "compaction", status: "completed", reason: compaction.auto ? "auto" : "manual", + model: { id: summary.value.modelID, providerID: summary.value.providerID }, summary: summaryText, recent: serializeRecent(tail, byMessage), time: { created: item.row.time_created }, @@ -712,7 +714,7 @@ function importNextDatabase( db: Database.Interface["db"], sourcePath: string | undefined, onProgress: (completed: number) => void, -): Effect.Effect { +): Effect.Effect { if (!sourcePath || !existsSync(sourcePath)) return Effect.void return Effect.scoped( Effect.gen(function* () { @@ -813,6 +815,8 @@ function importNextDatabase( onProgress(index + 1) yield* Effect.yieldNow } + // This source is imported after normal database migrations have already run. + yield* db.transaction((tx) => compactionModelMigration.up(tx)) source.run("COMMIT") }), ) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index c266e6fc6099..912a9ac498e6 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -367,6 +367,7 @@ export const layer = Layer.effect( const chunks: string[] = [] let failure: SessionError.Error | undefined let usage: SessionUsage.Recorded | undefined + let providerState: SessionMessage.ProviderState | undefined const recordUsage = Effect.suspend(() => usage ? bus.publish(SessionEvent.UsageRecorded, { @@ -407,6 +408,7 @@ export const layer = Layer.effect( // Ignored tool calls never enter the follow-up history or need fabricated results. for (let attempt = 0; attempt < 2; attempt++) { chunks.length = 0 + providerState = undefined yield* llm .stream( attempt === 0 @@ -436,6 +438,10 @@ export const layer = Layer.effect( }) } if (LLMEvent.is.stepFinish(event)) { + providerState = + event.providerMetadata?.[ + context.model.model.route.providerMetadataKey ?? context.model.model.provider + ] const step = SessionUsage.record(event.usage, context.model.cost) usage = usage ? SessionUsage.add(usage, step) : step } @@ -482,6 +488,8 @@ export const layer = Layer.effect( yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID: context.session.id, reason: input.reason, + model: context.model.ref, + providerState, text: summary, recent: history.recent, }) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 490b4db56af7..126e9d28b69a 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -410,6 +410,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { ...current, status: "completed", reason: event.data.reason, + model: event.data.model, + providerState: event.data.providerState, summary: event.data.text, recent: event.data.recent, }) @@ -422,6 +424,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { status: "completed", metadata: event.metadata, reason: event.data.reason, + model: event.data.model, + providerState: event.data.providerState, summary: event.data.text, recent: event.data.recent, time: { created }, diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index 1cfcb7ef72a2..6aa9c095d7c0 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -3,6 +3,7 @@ export * as SessionTransfer from "./transfer.js" import { SessionTransfer } from "@opencode-ai/schema/session-transfer" import { Tool } from "@opencode-ai/schema/tool" import { Skill } from "@opencode-ai/schema/skill" +import { Model } from "@opencode-ai/schema/model" import { eq } from "drizzle-orm" import { Context, DateTime, Effect, Layer, Schema } from "effect" import { map } from "effect/Array" @@ -36,7 +37,7 @@ export interface Interface { sanitize?: boolean }) => Effect.Effect readonly import: (input: { - data: Data + data: SessionTransfer.Import location: Location.Ref }) => Effect.Effect } @@ -73,8 +74,26 @@ const layer = Layer.effect( if (input.data.info.parentID) yield* sessions.get(input.data.info.parentID) const project = yield* projects.resolve(input.location.directory) yield* upsertProject(db, project).pipe(Effect.orDie) - const messages = input.data.messages.filter(isSettled).map((message, index) => { - const encoded = encodeMessage(message) + const settled = input.data.messages.filter(isSettled) + const models = settled.flatMap((message, index) => + message.type === "assistant" || message.type === "model-switched" ? [{ model: message.model, index }] : [], + ) + const messages = settled.map((message, index) => { + const encoded = encodeMessage( + message.type === "compaction" && message.status === "completed" + ? { + ...message, + model: + message.model ?? + models.findLast((entry) => entry.index < index)?.model ?? + models.find((entry) => entry.index > index)?.model ?? + input.data.info.model ?? + Model.Ref.parse("unknown/unknown"), + // An inferred model cannot authenticate provider state from an old export. + providerState: message.model ? message.providerState : undefined, + } + : message, + ) const { id: _, type, ...data } = encoded return { id: message.id, @@ -160,7 +179,7 @@ export const node = makeGlobalNode({ deps: [App.node, Bus.node, Database.node, Project.node, Session.node], }) -function isSettled(message: SessionMessage.Info) { +function isSettled(message: SessionTransfer.Import["messages"][number]) { if (message.type === "assistant") return message.time.completed !== undefined if (message.type === "shell" || message.type === "compaction") return message.status !== "running" return true @@ -296,6 +315,9 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info { metadata: meta, summary: redact("compaction-summary", message.id, message.summary), recent: redact("compaction-recent", message.id, message.recent), + ...(message.status === "completed" + ? { providerState: metadata("compaction-provider-state", message.id, message.providerState) } + : {}), } } return { ...message, metadata: meta } diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 3708a5dc6ca6..2ba7ac52f344 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -20,6 +20,7 @@ import workspaceMigration from "@opencode-ai/core/database/migration/20260808023 import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts" import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox" import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260819222447_session_viewed_state" +import compactionModelMigration from "@opencode-ai/core/database/migration/20260902000000_compaction_model" import { Global } from "@opencode-ai/util/global" const run = ( @@ -62,6 +63,100 @@ const parkedClient = (arrived: Deferred.Deferred, gate: Deferred.Deferred< ).pipe(Layer.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Layer.provide(Reactivity.layer)) describe("DatabaseMigration", () => { + for (const source of ["messages", "events"] as const) { + test.each([ + ["previous", "next", "selected", undefined, "previous"], + [undefined, "next", "selected", undefined, "next"], + [undefined, undefined, "selected", undefined, "selected"], + [undefined, undefined, undefined, undefined, "unknown"], + ["previous", "next", "selected", "recorded", "recorded"], + ])( + `backfills compaction models from ${source}: %s / %s / %s / %s`, + async (before, after, selected, recorded, expected) => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session_v2 (id TEXT PRIMARY KEY, model TEXT)`) + yield* db.run( + sql`CREATE TABLE session_message (id TEXT, session_id TEXT, seq INTEGER, type TEXT, data TEXT)`, + ) + yield* db.run(sql`CREATE TABLE event (id TEXT, aggregate_id TEXT, seq INTEGER, type TEXT, data TEXT)`) + const model = (id: string) => ({ + id, + providerID: id === "unknown" ? "unknown" : "provider", + variant: "variant", + }) + yield* db.run( + sql`INSERT INTO session_v2 VALUES ('ses_test', ${selected ? JSON.stringify(model(selected)) : null})`, + ) + for (const entry of [ + { id: "older", seq: 0 }, + { id: before, seq: 1 }, + { id: after, seq: 5 }, + ]) { + if (!entry.id || (entry.seq === 0 && !before)) continue + const data = JSON.stringify({ model: model(entry.id) }) + if (source === "messages") + yield* db.run( + sql`INSERT INTO session_message VALUES (${entry.id}, 'ses_test', ${entry.seq}, 'assistant', ${data})`, + ) + if (source === "events") + yield* db.run( + sql`INSERT INTO event VALUES (${entry.id}, 'ses_test', ${entry.seq}, 'session.step.started.1', ${data})`, + ) + } + // A different session must never supply the inferred model. + yield* db.run( + sql`INSERT INTO session_message VALUES ('other', 'ses_other', 2, 'assistant', '{"model":{"id":"other","providerID":"other"}}')`, + ) + const data = JSON.stringify({ + status: "completed", + summary: "Summary", + recent: "Recent", + ...(recorded ? { model: model(recorded), providerState: { opaque: "keep" } } : {}), + }) + yield* db.run(sql`INSERT INTO session_message VALUES ('checkpoint', 'ses_test', 3, 'compaction', ${data})`) + yield* db.run(sql`INSERT INTO event VALUES ('ended', 'ses_test', 4, 'session.compaction.ended.1', ${data})`) + yield* db.run( + sql`INSERT INTO session_message VALUES ('running', 'ses_test', 6, 'compaction', '{"status":"running"}')`, + ) + yield* db.run( + sql`INSERT INTO session_message VALUES ('failed', 'ses_test', 7, 'compaction', '{"status":"failed"}')`, + ) + + yield* DatabaseMigration.applyOnly(db, [compactionModelMigration]) + yield* DatabaseMigration.applyOnly(db, [compactionModelMigration]) + + for (const table of ["session_message", "event"]) { + const row = yield* db.get<{ + model: string + provider: string + variant: string | null + state: string | null + summary: string + }>(sql` + SELECT json_extract(data, '$.model.id') AS model, json_extract(data, '$.model.providerID') AS provider, + json_extract(data, '$.model.variant') AS variant, json_extract(data, '$.providerState.opaque') AS state, + json_extract(data, '$.summary') AS summary + FROM ${sql.identifier(table)} WHERE id = ${table === "event" ? "ended" : "checkpoint"} + `) + expect(row).toEqual({ + model: expected, + provider: expected === "unknown" ? "unknown" : "provider", + variant: expected === "unknown" ? null : "variant", + state: recorded ? "keep" : null, + summary: "Summary", + }) + } + expect( + yield* db.all(sql`SELECT data FROM session_message WHERE id IN ('running', 'failed') ORDER BY seq`), + ).toEqual([{ data: '{"status":"running"}' }, { data: '{"status":"failed"}' }]) + }), + ) + }, + ) + } + test("defaults missing workspace names while preserving legacy workspace data", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index df45f6f5d01a..f88e50629b41 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -263,6 +263,7 @@ it.effect("auto compaction estimates current content against the buffered prompt type: "compaction", status: "completed", reason: "auto", + model: resolved.ref, summary: "x".repeat(400_000), recent: "", time: { created: 0, completed: 0 }, diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index ab49cbfae70a..69ec4b04dfd6 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1312,9 +1312,90 @@ describe("SessionTransfer", () => { completedCompactionID, ]) expect(yield* Bus.latestSequence(db, sessionID)).toBe(4) + expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ + id: completedCompactionID, + model, + }) }), ) + it.effect("round-trips compaction model and provider state and sanitizes opaque state", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const transfer = yield* SessionTransfer.Service + const bus = yield* Bus.Service + const source = yield* sessions.create({ location }) + const model = Model.Ref.parse("provider/model#variant") + const providerState = { responseId: "private-response", nested: { secret: "opaque" } } + yield* bus.publish(SessionEvent.Compaction.Ended, { + sessionID: source.id, + reason: "manual", + model, + providerState, + text: "Summary", + recent: "", + }) + const data = yield* transfer.export({ sessionID: source.id }) + const imported = yield* transfer.import({ + data: { + ...data, + info: { ...data.info, id: Session.ID.create() }, + messages: data.messages.map((message) => ({ ...message, id: SessionMessage.ID.create() })), + }, + location, + }) + expect((yield* sessions.messages({ sessionID: imported.id, order: "asc" }))[0]).toMatchObject({ + model, + providerState, + }) + const sanitized = yield* transfer.export({ sessionID: source.id, sanitize: true }) + expect(sanitized.messages[0]).toMatchObject({ + model, + providerState: { redacted: `compaction-provider-state:${data.messages[0].id}` }, + }) + }), + ) + + for (const source of ["before", "after", "session", "unknown"] as const) { + it.effect(`backfills imported compactions from ${source} without inventing provider state`, () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const transfer = yield* SessionTransfer.Service + const template = yield* sessions.create({ location }) + const model = Model.Ref.parse("provider/model#variant") + const checkpoint = { + id: SessionMessage.ID.create(), + type: "compaction" as const, + status: "completed" as const, + reason: "manual" as const, + summary: "Summary", + recent: "", + providerState: { unbound: "discard" }, + time: { created: DateTime.makeUnsafe(1) }, + } + const selected = SessionMessage.ModelSelected.make({ + id: SessionMessage.ID.create(), + type: "model-switched", + model, + time: { created: DateTime.makeUnsafe(1) }, + }) + const imported = yield* transfer.import({ + data: { + info: { ...template, id: Session.ID.create(), model: source === "session" ? model : undefined }, + messages: + source === "before" ? [selected, checkpoint] : source === "after" ? [checkpoint, selected] : [checkpoint], + }, + location, + }) + const result = (yield* transfer.export({ sessionID: imported.id })).messages.find( + (message) => message.id === checkpoint.id, + ) + expect(result).toMatchObject({ model: source === "unknown" ? Model.Ref.parse("unknown/unknown") : model }) + expect(result && "providerState" in result ? result.providerState : undefined).toBeUndefined() + }), + ) + } + it.effect("imports projected messages and reserves their aggregate sequence", () => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index bd9decea5c27..289bad1c52f2 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -252,7 +252,13 @@ describe("SessionInstructions", () => { // A completed compaction truncates model-visible history at its boundary, dropping // the synthetic that carried sub's instructions. yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" }) - yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "" }) + yield* bus.publish(SessionEvent.Compaction.Ended, { + sessionID, + reason: "manual", + model: Model.Ref.parse("test/model"), + text: "summary", + recent: "", + }) expect(yield* synthetics(sessionID)).toHaveLength(0) // The model no longer has the rules, so the next read under the subtree must diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 0043dffdf4df..22dc7e9c5abe 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -415,6 +415,8 @@ describe("SessionProjector", () => { yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", + model, + providerState: { responseId: "summary-response" }, text: "summary", recent: "recent context", }) @@ -455,6 +457,8 @@ describe("SessionProjector", () => { time: { completed: DateTime.makeUnsafe(0) }, }) expect(messages.find((message) => message.type === "compaction")).toMatchObject({ + model, + providerState: { responseId: "summary-response" }, summary: "summary", recent: "recent context", }) @@ -468,6 +472,25 @@ describe("SessionProjector", () => { }), ) + it.effect("replays a compaction completion with its model and provider state", () => + Effect.gen(function* () { + yield* seedSession() + const bus = yield* Bus.Service + const store = yield* SessionStore.Service + yield* bus.replay({ + id: Event.ID.create(), + created: 1, + aggregateID: sessionID, + seq: 0, + type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1), + data: { sessionID, reason: "manual", model, providerState: { opaque: "state" }, text: "summary", recent: "" }, + }) + expect(yield* store.context(sessionID)).toMatchObject([ + { type: "compaction", status: "completed", model, providerState: { opaque: "state" } }, + ]) + }), + ) + it.effect("rejects distinct creator events that reuse one projected message ID", () => Effect.gen(function* () { const db = yield* seedSession() diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 5f37a4d19aab..8f5e1c96225b 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -147,6 +147,8 @@ describe("toLLMMessages", () => { type: "compaction", status: "completed", reason: "auto", + model, + providerState: { responseId: "storage-only" }, summary: "Earlier work", recent: "Recent work", time: { created }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 3a6486655a13..5fdaf8c09fda 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2177,6 +2177,9 @@ describe("SessionRunnerLLM", () => { expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.") expect(requestAgents[2]).toBe(Agent.ID.make("compaction")) expect(s.executions).toEqual(["x".repeat(4_000)]) + expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({ + model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant }, + }) // Compare wire content without the cache breakpoints that move to the new final message. const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" })) @@ -2243,6 +2246,54 @@ describe("SessionRunnerLLM", () => { } } + for (const state of [true, false]) { + scenario(`compaction retains only accepted response state (state=${state})`, function* (s) { + // Provider aliases use the route's metadata namespace, not the catalog provider ID. + s.currentModel = LanguageModel.make({ + id: "gpt-5", + provider: "alias", + route: OpenAIResponses.route, + }) + yield* s.llm.push(TestLLM.text("Earlier answer", "state-history")) + yield* s.runPrompt("Earlier question") + const rejected = { responseId: "rejected-response" } + const accepted = { responseId: "accepted-response", opaque: { value: "provider-data" } } + const key = OpenAIResponses.route.providerMetadataKey ?? s.currentModel.provider + yield* s.llm.push( + TestLLM.complete( + { reason: { normalized: "stop" }, providerMetadata: { [key]: rejected } }, + LLMEvent.textDelta({ id: "invalid", text: "Not a summary" }), + ), + TestLLM.complete( + { + reason: { normalized: "stop" }, + providerMetadata: state ? { [key]: accepted, unrelated: { ignored: true } } : undefined, + }, + LLMEvent.textDelta({ id: "summary", text: "## Objective\n- Accepted summary" }), + ), + ) + const compact = yield* s.session.compact({ sessionID }) + yield* s.resume + const checkpoint = (yield* s.messages).find((message) => message.id === compact.id) + expect(checkpoint).toMatchObject({ + status: "completed", + model: { providerID: "alias", id: "gpt-5" }, + summary: "## Objective\n- Accepted summary", + }) + if (checkpoint?.type !== "compaction" || checkpoint.status !== "completed") + return yield* Effect.die("Missing completed checkpoint") + expect(checkpoint.providerState).toEqual(state ? accepted : undefined) + const event = yield* s.db + .select({ data: EventTable.data }) + .from(EventTable) + .where(sql`${EventTable.type} = 'session.compaction.ended.1'`) + .get() + .pipe(Effect.orDie) + expect(event?.data.model).toEqual(checkpoint.model) + expect(event?.data.providerState).toEqual(state ? accepted : undefined) + }) + } + scenario("preserves typed provider failures from manual compaction", function* (s) { yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-failure-history")) yield* s.runPrompt("Earlier question") @@ -2648,6 +2699,7 @@ describe("SessionRunnerLLM", () => { yield* s.bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", + model: Model.Ref.parse(`${s.currentModel.provider}/${s.currentModel.id}`), text: "summary", recent: "", }) diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index b8ca1879f087..0d9cc711ab85 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -867,6 +867,7 @@ describe("V1Migration database workflow", () => { ('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21), ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '2', NULL, NULL, 12, 22); INSERT INTO session_message VALUES + ('msg_next_checkpoint', 'ses_next', 'compaction', 3, 11, 12, '{"status":"completed","reason":"manual","summary":"Summary","recent":"","time":{"created":11}}'), ('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next''s history","time":{"created":12}}'), ('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'), ('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}'); @@ -908,13 +909,21 @@ describe("V1Migration database workflow", () => { .where(eq(SessionTable.id, SessionSchema.ID.make("ses_next"))) .get(), ).toEqual({ directory: process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer" }) - expect(yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next'`)).toEqual([ + expect( + yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next' AND type = 'user'`), + ).toEqual([ { id: "msg_next", seq: 4, data: '{"text":"from next\'s history","time":{"created":12}}', }, ]) + expect( + yield* db.get(sql` + SELECT json_extract(data, '$.model') AS model, json_extract(data, '$.providerState') AS state + FROM session_message WHERE id = 'msg_next_checkpoint' + `), + ).toEqual({ model: '{"id":"model","providerID":"provider"}', state: null }) expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_next'`)).toEqual({ seq: 4, owner_id: null, diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 442d9eafadc7..c7dc15815d48 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -188,7 +188,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.post("session.import", "/api/session/import", { payload: Schema.Struct({ - ...SessionTransfer.Data.fields, + ...SessionTransfer.Import.fields, location: Location.Ref.pipe(Schema.optional), }), success: Schema.Struct({ data: Session.Info }), diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index c627366452fa..34ebe69b1067 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -585,6 +585,8 @@ export namespace Compaction { schema: { ...Base, reason: Started.data.fields.reason, + model: SessionMessage.CompactionCompleted.fields.model, + providerState: SessionMessage.CompactionCompleted.fields.providerState, text: Schema.String, recent: Schema.String, }, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 64b82ee50230..827af7cb4af8 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -250,6 +250,9 @@ export const CompactionCompleted = Schema.Struct({ ...CompactionBase, status: Schema.tag("completed"), reason: Schema.Literals(["auto", "manual"]), + /** Producing model. Historical checkpoints may contain a best-effort backfill. */ + model: Model.Ref, + providerState: ProviderState.pipe(optional), summary: Schema.String, recent: Schema.String, }).annotate({ identifier: "Session.Message.Compaction.Completed" }) diff --git a/packages/schema/src/session-transfer.ts b/packages/schema/src/session-transfer.ts index d06e6f634420..f06c23f8702c 100644 --- a/packages/schema/src/session-transfer.ts +++ b/packages/schema/src/session-transfer.ts @@ -3,9 +3,26 @@ export * as SessionTransfer from "./session-transfer.js" import { Schema } from "effect" import { Session } from "./session.js" import { SessionMessage } from "./session-message.js" +import { optional } from "./schema.js" export interface Data extends Schema.Schema.Type {} export const Data = Schema.Struct({ info: Session.Info, messages: Schema.Array(SessionMessage.Info), }).annotate({ identifier: "SessionTransfer.Data" }) + +// Older exports omitted the compaction model. Only the import boundary accepts +// this shape; Core fills the model before storing it under the current contract. +export interface Import extends Schema.Schema.Type {} +export const Import = Schema.Struct({ + info: Session.Info, + messages: Schema.Array( + Schema.Union([ + ...SessionMessage.Info.members, + Schema.Struct({ + ...SessionMessage.CompactionCompleted.fields, + model: SessionMessage.CompactionCompleted.fields.model.pipe(optional), + }), + ]), + ), +}).annotate({ identifier: "SessionTransfer.Import" }) diff --git a/packages/schema/test/compaction-state.test.ts b/packages/schema/test/compaction-state.test.ts new file mode 100644 index 000000000000..80f8490e60ee --- /dev/null +++ b/packages/schema/test/compaction-state.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { SessionMessage } from "../src/session-message.js" +import { SessionEvent } from "../src/session-event.js" +import { SessionTransfer } from "../src/session-transfer.js" +import { Model } from "../src/model.js" + +const checkpoint = { + id: "msg_checkpoint", + type: "compaction", + status: "completed", + reason: "manual", + summary: "Summary", + recent: "", + time: { created: 1 }, +} as const +const model = Model.Ref.parse("provider/model#variant") + +test("completed compactions require a model and preserve optional opaque provider state", () => { + const decode = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted) + const encode = Schema.encodeSync(SessionMessage.CompactionCompleted) + expect(() => decode(checkpoint)).toThrow() + expect(encode({ ...decode({ ...checkpoint, model }), providerState: undefined })).toEqual({ ...checkpoint, model }) + const providerState = { responseId: "response", nested: { opaque: [1, "value"] } } + expect(encode(decode({ ...checkpoint, model, providerState }))).toEqual({ ...checkpoint, model, providerState }) +}) + +test("compaction completion events require the producing model", () => { + const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data) + const data = { sessionID: "ses_checkpoint", reason: "manual", text: "Summary", recent: "" } as const + expect(() => decode(data)).toThrow() + expect(Schema.encodeSync(SessionEvent.Compaction.Ended.data)(decode({ ...data, model }))).toEqual({ ...data, model }) +}) + +test("only the import boundary accepts a historical checkpoint without a model", () => { + const data = { + info: { + id: "ses_checkpoint", + projectID: "global", + location: { directory: "/project" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + }, + messages: [checkpoint], + } + expect(() => Schema.decodeUnknownSync(SessionTransfer.Import)(data)).not.toThrow() + expect(() => Schema.decodeUnknownSync(SessionTransfer.Data)(data)).toThrow() +}) diff --git a/packages/session-ui/src/storybook/current-session-fixtures.ts b/packages/session-ui/src/storybook/current-session-fixtures.ts index f1fe93a01cc5..9cfecabd1046 100644 --- a/packages/session-ui/src/storybook/current-session-fixtures.ts +++ b/packages/session-ui/src/storybook/current-session-fixtures.ts @@ -996,6 +996,7 @@ export const compactionDocument = document([ type: "compaction", status: "completed", reason: "auto", + model: STORY_MODEL, summary: "The Session timeline now consumes current nested assistant content.", recent: "Add deterministic stories and verify Storybook.", time: { created: STORY_TIME + 63_000 }, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 7776514ea54b..d80c84c72fe9 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1514,7 +1514,13 @@ test("tracks session status from active sessions and execution events", async () created: 3, type: "session.compaction.ended", durable: durable("session-manual", 4), - data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" }, + data: { + sessionID: "session-manual", + reason: "manual", + model: { id: "model", providerID: "provider" }, + text: "Streamed summary", + recent: "recent", + }, }) await wait(() => { const message = data.session.message.get("session-manual", "message-compaction") @@ -1557,7 +1563,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: { id: "model", providerID: "provider" }, + text: "Live summary", + recent: "recent", + }, }) await wait(() => { const message = data.session.message.get("session-live", "msg_compaction_started") @@ -1696,7 +1708,13 @@ test("restores queued compaction from durable pending input", async () => { created: 3, type: "session.compaction.ended", durable: durable(sessionID, 7), - data: { sessionID, reason: "manual", text: "Summary", recent: "" }, + data: { + sessionID, + reason: "manual", + model: { id: "model", providerID: "provider" }, + text: "Summary", + recent: "", + }, }) expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"]) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 2f29fb494aaf..df76107853a1 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -211,6 +211,7 @@ test("resets the cross-turn cache baseline after compaction", () => { id: "compaction-1", status: "completed", reason: "auto", + model: first.model, summary: "Compacted context", recent: "", time: { created: 2 }, diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index a77624f0324b..97292bf30737 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -120,7 +120,7 @@ function compaction(status: "running" | "completed", summary: string): SessionMe time: { created: 1 }, } if (status === "running") return { ...message, status } - return { ...message, status } + return { ...message, status, model: { id: "model", providerID: "provider" } } } function form(id: string, sessionID: string, title = id): FormInfo { @@ -536,7 +536,13 @@ describe("V2 mini transport", () => { created: 3, type: "session.compaction.ended", durable: durable("ses_1", 3), - data: { sessionID: "ses_1", reason: "auto", text: "Transport", recent: "" }, + data: { + sessionID: "ses_1", + reason: "auto", + model: { id: "model", providerID: "provider" }, + text: "Transport", + recent: "", + }, }) while (!ui.commits.some((commit) => commit.phase === "final")) await Bun.sleep(0) diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index b3e32aa6340e..a70b7a37aa18 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -49,6 +49,7 @@ describe("util.session", () => { type: "compaction", status: "completed", reason: "manual", + model: { id: "model", providerID: "provider" }, summary: "Current state", recent: "", time: { created: 0 }, From 596d3112303a852a0acb9b263251b2f67d1aaa80 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 3 Sep 2026 11:04:00 -0500 Subject: [PATCH 2/3] refactor(core): make compaction model optional --- .../session-timeline-notices.spec.ts | 1 - packages/client/src/effect/api/api.ts | 27 +----- .../client/src/promise/generated/types.ts | 46 +-------- packages/core/src/database/migration.gen.ts | 2 - .../20260902000000_compaction_model.ts | 59 ------------ .../core/src/database/v1-migration.bun.ts | 6 +- packages/core/src/session/transfer.ts | 27 +----- packages/core/test/database-migration.test.ts | 95 ------------------- packages/core/test/session-compaction.test.ts | 1 - packages/core/test/session-create.test.ts | 44 --------- .../core/test/session-instructions.test.ts | 8 +- packages/core/test/session-projector.test.ts | 23 ----- .../core/test/session-runner-message.test.ts | 2 - packages/core/test/session-runner.test.ts | 1 - packages/core/test/v1-migration.test.ts | 11 +-- packages/protocol/src/groups/session.ts | 2 +- packages/schema/src/session-message.ts | 3 +- packages/schema/src/session-transfer.ts | 17 ---- packages/schema/test/compaction-state.test.ts | 26 +---- .../src/storybook/current-session-fixtures.ts | 1 - packages/tui/test/cli/tui/data.test.tsx | 24 +---- .../tui/test/cli/tui/session-rows.test.ts | 1 - .../tui/test/mini/stream-v2.transport.test.ts | 10 +- packages/tui/test/util/session.test.ts | 1 - 24 files changed, 25 insertions(+), 413 deletions(-) delete mode 100644 packages/core/src/database/migration/20260902000000_compaction_model.ts diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index 710571f36d0d..4183fc3c666a 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -110,7 +110,6 @@ test("renders a compaction summary while it streams and after completion", async compactionEnded({ sessionID, reason: "manual", - model: { id: "model", providerID: "provider" }, text: "## Checkpoint\n\nFinal implementation details.", recent: "", }), diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 44ebf480726e..7e80306bf1ad 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -215,30 +215,7 @@ export type SessionCreateOperation = (input?: SessionCreateInput) => export type SessionImportInput = { readonly info: Session.Info - readonly messages: ReadonlyArray< - | SessionMessage.AgentSelected - | SessionMessage.ModelSelected - | SessionMessage.LocationSwitched - | SessionMessage.User - | SessionMessage.Synthetic - | SessionMessage.System - | SessionMessage.Skill - | SessionMessage.Shell - | SessionMessage.Assistant - | SessionMessage.Compaction - | { - readonly type: "compaction" - readonly id: SessionMessage.ID - readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly time: { readonly created: DateTime.Utc } - readonly status: "completed" - readonly reason: "auto" | "manual" - readonly model?: Model.Ref | undefined - readonly providerState?: SessionMessage.ProviderState | undefined - readonly summary: string - readonly recent: string - } - > + readonly messages: ReadonlyArray readonly location?: Location.Ref | undefined } export type SessionImportOutput = Session.Info @@ -988,7 +965,7 @@ export type SessionLogOutput = readonly data: { readonly sessionID: Session.ID readonly reason: "auto" | "manual" - readonly model: Model.Ref + readonly model?: Model.Ref | undefined readonly providerState?: SessionMessage.ProviderState | undefined readonly text: string readonly recent: string diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 899479c78bef..81f60180d878 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -517,7 +517,7 @@ export type SessionMessageCompactionCompleted = { time: { created: number } status: "completed" reason: "auto" | "manual" - model: ModelRef + model?: ModelRef providerState?: SessionMessageProviderState summary: string recent: string @@ -1353,7 +1353,7 @@ export type SessionCompactionEnded = { data: { sessionID: string reason: "auto" | "manual" - model: ModelRef + model?: ModelRef providerState?: SessionMessageProviderState1 text: string recent: string @@ -3072,7 +3072,7 @@ export type SessionImportInput = { readonly time: { readonly created: number } readonly status: "completed" readonly reason: "auto" | "manual" - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly providerState?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string @@ -3087,18 +3087,6 @@ export type SessionImportInput = { readonly error: { readonly type: string; readonly message: string; readonly status?: number } } ) - | { - readonly type: "compaction" - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } - readonly status: "completed" - readonly reason: "auto" | "manual" - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } - readonly providerState?: { readonly [x: string]: JsonValue } - readonly summary: string - readonly recent: string - } > readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["info"] @@ -3363,7 +3351,7 @@ export type SessionImportInput = { readonly time: { readonly created: number } readonly status: "completed" readonly reason: "auto" | "manual" - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly providerState?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string @@ -3378,18 +3366,6 @@ export type SessionImportInput = { readonly error: { readonly type: string; readonly message: string; readonly status?: number } } ) - | { - readonly type: "compaction" - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } - readonly status: "completed" - readonly reason: "auto" | "manual" - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } - readonly providerState?: { readonly [x: string]: JsonValue } - readonly summary: string - readonly recent: string - } > readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["messages"] @@ -3654,7 +3630,7 @@ export type SessionImportInput = { readonly time: { readonly created: number } readonly status: "completed" readonly reason: "auto" | "manual" - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly providerState?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string @@ -3669,18 +3645,6 @@ export type SessionImportInput = { readonly error: { readonly type: string; readonly message: string; readonly status?: number } } ) - | { - readonly type: "compaction" - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } - readonly status: "completed" - readonly reason: "auto" | "manual" - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } - readonly providerState?: { readonly [x: string]: JsonValue } - readonly summary: string - readonly recent: string - } > readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["location"] diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index d3d3da8ae68a..382849b4ce5b 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -45,7 +45,6 @@ import m42 from "./migration/20260812181746_session_inbox.js" import m43 from "./migration/20260812213948_worktree.js" import m44 from "./migration/20260819222447_session_viewed_state.js" import m45 from "./migration/20260823191254_nullable_workspace_binding.js" -import m46 from "./migration/20260902000000_compaction_model.js" export const migrations = [ m00, @@ -94,5 +93,4 @@ export const migrations = [ m43, m44, m45, - m46, ] satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260902000000_compaction_model.ts b/packages/core/src/database/migration/20260902000000_compaction_model.ts deleted file mode 100644 index d63346dd3521..000000000000 --- a/packages/core/src/database/migration/20260902000000_compaction_model.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Effect } from "effect" -import type { DatabaseMigration } from "../migration.js" - -const migration: DatabaseMigration.Migration = { - id: "20260902000000_compaction_model", - up(tx) { - return Effect.gen(function* () { - // Old checkpoints have no provider state. Infer display provenance without - // treating a nearby model as proof that it produced replayable provider data. - for (const table of ["session_message", "event"] as const) { - const session = table === "session_message" ? "session_id" : "aggregate_id" - yield* tx.run(` - UPDATE ${table} AS checkpoint - SET data = json_set(checkpoint.data, '$.model', json(coalesce( - ( - SELECT json_extract(source.data, '$.model') FROM session_message AS source - WHERE source.session_id = checkpoint.${session} - AND source.type IN ('assistant', 'model-switched') - AND json_type(source.data, '$.model') = 'object' - AND source.seq < checkpoint.seq - ORDER BY source.seq DESC LIMIT 1 - ), - ( - SELECT json_extract(source.data, '$.model') FROM event AS source - WHERE source.aggregate_id = checkpoint.${session} - AND source.type IN ('session.step.started.1', 'session.model.selected.1', 'session.created.1') - AND json_type(source.data, '$.model') = 'object' - AND source.seq < checkpoint.seq - ORDER BY source.seq DESC LIMIT 1 - ), - ( - SELECT json_extract(source.data, '$.model') FROM session_message AS source - WHERE source.session_id = checkpoint.${session} - AND source.type IN ('assistant', 'model-switched') - AND json_type(source.data, '$.model') = 'object' - AND source.seq > checkpoint.seq - ORDER BY source.seq ASC LIMIT 1 - ), - ( - SELECT json_extract(source.data, '$.model') FROM event AS source - WHERE source.aggregate_id = checkpoint.${session} - AND source.type IN ('session.step.started.1', 'session.model.selected.1', 'session.created.1') - AND json_type(source.data, '$.model') = 'object' - AND source.seq > checkpoint.seq - ORDER BY source.seq ASC LIMIT 1 - ), - (SELECT model FROM session_v2 WHERE id = checkpoint.${session}), - '{"id":"unknown","providerID":"unknown"}' - ))) - WHERE checkpoint.type = '${table === "session_message" ? "compaction" : "session.compaction.ended.1"}' - ${table === "session_message" ? "AND json_extract(checkpoint.data, '$.status') = 'completed'" : ""} - AND json_type(checkpoint.data, '$.model') IS NULL - `) - } - }) - }, -} - -export default migration diff --git a/packages/core/src/database/v1-migration.bun.ts b/packages/core/src/database/v1-migration.bun.ts index 57cab6f9a66c..193d454831dc 100644 --- a/packages/core/src/database/v1-migration.bun.ts +++ b/packages/core/src/database/v1-migration.bun.ts @@ -14,7 +14,6 @@ import { existsSync } from "node:fs" import path from "node:path" import type { Database as SQLiteDatabase } from "bun:sqlite" import { Project } from "@opencode-ai/schema/project" -import compactionModelMigration from "./migration/20260902000000_compaction_model.js" export type SourceMessage = { readonly id: string @@ -306,7 +305,6 @@ export function transformSession(input: TransformInput): TransformResult { type: "compaction", status: "completed", reason: compaction.auto ? "auto" : "manual", - model: { id: summary.value.modelID, providerID: summary.value.providerID }, summary: summaryText, recent: serializeRecent(tail, byMessage), time: { created: item.row.time_created }, @@ -714,7 +712,7 @@ function importNextDatabase( db: Database.Interface["db"], sourcePath: string | undefined, onProgress: (completed: number) => void, -): Effect.Effect { +): Effect.Effect { if (!sourcePath || !existsSync(sourcePath)) return Effect.void return Effect.scoped( Effect.gen(function* () { @@ -815,8 +813,6 @@ function importNextDatabase( onProgress(index + 1) yield* Effect.yieldNow } - // This source is imported after normal database migrations have already run. - yield* db.transaction((tx) => compactionModelMigration.up(tx)) source.run("COMMIT") }), ) diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index 6aa9c095d7c0..5c8f8e4828e1 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -3,7 +3,6 @@ export * as SessionTransfer from "./transfer.js" import { SessionTransfer } from "@opencode-ai/schema/session-transfer" import { Tool } from "@opencode-ai/schema/tool" import { Skill } from "@opencode-ai/schema/skill" -import { Model } from "@opencode-ai/schema/model" import { eq } from "drizzle-orm" import { Context, DateTime, Effect, Layer, Schema } from "effect" import { map } from "effect/Array" @@ -37,7 +36,7 @@ export interface Interface { sanitize?: boolean }) => Effect.Effect readonly import: (input: { - data: SessionTransfer.Import + data: Data location: Location.Ref }) => Effect.Effect } @@ -74,26 +73,8 @@ const layer = Layer.effect( if (input.data.info.parentID) yield* sessions.get(input.data.info.parentID) const project = yield* projects.resolve(input.location.directory) yield* upsertProject(db, project).pipe(Effect.orDie) - const settled = input.data.messages.filter(isSettled) - const models = settled.flatMap((message, index) => - message.type === "assistant" || message.type === "model-switched" ? [{ model: message.model, index }] : [], - ) - const messages = settled.map((message, index) => { - const encoded = encodeMessage( - message.type === "compaction" && message.status === "completed" - ? { - ...message, - model: - message.model ?? - models.findLast((entry) => entry.index < index)?.model ?? - models.find((entry) => entry.index > index)?.model ?? - input.data.info.model ?? - Model.Ref.parse("unknown/unknown"), - // An inferred model cannot authenticate provider state from an old export. - providerState: message.model ? message.providerState : undefined, - } - : message, - ) + const messages = input.data.messages.filter(isSettled).map((message, index) => { + const encoded = encodeMessage(message) const { id: _, type, ...data } = encoded return { id: message.id, @@ -179,7 +160,7 @@ export const node = makeGlobalNode({ deps: [App.node, Bus.node, Database.node, Project.node, Session.node], }) -function isSettled(message: SessionTransfer.Import["messages"][number]) { +function isSettled(message: SessionMessage.Info) { if (message.type === "assistant") return message.time.completed !== undefined if (message.type === "shell" || message.type === "compaction") return message.status !== "running" return true diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 2ba7ac52f344..3708a5dc6ca6 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -20,7 +20,6 @@ import workspaceMigration from "@opencode-ai/core/database/migration/20260808023 import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts" import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox" import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260819222447_session_viewed_state" -import compactionModelMigration from "@opencode-ai/core/database/migration/20260902000000_compaction_model" import { Global } from "@opencode-ai/util/global" const run = ( @@ -63,100 +62,6 @@ const parkedClient = (arrived: Deferred.Deferred, gate: Deferred.Deferred< ).pipe(Layer.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Layer.provide(Reactivity.layer)) describe("DatabaseMigration", () => { - for (const source of ["messages", "events"] as const) { - test.each([ - ["previous", "next", "selected", undefined, "previous"], - [undefined, "next", "selected", undefined, "next"], - [undefined, undefined, "selected", undefined, "selected"], - [undefined, undefined, undefined, undefined, "unknown"], - ["previous", "next", "selected", "recorded", "recorded"], - ])( - `backfills compaction models from ${source}: %s / %s / %s / %s`, - async (before, after, selected, recorded, expected) => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session_v2 (id TEXT PRIMARY KEY, model TEXT)`) - yield* db.run( - sql`CREATE TABLE session_message (id TEXT, session_id TEXT, seq INTEGER, type TEXT, data TEXT)`, - ) - yield* db.run(sql`CREATE TABLE event (id TEXT, aggregate_id TEXT, seq INTEGER, type TEXT, data TEXT)`) - const model = (id: string) => ({ - id, - providerID: id === "unknown" ? "unknown" : "provider", - variant: "variant", - }) - yield* db.run( - sql`INSERT INTO session_v2 VALUES ('ses_test', ${selected ? JSON.stringify(model(selected)) : null})`, - ) - for (const entry of [ - { id: "older", seq: 0 }, - { id: before, seq: 1 }, - { id: after, seq: 5 }, - ]) { - if (!entry.id || (entry.seq === 0 && !before)) continue - const data = JSON.stringify({ model: model(entry.id) }) - if (source === "messages") - yield* db.run( - sql`INSERT INTO session_message VALUES (${entry.id}, 'ses_test', ${entry.seq}, 'assistant', ${data})`, - ) - if (source === "events") - yield* db.run( - sql`INSERT INTO event VALUES (${entry.id}, 'ses_test', ${entry.seq}, 'session.step.started.1', ${data})`, - ) - } - // A different session must never supply the inferred model. - yield* db.run( - sql`INSERT INTO session_message VALUES ('other', 'ses_other', 2, 'assistant', '{"model":{"id":"other","providerID":"other"}}')`, - ) - const data = JSON.stringify({ - status: "completed", - summary: "Summary", - recent: "Recent", - ...(recorded ? { model: model(recorded), providerState: { opaque: "keep" } } : {}), - }) - yield* db.run(sql`INSERT INTO session_message VALUES ('checkpoint', 'ses_test', 3, 'compaction', ${data})`) - yield* db.run(sql`INSERT INTO event VALUES ('ended', 'ses_test', 4, 'session.compaction.ended.1', ${data})`) - yield* db.run( - sql`INSERT INTO session_message VALUES ('running', 'ses_test', 6, 'compaction', '{"status":"running"}')`, - ) - yield* db.run( - sql`INSERT INTO session_message VALUES ('failed', 'ses_test', 7, 'compaction', '{"status":"failed"}')`, - ) - - yield* DatabaseMigration.applyOnly(db, [compactionModelMigration]) - yield* DatabaseMigration.applyOnly(db, [compactionModelMigration]) - - for (const table of ["session_message", "event"]) { - const row = yield* db.get<{ - model: string - provider: string - variant: string | null - state: string | null - summary: string - }>(sql` - SELECT json_extract(data, '$.model.id') AS model, json_extract(data, '$.model.providerID') AS provider, - json_extract(data, '$.model.variant') AS variant, json_extract(data, '$.providerState.opaque') AS state, - json_extract(data, '$.summary') AS summary - FROM ${sql.identifier(table)} WHERE id = ${table === "event" ? "ended" : "checkpoint"} - `) - expect(row).toEqual({ - model: expected, - provider: expected === "unknown" ? "unknown" : "provider", - variant: expected === "unknown" ? null : "variant", - state: recorded ? "keep" : null, - summary: "Summary", - }) - } - expect( - yield* db.all(sql`SELECT data FROM session_message WHERE id IN ('running', 'failed') ORDER BY seq`), - ).toEqual([{ data: '{"status":"running"}' }, { data: '{"status":"failed"}' }]) - }), - ) - }, - ) - } - test("defaults missing workspace names while preserving legacy workspace data", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index f88e50629b41..df45f6f5d01a 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -263,7 +263,6 @@ it.effect("auto compaction estimates current content against the buffered prompt type: "compaction", status: "completed", reason: "auto", - model: resolved.ref, summary: "x".repeat(400_000), recent: "", time: { created: 0, completed: 0 }, diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 69ec4b04dfd6..0602a3a99c7d 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1312,10 +1312,6 @@ describe("SessionTransfer", () => { completedCompactionID, ]) expect(yield* Bus.latestSequence(db, sessionID)).toBe(4) - expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ - id: completedCompactionID, - model, - }) }), ) @@ -1356,46 +1352,6 @@ describe("SessionTransfer", () => { }), ) - for (const source of ["before", "after", "session", "unknown"] as const) { - it.effect(`backfills imported compactions from ${source} without inventing provider state`, () => - Effect.gen(function* () { - const sessions = yield* Session.Service - const transfer = yield* SessionTransfer.Service - const template = yield* sessions.create({ location }) - const model = Model.Ref.parse("provider/model#variant") - const checkpoint = { - id: SessionMessage.ID.create(), - type: "compaction" as const, - status: "completed" as const, - reason: "manual" as const, - summary: "Summary", - recent: "", - providerState: { unbound: "discard" }, - time: { created: DateTime.makeUnsafe(1) }, - } - const selected = SessionMessage.ModelSelected.make({ - id: SessionMessage.ID.create(), - type: "model-switched", - model, - time: { created: DateTime.makeUnsafe(1) }, - }) - const imported = yield* transfer.import({ - data: { - info: { ...template, id: Session.ID.create(), model: source === "session" ? model : undefined }, - messages: - source === "before" ? [selected, checkpoint] : source === "after" ? [checkpoint, selected] : [checkpoint], - }, - location, - }) - const result = (yield* transfer.export({ sessionID: imported.id })).messages.find( - (message) => message.id === checkpoint.id, - ) - expect(result).toMatchObject({ model: source === "unknown" ? Model.Ref.parse("unknown/unknown") : model }) - expect(result && "providerState" in result ? result.providerState : undefined).toBeUndefined() - }), - ) - } - it.effect("imports projected messages and reserves their aggregate sequence", () => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 289bad1c52f2..bd9decea5c27 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -252,13 +252,7 @@ describe("SessionInstructions", () => { // A completed compaction truncates model-visible history at its boundary, dropping // the synthetic that carried sub's instructions. yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" }) - yield* bus.publish(SessionEvent.Compaction.Ended, { - sessionID, - reason: "manual", - model: Model.Ref.parse("test/model"), - text: "summary", - recent: "", - }) + yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "" }) expect(yield* synthetics(sessionID)).toHaveLength(0) // The model no longer has the rules, so the next read under the subtree must diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 22dc7e9c5abe..0043dffdf4df 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -415,8 +415,6 @@ describe("SessionProjector", () => { yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", - model, - providerState: { responseId: "summary-response" }, text: "summary", recent: "recent context", }) @@ -457,8 +455,6 @@ describe("SessionProjector", () => { time: { completed: DateTime.makeUnsafe(0) }, }) expect(messages.find((message) => message.type === "compaction")).toMatchObject({ - model, - providerState: { responseId: "summary-response" }, summary: "summary", recent: "recent context", }) @@ -472,25 +468,6 @@ describe("SessionProjector", () => { }), ) - it.effect("replays a compaction completion with its model and provider state", () => - Effect.gen(function* () { - yield* seedSession() - const bus = yield* Bus.Service - const store = yield* SessionStore.Service - yield* bus.replay({ - id: Event.ID.create(), - created: 1, - aggregateID: sessionID, - seq: 0, - type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1), - data: { sessionID, reason: "manual", model, providerState: { opaque: "state" }, text: "summary", recent: "" }, - }) - expect(yield* store.context(sessionID)).toMatchObject([ - { type: "compaction", status: "completed", model, providerState: { opaque: "state" } }, - ]) - }), - ) - it.effect("rejects distinct creator events that reuse one projected message ID", () => Effect.gen(function* () { const db = yield* seedSession() diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 8f5e1c96225b..5f37a4d19aab 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -147,8 +147,6 @@ describe("toLLMMessages", () => { type: "compaction", status: "completed", reason: "auto", - model, - providerState: { responseId: "storage-only" }, summary: "Earlier work", recent: "Recent work", time: { created }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 5fdaf8c09fda..13a2e481c005 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2699,7 +2699,6 @@ describe("SessionRunnerLLM", () => { yield* s.bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", - model: Model.Ref.parse(`${s.currentModel.provider}/${s.currentModel.id}`), text: "summary", recent: "", }) diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index 0d9cc711ab85..b8ca1879f087 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -867,7 +867,6 @@ describe("V1Migration database workflow", () => { ('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21), ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '2', NULL, NULL, 12, 22); INSERT INTO session_message VALUES - ('msg_next_checkpoint', 'ses_next', 'compaction', 3, 11, 12, '{"status":"completed","reason":"manual","summary":"Summary","recent":"","time":{"created":11}}'), ('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next''s history","time":{"created":12}}'), ('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'), ('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}'); @@ -909,21 +908,13 @@ describe("V1Migration database workflow", () => { .where(eq(SessionTable.id, SessionSchema.ID.make("ses_next"))) .get(), ).toEqual({ directory: process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer" }) - expect( - yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next' AND type = 'user'`), - ).toEqual([ + expect(yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next'`)).toEqual([ { id: "msg_next", seq: 4, data: '{"text":"from next\'s history","time":{"created":12}}', }, ]) - expect( - yield* db.get(sql` - SELECT json_extract(data, '$.model') AS model, json_extract(data, '$.providerState') AS state - FROM session_message WHERE id = 'msg_next_checkpoint' - `), - ).toEqual({ model: '{"id":"model","providerID":"provider"}', state: null }) expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_next'`)).toEqual({ seq: 4, owner_id: null, diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index c7dc15815d48..442d9eafadc7 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -188,7 +188,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.post("session.import", "/api/session/import", { payload: Schema.Struct({ - ...SessionTransfer.Import.fields, + ...SessionTransfer.Data.fields, location: Location.Ref.pipe(Schema.optional), }), success: Schema.Struct({ data: Session.Info }), diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 827af7cb4af8..c3f2ae30dd6a 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -250,8 +250,7 @@ export const CompactionCompleted = Schema.Struct({ ...CompactionBase, status: Schema.tag("completed"), reason: Schema.Literals(["auto", "manual"]), - /** Producing model. Historical checkpoints may contain a best-effort backfill. */ - model: Model.Ref, + model: Model.Ref.pipe(optional), providerState: ProviderState.pipe(optional), summary: Schema.String, recent: Schema.String, diff --git a/packages/schema/src/session-transfer.ts b/packages/schema/src/session-transfer.ts index f06c23f8702c..d06e6f634420 100644 --- a/packages/schema/src/session-transfer.ts +++ b/packages/schema/src/session-transfer.ts @@ -3,26 +3,9 @@ export * as SessionTransfer from "./session-transfer.js" import { Schema } from "effect" import { Session } from "./session.js" import { SessionMessage } from "./session-message.js" -import { optional } from "./schema.js" export interface Data extends Schema.Schema.Type {} export const Data = Schema.Struct({ info: Session.Info, messages: Schema.Array(SessionMessage.Info), }).annotate({ identifier: "SessionTransfer.Data" }) - -// Older exports omitted the compaction model. Only the import boundary accepts -// this shape; Core fills the model before storing it under the current contract. -export interface Import extends Schema.Schema.Type {} -export const Import = Schema.Struct({ - info: Session.Info, - messages: Schema.Array( - Schema.Union([ - ...SessionMessage.Info.members, - Schema.Struct({ - ...SessionMessage.CompactionCompleted.fields, - model: SessionMessage.CompactionCompleted.fields.model.pipe(optional), - }), - ]), - ), -}).annotate({ identifier: "SessionTransfer.Import" }) diff --git a/packages/schema/test/compaction-state.test.ts b/packages/schema/test/compaction-state.test.ts index 80f8490e60ee..190e41214f33 100644 --- a/packages/schema/test/compaction-state.test.ts +++ b/packages/schema/test/compaction-state.test.ts @@ -2,7 +2,6 @@ import { expect, test } from "bun:test" import { Schema } from "effect" import { SessionMessage } from "../src/session-message.js" import { SessionEvent } from "../src/session-event.js" -import { SessionTransfer } from "../src/session-transfer.js" import { Model } from "../src/model.js" const checkpoint = { @@ -16,34 +15,17 @@ const checkpoint = { } as const const model = Model.Ref.parse("provider/model#variant") -test("completed compactions require a model and preserve optional opaque provider state", () => { +test("completed compactions preserve optional model and provider state without changing old records", () => { const decode = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted) const encode = Schema.encodeSync(SessionMessage.CompactionCompleted) - expect(() => decode(checkpoint)).toThrow() - expect(encode({ ...decode({ ...checkpoint, model }), providerState: undefined })).toEqual({ ...checkpoint, model }) + expect(encode({ ...decode(checkpoint), model: undefined, providerState: undefined })).toEqual(checkpoint) const providerState = { responseId: "response", nested: { opaque: [1, "value"] } } expect(encode(decode({ ...checkpoint, model, providerState }))).toEqual({ ...checkpoint, model, providerState }) }) -test("compaction completion events require the producing model", () => { +test("compaction completion events remain compatible without model or provider state", () => { const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data) const data = { sessionID: "ses_checkpoint", reason: "manual", text: "Summary", recent: "" } as const - expect(() => decode(data)).toThrow() + expect(Schema.encodeSync(SessionEvent.Compaction.Ended.data)(decode(data))).toEqual(data) expect(Schema.encodeSync(SessionEvent.Compaction.Ended.data)(decode({ ...data, model }))).toEqual({ ...data, model }) }) - -test("only the import boundary accepts a historical checkpoint without a model", () => { - const data = { - info: { - id: "ses_checkpoint", - projectID: "global", - location: { directory: "/project" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - }, - messages: [checkpoint], - } - expect(() => Schema.decodeUnknownSync(SessionTransfer.Import)(data)).not.toThrow() - expect(() => Schema.decodeUnknownSync(SessionTransfer.Data)(data)).toThrow() -}) diff --git a/packages/session-ui/src/storybook/current-session-fixtures.ts b/packages/session-ui/src/storybook/current-session-fixtures.ts index 9cfecabd1046..f1fe93a01cc5 100644 --- a/packages/session-ui/src/storybook/current-session-fixtures.ts +++ b/packages/session-ui/src/storybook/current-session-fixtures.ts @@ -996,7 +996,6 @@ export const compactionDocument = document([ type: "compaction", status: "completed", reason: "auto", - model: STORY_MODEL, summary: "The Session timeline now consumes current nested assistant content.", recent: "Add deterministic stories and verify Storybook.", time: { created: STORY_TIME + 63_000 }, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d80c84c72fe9..7776514ea54b 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1514,13 +1514,7 @@ test("tracks session status from active sessions and execution events", async () created: 3, type: "session.compaction.ended", durable: durable("session-manual", 4), - data: { - sessionID: "session-manual", - reason: "manual", - model: { id: "model", providerID: "provider" }, - text: "Streamed summary", - recent: "recent", - }, + data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" }, }) await wait(() => { const message = data.session.message.get("session-manual", "message-compaction") @@ -1563,13 +1557,7 @@ 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", - model: { id: "model", providerID: "provider" }, - text: "Live summary", - recent: "recent", - }, + data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" }, }) await wait(() => { const message = data.session.message.get("session-live", "msg_compaction_started") @@ -1708,13 +1696,7 @@ test("restores queued compaction from durable pending input", async () => { created: 3, type: "session.compaction.ended", durable: durable(sessionID, 7), - data: { - sessionID, - reason: "manual", - model: { id: "model", providerID: "provider" }, - text: "Summary", - recent: "", - }, + data: { sessionID, reason: "manual", text: "Summary", recent: "" }, }) expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"]) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index df76107853a1..2f29fb494aaf 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -211,7 +211,6 @@ test("resets the cross-turn cache baseline after compaction", () => { id: "compaction-1", status: "completed", reason: "auto", - model: first.model, summary: "Compacted context", recent: "", time: { created: 2 }, diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 97292bf30737..a77624f0324b 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -120,7 +120,7 @@ function compaction(status: "running" | "completed", summary: string): SessionMe time: { created: 1 }, } if (status === "running") return { ...message, status } - return { ...message, status, model: { id: "model", providerID: "provider" } } + return { ...message, status } } function form(id: string, sessionID: string, title = id): FormInfo { @@ -536,13 +536,7 @@ describe("V2 mini transport", () => { created: 3, type: "session.compaction.ended", durable: durable("ses_1", 3), - data: { - sessionID: "ses_1", - reason: "auto", - model: { id: "model", providerID: "provider" }, - text: "Transport", - recent: "", - }, + data: { sessionID: "ses_1", reason: "auto", text: "Transport", recent: "" }, }) while (!ui.commits.some((commit) => commit.phase === "final")) await Bun.sleep(0) diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index a70b7a37aa18..b3e32aa6340e 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -49,7 +49,6 @@ describe("util.session", () => { type: "compaction", status: "completed", reason: "manual", - model: { id: "model", providerID: "provider" }, summary: "Current state", recent: "", time: { created: 0 }, From b402049bee77f211597fd58c30ddbfc98b66f778 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 3 Sep 2026 11:30:45 -0500 Subject: [PATCH 3/3] test(core): minimize compaction state coverage --- packages/client/test/solid-compaction.test.ts | 33 ++------- packages/core/test/session-create.test.ts | 41 ++--------- packages/core/test/session-runner.test.ts | 68 +++++-------------- packages/schema/test/compaction-state.test.ts | 31 --------- 4 files changed, 27 insertions(+), 146 deletions(-) delete mode 100644 packages/schema/test/compaction-state.test.ts diff --git a/packages/client/test/solid-compaction.test.ts b/packages/client/test/solid-compaction.test.ts index 45a74d96e898..f33596cc5641 100644 --- a/packages/client/test/solid-compaction.test.ts +++ b/packages/client/test/solid-compaction.test.ts @@ -3,20 +3,6 @@ import { createRoot } from "solid-js" import { createData, type CreateDataInput } from "../src/solid" import { OpenCode, type OpenCodeEvent, type SessionInboxCompaction, type SessionInboxInfo } from "../src/promise" -test("projects model and provider state when the compaction start was not observed", () => { - using fixture = setup() - const model = { providerID: "demo", id: "model", variant: "variant" } - const providerState = { responseId: "summary-response" } - fixture.emit({ - ...event, - type: "session.compaction.ended", - data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "" }, - }) - expect(fixture.data.session.message.list(sessionID)).toMatchObject([ - { type: "compaction", status: "completed", summary: "Summary", model, providerState }, - ]) -}) - test("admits compaction before model setup and serializes the following prompt", async () => { using fixture = setup() const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } }) @@ -112,26 +98,15 @@ test.each(["started", "cancelled", "failed"])( expect(fixture.data.session.pending.list(sessionID)).toEqual([]) if (kind === "started") { expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }]) + const model = { providerID: "demo", id: "model" } + const providerState = { responseId: "summary-response" } fixture.emit({ ...event, type: "session.compaction.ended", - data: { - sessionID, - reason: "manual", - model: { providerID: "demo", id: "model" }, - providerState: { responseId: "summary-response" }, - text: "Summary", - recent: "Recent", - }, + data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" }, }) expect(fixture.data.session.message.list(sessionID)).toMatchObject([ - { - type: "compaction", - status: "completed", - summary: "Summary", - model: { providerID: "demo", id: "model" }, - providerState: { responseId: "summary-response" }, - }, + { type: "compaction", status: "completed", summary: "Summary", model, providerState }, ]) } }, diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 87b6b8f1ed73..b30334e9d196 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1243,6 +1243,7 @@ describe("SessionTransfer", () => { const runningCompactionID = SessionMessage.ID.create() const completedCompactionID = SessionMessage.ID.create() const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }) + const providerState = { responseId: "summary-response" } yield* transfer.import({ data: { @@ -1297,6 +1298,8 @@ describe("SessionTransfer", () => { type: "compaction", status: "completed", reason: "manual", + model, + providerState, summary: "summary", recent: "recent", time: { created: DateTime.makeUnsafe(9) }, @@ -1313,42 +1316,10 @@ describe("SessionTransfer", () => { completedCompactionID, ]) expect(yield* Bus.latestSequence(db, sessionID)).toBe(4) - }), - ) - - it.effect("round-trips compaction model and provider state and sanitizes opaque state", () => - Effect.gen(function* () { - const sessions = yield* Session.Service - const transfer = yield* SessionTransfer.Service - const bus = yield* Bus.Service - const source = yield* sessions.create({ location }) - const model = Model.Ref.parse("provider/model#variant") - const providerState = { responseId: "private-response", nested: { secret: "opaque" } } - yield* bus.publish(SessionEvent.Compaction.Ended, { - sessionID: source.id, - reason: "manual", - model, - providerState, - text: "Summary", - recent: "", - }) - const data = yield* transfer.export({ sessionID: source.id }) - const imported = yield* transfer.import({ - data: { - ...data, - info: { ...data.info, id: Session.ID.create() }, - messages: data.messages.map((message) => ({ ...message, id: SessionMessage.ID.create() })), - }, - location, - }) - expect((yield* sessions.messages({ sessionID: imported.id, order: "asc" }))[0]).toMatchObject({ - model, - providerState, - }) - const sanitized = yield* transfer.export({ sessionID: source.id, sanitize: true }) - expect(sanitized.messages[0]).toMatchObject({ + expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState }) + expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({ model, - providerState: { redacted: `compaction-provider-state:${data.messages[0].id}` }, + providerState: { redacted: `compaction-provider-state:${completedCompactionID}` }, }) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 13a2e481c005..f8bc581d617a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2141,7 +2141,13 @@ describe("SessionRunnerLLM", () => { yield* s.llm.push( TestLLM.tool("call-prefix", "echo", { text: "x".repeat(4_000) }), TestLLM.textWithUsage("Earlier answer", "prefix-answer", 185_000), - TestLLM.text("## Objective\n- Checkpoint summary", "prefix-summary"), + TestLLM.complete( + { + reason: { normalized: "stop" }, + providerMetadata: { [s.currentModel.provider]: { responseId: "summary" } }, + }, + LLMEvent.textDelta({ id: "prefix-summary", text: "## Objective\n- Checkpoint summary" }), + ), ) yield* s.runPrompt("Review these changes") if (reason === "manual") { @@ -2179,6 +2185,7 @@ describe("SessionRunnerLLM", () => { expect(s.executions).toEqual(["x".repeat(4_000)]) expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({ model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant }, + providerState: { responseId: "summary" }, }) // Compare wire content without the cache breakpoints that move to the new final message. @@ -2212,8 +2219,14 @@ describe("SessionRunnerLLM", () => { ) : TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary") yield* s.llm.push( - invalid, - summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid, + invalid.map((event) => + LLMEvent.is.stepFinish(event) + ? { ...event, providerMetadata: { openai: { responseId: "rejected-summary-state" } } } + : event, + ), + summary + ? [LLMEvent.textDelta({ id: "summary-recovered", text: "### Active\n- Recovered summary" })] + : invalid, ) const compact = yield* s.session.compact({ sessionID }) yield* s.resume @@ -2223,6 +2236,7 @@ describe("SessionRunnerLLM", () => { expect(userTexts(s.requests[1]).at(-1)).toContain("did not fill in the required summary template") expect(s.requests.every((request) => request.toolChoice === undefined)).toBe(true) expect(s.executions).toEqual([]) + expect(JSON.stringify(yield* s.messages)).not.toContain("rejected-summary-state") expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject( summary ? { status: "completed", summary: "### Active\n- Recovered summary" } @@ -2246,54 +2260,6 @@ describe("SessionRunnerLLM", () => { } } - for (const state of [true, false]) { - scenario(`compaction retains only accepted response state (state=${state})`, function* (s) { - // Provider aliases use the route's metadata namespace, not the catalog provider ID. - s.currentModel = LanguageModel.make({ - id: "gpt-5", - provider: "alias", - route: OpenAIResponses.route, - }) - yield* s.llm.push(TestLLM.text("Earlier answer", "state-history")) - yield* s.runPrompt("Earlier question") - const rejected = { responseId: "rejected-response" } - const accepted = { responseId: "accepted-response", opaque: { value: "provider-data" } } - const key = OpenAIResponses.route.providerMetadataKey ?? s.currentModel.provider - yield* s.llm.push( - TestLLM.complete( - { reason: { normalized: "stop" }, providerMetadata: { [key]: rejected } }, - LLMEvent.textDelta({ id: "invalid", text: "Not a summary" }), - ), - TestLLM.complete( - { - reason: { normalized: "stop" }, - providerMetadata: state ? { [key]: accepted, unrelated: { ignored: true } } : undefined, - }, - LLMEvent.textDelta({ id: "summary", text: "## Objective\n- Accepted summary" }), - ), - ) - const compact = yield* s.session.compact({ sessionID }) - yield* s.resume - const checkpoint = (yield* s.messages).find((message) => message.id === compact.id) - expect(checkpoint).toMatchObject({ - status: "completed", - model: { providerID: "alias", id: "gpt-5" }, - summary: "## Objective\n- Accepted summary", - }) - if (checkpoint?.type !== "compaction" || checkpoint.status !== "completed") - return yield* Effect.die("Missing completed checkpoint") - expect(checkpoint.providerState).toEqual(state ? accepted : undefined) - const event = yield* s.db - .select({ data: EventTable.data }) - .from(EventTable) - .where(sql`${EventTable.type} = 'session.compaction.ended.1'`) - .get() - .pipe(Effect.orDie) - expect(event?.data.model).toEqual(checkpoint.model) - expect(event?.data.providerState).toEqual(state ? accepted : undefined) - }) - } - scenario("preserves typed provider failures from manual compaction", function* (s) { yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-failure-history")) yield* s.runPrompt("Earlier question") diff --git a/packages/schema/test/compaction-state.test.ts b/packages/schema/test/compaction-state.test.ts deleted file mode 100644 index 190e41214f33..000000000000 --- a/packages/schema/test/compaction-state.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, test } from "bun:test" -import { Schema } from "effect" -import { SessionMessage } from "../src/session-message.js" -import { SessionEvent } from "../src/session-event.js" -import { Model } from "../src/model.js" - -const checkpoint = { - id: "msg_checkpoint", - type: "compaction", - status: "completed", - reason: "manual", - summary: "Summary", - recent: "", - time: { created: 1 }, -} as const -const model = Model.Ref.parse("provider/model#variant") - -test("completed compactions preserve optional model and provider state without changing old records", () => { - const decode = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted) - const encode = Schema.encodeSync(SessionMessage.CompactionCompleted) - expect(encode({ ...decode(checkpoint), model: undefined, providerState: undefined })).toEqual(checkpoint) - const providerState = { responseId: "response", nested: { opaque: [1, "value"] } } - expect(encode(decode({ ...checkpoint, model, providerState }))).toEqual({ ...checkpoint, model, providerState }) -}) - -test("compaction completion events remain compatible without model or provider state", () => { - const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data) - const data = { sessionID: "ses_checkpoint", reason: "manual", text: "Summary", recent: "" } as const - expect(Schema.encodeSync(SessionEvent.Compaction.Ended.data)(decode(data))).toEqual(data) - expect(Schema.encodeSync(SessionEvent.Compaction.Ended.data)(decode({ ...data, model }))).toEqual({ ...data, model }) -})