From 85962e49b7743cdfde696e18fa016c1b3cc3dc50 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 20 Sep 2026 21:41:03 -0500 Subject: [PATCH] refactor(schema): rename message provider blobs to native --- .../client/src/promise/generated/types.ts | 36 +++++++++---------- packages/client/src/solid/data.ts | 14 ++++---- packages/client/test/solid-compaction.test.ts | 2 +- packages/core/src/database/migration.gen.ts | 2 ++ .../20260920120000_message_native.ts | 33 +++++++++++++++++ packages/core/src/session/history.ts | 2 +- packages/core/src/session/inbox.ts | 2 +- packages/core/src/session/message-updater.ts | 16 ++++----- packages/core/src/session/projector.ts | 2 +- packages/core/src/session/revert.ts | 4 ++- .../core/src/session/runner/to-llm-message.ts | 4 +-- packages/core/src/session/transfer.ts | 6 ++-- packages/core/test/session-create.test.ts | 8 ++--- .../test/session-native-compaction.test.ts | 2 +- packages/core/test/session-projector.test.ts | 4 +-- .../core/test/session-runner-message.test.ts | 18 +++++----- packages/core/test/session-runner.test.ts | 6 ++-- packages/schema/src/session-event.ts | 2 +- packages/schema/src/session-message.ts | 32 ++++++++++++++--- packages/schema/test/contract-hygiene.test.ts | 4 +-- packages/schema/test/session-terminal.test.ts | 14 ++++++-- 21 files changed, 141 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/database/migration/20260920120000_message_native.ts diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 9cacfc6bc36..16ec97da227 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -509,12 +509,12 @@ export type PromptAgentAttachment = { name: string; mention?: PromptMention } export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention } -export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState } +export type SessionMessageAssistantText = { type: "text"; text: string; native?: SessionMessageProviderState } export type SessionMessageAssistantReasoning = { type: "reasoning" text: string - state?: SessionMessageProviderState + native?: SessionMessageProviderState time?: { created: number; completed?: number } } @@ -1354,12 +1354,12 @@ export type SessionToolCalled = { } } -export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 } +export type SessionMessageAssistantText1 = { type: "text"; text: string; native?: SessionMessageProviderState1 } export type SessionMessageAssistantReasoning1 = { type: "reasoning" text: string - state?: SessionMessageProviderState1 + native?: SessionMessageProviderState1 time?: { created: number; completed?: number } } @@ -1762,7 +1762,7 @@ export type SessionMessageCompactionCompleted = { status: "completed" reason: "auto" | "manual" model?: ModelRef - providerState?: SessionMessageProviderState + native?: SessionMessageProviderState summary: string recent: string providerContext?: SessionProviderContext @@ -2228,7 +2228,7 @@ export type SessionMessageAssistant = { snapshot?: { start?: string; end?: string; files?: Array } finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" rawFinish?: string - providerState?: SessionMessageProviderState + native?: SessionMessageProviderState cost?: MoneyUSD tokens?: TokenUsageInfo error?: SessionStructuredError @@ -3102,11 +3102,11 @@ export type SessionImportInput = { readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< - | { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } } + | { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } } | { readonly type: "reasoning" readonly text: string - readonly state?: { readonly [x: string]: JsonValue } + readonly native?: { readonly [x: string]: JsonValue } readonly time?: { readonly created: number; readonly completed?: number } } | { @@ -3180,7 +3180,7 @@ export type SessionImportInput = { readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly rawFinish?: string - readonly providerState?: { readonly [x: string]: JsonValue } + readonly native?: { readonly [x: string]: JsonValue } readonly cost?: number readonly tokens?: { readonly input: number @@ -3214,7 +3214,7 @@ export type SessionImportInput = { 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 native?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string readonly providerContext?: { @@ -3419,11 +3419,11 @@ export type SessionImportInput = { readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< - | { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } } + | { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } } | { readonly type: "reasoning" readonly text: string - readonly state?: { readonly [x: string]: JsonValue } + readonly native?: { readonly [x: string]: JsonValue } readonly time?: { readonly created: number; readonly completed?: number } } | { @@ -3497,7 +3497,7 @@ export type SessionImportInput = { readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly rawFinish?: string - readonly providerState?: { readonly [x: string]: JsonValue } + readonly native?: { readonly [x: string]: JsonValue } readonly cost?: number readonly tokens?: { readonly input: number @@ -3531,7 +3531,7 @@ export type SessionImportInput = { 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 native?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string readonly providerContext?: { @@ -3736,11 +3736,11 @@ export type SessionImportInput = { readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< - | { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } } + | { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } } | { readonly type: "reasoning" readonly text: string - readonly state?: { readonly [x: string]: JsonValue } + readonly native?: { readonly [x: string]: JsonValue } readonly time?: { readonly created: number; readonly completed?: number } } | { @@ -3814,7 +3814,7 @@ export type SessionImportInput = { readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly rawFinish?: string - readonly providerState?: { readonly [x: string]: JsonValue } + readonly native?: { readonly [x: string]: JsonValue } readonly cost?: number readonly tokens?: { readonly input: number @@ -3848,7 +3848,7 @@ export type SessionImportInput = { 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 native?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string readonly providerContext?: { diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 4716d2599ff..b99c562956e 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -846,7 +846,7 @@ export function createData(config: CreateDataInput) { existing.error = undefined existing.finish = undefined existing.rawFinish = undefined - existing.providerState = undefined + existing.native = undefined existing.time.created = event.data.started existing.time.streamed = undefined existing.time.completed = undefined @@ -880,7 +880,7 @@ export function createData(config: CreateDataInput) { assistant.time.completed = event.created assistant.finish = event.data.finish assistant.rawFinish = event.data.rawFinish - assistant.providerState = event.data.providerState + assistant.native = event.data.providerState assistant.cost = event.data.cost assistant.tokens = event.data.tokens if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot } @@ -892,7 +892,7 @@ export function createData(config: CreateDataInput) { assistant.time.completed = event.created assistant.finish = event.data.finish ?? "error" assistant.rawFinish = event.data.rawFinish - assistant.providerState = event.data.providerState + assistant.native = event.data.providerState assistant.error = event.data.error assistant.retry = undefined if (event.data.cost !== undefined && event.data.tokens !== undefined) { @@ -984,7 +984,7 @@ export function createData(config: CreateDataInput) { assistant.content.push({ type: "reasoning", text: "", - state: event.data.state, + native: event.data.state, time: { created: event.created }, }) }) @@ -998,7 +998,7 @@ export function createData(config: CreateDataInput) { message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => { reasoning.text = event.data.text reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created } - if (event.data.state !== undefined) reasoning.state = event.data.state + if (event.data.state !== undefined) reasoning.native = event.data.state }) return case "session.retry.scheduled": @@ -1105,7 +1105,7 @@ export function createData(config: CreateDataInput) { status: "completed", reason: event.data.reason, model: event.data.model, - providerState: event.data.providerState, + native: event.data.providerState, providerContext: event.data.providerContext, summary: event.data.text, recent: event.data.recent, @@ -1120,7 +1120,7 @@ export function createData(config: CreateDataInput) { status: "completed", reason: event.data.reason, model: event.data.model, - providerState: event.data.providerState, + native: event.data.providerState, providerContext: event.data.providerContext, summary: event.data.text, recent: event.data.recent, diff --git a/packages/client/test/solid-compaction.test.ts b/packages/client/test/solid-compaction.test.ts index 76f9d4bb207..21b7f61a183 100644 --- a/packages/client/test/solid-compaction.test.ts +++ b/packages/client/test/solid-compaction.test.ts @@ -135,7 +135,7 @@ test.each(["started", "cancelled", "failed"])( status: "completed", summary: "Summary", model, - providerState, + native: providerState, providerContext, cost: 0.01, tokens, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 0d6a015dc27..4bc895a750f 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -46,6 +46,7 @@ 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/20260910120000_clear_v1_session_permission.js" +import m47 from "./migration/20260920120000_message_native.js" export const migrations = [ m00, @@ -95,4 +96,5 @@ export const migrations = [ m44, m45, m46, + m47, ] satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260920120000_message_native.ts b/packages/core/src/database/migration/20260920120000_message_native.ts new file mode 100644 index 00000000000..fe986a9aa72 --- /dev/null +++ b/packages/core/src/database/migration/20260920120000_message_native.ts @@ -0,0 +1,33 @@ +import { Effect, Predicate, Schema } from "effect" +import { SessionMessage } from "../../session/message.js" +import type { DatabaseMigration } from "../migration.js" + +const migration: DatabaseMigration.Migration = { + id: "20260920120000_message_native", + up(tx) { + return Effect.gen(function* () { + const rows = yield* tx.all<{ id: string; type: string; data: string }>( + `SELECT id, type, data FROM session_message WHERE type IN ('assistant', 'compaction')`, + ) + yield* Effect.forEach(rows, (row) => + Effect.gen(function* () { + const stored = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(row.data) + if (!Predicate.isObject(stored)) return + const next = SessionMessage.persisted({ ...stored, type: row.type }) + if (!Predicate.isObject(next)) return + const data = Object.fromEntries(Object.entries(next).filter(([key]) => key !== "type")) + if (JSON.stringify(data) === JSON.stringify(stored)) return + yield* tx.run( + `UPDATE session_message SET data = '${quote(JSON.stringify(data))}' WHERE id = '${quote(row.id)}'`, + ) + }), + ) + }) + }, +} + +function quote(value: string) { + return value.replaceAll("'", "''") +} + +export default migration diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index aeb7cfc9f81..0a3035cce8a 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -60,7 +60,7 @@ export const latestCompaction = Effect.fnUntraced(function* ( }) export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) => - decode({ ...row.data, id: row.id, type: row.type }).pipe( + decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe( Effect.tap((message) => SessionProviderContext.isCheckpoint(message) ? SessionProviderContext.validate(message.providerContext) diff --git a/packages/core/src/session/inbox.ts b/packages/core/src/session/inbox.ts index 3688575e2e9..be7ee8dd01e 100644 --- a/packages/core/src/session/inbox.ts +++ b/packages/core/src/session/inbox.ts @@ -125,7 +125,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi if (row === undefined) return undefined if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic")) return yield* new LifecycleConflict({ id }) - const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) + const message = decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })) const base = { id, sessionID, time: { created: message.time.created }, delivery } if (message.type === "user") return User.make({ diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index dfe836f30d8..789f03b5e86 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -222,7 +222,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { draft.error = undefined draft.finish = undefined draft.rawFinish = undefined - draft.providerState = undefined + draft.native = undefined draft.time.created = DateTime.makeUnsafe(event.data.started) draft.time.streamed = undefined draft.time.completed = undefined @@ -263,7 +263,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { draft.time.completed = created draft.finish = event.data.finish draft.rawFinish = event.data.rawFinish - draft.providerState = castDraft(event.data.providerState) + draft.native = castDraft(event.data.providerState) draft.cost = event.data.cost draft.tokens = event.data.tokens projectTerminalSnapshot(draft, event) @@ -274,7 +274,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { draft.time.completed = created draft.finish = event.data.finish ?? "error" draft.rawFinish = event.data.rawFinish - draft.providerState = castDraft(event.data.providerState) + draft.native = castDraft(event.data.providerState) draft.error = castDraft(event.data.error) draft.retry = undefined if (event.data.cost !== undefined && event.data.tokens !== undefined) { @@ -294,7 +294,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { const match = latestText(draft) if (match) { match.text = event.data.text - match.state = castDraft(event.data.state) + match.native = castDraft(event.data.state) } }) }, @@ -382,7 +382,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "", - state: event.data.state, + native: event.data.state, time: { created }, }), ), @@ -395,7 +395,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { if (match) { match.text = event.data.text match.time = { created: match.time?.created ?? created, completed: created } - if (event.data.state !== undefined) match.state = event.data.state + if (event.data.state !== undefined) match.native = event.data.state } }) }, @@ -431,7 +431,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { metadata: event.metadata ? { ...current.metadata, ...event.metadata } : current.metadata, reason: event.data.reason, model: event.data.model, - providerState: event.data.providerState, + native: event.data.providerState, summary: event.data.text, providerContext: event.data.providerContext, recent: event.data.recent, @@ -448,7 +448,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { metadata: event.metadata, reason: event.data.reason, model: event.data.model, - providerState: event.data.providerState, + native: event.data.providerState, summary: event.data.text, providerContext: event.data.providerContext, recent: event.data.recent, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 943072aac11..dcc54b9f1d8 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -228,7 +228,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( function run(db: DatabaseService, event: MessageEvent) { return Effect.gen(function* () { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => - decodeMessage({ ...row.data, id: row.id, type: row.type }) + decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })) const updateMessage = (message: SessionMessage.Info) => { const encoded = encodeMessage(message) const { id, type, ...data } = encoded diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index aaf8405ef57..87ecddce5d9 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -107,7 +107,9 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface[" const decode = Schema.decodeUnknownEffect(SessionMessage.Info) const files = new Map() for (const row of rows) { - const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie) + const message = yield* decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe( + Effect.orDie, + ) if (message.type !== "assistant" || !message.snapshot?.start) continue for (const file of message.snapshot.files ?? []) if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start)) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 313724fa9a4..b4d266af536 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -162,7 +162,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider text: item.text, // Text can carry provider-bound state (e.g. Gemini thought signatures), // which is only replayable against the model that produced it. - providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined, + providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.native) : undefined, }, ] // Let the destination adapter handle readable reasoning after a model/provider switch. @@ -172,7 +172,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider { type: "reasoning", text: item.text, - providerMetadata: providerMetadata(providerMetadataKey, item.state), + providerMetadata: providerMetadata(providerMetadataKey, item.native), }, ] : item.text.length > 0 diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index ba7c296fd50..32a7e482e4d 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -269,13 +269,13 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info { return { ...content, text: redact("text", message.id, content.text), - state: content.state ? { redacted: `text-state:${message.id}` } : undefined, + native: content.native ? { redacted: `text-native:${message.id}` } : undefined, } if (content.type === "reasoning") return { ...content, text: redact("reasoning", message.id, content.text), - state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined, + native: content.native ? { redacted: `reasoning-native:${message.id}` } : undefined, } return { ...content, @@ -299,7 +299,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info { 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) } + ? { native: metadata("compaction-native", message.id, message.native) } : {}), } } diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 5bda7ff35e1..c285b593d0a 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1272,7 +1272,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" } + const native = { responseId: "summary-response" } yield* transfer.import({ data: { @@ -1328,7 +1328,7 @@ describe("SessionTransfer", () => { status: "completed", reason: "manual", model, - providerState, + native, summary: "summary", recent: "recent", time: { created: DateTime.makeUnsafe(9) }, @@ -1345,10 +1345,10 @@ describe("SessionTransfer", () => { completedCompactionID, ]) expect(yield* Bus.latestSequence(db, sessionID)).toBe(4) - expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState }) + expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, native }) expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({ model, - providerState: { redacted: `compaction-provider-state:${completedCompactionID}` }, + native: { redacted: `compaction-native:${completedCompactionID}` }, }) }), ) diff --git a/packages/core/test/session-native-compaction.test.ts b/packages/core/test/session-native-compaction.test.ts index 502f88e7ce7..e8c29b137d6 100644 --- a/packages/core/test/session-native-compaction.test.ts +++ b/packages/core/test/session-native-compaction.test.ts @@ -426,7 +426,7 @@ it.live("compaction hooks supply the summary instead of provider compaction", () status: "completed", summary: "## Objective\n- hooked summary", recent: "", - providerState: { responseId: "plugin" }, + native: { responseId: "plugin" }, metadata: { plugin: "custom" }, tokens: { input: 10, output: 5 }, }) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index ebb76a2fff7..efa0a28e4a0 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -690,7 +690,7 @@ describe("SessionProjector", () => { type: "assistant", finish: "stop", rawFinish: "stop_sequence", - providerState: { response: "ended" }, + native: { response: "ended" }, cost: Money.USD.make(1), tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } }, snapshot: { end: "snap_ended", files: ["src/ended.ts"] }, @@ -700,7 +700,7 @@ describe("SessionProjector", () => { type: "assistant", finish: "content-filter", rawFinish: "blocked", - providerState: { response: "failed" }, + native: { response: "failed" }, error: { type: "provider.invalid-request", message: "Failed" }, snapshot: { end: "snap_failed", files: ["src/failed.ts"] }, time: { completed: created }, diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 907bbf978c5..c8e10bc37af 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -72,7 +72,7 @@ describe("toLLMMessages", () => { SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "", - state: { signature: "sig_1" }, + native: { signature: "sig_1" }, }), ]), ], @@ -711,7 +711,7 @@ Recent work SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "Think", - state: { signature: "sig_1" }, + native: { signature: "sig_1" }, }), SessionMessage.AssistantTool.make({ type: "tool", @@ -860,7 +860,7 @@ Recent work SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "Think", - state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, + native: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, }), ], time: { created, completed: created }, @@ -891,7 +891,7 @@ Recent work SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "Think", - state: { signature: "signed" }, + native: { signature: "signed" }, }), ], time: { created, completed: created }, @@ -918,7 +918,7 @@ Recent work SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "Partial thought", - state: { itemId: "rs_failed", reasoningEncryptedContent: null }, + native: { itemId: "rs_failed", reasoningEncryptedContent: null }, }), SessionMessage.AssistantTool.make({ type: "tool", @@ -1016,7 +1016,7 @@ Recent work SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "Visible thought", - state: { signature: "sig_old" }, + native: { signature: "sig_old" }, }), SessionMessage.AssistantTool.make({ type: "tool", @@ -1110,7 +1110,7 @@ Recent work SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "Visible thought", - state: { reasoningEncryptedContent: "encrypted" }, + native: { reasoningEncryptedContent: "encrypted" }, }), ], time: { created, completed: created }, @@ -1140,7 +1140,7 @@ Recent work SessionMessage.AssistantText.make({ type: "text", text: "Checking.", - state: { phase: "commentary" }, + native: { phase: "commentary" }, }), ], error: { type: "provider.unknown", message: "Interrupted after commentary" }, @@ -1171,7 +1171,7 @@ Recent work SessionMessage.AssistantText.make({ type: "text", text: "Checking.", - state: { phase: "commentary" }, + native: { phase: "commentary" }, }), ], time: { created, completed: created }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 66f1af14af0..3940915d547 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2558,7 +2558,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" }, + native: { responseId: "summary" }, }) // Compare wire content without the cache breakpoints that move to the new final message. @@ -4965,7 +4965,7 @@ describe("SessionRunnerLLM", () => { type: "assistant", finish: "stop", rawFinish: "end_turn", - providerState: { responseId: "response-1", serviceTier: "priority" }, + native: { responseId: "response-1", serviceTier: "priority" }, content: [Expected.text("Complete")], }, ]) @@ -4996,7 +4996,7 @@ describe("SessionRunnerLLM", () => { type: "assistant", finish: "content-filter", rawFinish: "SAFETY", - providerState: { + native: { responseId: "response-blocked", refusal: { category: "safety", explanation: "Prompt blocked" }, }, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index bb8a56e454c..9dd84b1778b 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -601,7 +601,7 @@ export namespace Compaction { ...Base, reason: Started.data.fields.reason, model: SessionMessage.CompactionCompleted.fields.model, - providerState: SessionMessage.CompactionCompleted.fields.providerState, + providerState: SessionMessage.CompactionCompleted.fields.native, providerContext: SessionMessage.CompactionCompleted.fields.providerContext, text: Schema.String, recent: Schema.String, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 55ef745584d..1b04766f5d0 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -1,6 +1,6 @@ export * as SessionMessage from "./session-message.js" -import { Schema } from "effect" +import { Predicate, Schema } from "effect" import { SessionProviderContext } from "./session-provider-context.js" import { optional } from "./schema.js" import { Content } from "./tool.js" @@ -177,14 +177,14 @@ export interface AssistantText extends Schema.Schema.Type export const AssistantText = Schema.Struct({ type: Schema.tag("text"), text: Schema.String, - state: ProviderState.pipe(optional), + native: ProviderState.pipe(optional), }).annotate({ identifier: "Session.Message.Assistant.Text" }) export interface AssistantReasoning extends Schema.Schema.Type {} export const AssistantReasoning = Schema.Struct({ type: Schema.tag("reasoning"), text: Schema.String, - state: ProviderState.pipe(optional), + native: ProviderState.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, completed: DateTimeUtcFromMillis.pipe(optional), @@ -222,7 +222,7 @@ export const Assistant = Schema.Struct({ }).pipe(optional), finish: FinishReason.pipe(optional), rawFinish: Schema.String.pipe(optional), - providerState: ProviderState.pipe(optional), + native: ProviderState.pipe(optional), cost: Money.USD.pipe(optional), tokens: TokenUsage.Info.pipe(optional), error: SessionError.Error.pipe(optional), @@ -258,7 +258,7 @@ export const CompactionCompleted = Schema.Struct({ status: Schema.tag("completed"), reason: Schema.Literals(["auto", "manual"]), model: Model.Ref.pipe(optional), - providerState: ProviderState.pipe(optional), + native: ProviderState.pipe(optional), summary: Schema.String, recent: Schema.String, providerContext: SessionProviderContext.Info.pipe(optional), @@ -318,3 +318,25 @@ export type Info = | Compaction | Idle export type Type = Info["type"] + +/** Reads messages stored before provider blobs were renamed to `native`. Tool parts are unchanged. */ +export function persisted(input: unknown) { + if (!Predicate.isObject(input)) return input + const message = + input.type === "assistant" || input.type === "compaction" ? rename(input, "providerState", "native") : input + if (message.type !== "assistant" || !Array.isArray(message.content)) return message + return { + ...message, + content: message.content.map((part) => { + if (!Predicate.isObject(part) || (part.type !== "text" && part.type !== "reasoning")) return part + return rename(part, "state", "native") + }), + } +} + +function rename(record: Record, from: string, to: string) { + if (record[from] === undefined || record[to] !== undefined) return record + const value = record[from] + const rest = Object.fromEntries(Object.entries(record).filter(([key]) => key !== from)) + return { ...rest, [to]: value } +} diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index 19836738a29..db2d81bd4ad 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -257,8 +257,8 @@ describe("contract hygiene", () => { text: "hello", }) expect( - SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", state: { id: "opaque" } }), - ).toEqual({ type: "reasoning", text: "thinking", state: { id: "opaque" } }) + SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", native: { id: "opaque" } }), + ).toEqual({ type: "reasoning", text: "thinking", native: { id: "opaque" } }) expect( SessionMessage.AssistantTool.make({ type: "tool", diff --git a/packages/schema/test/session-terminal.test.ts b/packages/schema/test/session-terminal.test.ts index 3659c17a5e4..1ffb2e3c763 100644 --- a/packages/schema/test/session-terminal.test.ts +++ b/packages/schema/test/session-terminal.test.ts @@ -23,14 +23,24 @@ test("assistant terminal diagnostics remain optional and round trip", () => { ...assistant, finish: "content-filter", rawFinish: "SAFETY", - providerState: { promptFeedback: { blockReason: "SAFETY" } }, + native: { promptFeedback: { blockReason: "SAFETY" } }, }), ), ).toMatchObject({ finish: "content-filter", rawFinish: "SAFETY", - providerState: { promptFeedback: { blockReason: "SAFETY" } }, + native: { promptFeedback: { blockReason: "SAFETY" } }, }) + const legacy = SessionMessage.persisted({ + ...assistant, + providerState: { promptFeedback: { blockReason: "SAFETY" } }, + content: [{ type: "text", text: "hello", state: { signature: "sig" } }], + }) + expect(decode(legacy)).toMatchObject({ + native: { promptFeedback: { blockReason: "SAFETY" } }, + content: [{ type: "text", native: { signature: "sig" } }], + }) + expect(encode(decode(legacy))).not.toHaveProperty("providerState") }) test("failed steps only override the assistant finish for content filters", () => {