Compare commits

..
50 changed files with 1004 additions and 569 deletions
+84 -86
View File
@@ -304,6 +304,11 @@ export const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
// later item events (Bedrock Mantle renames it to `item_id` on
// `output_item.done` and `response.completed.output`). Decode it as optional
// and let `normalize` recover or mint it once before the parser runs.
// https://www.openresponses.org/specification#extending-items
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -316,6 +321,7 @@ export const StreamItem = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
@@ -395,6 +401,7 @@ export const Event = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
export interface ProviderAdapter {
readonly id: string
@@ -416,14 +423,13 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -875,9 +881,6 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
[state.providerMetadataKey]: metadata,
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -921,9 +924,34 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
export const outputItemID = (state: ParserState, event: Event) =>
const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
message: "msg",
reasoning: "rs",
function_call: "fc",
compaction: "cmp",
}
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
...item,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
`${ITEM_ID_PREFIX[item.type] ?? "item"}_${crypto.randomUUID().replaceAll("-", "")}`,
})
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
})
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
@@ -997,7 +1025,7 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
@@ -1010,18 +1038,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
if (state.message !== undefined && state.message.id !== item.id) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.filter((id) => id !== item.id)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
@@ -1038,14 +1066,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
},
},
events,
]
}
if (item && isReasoningItem(item)) {
if (item.type === "reasoning") {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
@@ -1065,18 +1093,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
events,
]
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined || state.completedTools.has(item.id)) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, id, {
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
@@ -1148,13 +1174,13 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: Event["item"],
item: NormalizedEvent["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
if (typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1171,7 +1197,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}
if (item.type === "message" && item.id !== undefined) {
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
@@ -1204,36 +1230,23 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
if (state.completedTools.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered !== undefined || finished.length === 0
registered || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1244,13 +1257,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
completedTools: new Set([...state.completedTools, item.id]),
},
events,
] satisfies StepResult
}
if (isReasoningItem(item)) {
if (item.type === "reasoning") {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
@@ -1334,21 +1347,17 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
if (item.type !== "compaction" && item.type !== "function_call") continue
if (item.type === "compaction") {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
}
if (
item.type === "function_call" &&
(!item.call_id || !Object.values(current.tools).some((tool) => tool?.id === item.call_id))
)
continue
// An output item's array position is its output index.
for (const item of (event.response?.output ?? []).map((item, index) => resolveItem(state, item, index))) {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (item.type === "compaction" && state.lifecycle.stepStarted && !state.completedCompactions.has(item.id))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
const recoverable =
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
if (!recoverable) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1415,12 +1424,9 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
return new AIError({ reason })
}
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
// Callers must pass events through `normalize` first. The OpenAPI requires
// string IDs but imposes no minLength; empty is not missing.
export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1460,20 +1466,16 @@ export const step = (state: ParserState, input: Event) => {
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item &&
isReasoningItem(event.item) &&
event.item?.type === "reasoning" &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
event.output_index !== undefined && event.item
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
: state,
event,
),
@@ -1483,11 +1485,7 @@ export const step = (state: ParserState, input: Event) => {
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
@@ -1537,7 +1535,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(Event),
initial,
step,
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
terminal,
},
})
@@ -201,12 +201,11 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -3,8 +3,7 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.StreamItem & {
readonly id: string
export type Item = OpenResponses.OutputItem & {
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
@@ -27,8 +26,8 @@ export interface Definition {
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
item.type in tools
export const onDone: (
state: OpenResponses.ParserState,
+2 -1
View File
@@ -69,7 +69,8 @@ const HOSTED_TOOLS = {
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+8 -5
View File
@@ -125,12 +125,15 @@ testEffect(
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("rejects terminal checkpoints missing an id", () =>
).effect("mints an id for terminal checkpoints that omit one", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
const response = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("missing its id")
)
const part = response.message.content[0]
expect(part?.type).toBe("compaction")
if (part?.type !== "compaction") return
expect(part.id).toMatch(/^cmp_[0-9a-f]{32}$/)
expect(part.encrypted).toBe("opaque")
}),
)
@@ -329,69 +329,126 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
Effect.gen(function* () {
const terminal = {
type: "function_call",
item_id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: '{\n "city": "Paris"\n}',
output_index: 1,
status: "completed",
}
const events = yield* collect(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "msg_879a68b589198b4c" },
},
{ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "msg_879a68b589198b4c" } },
{
type: "response.output_item.added",
output_index: 1,
item: {
type: "function_call",
id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: "",
status: "in_progress",
},
])
}),
)
},
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "fc_828bee50dee1d029",
delta: '{\n "city": "Paris"\n}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_828bee50dee1d029",
arguments: '{\n "city": "Paris"\n}',
},
{ type: "response.output_item.done", output_index: 1, item: terminal },
{
type: "response.completed",
response: { id: "resp_1", output: [{ type: "reasoning", id: "msg_879a68b589198b4c" }, terminal] },
},
)
const providerMetadata = { "openai-compatible": { itemId: "fc_828bee50dee1d029" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-input-delta",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
text: '{\n "city": "Paris"\n}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-call",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
input: { city: "Paris" },
providerMetadata,
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("mints an id for a done-only tool that never had one", () =>
Effect.gen(function* () {
const events = yield* collect(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
completed,
)
const call = events.find(LLMEvent.is.toolCall)
expect(call).toMatchObject({ id: "call_1", name: "lookup", input: { query: "weather" } })
expect(call?.providerMetadata?.["openai-compatible"]).toMatchObject({
itemId: expect.stringMatching(/^fc_[0-9a-f]{32}$/),
})
}),
)
it.effect("opens and closes a done-only tool once", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
@@ -436,21 +493,6 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
@@ -500,14 +542,15 @@ describe("Open Responses basic-item lifecycles", () => {
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
{ type: "text-end", id: "msg_1" },
])
}),
@@ -586,23 +586,21 @@ describe("Open Responses-compatible route", () => {
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
Effect.forEach([null, 0, false, {}, []], (id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
@@ -610,43 +608,6 @@ describe("Open Responses-compatible route", () => {
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -469,7 +469,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues an item-id-less tool call with only the new tool output", () =>
it.effect("continues a streamed tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,6 +485,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -2129,47 +2130,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2389,7 +2349,7 @@ describe("OpenAI Responses route", () => {
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2931,14 +2891,10 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
// A completed item that is re-added stays closed.
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -3793,43 +3749,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -4017,7 +3936,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,6 +3951,7 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -4053,37 +3973,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -110,6 +110,7 @@ test("renders a compaction summary while it streams and after completion", async
compactionEnded({
sessionID,
reason: "manual",
model: { id: "model", providerID: "provider" },
text: "## Checkpoint\n\nFinal implementation details.",
recent: "",
}),
-2
View File
@@ -230,8 +230,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
if (event.type === "reference.updated" && children.active(key))
void data.location.reference.sync({ directory: key }).catch(() => undefined)
})
onCleanup(unsub)
+26 -1
View File
@@ -215,7 +215,30 @@ export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) =>
export type SessionImportInput = {
readonly info: Session.Info
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly messages: ReadonlyArray<
| SessionMessage.AgentSelected
| SessionMessage.ModelSelected
| SessionMessage.LocationSwitched
| SessionMessage.User
| SessionMessage.Synthetic
| SessionMessage.System
| SessionMessage.Skill
| SessionMessage.Shell
| SessionMessage.Assistant
| SessionMessage.Compaction
| {
readonly type: "compaction"
readonly id: SessionMessage.ID
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly time: { readonly created: DateTime.Utc }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly summary: string
readonly recent: string
}
>
readonly location?: Location.Ref | undefined
}
export type SessionImportOutput = Session.Info
@@ -965,6 +988,8 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly model: Model.Ref
readonly providerState?: SessionMessage.ProviderState | undefined
readonly text: string
readonly recent: string
}
+72 -21
View File
@@ -138,17 +138,6 @@ export type SessionMessageCompactionRunning = {
recent: string
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -521,6 +510,19 @@ export type SessionMessageAssistantReasoning = {
time?: { created: number; completed?: number }
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
}
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
@@ -809,16 +811,6 @@ export type SessionCompactionStarted = {
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
}
export type SessionCompactionFailed = {
id: string
created: number
@@ -1351,6 +1343,23 @@ export type SessionToolCalled = {
}
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model: ModelRef
providerState?: SessionMessageProviderState1
text: string
recent: string
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
@@ -3063,6 +3072,8 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
@@ -3076,6 +3087,18 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3340,6 +3363,8 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
@@ -3353,6 +3378,18 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3617,6 +3654,8 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
@@ -3630,6 +3669,18 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
+6 -2
View File
@@ -1038,6 +1038,8 @@ export function createData(config: CreateDataInput) {
Object.assign(current, {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
})
@@ -1048,6 +1050,8 @@ export function createData(config: CreateDataInput) {
type: "compaction",
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.created },
@@ -1180,8 +1184,8 @@ export function createData(config: CreateDataInput) {
}))
break
case "reference.updated":
result.location.reference.invalidate()
void result.location.reference.sync()
result.location.reference.invalidate(location)
void result.location.reference.sync(location)
break
case "integration.updated":
result.location.integration.invalidate(location)
+29 -2
View File
@@ -3,6 +3,20 @@ import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInboxCompaction, type SessionInboxInfo } from "../src/promise"
test("projects model and provider state when the compaction start was not observed", () => {
using fixture = setup()
const model = { providerID: "demo", id: "model", variant: "variant" }
const providerState = { responseId: "summary-response" }
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "" },
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
])
})
test("admits compaction before model setup and serializes the following prompt", async () => {
using fixture = setup()
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
@@ -101,10 +115,23 @@ test.each(["started", "cancelled", "failed"])(
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
data: {
sessionID,
reason: "manual",
model: { providerID: "demo", id: "model" },
providerState: { responseId: "summary-response" },
text: "Summary",
recent: "Recent",
},
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary" },
{
type: "compaction",
status: "completed",
summary: "Summary",
model: { providerID: "demo", id: "model" },
providerState: { responseId: "summary-response" },
},
])
}
},
+60
View File
@@ -409,6 +409,66 @@ test("refreshes global credential events across every loaded location and worksp
}
})
test("refreshes references for the location an update names", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
const directory = url.searchParams.get("location[directory]") ?? "/project"
return Response.json({
location: {
directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "project", directory, canonical: directory },
},
data: [],
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
const other = { directory: "/other", workspaceID: "workspace-other" }
try {
await Promise.all([setup.data.location.reference.sync(), setup.data.location.reference.sync(other)])
requests.length = 0
const updated: OpenCodeEvent = {
id: "evt_reference.updated",
created: 1,
type: "reference.updated",
location: other,
data: {},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
await wait(() => requests.length === 1)
expect([
requests[0]!.pathname,
requests[0]!.searchParams.get("location[directory]"),
requests[0]!.searchParams.get("location[workspace]"),
]).toEqual(["/api/reference", "/other", "workspace-other"])
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
+1
View File
@@ -100,6 +100,7 @@ const layer = Layer.effect(
editor.providers.set(providerID, current)
}
fn(current.provider)
current.provider.id = providerID
},
remove: (providerID) => {
editor.providers.delete(providerID)
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260902000000_compaction_model.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,59 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260902000000_compaction_model",
up(tx) {
return Effect.gen(function* () {
// Old checkpoints have no provider state. Infer display provenance without
// treating a nearby model as proof that it produced replayable provider data.
for (const table of ["session_message", "event"] as const) {
const session = table === "session_message" ? "session_id" : "aggregate_id"
yield* tx.run(`
UPDATE ${table} AS checkpoint
SET data = json_set(checkpoint.data, '$.model', json(coalesce(
(
SELECT json_extract(source.data, '$.model') FROM session_message AS source
WHERE source.session_id = checkpoint.${session}
AND source.type IN ('assistant', 'model-switched')
AND json_type(source.data, '$.model') = 'object'
AND source.seq < checkpoint.seq
ORDER BY source.seq DESC LIMIT 1
),
(
SELECT json_extract(source.data, '$.model') FROM event AS source
WHERE source.aggregate_id = checkpoint.${session}
AND source.type IN ('session.step.started.1', 'session.model.selected.1', 'session.created.1')
AND json_type(source.data, '$.model') = 'object'
AND source.seq < checkpoint.seq
ORDER BY source.seq DESC LIMIT 1
),
(
SELECT json_extract(source.data, '$.model') FROM session_message AS source
WHERE source.session_id = checkpoint.${session}
AND source.type IN ('assistant', 'model-switched')
AND json_type(source.data, '$.model') = 'object'
AND source.seq > checkpoint.seq
ORDER BY source.seq ASC LIMIT 1
),
(
SELECT json_extract(source.data, '$.model') FROM event AS source
WHERE source.aggregate_id = checkpoint.${session}
AND source.type IN ('session.step.started.1', 'session.model.selected.1', 'session.created.1')
AND json_type(source.data, '$.model') = 'object'
AND source.seq > checkpoint.seq
ORDER BY source.seq ASC LIMIT 1
),
(SELECT model FROM session_v2 WHERE id = checkpoint.${session}),
'{"id":"unknown","providerID":"unknown"}'
)))
WHERE checkpoint.type = '${table === "session_message" ? "compaction" : "session.compaction.ended.1"}'
${table === "session_message" ? "AND json_extract(checkpoint.data, '$.status') = 'completed'" : ""}
AND json_type(checkpoint.data, '$.model') IS NULL
`)
}
})
},
}
export default migration
@@ -14,6 +14,7 @@ import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
import compactionModelMigration from "./migration/20260902000000_compaction_model.js"
export type SourceMessage = {
readonly id: string
@@ -305,6 +306,7 @@ export function transformSession(input: TransformInput): TransformResult {
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
model: { id: summary.value.modelID, providerID: summary.value.providerID },
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
@@ -712,7 +714,7 @@ function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
): Effect.Effect<void, unknown, Global.Service> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
@@ -813,6 +815,8 @@ function importNextDatabase(
onProgress(index + 1)
yield* Effect.yieldNow
}
// This source is imported after normal database migrations have already run.
yield* db.transaction((tx) => compactionModelMigration.up(tx))
source.run("COMMIT")
}),
)
+1 -4
View File
@@ -18,7 +18,6 @@ import { KV } from "./kv.js"
const Refresh = Schema.Struct({
attemptedAt: Schema.Number,
refreshedAt: Schema.optionalKey(Schema.Number),
})
const refreshInterval = Duration.toMillis(Duration.days(1))
@@ -161,7 +160,7 @@ const layer = Layer.effect(
if (status !== "cached") {
// Record attempts before network work so failures obey the same refresh interval.
yield* kv.set(key, { ...previous, attemptedAt: now })
yield* kv.set(key, { attemptedAt: now })
if (status === "cloned") {
yield* git.repo
@@ -205,8 +204,6 @@ const layer = Layer.effect(
.resetHard(existing, target ? `origin/${target}` : "HEAD")
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
}
yield* kv.set(key, { attemptedAt: now, refreshedAt: yield* Clock.currentTimeMillis })
}
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
+1 -9
View File
@@ -121,15 +121,7 @@ const layer = Layer.effect(
// The heterogeneous registry erases handlers after their selected schema validates input.
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
return execution
}).pipe(
Effect.catch((error) => encodeError(method, error)),
// Normalize handler bugs here so direct callers can recover just like HTTP callers.
Effect.catchDefect((defect) =>
Effect.logError("rpc handler failed", { rpc: rpcID, method: name, defect }).pipe(
Effect.andThen(Effect.fail(failure("rpc.internal", "RPC call failed"))),
),
),
)
}).pipe(Effect.catch((error) => encodeError(method, error)))
return yield* encode(method.output, result).pipe(
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
)
+8
View File
@@ -367,6 +367,7 @@ export const layer = Layer.effect(
const chunks: string[] = []
let failure: SessionError.Error | undefined
let usage: SessionUsage.Recorded | undefined
let providerState: SessionMessage.ProviderState | undefined
const recordUsage = Effect.suspend(() =>
usage
? bus.publish(SessionEvent.UsageRecorded, {
@@ -407,6 +408,7 @@ export const layer = Layer.effect(
// Ignored tool calls never enter the follow-up history or need fabricated results.
for (let attempt = 0; attempt < 2; attempt++) {
chunks.length = 0
providerState = undefined
yield* llm
.stream(
attempt === 0
@@ -436,6 +438,10 @@ export const layer = Layer.effect(
})
}
if (LLMEvent.is.stepFinish(event)) {
providerState =
event.providerMetadata?.[
context.model.model.route.providerMetadataKey ?? context.model.model.provider
]
const step = SessionUsage.record(event.usage, context.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
@@ -482,6 +488,8 @@ export const layer = Layer.effect(
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: context.session.id,
reason: input.reason,
model: context.model.ref,
providerState,
text: summary,
recent: history.recent,
})
@@ -410,6 +410,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
...current,
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
})
@@ -422,6 +424,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
status: "completed",
metadata: event.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
time: { created },
+26 -4
View File
@@ -3,6 +3,7 @@ export * as SessionTransfer from "./transfer.js"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Tool } from "@opencode-ai/schema/tool"
import { Skill } from "@opencode-ai/schema/skill"
import { Model } from "@opencode-ai/schema/model"
import { eq } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { map } from "effect/Array"
@@ -36,7 +37,7 @@ export interface Interface {
sanitize?: boolean
}) => Effect.Effect<Data, Session.NotFoundError | Session.MessageDecodeError>
readonly import: (input: {
data: Data
data: SessionTransfer.Import
location: Location.Ref
}) => Effect.Effect<Session.Info, ImportConflictError | Session.NotFoundError>
}
@@ -73,8 +74,26 @@ const layer = Layer.effect(
if (input.data.info.parentID) yield* sessions.get(input.data.info.parentID)
const project = yield* projects.resolve(input.location.directory)
yield* upsertProject(db, project).pipe(Effect.orDie)
const messages = input.data.messages.filter(isSettled).map((message, index) => {
const encoded = encodeMessage(message)
const settled = input.data.messages.filter(isSettled)
const models = settled.flatMap((message, index) =>
message.type === "assistant" || message.type === "model-switched" ? [{ model: message.model, index }] : [],
)
const messages = settled.map((message, index) => {
const encoded = encodeMessage(
message.type === "compaction" && message.status === "completed"
? {
...message,
model:
message.model ??
models.findLast((entry) => entry.index < index)?.model ??
models.find((entry) => entry.index > index)?.model ??
input.data.info.model ??
Model.Ref.parse("unknown/unknown"),
// An inferred model cannot authenticate provider state from an old export.
providerState: message.model ? message.providerState : undefined,
}
: message,
)
const { id: _, type, ...data } = encoded
return {
id: message.id,
@@ -160,7 +179,7 @@ export const node = makeGlobalNode({
deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
})
function isSettled(message: SessionMessage.Info) {
function isSettled(message: SessionTransfer.Import["messages"][number]) {
if (message.type === "assistant") return message.time.completed !== undefined
if (message.type === "shell" || message.type === "compaction") return message.status !== "running"
return true
@@ -296,6 +315,9 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
metadata: meta,
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
...(message.status === "completed"
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
: {}),
}
}
return { ...message, metadata: meta }
+26
View File
@@ -93,6 +93,32 @@ describe("Catalog", () => {
}),
)
it.effect("preserves provider identity when updating new and existing providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("original")
const renamed = Provider.ID.make("renamed")
yield* catalog.transform((editor) => {
editor.provider.update(providerID, (provider) => {
provider.id = renamed
provider.name = "Created"
})
expect(editor.provider.get(providerID)?.provider.id).toBe(providerID)
editor.provider.update(providerID, (provider) => {
provider.id = renamed
provider.name = "Updated"
})
})
expect(yield* catalog.provider.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
expect(yield* catalog.provider.get(renamed)).toBeUndefined()
expect((yield* catalog.provider.all()).map((provider) => provider.id)).toEqual([providerID])
yield* catalog.reload()
expect(yield* catalog.provider.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
}),
)
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const localCatalogLayer = Layer.fresh(
@@ -20,6 +20,7 @@ import workspaceMigration from "@opencode-ai/core/database/migration/20260808023
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260819222447_session_viewed_state"
import compactionModelMigration from "@opencode-ai/core/database/migration/20260902000000_compaction_model"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -62,6 +63,100 @@ const parkedClient = (arrived: Deferred.Deferred<void>, gate: Deferred.Deferred<
).pipe(Layer.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Layer.provide(Reactivity.layer))
describe("DatabaseMigration", () => {
for (const source of ["messages", "events"] as const) {
test.each([
["previous", "next", "selected", undefined, "previous"],
[undefined, "next", "selected", undefined, "next"],
[undefined, undefined, "selected", undefined, "selected"],
[undefined, undefined, undefined, undefined, "unknown"],
["previous", "next", "selected", "recorded", "recorded"],
])(
`backfills compaction models from ${source}: %s / %s / %s / %s`,
async (before, after, selected, recorded, expected) => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session_v2 (id TEXT PRIMARY KEY, model TEXT)`)
yield* db.run(
sql`CREATE TABLE session_message (id TEXT, session_id TEXT, seq INTEGER, type TEXT, data TEXT)`,
)
yield* db.run(sql`CREATE TABLE event (id TEXT, aggregate_id TEXT, seq INTEGER, type TEXT, data TEXT)`)
const model = (id: string) => ({
id,
providerID: id === "unknown" ? "unknown" : "provider",
variant: "variant",
})
yield* db.run(
sql`INSERT INTO session_v2 VALUES ('ses_test', ${selected ? JSON.stringify(model(selected)) : null})`,
)
for (const entry of [
{ id: "older", seq: 0 },
{ id: before, seq: 1 },
{ id: after, seq: 5 },
]) {
if (!entry.id || (entry.seq === 0 && !before)) continue
const data = JSON.stringify({ model: model(entry.id) })
if (source === "messages")
yield* db.run(
sql`INSERT INTO session_message VALUES (${entry.id}, 'ses_test', ${entry.seq}, 'assistant', ${data})`,
)
if (source === "events")
yield* db.run(
sql`INSERT INTO event VALUES (${entry.id}, 'ses_test', ${entry.seq}, 'session.step.started.1', ${data})`,
)
}
// A different session must never supply the inferred model.
yield* db.run(
sql`INSERT INTO session_message VALUES ('other', 'ses_other', 2, 'assistant', '{"model":{"id":"other","providerID":"other"}}')`,
)
const data = JSON.stringify({
status: "completed",
summary: "Summary",
recent: "Recent",
...(recorded ? { model: model(recorded), providerState: { opaque: "keep" } } : {}),
})
yield* db.run(sql`INSERT INTO session_message VALUES ('checkpoint', 'ses_test', 3, 'compaction', ${data})`)
yield* db.run(sql`INSERT INTO event VALUES ('ended', 'ses_test', 4, 'session.compaction.ended.1', ${data})`)
yield* db.run(
sql`INSERT INTO session_message VALUES ('running', 'ses_test', 6, 'compaction', '{"status":"running"}')`,
)
yield* db.run(
sql`INSERT INTO session_message VALUES ('failed', 'ses_test', 7, 'compaction', '{"status":"failed"}')`,
)
yield* DatabaseMigration.applyOnly(db, [compactionModelMigration])
yield* DatabaseMigration.applyOnly(db, [compactionModelMigration])
for (const table of ["session_message", "event"]) {
const row = yield* db.get<{
model: string
provider: string
variant: string | null
state: string | null
summary: string
}>(sql`
SELECT json_extract(data, '$.model.id') AS model, json_extract(data, '$.model.providerID') AS provider,
json_extract(data, '$.model.variant') AS variant, json_extract(data, '$.providerState.opaque') AS state,
json_extract(data, '$.summary') AS summary
FROM ${sql.identifier(table)} WHERE id = ${table === "event" ? "ended" : "checkpoint"}
`)
expect(row).toEqual({
model: expected,
provider: expected === "unknown" ? "unknown" : "provider",
variant: expected === "unknown" ? null : "variant",
state: recorded ? "keep" : null,
summary: "Summary",
})
}
expect(
yield* db.all(sql`SELECT data FROM session_message WHERE id IN ('running', 'failed') ORDER BY seq`),
).toEqual([{ data: '{"status":"running"}' }, { data: '{"status":"failed"}' }])
}),
)
},
)
}
test("defaults missing workspace names while preserving legacy workspace data", async () => {
await run(
Effect.gen(function* () {
+30 -15
View File
@@ -2,7 +2,7 @@ import { describe, expect, setDefaultTimeout } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { Clock, Duration, Effect, Layer, Schema } from "effect"
import { Clock, Duration, Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Repository } from "@opencode-ai/core/repository"
@@ -35,7 +35,7 @@ describe("RepositoryCache", () => {
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday, refreshedAt: yesterday })
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday })
}).pipe(Effect.provide(cacheLayer(fixture.root)))
const results = yield* Effect.all(
@@ -61,7 +61,30 @@ describe("RepositoryCache", () => {
),
)
it.live("throttles failed refresh attempts without marking them successful", () =>
it.live("honors legacy attempt records while allowing forced refreshes", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
yield* Effect.promise(() => commit(fixture.source, "two\n", "advance main"))
// Persisted records from older versions include an unused success timestamp.
yield* kv.set(`repository-cache:${initial.localPath}`, {
attemptedAt: yield* Clock.currentTimeMillis,
refreshedAt: 0,
})
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
expect((yield* cache.ensure({ reference: fixture.reference, refresh: true })).status).toBe("refreshed")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("throttles failed refresh attempts until the next interval", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
@@ -69,31 +92,23 @@ describe("RepositoryCache", () => {
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
const key = `repository-cache:${initial.localPath}`
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
yield* kv.set(key, { attemptedAt: yesterday })
yield* Effect.promise(() =>
fs.rename(path.join(fixture.root, "origin.git"), path.join(fixture.root, "offline.git")),
)
const error = yield* Effect.flip(cache.ensure({ reference: fixture.reference, refresh: "daily" }))
expect(error).toBeInstanceOf(RepositoryCache.FetchFailedError)
const stored = yield* kv.get(key)
const stamp = Schema.decodeUnknownSync(
Schema.Struct({ attemptedAt: Schema.Number, refreshedAt: Schema.Number }),
)(stored)
expect(stamp.attemptedAt).toBeGreaterThan(yesterday)
expect(stamp.refreshedAt).toBe(yesterday)
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* kv.get(key)).toEqual(stored)
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
yield* Effect.promise(async () => {
await fs.rename(path.join(fixture.root, "offline.git"), path.join(fixture.root, "origin.git"))
await commit(fixture.source, "two\n", "advance main")
})
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
yield* kv.set(key, { attemptedAt: yesterday })
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("refreshed")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
expect(yield* kv.get(key)).not.toEqual(stored)
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
@@ -157,13 +172,13 @@ describe("RepositoryCache", () => {
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const initial = yield* cache.ensure({ reference: fixture.reference })
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
yield* Effect.promise(async () => {
await git(initial.localPath, "config", "remote.origin.url", "https://github.com/other/repo.git")
await fs.writeFile(path.join(initial.localPath, "stale.txt"), "stale")
})
const replaced = yield* cache.ensure({ reference: fixture.reference })
const replaced = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
expect(replaced.status).toBe("cloned")
expect(yield* exists(path.join(replaced.localPath, "stale.txt"))).toBe(false)
@@ -1,62 +0,0 @@
import { expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Rpc } from "@opencode-ai/core/rpc"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer, Logger, Schema } from "effect"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(Rpc.node, [
Location.node.replace(Layer.succeed(Location.Service, location({ directory: AbsolutePath.make("/rpc-project") }))),
]),
)
const Broken = Rpc.define({
id: "broken",
methods: {
dies: { input: Schema.Undefined, output: Schema.String },
throws: { input: Schema.Undefined, output: Schema.String },
raw: { input: Schema.Undefined, output: Schema.String },
undeclared: { input: Schema.Undefined, output: Schema.String },
invalidError: {
input: Schema.Undefined,
output: Schema.String,
errors: { known: Schema.Struct({ count: Schema.Int }) },
},
},
events: {},
})
for (const method of ["dies", "throws", "raw", "undeclared", "invalidError"] as const) {
it.effect(`recovers from ${method} through the typed rpc.internal failure`, () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
yield* rpc.register(Broken, {
dies: () => Effect.die(new Error("handler defect")),
throws: () => {
throw new Error("handler threw")
},
// Raw Promise rejections reach this boundary as failed Effects.
// @ts-expect-error intentionally exercise an undeclared failure
raw: () => Effect.fail(new Error("raw failure")),
// @ts-expect-error intentionally exercise an undeclared error name
undeclared: (_input, context) => Effect.fail(context.error("unknown", "Unknown")),
invalidError: (_input, context) => Effect.fail(context.error("known", "Invalid count", { count: 1.5 })),
})
const logged: unknown[] = []
const result = yield* rpc
.client(Broken)
[method]()
.pipe(
Effect.catchIf(
(error) => "type" in error && error.type === "rpc.internal",
(error) => Effect.succeed(error),
),
Effect.provideService(Logger.CurrentLoggers, new Set([Logger.make((entry) => logged.push(entry.message))])),
)
expect(result).toEqual({ type: "rpc.internal", message: "RPC call failed" })
expect(logged).toHaveLength(1)
}),
)
}
@@ -263,6 +263,7 @@ it.effect("auto compaction estimates current content against the buffered prompt
type: "compaction",
status: "completed",
reason: "auto",
model: resolved.ref,
summary: "x".repeat(400_000),
recent: "",
time: { created: 0, completed: 0 },
+81
View File
@@ -1312,9 +1312,90 @@ describe("SessionTransfer", () => {
completedCompactionID,
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({
id: completedCompactionID,
model,
})
}),
)
it.effect("round-trips compaction model and provider state and sanitizes opaque state", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const source = yield* sessions.create({ location })
const model = Model.Ref.parse("provider/model#variant")
const providerState = { responseId: "private-response", nested: { secret: "opaque" } }
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: source.id,
reason: "manual",
model,
providerState,
text: "Summary",
recent: "",
})
const data = yield* transfer.export({ sessionID: source.id })
const imported = yield* transfer.import({
data: {
...data,
info: { ...data.info, id: Session.ID.create() },
messages: data.messages.map((message) => ({ ...message, id: SessionMessage.ID.create() })),
},
location,
})
expect((yield* sessions.messages({ sessionID: imported.id, order: "asc" }))[0]).toMatchObject({
model,
providerState,
})
const sanitized = yield* transfer.export({ sessionID: source.id, sanitize: true })
expect(sanitized.messages[0]).toMatchObject({
model,
providerState: { redacted: `compaction-provider-state:${data.messages[0].id}` },
})
}),
)
for (const source of ["before", "after", "session", "unknown"] as const) {
it.effect(`backfills imported compactions from ${source} without inventing provider state`, () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const template = yield* sessions.create({ location })
const model = Model.Ref.parse("provider/model#variant")
const checkpoint = {
id: SessionMessage.ID.create(),
type: "compaction" as const,
status: "completed" as const,
reason: "manual" as const,
summary: "Summary",
recent: "",
providerState: { unbound: "discard" },
time: { created: DateTime.makeUnsafe(1) },
}
const selected = SessionMessage.ModelSelected.make({
id: SessionMessage.ID.create(),
type: "model-switched",
model,
time: { created: DateTime.makeUnsafe(1) },
})
const imported = yield* transfer.import({
data: {
info: { ...template, id: Session.ID.create(), model: source === "session" ? model : undefined },
messages:
source === "before" ? [selected, checkpoint] : source === "after" ? [checkpoint, selected] : [checkpoint],
},
location,
})
const result = (yield* transfer.export({ sessionID: imported.id })).messages.find(
(message) => message.id === checkpoint.id,
)
expect(result).toMatchObject({ model: source === "unknown" ? Model.Ref.parse("unknown/unknown") : model })
expect(result && "providerState" in result ? result.providerState : undefined).toBeUndefined()
}),
)
}
it.effect("imports projected messages and reserves their aggregate sequence", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -252,7 +252,13 @@ describe("SessionInstructions", () => {
// A completed compaction truncates model-visible history at its boundary, dropping
// the synthetic that carried sub's instructions.
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "" })
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
model: Model.Ref.parse("test/model"),
text: "summary",
recent: "",
})
expect(yield* synthetics(sessionID)).toHaveLength(0)
// The model no longer has the rules, so the next read under the subtree must
@@ -415,6 +415,8 @@ describe("SessionProjector", () => {
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
model,
providerState: { responseId: "summary-response" },
text: "summary",
recent: "recent context",
})
@@ -455,6 +457,8 @@ describe("SessionProjector", () => {
time: { completed: DateTime.makeUnsafe(0) },
})
expect(messages.find((message) => message.type === "compaction")).toMatchObject({
model,
providerState: { responseId: "summary-response" },
summary: "summary",
recent: "recent context",
})
@@ -468,6 +472,25 @@ describe("SessionProjector", () => {
}),
)
it.effect("replays a compaction completion with its model and provider state", () =>
Effect.gen(function* () {
yield* seedSession()
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
yield* bus.replay({
id: Event.ID.create(),
created: 1,
aggregateID: sessionID,
seq: 0,
type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1),
data: { sessionID, reason: "manual", model, providerState: { opaque: "state" }, text: "summary", recent: "" },
})
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", model, providerState: { opaque: "state" } },
])
}),
)
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
Effect.gen(function* () {
const db = yield* seedSession()
@@ -147,6 +147,8 @@ describe("toLLMMessages", () => {
type: "compaction",
status: "completed",
reason: "auto",
model,
providerState: { responseId: "storage-only" },
summary: "Earlier work",
recent: "Recent work",
time: { created },
+52
View File
@@ -2177,6 +2177,9 @@ describe("SessionRunnerLLM", () => {
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
expect(s.executions).toEqual(["x".repeat(4_000)])
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
})
// Compare wire content without the cache breakpoints that move to the new final message.
const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" }))
@@ -2243,6 +2246,54 @@ describe("SessionRunnerLLM", () => {
}
}
for (const state of [true, false]) {
scenario(`compaction retains only accepted response state (state=${state})`, function* (s) {
// Provider aliases use the route's metadata namespace, not the catalog provider ID.
s.currentModel = LanguageModel.make({
id: "gpt-5",
provider: "alias",
route: OpenAIResponses.route,
})
yield* s.llm.push(TestLLM.text("Earlier answer", "state-history"))
yield* s.runPrompt("Earlier question")
const rejected = { responseId: "rejected-response" }
const accepted = { responseId: "accepted-response", opaque: { value: "provider-data" } }
const key = OpenAIResponses.route.providerMetadataKey ?? s.currentModel.provider
yield* s.llm.push(
TestLLM.complete(
{ reason: { normalized: "stop" }, providerMetadata: { [key]: rejected } },
LLMEvent.textDelta({ id: "invalid", text: "Not a summary" }),
),
TestLLM.complete(
{
reason: { normalized: "stop" },
providerMetadata: state ? { [key]: accepted, unrelated: { ignored: true } } : undefined,
},
LLMEvent.textDelta({ id: "summary", text: "## Objective\n- Accepted summary" }),
),
)
const compact = yield* s.session.compact({ sessionID })
yield* s.resume
const checkpoint = (yield* s.messages).find((message) => message.id === compact.id)
expect(checkpoint).toMatchObject({
status: "completed",
model: { providerID: "alias", id: "gpt-5" },
summary: "## Objective\n- Accepted summary",
})
if (checkpoint?.type !== "compaction" || checkpoint.status !== "completed")
return yield* Effect.die("Missing completed checkpoint")
expect(checkpoint.providerState).toEqual(state ? accepted : undefined)
const event = yield* s.db
.select({ data: EventTable.data })
.from(EventTable)
.where(sql`${EventTable.type} = 'session.compaction.ended.1'`)
.get()
.pipe(Effect.orDie)
expect(event?.data.model).toEqual(checkpoint.model)
expect(event?.data.providerState).toEqual(state ? accepted : undefined)
})
}
scenario("preserves typed provider failures from manual compaction", function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-failure-history"))
yield* s.runPrompt("Earlier question")
@@ -2648,6 +2699,7 @@ describe("SessionRunnerLLM", () => {
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
model: Model.Ref.parse(`${s.currentModel.provider}/${s.currentModel.id}`),
text: "summary",
recent: "",
})
+10 -1
View File
@@ -867,6 +867,7 @@ describe("V1Migration database workflow", () => {
('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21),
('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '2', NULL, NULL, 12, 22);
INSERT INTO session_message VALUES
('msg_next_checkpoint', 'ses_next', 'compaction', 3, 11, 12, '{"status":"completed","reason":"manual","summary":"Summary","recent":"","time":{"created":11}}'),
('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next''s history","time":{"created":12}}'),
('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'),
('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}');
@@ -908,13 +909,21 @@ describe("V1Migration database workflow", () => {
.where(eq(SessionTable.id, SessionSchema.ID.make("ses_next")))
.get(),
).toEqual({ directory: process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer" })
expect(yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next'`)).toEqual([
expect(
yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next' AND type = 'user'`),
).toEqual([
{
id: "msg_next",
seq: 4,
data: '{"text":"from next\'s history","time":{"created":12}}',
},
])
expect(
yield* db.get(sql`
SELECT json_extract(data, '$.model') AS model, json_extract(data, '$.providerState') AS state
FROM session_message WHERE id = 'msg_next_checkpoint'
`),
).toEqual({ model: '{"id":"model","providerID":"provider"}', state: null })
expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_next'`)).toEqual({
seq: 4,
owner_id: null,
+1 -1
View File
@@ -22,7 +22,7 @@ export type Info<
) => Promise<Tool.Result<Output>>
}
interface ToolEditor {
export interface ToolEditor {
list(): readonly (Info & { readonly id: string })[]
get(id: string): (Info & { readonly id: string }) | undefined
namespace(namespace: Tool.Namespace): void
+1 -1
View File
@@ -188,7 +188,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.post("session.import", "/api/session/import", {
payload: Schema.Struct({
...SessionTransfer.Data.fields,
...SessionTransfer.Import.fields,
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
+2
View File
@@ -585,6 +585,8 @@ export namespace Compaction {
schema: {
...Base,
reason: Started.data.fields.reason,
model: SessionMessage.CompactionCompleted.fields.model,
providerState: SessionMessage.CompactionCompleted.fields.providerState,
text: Schema.String,
recent: Schema.String,
},
+3
View File
@@ -250,6 +250,9 @@ export const CompactionCompleted = Schema.Struct({
...CompactionBase,
status: Schema.tag("completed"),
reason: Schema.Literals(["auto", "manual"]),
/** Producing model. Historical checkpoints may contain a best-effort backfill. */
model: Model.Ref,
providerState: ProviderState.pipe(optional),
summary: Schema.String,
recent: Schema.String,
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
+17
View File
@@ -3,9 +3,26 @@ export * as SessionTransfer from "./session-transfer.js"
import { Schema } from "effect"
import { Session } from "./session.js"
import { SessionMessage } from "./session-message.js"
import { optional } from "./schema.js"
export interface Data extends Schema.Schema.Type<typeof Data> {}
export const Data = Schema.Struct({
info: Session.Info,
messages: Schema.Array(SessionMessage.Info),
}).annotate({ identifier: "SessionTransfer.Data" })
// Older exports omitted the compaction model. Only the import boundary accepts
// this shape; Core fills the model before storing it under the current contract.
export interface Import extends Schema.Schema.Type<typeof Import> {}
export const Import = Schema.Struct({
info: Session.Info,
messages: Schema.Array(
Schema.Union([
...SessionMessage.Info.members,
Schema.Struct({
...SessionMessage.CompactionCompleted.fields,
model: SessionMessage.CompactionCompleted.fields.model.pipe(optional),
}),
]),
),
}).annotate({ identifier: "SessionTransfer.Import" })
@@ -0,0 +1,49 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { SessionMessage } from "../src/session-message.js"
import { SessionEvent } from "../src/session-event.js"
import { SessionTransfer } from "../src/session-transfer.js"
import { Model } from "../src/model.js"
const checkpoint = {
id: "msg_checkpoint",
type: "compaction",
status: "completed",
reason: "manual",
summary: "Summary",
recent: "",
time: { created: 1 },
} as const
const model = Model.Ref.parse("provider/model#variant")
test("completed compactions require a model and preserve optional opaque provider state", () => {
const decode = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted)
const encode = Schema.encodeSync(SessionMessage.CompactionCompleted)
expect(() => decode(checkpoint)).toThrow()
expect(encode({ ...decode({ ...checkpoint, model }), providerState: undefined })).toEqual({ ...checkpoint, model })
const providerState = { responseId: "response", nested: { opaque: [1, "value"] } }
expect(encode(decode({ ...checkpoint, model, providerState }))).toEqual({ ...checkpoint, model, providerState })
})
test("compaction completion events require the producing model", () => {
const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data)
const data = { sessionID: "ses_checkpoint", reason: "manual", text: "Summary", recent: "" } as const
expect(() => decode(data)).toThrow()
expect(Schema.encodeSync(SessionEvent.Compaction.Ended.data)(decode({ ...data, model }))).toEqual({ ...data, model })
})
test("only the import boundary accepts a historical checkpoint without a model", () => {
const data = {
info: {
id: "ses_checkpoint",
projectID: "global",
location: { directory: "/project" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
},
messages: [checkpoint],
}
expect(() => Schema.decodeUnknownSync(SessionTransfer.Import)(data)).not.toThrow()
expect(() => Schema.decodeUnknownSync(SessionTransfer.Data)(data)).toThrow()
})
+7 -5
View File
@@ -14,7 +14,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
return output === undefined ? {} : { output }
}).pipe(
Effect.mapError((error) =>
error.type === "rpc.invalid_output" || error.type === "rpc.internal"
error.type === "rpc.invalid_output"
? new RpcInternalError({ type: error.type, message: error.message })
: new RpcError({
type: error.type,
@@ -22,10 +22,12 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
...(error.data === undefined ? {} : { data: error.data }),
}),
),
// Defects outside handler execution are still logged, never echoed to the client.
Effect.catchDefect((defect) =>
Effect.logError("rpc call failed", { rpc: params.rpcID, method: params.method, defect }).pipe(
Effect.andThen(Effect.fail(new RpcInternalError({ type: "rpc.internal", message: "RPC call failed" }))),
Effect.catchDefect((error) =>
Effect.fail(
new RpcInternalError({
type: "rpc.internal",
message: error instanceof Error ? error.message : "RPC call failed",
}),
),
),
),
@@ -1,74 +0,0 @@
import { expect } from "bun:test"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Context, Effect, Layer, Schema } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createEmbeddedRoutes } from "../src/routes"
const Broken = Rpc.define({
id: "broken",
methods: {
handler: { input: Schema.String, output: Schema.String },
schema: {
input: Schema.String.check(
Schema.makeFilter(() => {
throw new Error("private schema detail")
}),
),
output: Schema.String,
},
},
events: {},
})
for (const method of ["handler", "schema"] as const) {
it.live(`returns HTTP 500 without exposing the ${method} defect`, () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const context = yield* Layer.build(
createEmbeddedRoutes({
database: { path: ":memory:" },
models: { fetch: false },
config: { directory: directory.path, project: false, content: "{}" },
fs: { filewatcher: false },
}).pipe(Layer.provide(HttpServer.layerServices)),
)
const sdk = Context.get(context, SdkPlugins.Service)
yield* sdk.register(
define({
id: "broken-rpc",
effect: (ctx) =>
ctx.rpc
.register(Broken, {
handler: () => Effect.die(new Error("private handler detail")),
schema: Effect.succeed,
})
.pipe(Effect.asVoid, Effect.orDie),
}),
)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const url = new URL(`/api/rpc/broken/${method}`, "http://opencode.local")
url.searchParams.set("location[directory]", directory.path)
const response = yield* Effect.promise(() =>
handler(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input: "hello" }),
}),
),
)
expect(response.status).toBe(500)
expect(yield* Effect.promise(() => response.json())).toEqual({
_tag: "RpcInternalError",
type: "rpc.internal",
message: "RPC call failed",
})
}),
)
}
@@ -252,13 +252,25 @@
justify-content: center;
flex-shrink: 0;
color: var(--v2-text-text-faint);
margin-left: auto;
margin-inline-start: auto;
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.15s ease,
color 0.15s ease;
color 0.15s ease,
transform 0.15s ease;
@media (hover: hover) {
opacity: 0;
:dir(rtl) & {
transform: translateX(4px);
}
@media (hover: none) {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
@@ -285,6 +297,7 @@
[data-component="task-tool-action"] {
opacity: 1;
transform: none;
}
}
}
@@ -996,6 +996,7 @@ export const compactionDocument = document([
type: "compaction",
status: "completed",
reason: "auto",
model: STORY_MODEL,
summary: "The Session timeline now consumes current nested assistant content.",
recent: "Add deterministic stories and verify Storybook.",
time: { created: STORY_TIME + 63_000 },
@@ -1398,13 +1398,13 @@ ToolRegistry.register({
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Show>
</div>
<Show when={clickable()}>
<div data-component="task-tool-action">
<Icon name="chevron-right" size="small" />
</div>
</Show>
</div>
</div>
<Show when={clickable()}>
<div data-component="task-tool-action">
<Icon name="square-arrow-top-right" size="small" />
</div>
</Show>
</div>
)
+21 -3
View File
@@ -1514,7 +1514,13 @@ test("tracks session status from active sessions and execution events", async ()
created: 3,
type: "session.compaction.ended",
durable: durable("session-manual", 4),
data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" },
data: {
sessionID: "session-manual",
reason: "manual",
model: { id: "model", providerID: "provider" },
text: "Streamed summary",
recent: "recent",
},
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
@@ -1557,7 +1563,13 @@ test("tracks session status from active sessions and execution events", async ()
created: 0,
type: "session.compaction.ended",
durable: durable("session-live", 5),
data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" },
data: {
sessionID: "session-live",
reason: "auto",
model: { id: "model", providerID: "provider" },
text: "Live summary",
recent: "recent",
},
})
await wait(() => {
const message = data.session.message.get("session-live", "msg_compaction_started")
@@ -1696,7 +1708,13 @@ test("restores queued compaction from durable pending input", async () => {
created: 3,
type: "session.compaction.ended",
durable: durable(sessionID, 7),
data: { sessionID, reason: "manual", text: "Summary", recent: "" },
data: {
sessionID,
reason: "manual",
model: { id: "model", providerID: "provider" },
text: "Summary",
recent: "",
},
})
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
@@ -211,6 +211,7 @@ test("resets the cross-turn cache baseline after compaction", () => {
id: "compaction-1",
status: "completed",
reason: "auto",
model: first.model,
summary: "Compacted context",
recent: "",
time: { created: 2 },
@@ -120,7 +120,7 @@ function compaction(status: "running" | "completed", summary: string): SessionMe
time: { created: 1 },
}
if (status === "running") return { ...message, status }
return { ...message, status }
return { ...message, status, model: { id: "model", providerID: "provider" } }
}
function form(id: string, sessionID: string, title = id): FormInfo {
@@ -536,7 +536,13 @@ describe("V2 mini transport", () => {
created: 3,
type: "session.compaction.ended",
durable: durable("ses_1", 3),
data: { sessionID: "ses_1", reason: "auto", text: "Transport", recent: "" },
data: {
sessionID: "ses_1",
reason: "auto",
model: { id: "model", providerID: "provider" },
text: "Transport",
recent: "",
},
})
while (!ui.commits.some((commit) => commit.phase === "final")) await Bun.sleep(0)
+1
View File
@@ -49,6 +49,7 @@ describe("util.session", () => {
type: "compaction",
status: "completed",
reason: "manual",
model: { id: "model", providerID: "provider" },
summary: "Current state",
recent: "",
time: { created: 0 },