refactor(schema): rename message provider blobs to native

This commit is contained in:
Aiden Cline
2026-09-20 21:41:03 -05:00
parent cbdd1f66da
commit 85962e49b7
21 changed files with 141 additions and 72 deletions
+18 -18
View File
@@ -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<string> }
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<string> }
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<string> }
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<string> }
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?: {
+7 -7
View File
@@ -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,
@@ -135,7 +135,7 @@ test.each(["started", "cancelled", "failed"])(
status: "completed",
summary: "Summary",
model,
providerState,
native: providerState,
providerContext,
cost: 0.01,
tokens,
+2
View File
@@ -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[]
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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({
+8 -8
View File
@@ -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,
+1 -1
View File
@@ -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
+3 -1
View File
@@ -107,7 +107,9 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
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))
@@ -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
+3 -3
View File
@@ -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) }
: {}),
}
}
+4 -4
View File
@@ -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}` },
})
}),
)
@@ -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 },
})
+2 -2
View File
@@ -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 },
@@ -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 },
+3 -3
View File
@@ -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" },
},
+1 -1
View File
@@ -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,
+27 -5
View File
@@ -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<typeof AssistantText>
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<typeof AssistantReasoning> {}
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<string, unknown>, 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 }
}
@@ -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",
+12 -2
View File
@@ -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", () => {