mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 15:36:22 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a5ec6198 | ||
|
|
22de01e84f | ||
|
|
8565cb52a1 | ||
|
|
050398f51f | ||
|
|
5f1d74fd3f | ||
|
|
d9c85d8d95 | ||
|
|
1c77b1c920 | ||
|
|
27f838f249 |
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "",
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"))),
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: "",
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user