Compare commits

...
8 Commits
Author SHA1 Message Date
Aiden Cline 396d7a0fc2 refactor(schema): rename tool providerState to native 2026-09-22 20:50:44 -05:00
Aiden Cline 873a6cb43a refactor(plugin): rename compaction result providerState to native 2026-09-22 20:39:58 -05:00
Aiden Cline 75a70dbd7e refactor(schema): rename session event provider blobs to native
Durable event payloads are not persisted yet, so the six non-tool events
that carry a provider blob adopt the message field name directly instead
of freezing the old shape.
2026-09-22 20:36:58 -05:00
Aiden Cline 8997d8662f fix(schema): keep legacy blobs on replay, import, and V1 migration
Freeze session.message.content.updated at the stored shape and translate
on replay, run persisted() on CLI session import, write native from the
V1 migration, regenerate the OpenAPI docs, and mirror text.ended state in
the client fold.
2026-09-22 00:37:59 -05:00
Aiden Cline e4dd41b033 test(core): use native for text and reasoning provider blobs 2026-09-21 23:39:41 -05:00
Aiden Cline 347bf1d749 Merge remote-tracking branch 'origin/v2' into message-native 2026-09-21 23:37:54 -05:00
Aiden Cline e7c4bffd38 refactor(schema): drop native migration and fix fixtures 2026-09-21 23:27:12 -05:00
Aiden Cline 85962e49b7 refactor(schema): rename message provider blobs to native 2026-09-20 21:41:03 -05:00
41 changed files with 234 additions and 228 deletions
@@ -64,7 +64,7 @@ type ToolSeed = {
name: string
messageID?: string
executed?: boolean
providerState?: Record<string, unknown>
native?: Record<string, unknown>
providerResultState?: Record<string, unknown>
state:
| { status: "streaming"; input: Record<string, unknown>; raw: string }
@@ -406,7 +406,7 @@ export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal!,
state: jsonRecord(part.metadata),
native: jsonRecord(part.metadata),
}),
]
return [
@@ -417,7 +417,7 @@ export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal!,
state: jsonRecord(part.metadata),
native: jsonRecord(part.metadata),
}),
]),
makeEvent("session.reasoning.ended", {
@@ -425,7 +425,7 @@ export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent
assistantMessageID: messageID,
ordinal: ref.ordinal!,
text: part.text,
state: jsonRecord(part.metadata),
native: jsonRecord(part.metadata),
}),
]
}
@@ -703,7 +703,7 @@ function messageContent(
return {
type: "reasoning",
text: part.text,
state: jsonRecord(part.metadata),
native: jsonRecord(part.metadata),
time: part.time
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
@@ -721,7 +721,7 @@ function messageContent(
...(completed === undefined ? {} : { completed }),
},
...(part.executed === undefined ? {} : { executed: part.executed }),
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
...(part.native ? { native: jsonRecord(part.native) } : {}),
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
}
if (state.status === "streaming") return { ...base, state: { status: "streaming", input: state.raw } }
@@ -791,7 +791,7 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
id: part.id,
input: part.state.input,
executed: part.executed ?? true,
state: jsonRecord(part.providerState),
native: jsonRecord(part.native),
}),
)
}
@@ -1,8 +1,9 @@
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Predicate, Schema } from "effect"
import { EOL } from "node:os"
import path from "node:path"
import { Commands } from "../../commands"
@@ -23,7 +24,13 @@ export default Runtime.handler(
catch: (cause) =>
new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
})
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const raw = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(text)
// Exports written before provider blobs were renamed to `native` still carry the old keys.
const data = yield* Schema.decodeUnknownEffect(SessionTransfer.Data)(
Predicate.isObject(raw) && Array.isArray(raw.messages)
? { ...raw, messages: raw.messages.map(SessionMessage.persisted) }
: raw,
)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
+7 -7
View File
@@ -54,7 +54,7 @@ type ToolState = StartedPart & {
input: Record<string, JsonValue>
raw?: string
provider?: unknown
providerState?: SessionMessageAssistantTool["providerState"]
native?: SessionMessageAssistantTool["native"]
metadata: Record<string, JsonValue>
content: ToolContent[]
}
@@ -290,7 +290,7 @@ export async function runNonInteractivePrompt(input: Input) {
messageID: event.data.assistantMessageID,
type: "reasoning",
text: event.data.text,
metadata: event.data.state,
metadata: event.data.native,
time: { start: started?.timestamp ?? time, end: time },
}
renderedReasoning.set(key, event.data.text)
@@ -332,8 +332,8 @@ export async function runNonInteractivePrompt(input: Input) {
tool: current?.tool ?? "tool",
input: event.data.input,
raw: current?.raw,
provider: { executed: event.data.executed, state: event.data.state },
providerState: event.data.state,
provider: { executed: event.data.executed, state: event.data.native },
native: event.data.native,
metadata: {},
content: [],
})
@@ -354,7 +354,7 @@ export async function runNonInteractivePrompt(input: Input) {
id: event.data.id,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
native: current.native,
providerResultState: event.data.resultState,
state: {
status: "completed",
@@ -402,7 +402,7 @@ export async function runNonInteractivePrompt(input: Input) {
id: event.data.id,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
native: current.native,
providerResultState: event.data.resultState,
state: {
status: "error",
@@ -564,7 +564,7 @@ export async function runNonInteractivePrompt(input: Input) {
messageID: message.id,
type: "reasoning",
text,
metadata: item.state,
metadata: item.native,
time: { start: message.time.created, end: timestamp },
}
renderedReasoning.set(key, item.text)
+7 -7
View File
@@ -851,7 +851,7 @@ export type SessionLogOutput =
readonly assistantMessageID: SessionMessage.ID
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly native?: SessionMessage.ProviderState | undefined
readonly cost: number & Brand.Brand<"Money.USD">
readonly tokens: {
readonly input: number
@@ -881,7 +881,7 @@ export type SessionLogOutput =
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly finish?: "content-filter" | undefined
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly native?: SessionMessage.ProviderState | undefined
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -930,7 +930,7 @@ export type SessionLogOutput =
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly text: string
readonly state?: SessionMessage.ProviderState | undefined
readonly native?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -949,7 +949,7 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly state?: SessionMessage.ProviderState | undefined
readonly native?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -969,7 +969,7 @@ export type SessionLogOutput =
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly text: string
readonly state?: SessionMessage.ProviderState | undefined
readonly native?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -1028,7 +1028,7 @@ export type SessionLogOutput =
readonly id: string
readonly input: { readonly [x: string]: unknown }
readonly executed: boolean
readonly state?: SessionMessage.ProviderState | undefined
readonly native?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -1171,7 +1171,7 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly native?: SessionMessage.ProviderState | undefined
readonly providerContext?:
| {
readonly version: 1
+30 -30
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 }
}
@@ -1266,7 +1266,7 @@ export type SessionStepEnded = {
assistantMessageID: string
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState1
native?: SessionMessageProviderState1
cost: MoneyUSD
tokens: TokenUsageInfo
snapshot?: string
@@ -1287,7 +1287,7 @@ export type SessionStepFailed = {
error: SessionStructuredError
finish?: "content-filter"
rawFinish?: string
providerState?: SessionMessageProviderState1
native?: SessionMessageProviderState1
cost?: MoneyUSD
tokens?: TokenUsageInfo
snapshot?: string
@@ -1307,7 +1307,7 @@ export type SessionTextEnded = {
assistantMessageID: string
ordinal: number
text: string
state?: SessionMessageProviderState1
native?: SessionMessageProviderState1
}
}
@@ -1318,7 +1318,7 @@ export type SessionReasoningStarted = {
type: "session.reasoning.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState1 }
data: { sessionID: string; assistantMessageID: string; ordinal: number; native?: SessionMessageProviderState1 }
}
export type SessionReasoningEnded = {
@@ -1333,7 +1333,7 @@ export type SessionReasoningEnded = {
assistantMessageID: string
ordinal: number
text: string
state?: SessionMessageProviderState1
native?: SessionMessageProviderState1
}
}
@@ -1350,16 +1350,16 @@ export type SessionToolCalled = {
id: string
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState1
native?: SessionMessageProviderState1
}
}
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
@@ -1781,7 +1781,7 @@ export type SessionCompactionEnded = {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
native?: SessionMessageProviderState1
providerContext?: SessionProviderContext
text: string
recent: string
@@ -2169,7 +2169,7 @@ export type SessionMessageAssistantTool = {
id: string
name: string
executed?: boolean
providerState?: SessionMessageProviderState
native?: SessionMessageProviderState
providerResultState?: SessionMessageProviderState
state:
| SessionMessageToolStateStreaming
@@ -2189,7 +2189,7 @@ export type SessionMessageAssistantTool1 = {
id: string
name: string
executed?: boolean
providerState?: SessionMessageProviderState1
native?: SessionMessageProviderState1
providerResultState?: SessionMessageProviderState1
state:
| SessionMessageToolStateStreaming
@@ -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 }
}
| {
@@ -3114,7 +3114,7 @@ export type SessionImportInput = {
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "streaming"; readonly input: string }
@@ -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 }
}
| {
@@ -3431,7 +3431,7 @@ export type SessionImportInput = {
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "streaming"; readonly input: string }
@@ -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 }
}
| {
@@ -3748,7 +3748,7 @@ export type SessionImportInput = {
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "streaming"; readonly input: string }
@@ -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?: {
+9 -8
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.native
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.native
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -914,6 +914,7 @@ export function createData(config: CreateDataInput) {
case "session.text.ended":
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text = event.data.text
text.native = event.data.native
})
return
case "session.tool.input.started":
@@ -941,7 +942,7 @@ export function createData(config: CreateDataInput) {
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
tool.time.ran = event.created
tool.executed = event.data.executed
tool.providerState = event.data.state
tool.native = event.data.native
tool.state = { status: "running", input: event.data.input, metadata: {} }
})
return
@@ -984,7 +985,7 @@ export function createData(config: CreateDataInput) {
assistant.content.push({
type: "reasoning",
text: "",
state: event.data.state,
native: event.data.native,
time: { created: event.created },
})
})
@@ -998,7 +999,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.native !== undefined) reasoning.native = event.data.native
})
return
case "session.retry.scheduled":
@@ -1105,7 +1106,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.native,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -1120,7 +1121,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.native,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -120,7 +120,7 @@ test.each(["started", "cancelled", "failed"])(
sessionID,
reason: "manual",
model,
providerState,
native: providerState,
providerContext,
text: "Summary",
recent: "Recent",
@@ -135,7 +135,7 @@ test.each(["started", "cancelled", "failed"])(
status: "completed",
summary: "Summary",
model,
providerState,
native: providerState,
providerContext,
cost: 0.01,
tokens,
@@ -379,13 +379,13 @@ export function transformSession(input: TransformInput): TransformResult {
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
return [{ type: "text", text: part.text, ...(part.metadata ? { native: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
...(part.metadata ? { native: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
+5 -5
View File
@@ -447,7 +447,7 @@ export const layer = Layer.effect(
sessionID: context.session.id,
reason: input.reason,
model: context.model.ref,
providerState: result.providerState,
native: result.native,
text: result.summary,
recent,
...usage,
@@ -607,7 +607,7 @@ export const layer = Layer.effect(
const chunks: string[] = []
let failure: SessionError.Error | undefined
let usage: SessionUsage.Recorded | undefined
let providerState: SessionMessage.ProviderState | undefined
let native: SessionMessage.ProviderState | undefined
const recordUsage = Effect.suspend(() =>
usage
? bus.publish(SessionEvent.UsageRecorded, {
@@ -648,7 +648,7 @@ export const layer = Layer.effect(
]) {
yield* Stream.suspend(() => {
chunks.length = 0
providerState = undefined
native = undefined
failure = undefined
return llm.stream(request, prepared.options)
}).pipe(
@@ -666,7 +666,7 @@ export const layer = Layer.effect(
})
}
if (LLMEvent.is.stepFinish(event)) {
providerState =
native =
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
@@ -726,7 +726,7 @@ export const layer = Layer.effect(
sessionID: context.session.id,
reason: input.reason,
model: context.model.ref,
providerState,
native,
text: summary,
recent: history.recent,
...usage,
+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({
+9 -9
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.native)
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.native)
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.native)
}
})
},
@@ -324,7 +324,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const match = latestTool(draft, event.data.id)
if (match) {
match.executed = event.data.executed
match.providerState = event.data.state
match.native = event.data.native
match.time.ran = created
match.state = castDraft(
SessionMessage.ToolStateRunning.make({
@@ -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.native,
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.native !== undefined) match.native = event.data.native
}
})
},
@@ -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.native,
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.native,
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))
@@ -38,7 +38,7 @@ export interface StepRecord {
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly rawFinish?: string
readonly providerState?: SessionMessage.ProviderState
readonly native?: SessionMessage.ProviderState
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly needsContinuation: boolean
@@ -205,7 +205,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
text: value,
state,
native: state,
})
}),
(_textID, value, ordinal) =>
@@ -228,7 +228,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
text: value,
state,
native: state,
})
}),
(_reasoningID, value, ordinal) =>
@@ -373,7 +373,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
error: stepFailure,
finish: stepSettlement?.finish === "content-filter" ? stepSettlement.finish : undefined,
rawFinish: stepSettlement?.rawFinish,
providerState: stepSettlement?.providerState,
native: stepSettlement?.native,
...details,
})
})
@@ -409,7 +409,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
ordinal: startedReasoningOrdinal,
state: providerState(event.providerMetadata),
native: providerState(event.providerMetadata),
})
return
case "reasoning-delta":
@@ -453,7 +453,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
id: event.id,
input: asRecord(event.input),
executed: tool.providerExecuted,
state: providerState(event.providerMetadata),
native: providerState(event.providerMetadata),
})
return
}
@@ -521,7 +521,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
stepSettlement = {
finish: event.reason.normalized,
rawFinish: event.reason.raw,
providerState: providerState(event.providerMetadata),
native: providerState(event.providerMetadata),
tokens: SessionUsage.tokens(event.usage),
}
if (event.reason.normalized === "content-filter") {
+1 -1
View File
@@ -236,7 +236,7 @@ export const make = Effect.gen(function* () {
assistantMessageID: yield* publisher.startAssistant(),
finish: record.finish.finish,
rawFinish: record.finish.rawFinish,
providerState: record.finish.providerState,
native: record.finish.native,
...usage,
snapshot,
files,
@@ -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
@@ -186,19 +186,19 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
const call = toolCall(
item,
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.native) : undefined,
)
if (item.executed !== true) return [call]
// Hosted tools (e.g. google_search) run inside the provider, so their
// result payload (`providerResultState`) is provider-format data rather
// than model-scoped proof: it stays replayable across models of the same
// provider. After a model switch, echo only that payload — never fall
// back to `providerState`, whose call-side values are bound to the old
// back to `native`, whose call-side values are bound to the old
// model.
const result = toolResult(
item,
reuseToolProviderMetadata
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.native)
: sameProvider && item.providerResultState !== undefined
? providerMetadata(providerMetadataKey, item.providerResultState)
: undefined,
@@ -216,7 +216,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
toolResult(
item,
reuseProviderMetadata
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.native)
: undefined,
),
)
+4 -4
View File
@@ -269,17 +269,17 @@ 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,
providerState: content.providerState ? { redacted: `tool-provider-state:${message.id}` } : undefined,
native: content.native ? { redacted: `tool-native:${message.id}` } : undefined,
providerResultState: content.providerResultState
? { redacted: `tool-provider-result-state:${message.id}` }
: undefined,
@@ -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}` },
})
}),
)
@@ -413,7 +413,7 @@ it.live("compaction hooks supply the summary instead of provider compaction", ()
Effect.sync(() => {
event.result = {
summary: "## Objective\n- hooked summary",
providerState: { responseId: "plugin" },
native: { responseId: "plugin" },
metadata: { plugin: "custom" },
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
}
@@ -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 },
})
+4 -4
View File
@@ -659,7 +659,7 @@ describe("SessionProjector", () => {
assistantMessageID: endedID,
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: Snapshot.ID.make("snap_ended"),
@@ -670,7 +670,7 @@ describe("SessionProjector", () => {
assistantMessageID: failedID,
finish: "content-filter",
rawFinish: "blocked",
providerState: { response: "failed" },
native: { response: "failed" },
error: { type: "provider.invalid-request", message: "Failed" },
snapshot: Snapshot.ID.make("snap_failed"),
files: [RelativePath.make("src/failed.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 },
@@ -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",
@@ -755,7 +755,7 @@ Recent work
id: "hosted",
name: "web_search",
executed: true,
providerState: { continuation: "hosted-call" },
native: { continuation: "hosted-call" },
providerResultState: { continuation: "hosted-result" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
@@ -769,7 +769,7 @@ Recent work
id: "hosted-failed",
name: "write",
executed: true,
providerState: { continuation: "failed" },
native: { continuation: "failed" },
state: SessionMessage.ToolStateError.make({
status: "error",
input: { path: "README.md" },
@@ -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,14 +918,14 @@ 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",
id: "hosted-completed",
name: "web_search",
executed: true,
providerState: { itemId: "call_completed" },
native: { itemId: "call_completed" },
providerResultState: { itemId: "result_completed" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
@@ -939,7 +939,7 @@ Recent work
id: "hosted-failed",
name: "web_search",
executed: true,
providerState: { itemId: "call_failed" },
native: { itemId: "call_failed" },
providerResultState: { itemId: "result_failed" },
state: SessionMessage.ToolStateError.make({
status: "error",
@@ -1016,14 +1016,14 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Visible thought",
state: { signature: "sig_old" },
native: { signature: "sig_old" },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-old-model",
name: "web_search",
executed: true,
providerState: { itemId: "hosted-old-model" },
native: { itemId: "hosted-old-model" },
providerResultState: { itemId: "hosted-old-model" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
@@ -1037,7 +1037,7 @@ Recent work
id: "local-old-model",
name: "read",
executed: false,
providerState: { call: "old" },
native: { call: "old" },
providerResultState: { result: "old" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
@@ -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 },
@@ -299,7 +299,7 @@ test("provider metadata is flattened using the route key", async () => {
)
expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
state: { signature: "signed" },
native: { signature: "signed" },
})
})
@@ -326,7 +326,7 @@ test("reasoning state from start, empty delta, and end is merged", async () => {
)
expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
state: { blockType: "thinking", signature: "signed", stopReason: "tool_use" },
native: { blockType: "thinking", signature: "signed", stopReason: "tool_use" },
})
})
@@ -508,7 +508,7 @@ test("provider-executed tool metadata is flattened using the route key", async (
)
expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({
state: { itemId: "call" },
native: { itemId: "call" },
})
expect(published.find((event) => event.type === "session.tool.success.2")?.data).toMatchObject({
resultState: { itemId: "result" },
@@ -576,7 +576,7 @@ test("content-filter finish retains failure evidence until step closeout", async
expect(settlement).toMatchObject({
finish: "content-filter",
rawFinish: "refusal",
providerState: {
native: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
tokens: { input: 8, output: 2, reasoning: 1 },
@@ -595,7 +595,7 @@ test("content-filter finish retains failure evidence until step closeout", async
error: { type: "provider.content-filter", message: "Provider blocked the response" },
finish: "content-filter",
rawFinish: "refusal",
providerState: {
native: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
cost: 1.25,
+10 -10
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.
@@ -3336,7 +3336,7 @@ describe("SessionRunnerLLM", () => {
id: "call-provider",
name: "web_search",
executed: true,
providerState: { source: "provider" },
native: { source: "provider" },
providerResultState: { source: "provider" },
},
{
@@ -3508,12 +3508,12 @@ describe("SessionRunnerLLM", () => {
{
type: "reasoning",
text: "Signed thought",
state: { signature: "sig_1" },
native: { signature: "sig_1" },
},
{
type: "reasoning",
text: "Encrypted thought",
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
native: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
]),
])
@@ -3565,7 +3565,7 @@ describe("SessionRunnerLLM", () => {
{
type: "reasoning",
text: "thinking",
state: { reasoningField: "reasoning", reasoningDetails: details },
native: { reasoningField: "reasoning", reasoningDetails: details },
},
{ type: "text", text: "Hello world" },
]),
@@ -3609,7 +3609,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.context).toMatchObject([
Expected.user("Check first"),
Expected.assistant({}, [
{ type: "text", text: "Checking.", state: { itemId: "msg_commentary", phase: "commentary" } },
{ type: "text", text: "Checking.", native: { itemId: "msg_commentary", phase: "commentary" } },
]),
])
@@ -4302,7 +4302,7 @@ describe("SessionRunnerLLM", () => {
id: "call-hosted-interrupted",
input: { query: "stale" },
executed: true,
state: { itemId: "call-hosted-interrupted" },
native: { itemId: "call-hosted-interrupted" },
})
s.requests.length = 0
yield* s.llm.push([])
@@ -4966,7 +4966,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")],
},
])
@@ -4997,7 +4997,7 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "content-filter",
rawFinish: "SAFETY",
providerState: {
native: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
@@ -5444,7 +5444,7 @@ describe("SessionRunnerLLM", () => {
{
type: "reasoning",
text: "",
state: { itemId: "rs_disconnected", reasoningEncryptedContent: "encrypted-state" },
native: { itemId: "rs_disconnected", reasoningEncryptedContent: "encrypted-state" },
},
]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
+2 -2
View File
@@ -337,8 +337,8 @@ describe("V1Migration.transformSession", () => {
agent: "build",
model: { id: "model", providerID: "provider", variant: "fast" },
content: [
{ type: "text", text: "", state: { separator: true } },
{ type: "reasoning", text: "think", state: { provider: 1 }, time: { created: 21, completed: 22 } },
{ type: "text", text: "", native: { separator: true } },
{ type: "reasoning", text: "think", native: { provider: 1 }, time: { created: 21, completed: 22 } },
],
snapshot: { start: "snap_start", end: "snap_end", files: ["a.ts", "b.ts", "c.ts"] },
finish: "stop",
+1 -1
View File
@@ -37,7 +37,7 @@ export interface SessionContext extends SessionRequest {
export interface SessionCompactionResult {
summary: string
providerState?: SessionMessage.ProviderState
native?: SessionMessage.ProviderState
metadata?: Record<string, unknown>
tokens?: TokenUsage.Info
}
+1 -1
View File
@@ -37,7 +37,7 @@ export interface SessionContext extends SessionRequest {
export interface SessionCompactionResult {
summary: string
providerState?: SessionMessage.ProviderState
native?: SessionMessage.ProviderState
metadata?: Record<string, unknown>
tokens?: TokenUsage.Info
}
+5 -5
View File
@@ -17320,7 +17320,7 @@
"rawFinish": {
"type": "string"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
@@ -17349,7 +17349,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_1"
},
"time": {
@@ -17396,7 +17396,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState"
}
},
@@ -17419,7 +17419,7 @@
"executed": {
"type": "boolean"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_2"
},
"providerResultState": {
@@ -17509,7 +17509,7 @@
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
+7 -7
View File
@@ -353,7 +353,7 @@ export namespace Step {
assistantMessageID: SessionMessage.ID,
finish: FinishReason,
rawFinish: Schema.String.pipe(optional),
providerState: SessionMessage.ProviderState.pipe(optional),
native: SessionMessage.ProviderState.pipe(optional),
cost: Money.USD,
tokens: TokenUsage.Info,
snapshot: Snapshot.ID.pipe(optional),
@@ -371,7 +371,7 @@ export namespace Step {
error: SessionError.Error,
finish: Schema.Literals(["content-filter"]).pipe(optional),
rawFinish: Schema.String.pipe(optional),
providerState: SessionMessage.ProviderState.pipe(optional),
native: SessionMessage.ProviderState.pipe(optional),
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
snapshot: Snapshot.ID.pipe(optional),
@@ -413,7 +413,7 @@ export namespace Text {
assistantMessageID: SessionMessage.ID,
ordinal: NonNegativeInt,
text: Schema.String,
state: SessionMessage.ProviderState.pipe(optional),
native: SessionMessage.ProviderState.pipe(optional),
},
})
export type Ended = typeof Ended.Type
@@ -427,7 +427,7 @@ export namespace Reasoning {
...Base,
assistantMessageID: SessionMessage.ID,
ordinal: NonNegativeInt,
state: SessionMessage.ProviderState.pipe(optional),
native: SessionMessage.ProviderState.pipe(optional),
},
})
export type Started = typeof Started.Type
@@ -452,7 +452,7 @@ export namespace Reasoning {
assistantMessageID: SessionMessage.ID,
ordinal: NonNegativeInt,
text: Schema.String,
state: SessionMessage.ProviderState.pipe(optional),
native: SessionMessage.ProviderState.pipe(optional),
},
})
export type Ended = typeof Ended.Type
@@ -504,7 +504,7 @@ export namespace Tool {
...ToolBase,
input: Schema.Record(Schema.String, Schema.Unknown),
executed: Schema.Boolean,
state: SessionMessage.ProviderState.pipe(optional),
native: SessionMessage.ProviderState.pipe(optional),
},
})
export type Called = typeof Called.Type
@@ -601,7 +601,7 @@ export namespace Compaction {
...Base,
reason: Started.data.fields.reason,
model: SessionMessage.CompactionCompleted.fields.model,
providerState: SessionMessage.CompactionCompleted.fields.providerState,
native: SessionMessage.CompactionCompleted.fields.native,
providerContext: SessionMessage.CompactionCompleted.fields.providerContext,
text: Schema.String,
recent: Schema.String,
+29 -6
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"
@@ -163,7 +163,7 @@ export const AssistantTool = Schema.Struct({
id: Schema.String,
name: Schema.String,
executed: Schema.Boolean.pipe(optional),
providerState: ProviderState.pipe(optional),
native: ProviderState.pipe(optional),
providerResultState: ProviderState.pipe(optional),
state: ToolState,
time: Schema.Struct({
@@ -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,26 @@ export type Info =
| Compaction
| Idle
export type Type = Info["type"]
/** Reads messages stored before provider blobs were renamed to `native`. */
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
const stored: ReadonlyArray<unknown> = message.content
const content = stored.map((part) => {
if (!Predicate.isObject(part)) return part
if (part.type === "text" || part.type === "reasoning") return rename(part, "state", "native")
if (part.type === "tool") return rename(part, "providerState", "native")
return part
})
return content.every((part, index) => part === stored[index]) ? message : { ...message, content }
}
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,15 +257,15 @@ 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",
id: "call_1",
name: "search",
executed: true,
providerState: { itemId: "item_1" },
native: { itemId: "item_1" },
state: { status: "streaming", input: "" },
time: { created: DateTime.makeUnsafe(0) },
}),
+2 -2
View File
@@ -190,7 +190,7 @@ describe("public event manifest", () => {
assistantMessageID,
ordinal: 0,
text: "thought",
state: { signature: "sig" },
native: { signature: "sig" },
})
const tool = SessionEvent.Tool.Called.data.make({
sessionID,
@@ -198,7 +198,7 @@ describe("public event manifest", () => {
id: "call_test",
input: {},
executed: true,
state: { itemId: "item_test" },
native: { itemId: "item_test" },
})
expect(text).not.toHaveProperty("textID")
+11 -38
View File
@@ -23,50 +23,23 @@ 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",
native: { promptFeedback: { blockReason: "SAFETY" } },
})
const legacy = SessionMessage.persisted({
...assistant,
providerState: { promptFeedback: { blockReason: "SAFETY" } },
content: [{ type: "text", text: "hello", state: { signature: "sig" } }],
})
})
test("failed steps only override the assistant finish for content filters", () => {
const decode = Schema.decodeUnknownSync(SessionEvent.Step.Failed.data)
const input = {
sessionID: "ses_terminal",
assistantMessageID: "msg_terminal",
error: { type: "provider.content-filter", message: "Blocked" },
}
expect(decode(input)).toMatchObject(input)
expect(decode({ ...input, finish: "content-filter", rawFinish: "SAFETY" })).toMatchObject({
finish: "content-filter",
rawFinish: "SAFETY",
expect(decode(legacy)).toMatchObject({
native: { promptFeedback: { blockReason: "SAFETY" } },
content: [{ type: "text", native: { signature: "sig" } }],
})
expect(() => decode({ ...input, finish: "stop" })).toThrow()
})
test("provider compaction context is optional, versioned and JSON-only", () => {
const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data)
const encode = Schema.encodeSync(SessionEvent.Compaction.Ended.data)
const local = { sessionID: "ses_context", reason: "manual" as const, text: "summary", recent: "" }
expect(encode({ ...decode(local), providerContext: undefined })).toEqual(local)
const providerContext = {
version: 1 as const,
provenance: {
providerID: "openai",
provider: "openai",
modelID: "deployment",
route: "responses",
protocol: "responses",
endpoint: "digest",
},
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai", encrypted: "opaque" }] }],
}
expect(encode(decode({ ...local, providerContext }))).toEqual({ ...local, providerContext })
expect(() => decode({ ...local, providerContext: { ...providerContext, version: 2 } })).toThrow()
expect(() => decode({ ...local, providerContext: { ...providerContext, messages: [() => "invalid"] } })).toThrow()
expect(encode(decode(legacy))).not.toHaveProperty("providerState")
expect(SessionMessage.persisted(assistant)).toBe(assistant)
})
@@ -190,13 +190,13 @@ export const streamingDocument = document(
{
type: "reasoning",
text: "## Checking the current contract\n\nThe assistant content is nested on each current Session message.",
state: { phase: "streaming" },
native: { phase: "streaming" },
time: { created: STORY_TIME + 11_100 },
},
{
type: "text",
text: "I have the typed rows in place. Next I am checking the streaming presentation",
state: { phase: "streaming" },
native: { phase: "streaming" },
},
],
}),
@@ -47,7 +47,7 @@ function MermaidTimeline(props: { streaming: boolean }) {
"```mermaid\nsequenceDiagram\n Client->>Server: Send prompt\n Server->>Model: Generate response\n Model-->>Client: Response\n" +
(completed() ? "```" : ""),
].join("\n\n"),
...(completed() ? {} : { state: { phase: "streaming" } }),
...(completed() ? {} : { native: { phase: "streaming" } }),
},
],
},
+3 -3
View File
@@ -800,7 +800,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
id: event.data.id,
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
native: event.data.native,
state: { status: "running", input: event.data.input, metadata: {} },
time: { created: current?.part.time.created ?? event.created, ran: event.created },
},
@@ -822,7 +822,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
id: event.data.id,
name: part?.name ?? "tool",
executed: part?.executed,
providerState: part?.providerState,
native: part?.native,
state: {
status: "running",
input: part && part.state.status !== "streaming" ? part.state.input : {},
@@ -852,7 +852,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
id: event.data.id,
name: part?.name ?? "tool",
executed: event.data.executed,
providerState: part?.providerState,
native: part?.native,
providerResultState: event.data.resultState,
state: failed
? {
+3 -3
View File
@@ -1279,7 +1279,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
id: event.data.id,
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
native: event.data.native,
state: { status: "running", input: event.data.input, metadata: {} },
time: { created: current?.part.time.created ?? event.created, ran: event.created },
}
@@ -1296,7 +1296,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
id: event.data.id,
name: part?.name ?? "tool",
executed: part?.executed,
providerState: part?.providerState,
native: part?.native,
state: {
status: "running",
input: part && part.state.status !== "streaming" ? part.state.input : {},
@@ -1315,7 +1315,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
id: event.data.id,
name: part?.name ?? "tool",
executed: event.data.executed,
providerState: part?.providerState,
native: part?.native,
providerResultState: event.data.resultState,
state: failed
? {
+2 -2
View File
@@ -2792,7 +2792,7 @@ test("settles pending tools when a live failure arrives", async () => {
id: "call-1",
input: {},
executed: false,
state: { call: true },
native: { call: true },
},
})
emitEvent(events, {
@@ -2855,7 +2855,7 @@ test("settles pending tools when a live failure arrives", async () => {
expect(tool.state.metadata).toBeUndefined()
expect(tool.state.content).toBeUndefined()
expect(tool.executed).toBe(false)
expect(tool.providerState).toEqual({ call: true })
expect(tool.native).toEqual({ call: true })
expect(tool.providerResultState).toEqual({ result: true })
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
"agent-switched",
+5 -5
View File
@@ -17320,7 +17320,7 @@
"rawFinish": {
"type": "string"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
@@ -17349,7 +17349,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_1"
},
"time": {
@@ -17396,7 +17396,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState"
}
},
@@ -17419,7 +17419,7 @@
"executed": {
"type": "boolean"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_2"
},
"providerResultState": {
@@ -17509,7 +17509,7 @@
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
+5 -5
View File
@@ -17320,7 +17320,7 @@
"rawFinish": {
"type": "string"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
@@ -17349,7 +17349,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_1"
},
"time": {
@@ -17396,7 +17396,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState"
}
},
@@ -17419,7 +17419,7 @@
"executed": {
"type": "boolean"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_2"
},
"providerResultState": {
@@ -17509,7 +17509,7 @@
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
@@ -1566,7 +1566,7 @@ interface SessionContextHook extends SessionRequestHook {
interface SessionCompactionResult {
summary: string
providerState?: Record<string, unknown>
native?: Record<string, unknown>
metadata?: Record<string, unknown>
tokens?: TokenUsage
}