Compare commits

...
36 changed files with 1543 additions and 288 deletions
+479 -45
View File
@@ -55,6 +55,9 @@ const OpenResponsesOutputText = Schema.Struct({
text: Schema.String,
})
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
@@ -86,10 +89,14 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
Schema.Array(OpenResponsesFunctionCallOutputContent),
])
const OpenResponsesInputItem = Schema.Union([
export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenResponsesOutputText) }),
Schema.Struct({
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
}),
OpenResponsesReasoningItem,
OpenResponsesItemReference,
Schema.Struct({
@@ -104,7 +111,14 @@ const OpenResponsesInputItem = Schema.Union([
output: OpenResponsesFunctionCallOutput,
}),
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof OpenResponsesInputItem>
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem =
| OpenResponsesInputItem
| {
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
readonly phase?: MessagePhase | null
}
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
// multiple streamed summary parts into the same item before flushing.
@@ -135,7 +149,7 @@ export const ToolChoice = Schema.Union([
// transports in sync without a destructure-and-strip dance.
export const coreFields = {
model: Schema.String,
input: Schema.Array(OpenResponsesInputItem),
input: Schema.Array(InputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
@@ -179,6 +193,14 @@ const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
const StreamContent = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
text: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -187,6 +209,8 @@ export const StreamItem = Schema.StructWithRest(
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
content: optionalArray(StreamContent),
summary: optionalArray(StreamContent),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
@@ -206,7 +230,11 @@ export const Event = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
content_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -217,6 +245,7 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
output: optionalArray(StreamItem),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
@@ -238,6 +267,7 @@ export interface Extension {
readonly media: ProviderShared.ValidatedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -248,7 +278,16 @@ export interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly completedItems: ReadonlySet<string>
readonly functionArguments: Readonly<Record<string, string>>
readonly outputIndexes: Readonly<Record<string, number>>
readonly outputSequence: ReadonlyArray<string>
readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly outputText: Readonly<Record<string, string>>
readonly reasoningText: Readonly<Record<string, string>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
@@ -378,9 +417,9 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const system: OpenResponsesInputItem[] =
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: OpenResponsesInputItem[] = [...system]
const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -412,7 +451,27 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase =
ProviderShared.isRecord(metadata)
? messagePhase(metadata.phase, extension)
: undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
return groups
},
[],
)
input.push(
...groups.map((group) => ({
role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
)
content.splice(0, content.length)
}
for (const part of message.content) {
@@ -513,9 +572,9 @@ const lowerOptions = (request: LLMRequest) => {
}
}
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
request: LLMRequest,
extension: Extension = BASE,
extension: Extension,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
@@ -541,6 +600,12 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
}
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
})
// =============================================================================
// Stream Parsing
// =============================================================================
@@ -595,36 +660,105 @@ const NO_EVENTS: StepResult["1"] = []
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event): StepResult => {
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const key =
event.content_index === undefined
? id
: event.content_index === 0 && state.outputText[`${id}:0`] === undefined && state.outputText[id] !== undefined
? id
: `${id}:${event.content_index}`
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
{
...state,
lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta),
outputText: { ...state.outputText, [key]: `${state.outputText[key] ?? ""}${event.delta}` },
},
events,
]
}
const onOutputTextDone = (state: ParserState, event: Event): StepResult => {
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
}
const authoritativeSuffix = Effect.fn("OpenResponses.authoritativeSuffix")(function* (
state: ParserState,
kind: string,
id: string,
current: string,
value: string,
) {
if (value.startsWith(current)) return value.slice(current.length)
return yield* ProviderShared.eventError(
state.id,
`${kind} ${id} completed with content that conflicts with its streamed deltas`,
value,
)
})
export const onReasoningDelta = (state: ParserState, event: Event): StepResult => {
const onOutputTextDone = Effect.fn("OpenResponses.onOutputTextDone")(function* (
state: ParserState,
event: Event,
id: string,
) {
const key =
event.content_index === undefined
? id
: event.content_index === 0 && state.outputText[`${id}:0`] === undefined && state.outputText[id] !== undefined
? id
: `${id}:${event.content_index}`
const suffix =
event.text === undefined ? "" : yield* authoritativeSuffix(state, "output text", key, state.outputText[key] ?? "", event.text)
const [reconciled, deltaEvents] = onOutputTextDelta(state, { ...event, delta: suffix }, id)
if (reconciled.messageItems.has(id)) return [reconciled, deltaEvents] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.textEnd(reconciled.lifecycle, events, id)
return [{ ...reconciled, lifecycle }, [...deltaEvents, ...events]] satisfies StepResult
})
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const itemID = event.item_id ?? "reasoning-0"
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
reasoningText: { ...state.reasoningText, [id]: `${state.reasoningText[id] ?? ""}${event.delta}` },
},
events,
]
}
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
export const onReasoningDone = Effect.fn("OpenResponses.onReasoningDone")(function* (
state: ParserState,
event: Event,
) {
if (!event.item_id || event.text === undefined) return [state, NO_EVENTS] satisfies StepResult
const id =
event.summary_index !== undefined || state.reasoningItems[event.item_id]
? `${event.item_id}:${event.summary_index ?? 0}`
: event.item_id
const suffix = yield* authoritativeSuffix(state, "reasoning", id, state.reasoningText[id] ?? "", event.text)
const [reconciled, events] = onReasoningDelta(state, { ...event, delta: suffix }, event.item_id)
const item = reconciled.reasoningItems[event.item_id]
if (!suffix || !item || event.summary_index === undefined) return [reconciled, events] satisfies StepResult
return [
{
...reconciled,
reasoningItems: {
...reconciled.reasoningItems,
[event.item_id]: {
...item,
summaryParts: { ...item.summaryParts, [event.summary_index]: "active" },
},
},
},
events,
] satisfies StepResult
})
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
@@ -643,6 +777,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id)
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: (() => {
const phase = state.messagePhase(item.phase)
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
})(),
},
NO_EVENTS,
]
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -795,17 +941,78 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
export const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
event: Event,
) {
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
if (item.type === "message" && item.id) {
const itemID = item.id
const itemPhase = state.messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const messageItems = new Set([...state.messageItems, item.id])
const messagePhases = phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
const reconciledEvents: LLMEvent[] = []
const reconciled = yield* (item.content ?? [])
.map((part, contentIndex) => ({ part, contentIndex }))
.filter((entry) => entry.part.type === "output_text" && entry.part.text !== undefined)
.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, entry) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const [next, nextEvents] = yield* onOutputTextDone(
current,
{ ...event, content_index: entry.contentIndex, text: entry.part.text ?? "" },
itemID,
)
reconciledEvents.push(...nextEvents)
return next
}),
),
),
Effect.succeed({ ...state, messageItems, messagePhases }),
)
const events: LLMEvent[] = []
messageItems.delete(item.id)
const { [item.id]: _phase, ...remainingPhases } = reconciled.messagePhases
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textEnd(reconciled.lifecycle, events, item.id, metadata)
if (
!events.length &&
Object.keys(state.outputText).some((id) => id === item.id || id.startsWith(`${item.id}:`)) &&
metadata
)
events.push(LLMEvent.textEnd({ id: item.id, providerMetadata: metadata }))
return [
{
...reconciled,
lifecycle,
messageItems,
messagePhases: remainingPhases,
completedItems: new Set([...reconciled.completedItems, item.id]),
},
[...reconciledEvents, ...events],
] satisfies StepResult
}
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
if (state.completedItems.has(item.id)) {
if (item.arguments === undefined || item.arguments === state.functionArguments[item.id])
return [state, NO_EVENTS] satisfies StepResult
return yield* ProviderShared.eventError(
state.id,
`function call arguments ${item.id} completed with content that conflicts with its previous snapshot`,
item.arguments,
)
}
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
const authoritativeArguments = item.arguments ?? tools[item.id]?.input
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -821,6 +1028,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
hasFunctionCall:
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
completedItems: new Set([...state.completedItems, item.id]),
functionArguments: {
...state.functionArguments,
...(authoritativeArguments === undefined ? {} : { [item.id]: authoritativeArguments }),
},
tools: result.tools,
},
events,
@@ -830,25 +1042,147 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (isReasoningItem(item)) {
const events: LLMEvent[] = []
const metadata = reasoningMetadata(state, item)
const reasoningItem = state.reasoningItems[item.id]
const summaries = item.summary?.filter((part) => part.type === "summary_text" && part.text !== undefined) ?? []
if (state.completedItems.has(item.id)) {
const reconciled = yield* summaries
.map((part, index) => ({ part, index }))
.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, entry) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const [next, nextEvents] = yield* onReasoningDone(current, {
...event,
item_id: item.id,
summary_index: entry.index,
text: entry.part.text,
})
events.push(...nextEvents)
const endEvents: LLMEvent[] = []
const lifecycle = Lifecycle.reasoningEnd(
next.lifecycle,
endEvents,
`${item.id}:${entry.index}`,
metadata,
)
events.push(
...(endEvents.length
? endEvents
: [LLMEvent.reasoningEnd({ id: `${item.id}:${entry.index}`, providerMetadata: metadata })]),
)
return { ...next, lifecycle }
}),
),
),
Effect.succeed(state),
)
if (!summaries.length) {
const id = Object.keys(state.reasoningText)
.filter((id) => id === item.id || id.startsWith(`${item.id}:`))
.at(-1) ?? `${item.id}:0`
events.push(LLMEvent.reasoningEnd({ id, providerMetadata: metadata }))
}
return [reconciled, events] satisfies StepResult
}
if (summaries.length === 1 && !state.reasoningItems[item.id] && state.reasoningText[item.id] !== undefined) {
const [reconciled, reconciledEvents] = yield* onReasoningDone(state, {
...event,
item_id: item.id,
text: summaries[0]?.text,
})
events.push(...reconciledEvents)
return [
{
...reconciled,
lifecycle: Lifecycle.reasoningEnd(reconciled.lifecycle, events, item.id, metadata),
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
] satisfies StepResult
}
const needsSummaryReconciliation = summaries.some(
(part, index) => part.text !== state.reasoningText[`${item.id}:${index}`],
)
const seeded =
needsSummaryReconciliation && !state.reasoningItems[item.id]
? onOutputItemAdded(state, { ...event, item })
: ([state, NO_EVENTS] satisfies StepResult)
events.push(...seeded[1])
const reconciled = yield* (needsSummaryReconciliation ? summaries : [])
.map((part, index) => ({ part, index }))
.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, entry) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const added = current.reasoningItems[item.id]?.summaryParts[entry.index]
? ([current, NO_EVENTS] satisfies StepResult)
: onReasoningSummaryPartAdded(current, {
...event,
item_id: item.id,
summary_index: entry.index,
})
events.push(...added[1])
const [next, nextEvents] = yield* onReasoningDone(added[0], {
...event,
item_id: item.id,
summary_index: entry.index,
text: entry.part.text,
})
events.push(...nextEvents)
const [done, doneEvents] = onReasoningSummaryPartDone(next, {
...event,
item_id: item.id,
summary_index: entry.index,
})
events.push(...doneEvents)
return done
}),
),
),
Effect.succeed(seeded[0]),
)
const reasoningItem = reconciled.reasoningItems[item.id]
if (reasoningItem) {
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
reconciled.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
const { [item.id]: _removed, ...reasoningItems } = reconciled.reasoningItems
return [
{
...reconciled,
lifecycle,
reasoningItems,
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
if (summaries.length) {
events.push(LLMEvent.reasoningEnd({ id: `${item.id}:${summaries.length - 1}`, providerMetadata: metadata }))
return [
{ ...reconciled, completedItems: new Set([...reconciled.completedItems, item.id]) },
events,
] satisfies StepResult
}
if (!reconciled.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(reconciled.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
return [
{ ...reconciled, lifecycle, completedItems: new Set([...reconciled.completedItems, item.id]) },
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
{
...reconciled,
lifecycle: Lifecycle.reasoningEnd(reconciled.lifecycle, events, item.id, metadata),
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
] satisfies StepResult
}
@@ -856,11 +1190,47 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
export const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
state: ParserState,
event: Event,
onItemDone: (state: ParserState, event: Event) => Effect.Effect<StepResult, LLMError> = onOutputItemDone,
) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
const output = event.response?.output ?? []
const terminalIndexes = Object.fromEntries(
output.flatMap((item, index) => (item.id === undefined ? [] : [[item.id, index] as const])),
)
const emitted = state.outputSequence.filter((id) => terminalIndexes[id] !== undefined)
const expected = output.flatMap((item) =>
item.id !== undefined && state.outputIndexes[item.id] !== undefined ? [item.id] : [],
)
const missingBeforeEmitted = output.some(
(item, index) =>
item.id !== undefined &&
state.outputIndexes[item.id] === undefined &&
Object.values(state.outputIndexes).some((seen) => seen > index),
)
if (missingBeforeEmitted || emitted.some((id, index) => id !== expected[index]))
return yield* ProviderShared.eventError(
state.id,
`${state.name} terminal output conflicts with the streamed output order`,
)
const reconciled = yield* output.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, item) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const [next, nextEvents] = yield* onItemDone(current, { ...event, item })
events.push(...nextEvents)
return next
}),
),
),
Effect.succeed(state),
)
const lifecycle = Lifecycle.finish(reconciled.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, state.hasFunctionCall),
normalized: mapFinishReason(event, reconciled.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
@@ -872,8 +1242,8 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
})
: undefined,
})
return [{ ...state, lifecycle }, events]
}
return [{ ...reconciled, lifecycle }, events] satisfies StepResult
})
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
@@ -898,22 +1268,72 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
})
}
export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta")
return Effect.succeed(onReasoningDelta(state, event))
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done")
return Effect.succeed(onReasoningDone(state, event))
export const step = (state: ParserState, event: Event): Effect.Effect<StepResult, LLMError> => {
const outputItemID = event.item_id ?? event.item?.id
if (
outputItemID &&
event.output_index !== undefined &&
state.outputIndexes[outputItemID] === undefined
)
return step(
{
...state,
outputIndexes: { ...state.outputIndexes, [outputItemID]: event.output_index },
outputSequence: [...state.outputSequence, outputItemID],
},
{ ...event, output_index: undefined },
)
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return event.type === "response.output_text.delta"
? Effect.succeed(onOutputTextDelta(state, event, event.item_id))
: onOutputTextDone(state, event, event.item_id)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return onReasoningDone(state, event)
}
if (event.type === "response.reasoning_summary_part.added")
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_summary_part.done")
return Effect.succeed(onReasoningSummaryPartDone(state, event))
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
return event.item_id
? 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)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
}
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
if (event.type === "response.function_call_arguments.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.arguments === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing arguments`)
const tool = state.tools[event.item_id]
if (!tool)
return ProviderShared.eventError(state.id, `${state.name} completed tool arguments are missing their tool call`)
return Effect.succeed(
[
{
...state,
tools: { ...state.tools, [event.item_id]: { ...tool, input: event.arguments } },
},
NO_EVENTS,
] satisfies StepResult,
)
}
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
return onResponseFinish(state, event)
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
return Effect.succeed<StepResult>([state, NO_EVENTS])
@@ -931,12 +1351,26 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
completedItems: new Set<string>(),
functionArguments: {},
outputIndexes: {},
outputSequence: [],
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
outputText: {},
reasoningText: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
if (value === "commentary" || value === "final_answer") return value
return extension.messagePhase?.(value)
}
export const protocol = Protocol.make({
id: ADAPTER,
body: {
+41 -6
View File
@@ -35,8 +35,18 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.InputItem,
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -60,6 +70,7 @@ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIRes
const extension = {
id: ADAPTER,
name: NAME,
messagePhase: (value: unknown) => (value === null ? null : undefined),
lowerMedia: ({ part, media, request }) => {
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
return {
@@ -102,7 +113,7 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
})
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequest(
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
)
@@ -183,6 +194,7 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
state: OpenResponses.ParserState,
item: HostedToolItem,
) {
if (state.completedItems.has(item.id)) return [state, []] satisfies OpenResponses.StepResult
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
@@ -203,16 +215,39 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
return [
{ ...state, lifecycle, completedItems: new Set([...state.completedItems, item.id]) },
events,
] satisfies OpenResponses.StepResult
})
const onOutputItemDone = (state: OpenResponses.ParserState, event: OpenResponses.Event) =>
event.item && isHostedToolItem(event.item)
? onHostedToolDone(state, event.item)
: OpenResponses.onOutputItemDone(state, event)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const outputItemID = event.item_id ?? event.item?.id
if (outputItemID && event.output_index !== undefined && state.outputIndexes[outputItemID] === undefined)
return step(
{
...state,
outputIndexes: { ...state.outputIndexes, [outputItemID]: event.output_index },
outputSequence: [...state.outputSequence, outputItemID],
},
{ ...event, output_index: undefined },
)
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return Effect.succeed(OpenResponses.onReasoningDelta(state, event))
return 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.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return Effect.succeed(OpenResponses.onReasoningDone(state, event))
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
return onHostedToolDone(state, event.item)
return event.item_id
? OpenResponses.onReasoningDone(state, event)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
if (event.type === "response.completed" || event.type === "response.incomplete")
return OpenResponses.onResponseFinish(state, event, onOutputItemDone)
return OpenResponses.step(state, event)
}
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src"
import { LLM, LLMEvent, Message } from "../../src"
import { configure } from "../../src/providers/openai-compatible-responses"
import { OpenAI } from "../../src/providers"
import { OpenResponses } from "../../src/protocols/open-responses"
@@ -70,6 +70,31 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
Message.assistant({
type: "text",
text: "Unclassified.",
providerMetadata: { openresponses: { phase: null } },
}),
],
}),
)
expect(prepared.body).toMatchObject({
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
})
}),
)
it.effect("reads standard options from the Open Responses namespace", () =>
Effect.gen(function* () {
const model = configure({
@@ -882,6 +882,300 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles authoritative output text done values", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Hello!" },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Hello!" }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Hello!")
expect(response.events.filter(LLMEvent.is.textDelta)).toEqual([
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
])
}),
)
it.effect("reconciles output text independently by content index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{
type: "response.output_text.delta",
item_id: "msg_1",
content_index: 0,
delta: "First",
},
{
type: "response.output_text.done",
item_id: "msg_1",
content_index: 0,
text: "First.",
},
{
type: "response.output_text.delta",
item_id: "msg_1",
content_index: 1,
delta: "Second",
},
{
type: "response.output_text.done",
item_id: "msg_1",
content_index: 1,
text: "Second.",
},
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [
{ type: "output_text", text: "First." },
{ type: "output_text", text: "Second." },
],
},
},
{ type: "response.completed", response: {} },
),
),
),
)
expect(response.text).toBe("First.Second.")
}),
)
it.effect("recovers output from the authoritative terminal response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Terminal only." }],
},
],
},
},
),
),
),
)
expect(response.text).toBe("Terminal only.")
expect(response.events.filter(LLMEvent.is.textDelta)).toEqual([
{ type: "text-delta", id: "msg_1", text: "Terminal only." },
])
}),
)
it.effect("rejects terminal output that cannot be restored to authoritative order", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 1,
item: { type: "message", id: "msg_later" },
},
{
type: "response.output_text.delta",
item_id: "msg_later",
output_index: 1,
content_index: 0,
delta: "Later",
},
{
type: "response.completed",
response: {
output: [
{
type: "message",
id: "msg_earlier",
content: [{ type: "output_text", text: "Earlier" }],
},
{
type: "message",
id: "msg_later",
content: [{ type: "output_text", text: "Later" }],
},
],
},
},
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("terminal output conflicts with the streamed output order")
}),
)
it.effect("rejects authoritative text that conflicts with streamed deltas", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Goodbye" },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("completed with content that conflicts with its streamed deltas")
}),
)
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "message", id: "msg_commentary" },
},
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
{ type: "response.output_text.done", item_id: "msg_commentary" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_commentary", phase: "commentary" },
},
{
type: "response.output_item.added",
item: { type: "message", id: "msg_final", phase: "final_answer" },
},
{ type: "response.output_text.done", item_id: "msg_final", text: "Finished." },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_final", phase: "final_answer" },
},
{ type: "response.output_item.added", item: { type: "message", id: "msg_null", phase: null } },
{ type: "response.output_text.delta", item_id: "msg_null", delta: "Unclassified." },
{ type: "response.output_item.done", item: { type: "message", id: "msg_null", phase: null } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
providerMetadata: { openai: { phase: null } },
},
])
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(prepared.body.input).toEqual([
{
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
{
role: "assistant",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
{
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
])
}),
)
it.effect("rejects output text events without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", delta: "orphaned" },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.output_text.delta is missing item_id")
}),
)
it.effect("rejects reasoning events without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
{ type: "response.reasoning_summary_part.added", summary_index: 0 },
{ type: "response.reasoning_summary_part.done", summary_index: 0 },
{ type: "response.reasoning_text.done" },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain(`${event.type} is missing item_id`)
}
}),
)
it.effect("maps incomplete response reasons", () =>
Effect.gen(function* () {
const generate = (incompleteDetails: object) =>
@@ -971,6 +1265,110 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles authoritative reasoning done values", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "thinking..." },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("thinking...")
}),
)
it.effect("recovers terminal-only reasoning summaries sequentially", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.completed",
response: {
output: [
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
},
],
},
}),
),
),
)
expect(response.message.content.filter((part) => part.type === "reasoning")).toEqual([
{
type: "reasoning",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning",
text: "Second",
providerMetadata: {
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
},
])
expect(
response.events
.filter((event) => event.type.startsWith("reasoning-"))
.map((event) => `${event.type}:${event.id}`),
).toEqual([
"reasoning-start:rs_1:0",
"reasoning-delta:rs_1:0",
"reasoning-end:rs_1:0",
"reasoning-start:rs_1:1",
"reasoning-delta:rs_1:1",
"reasoning-end:rs_1:1",
])
}),
)
it.effect("updates completed reasoning metadata without adding a phantom block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "stale", summary: [] },
},
{
type: "response.completed",
response: {
output: [{ type: "reasoning", id: "rs_1", encrypted_content: "final", summary: [] }],
},
},
),
),
),
)
expect(response.message.content.filter((part) => part.type === "reasoning")).toEqual([
{
type: "reasoning",
text: "",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "final" } },
},
])
}),
)
it.effect("preserves encrypted reasoning metadata for continuation", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1453,6 +1851,57 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses authoritative function argument done values", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"stale"}' },
{
type: "response.function_call_arguments.done",
item_id: "item_1",
arguments: '{"query":"authoritative"}',
},
{
type: "response.output_item.done",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup" },
},
{
type: "response.completed",
response: {
output: [
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"authoritative"}',
},
],
},
},
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
input: { query: "authoritative" },
})
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -1527,7 +1976,10 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
{
type: "response.completed",
response: { output: [item], usage: { input_tokens: 5, output_tokens: 1 } },
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
@@ -1586,6 +2038,40 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("recovers hosted tools from the terminal response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.completed",
response: {
output: [
{
type: "image_generation_call",
id: "ig_1",
status: "completed",
result: "AQID",
},
],
},
}),
),
),
)
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
id: "ig_1",
name: "image_generation",
providerExecuted: true,
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
})
}),
)
it.effect("rejects malformed image generation base64", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
+27 -19
View File
@@ -98,8 +98,6 @@ export type SessionMessageShell = {
output?: { output: string; cursor: number; size: number; truncated: boolean }
}
export type SessionMessageAssistantText = { type: "text"; text: string }
export type SessionMessageProviderState = { [x: string]: JsonValue }
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
@@ -157,8 +155,6 @@ export type ShellInfo = {
time: { started: number; completed?: number }
}
export type SessionMessageProviderState3 = { [x: string]: any }
export type SessionMessageProviderState4 = { [x: string]: any }
export type SessionMessageProviderState5 = { [x: string]: any }
@@ -167,6 +163,10 @@ export type SessionMessageProviderState6 = { [x: string]: any }
export type SessionMessageProviderState7 = { [x: string]: any }
export type SessionMessageProviderState8 = { [x: string]: any }
export type SessionMessageProviderState9 = { [x: string]: any }
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
@@ -733,16 +733,6 @@ export type SessionTextStarted = {
data: { sessionID: string; assistantMessageID: string; ordinal: number }
}
export type SessionTextEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string }
}
export type SessionToolInputStarted = {
id: string
created: number
@@ -1249,6 +1239,8 @@ export type SessionPendingSynthetic = {
delivery: "steer" | "queue"
}
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
export type SessionMessageAssistantReasoning = {
type: "reasoning"
text: string
@@ -1359,6 +1351,22 @@ export type ShellCreated = {
data: { info: ShellInfo }
}
export type SessionTextEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
ordinal: number
text: string
state?: SessionMessageProviderState4
}
}
export type SessionReasoningStarted = {
id: string
created: number
@@ -1366,7 +1374,7 @@ export type SessionReasoningStarted = {
type: "session.reasoning.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState3 }
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState5 }
}
export type SessionReasoningEnded = {
@@ -1381,7 +1389,7 @@ export type SessionReasoningEnded = {
assistantMessageID: string
ordinal: number
text: string
state?: SessionMessageProviderState4
state?: SessionMessageProviderState6
}
}
@@ -1398,7 +1406,7 @@ export type SessionToolCalled = {
callID: string
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState5
state?: SessionMessageProviderState7
}
}
@@ -1853,7 +1861,7 @@ export type SessionToolSuccess = {
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState6
resultState?: SessionMessageProviderState8
}
}
@@ -1872,7 +1880,7 @@ export type SessionToolFailed = {
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState7
resultState?: SessionMessageProviderState9
}
}
+5 -3
View File
@@ -342,7 +342,6 @@ export type DiscoveryPlan = {
export type SearchEntry = {
readonly description: ToolDescription
readonly namespace: string
readonly searchText: string
}
@@ -373,7 +372,11 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
const scoped =
request.namespace === undefined
? searchIndex
: searchIndex.filter((entry) => entry.namespace === request.namespace)
: searchIndex.filter(
(entry) =>
entry.description.path === request.namespace ||
entry.description.path.startsWith(`${request.namespace}.`),
)
const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact =
@@ -428,7 +431,6 @@ export const searchSignature = (() => {
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
namespace: path.split(".", 1)[0]!,
searchText: [
path,
tool.description,
+21
View File
@@ -54,6 +54,27 @@ describe("dotted tool names", () => {
expect(flat.catalog()[0]?.path).toBe("issues.list")
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
})
test("search scopes to a nested namespace subtree", async () => {
const nested = CodeMode.make({
tools: {
slack: {
admin: echo("Admin", "admin"),
"admin.invite": echo("Invite", "invite"),
"admin.users.list": echo("List users", "users"),
"administrator.list": echo("List administrators", "administrators"),
read: echo("Read Slack", "read"),
},
},
})
const result = await value(nested, `return search({ query: "", namespace: "slack.admin" })`)
expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.slack.admin",
"tools.slack.admin.invite",
"tools.slack.admin.users.list",
])
})
})
describe("callable namespaces", () => {
-1
View File
@@ -63,7 +63,6 @@ const layer = Layer.effect(
if (rule?.resource === "*" && rule.effect === "deny") continue
registrations.set(name, registration)
}
if (registrations.size === 0) return {}
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
return {
+15 -15
View File
@@ -6,9 +6,7 @@ import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
Inside Code Mode, \`tools\` contains only the tools shown below${hasMoreTools ? " or returned by `search`" : ""}; surrounding top-level agent tools are not available and must not be called from the code.${hasMoreTools ? `
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
## Search
@@ -19,7 +17,8 @@ Use \`search\` to discover exact paths and signatures for additional tools:
## Available tools`
export function render(catalog: CodeModeCatalog.Summary) {
if (catalog.total === 0) return "No tools are currently available."
if (catalog.total === 0)
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
const tools = catalog.namespaces.flatMap((namespace) => {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
@@ -38,12 +37,13 @@ ${tools.join("\n")}`
}
export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) {
const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
const replacement = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
${render(current)}`
if (current.total === 0) return replacement
const previousComplete = previous.shown === previous.total
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return full
if (previousComplete !== currentComplete) return replacement
const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
@@ -54,7 +54,7 @@ ${render(current)}`
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0
if (!currentComplete) {
if (entriesChanged) return full
if (entriesChanged) return replacement
const namespaces = Instructions.diffByKey(
previous.namespaces,
current.namespaces,
@@ -62,7 +62,7 @@ ${render(current)}`
(before, after) => before.count !== after.count,
)
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
if (!changed) return full
if (!changed) return replacement
const parts = ["The Code Mode tool catalog has changed."]
if (namespaces.added.length > 0) {
@@ -87,11 +87,11 @@ ${render(current)}`
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
if (delta.length < replacement.length) return delta
return replacement
}
if (!entriesChanged) return full
if (!entriesChanged) return replacement
const parts = ["The Code Mode tool catalog has changed."]
if (diff.added.length > 0) {
parts.push(
@@ -117,19 +117,19 @@ ${render(current)}`
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
if (delta.length < replacement.length) return delta
return replacement
}
const key = Instructions.Key.make("core/codemode")
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.Instructions => {
const catalog = CodeModeCatalog.summarize(entries ?? [])
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
return Instructions.make({
key,
codec,
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
read: Effect.succeed(catalog),
render: {
initial: render,
changed: update,
+16 -16
View File
@@ -4,7 +4,7 @@ Use this guide as the starting point for work involving OpenCode itself. It
covers the core concepts needed to configure and customize OpenCode, extend it
with plugins, and build integrations with the OpenCode SDK, clients, and API.
Full documentation is available at <https://v2.opencode.ai/>. This overview is
Full documentation is available at <https://v2.opencode.ai/docs>. This overview is
only an index of core concepts. Before answering a question about a topic below,
fetch the URL named in that section and use the full page as the source of
truth. Follow links from that page when the question needs more detail. Fetch
@@ -16,7 +16,7 @@ documentation page.
Always answer for OpenCode V2 unless the user explicitly asks about V1,
legacy OpenCode, or migrating from V1.
Use only <https://v2.opencode.ai/> documentation as the source of truth for V2.
Use only <https://v2.opencode.ai/docs> documentation as the source of truth for V2.
Do not use <https://opencode.ai/docs/>, which documents V1, and do not use
general web search to resolve a V2 documentation question when the V2 docs or
their `llms.txt` index cover it. The schema served from
@@ -29,7 +29,7 @@ V1 documentation and syntax may be consulted only when the user explicitly
asks about V1 or when needed as migration input. Outputs and recommendations
must still use V2 unless the user specifically requests a V1 result.
## [Configuration](https://v2.opencode.ai/config)
## [Configuration](https://v2.opencode.ai/docs/config)
OpenCode configuration uses JSON or JSONC. Include the published schema so the
user's editor can validate fields and provide autocomplete:
@@ -60,14 +60,14 @@ linked topic guide as the source of truth, and preserve unrelated settings when
editing an existing file. Keep the published `$schema` URL in configuration
examples, but do not fetch it to determine the V2 configuration shape.
See the [full configuration guide](https://v2.opencode.ai/config) for
See the [full configuration guide](https://v2.opencode.ai/docs/config) for
every field, examples, config locations, and links to dedicated feature guides.
## [V1 to V2 migration](https://v2.opencode.ai/migrate-v1)
## [V1 to V2 migration](https://v2.opencode.ai/docs/migrate-v1)
For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://v2.opencode.ai/migrate-v1) before acting. In
[migration guide](https://v2.opencode.ai/docs/migrate-v1) before acting. In
the repository, its source is `packages/docs/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible.
@@ -76,18 +76,18 @@ V2 config uses more ergonomic shapes, but conversion is optional. When the user
requests conversion, inspect the complete configuration, preserve behavior and
unrelated settings, and apply only the relevant migrations from the guide. For
plugin migrations, fetch and follow both the migration guide and the full
[plugins guide](https://v2.opencode.ai/build/plugins). If non-API V1
[plugins guide](https://v2.opencode.ai/docs/build/plugins). If non-API V1
functionality fails in V2, use the `report` skill to file it as a compatibility
bug.
## [Plugins](https://v2.opencode.ai/build/plugins)
## [Plugins](https://v2.opencode.ai/docs/build/plugins)
For questions about creating, configuring, loading, publishing, or migrating
plugins, fetch the full [plugins guide](https://v2.opencode.ai/build/plugins)
plugins, fetch the full [plugins guide](https://v2.opencode.ai/docs/build/plugins)
before answering. This includes questions about the Effect plugin API, hooks,
transforms, tools, plugin context capabilities, and package entrypoints.
## [Service](https://v2.opencode.ai/troubleshooting#check-the-background-service)
## [Service](https://v2.opencode.ai/docs/troubleshooting#check-the-background-service)
OpenCode uses a client-server architecture. Interfaces such as the TUI connect
to a background OpenCode service, which owns sessions, configuration, plugins,
@@ -106,7 +106,7 @@ Check its status after restarting:
opencode2 service status
```
## [API](https://v2.opencode.ai/api)
## [API](https://v2.opencode.ai/docs/api)
OpenCode exposes an HTTP API from its server. The API is described by an
OpenAPI document available from the running server at `/openapi.json`.
@@ -135,15 +135,15 @@ connected to an explicit server instead of its managed background service, use
the same configured server and authentication context rather than constructing
an unauthenticated request separately.
See the [full API reference](https://v2.opencode.ai/api) for available
See the [full API reference](https://v2.opencode.ai/docs/api) for available
endpoints, parameters, request bodies, and response schemas. The
raw [OpenAPI specification](https://v2.opencode.ai/openapi.json) is also
available for code generation and other tooling.
## [Client](https://v2.opencode.ai/build/client)
## [Client](https://v2.opencode.ai/docs/build/client)
For questions about connecting an application to OpenCode over the network,
fetch the full [client guide](https://v2.opencode.ai/build/client) before
fetch the full [client guide](https://v2.opencode.ai/docs/build/client) before
answering.
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
@@ -154,7 +154,7 @@ exposes typed Effects, Streams, and decoded OpenCode schema values. Its
`Service` API can discover, start, stop, and authenticate with the local
background service from a Node application.
## [Troubleshooting](https://v2.opencode.ai/troubleshooting)
## [Troubleshooting](https://v2.opencode.ai/docs/troubleshooting)
OpenCode runs a client and a background server. Start by determining whether a
problem belongs to the client, the shared server, or one project.
@@ -174,6 +174,6 @@ problem belongs to the client, the shared server, or one project.
- Redact API keys, authorization headers, prompts, file contents, and other
sensitive data before sharing diagnostics.
See the [full troubleshooting guide](https://v2.opencode.ai/troubleshooting)
See the [full troubleshooting guide](https://v2.opencode.ai/docs/troubleshooting)
for service lifecycle commands, API inspection, log locations, explicit server
connections, issue-reporting details, and local development paths.
+4 -1
View File
@@ -319,7 +319,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft)
if (match) match.text = event.data.text
if (match) {
match.text = event.data.text
match.state = castDraft(event.data.state)
}
})
},
"session.tool.input.started": (event) => {
+105 -110
View File
@@ -1,7 +1,7 @@
export * as SessionRunnerLLM from "./llm"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent, type ToolCall } from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect"
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { PermissionV2 } from "../../permission"
@@ -18,7 +18,7 @@ import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { Service } from "./index"
import { createLLMEventPublisher } from "./publish-llm-event"
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event"
import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
@@ -70,7 +70,7 @@ const classifyToolExits = (
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeFailReason(reason.error)]) : [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})[0]
}).at(0)
return {
interrupted: causes.some(Cause.hasInterrupts),
declines,
@@ -79,6 +79,10 @@ const classifyToolExits = (
}
}
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -226,7 +230,6 @@ const layer = Layer.effect(
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
let needsContinuation = false
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
@@ -238,15 +241,9 @@ const layer = Layer.effect(
snapshot: startSnapshot,
assistantMessageID,
})
const publication = Semaphore.makeUnsafe(1)
// Durable publishes are serialized so tool fibers and step settlement never interleave
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens,
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
tokens: finish.tokens,
})
const captureStepEnd = Effect.fnUntraced(function* () {
@@ -260,20 +257,26 @@ const layer = Layer.effect(
return { snapshot, files }
})
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
const publishStepEnd = (finish: NonNullable<StepRecord["finish"]>) =>
Effect.gen(function* () {
const end = yield* captureStepEnd()
yield* serialized(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
...stepUsage(settlement),
...end,
}),
)
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: finish.finish,
...stepUsage(finish),
...end,
})
})
// Concurrent writers, no lock: the provider loop and each tool fiber publish
// durable events unserialized. This is safe because every publisher method commits
// its state marks synchronously before its first await (see publish-llm-event.ts),
// every required event order is per-source (each source is one sequential fiber),
// and a fiber's events are causally after its own Tool.Called: the fork happens
// below that publish. Cross-source order is unconstrained; either interleaving is
// a truthful history of concurrent work.
//
// The stream is defined here but runs inside the settlement mask below: publish each
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
@@ -283,20 +286,13 @@ const layer = Layer.effect(
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
if (isContextOverflowFailure(event) && !publisher.record().outputStarted) {
overflowFailure = event
return
}
}
yield* publish(event)
if (LLMEvent.is.toolInputError(event)) {
if (!prepared.stepLimitReached) needsContinuation = true
return
}
yield* publisher.publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
// Unavailable calls fail individually through the same execution seam;
// continuation depends only on remaining Step allowance.
if (!prepared.stepLimitReached) needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
toolRuns.push({
call: event,
@@ -307,20 +303,23 @@ const layer = Layer.effect(
agent: agent.id,
messageID: assistantMessageID,
call: event,
progress: (update) => serialized(publisher.progress(event.id, update)),
// Progress is ephemeral, not durable history: nothing to order.
progress: (update) => publisher.progress(event.id, update),
}),
).pipe(
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
// The fiber owns its call: it publishes its own completion, masked so a
// finished execution always reaches its durable settlement.
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
),
).pipe(Effect.forkScoped),
})
}),
),
Effect.ensuring(serialized(publisher.flush())),
Effect.ensuring(publisher.flush()),
)
// Settle: only the stream itself is interruptible (restore); every line after it is
// protected so a started call always reaches one durable outcome.
// Settle: only the stream and the fiber joins are interruptible (restore); every
// other line is protected so a started call always reaches one durable outcome.
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
@@ -329,107 +328,103 @@ const layer = Layer.effect(
// away non-interrupt failures, so both interrupt checks stay Cause-based.
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
// Join every owned tool run first: await all exits, not just the first failure.
// Afterwards no fiber is alive, settlement is the only writer, and the record
// is final. A failed join means the waiting itself was interrupted, so the runs
// we abandoned are interrupted before settlement closes them out.
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
).pipe(Effect.exit)
if (joined._tag === "Failure") yield* interruptTools
const tools = classifyToolExits(
joined,
toolRuns.map((run) => run.call),
)
// A context overflow before any assistant output is recoverable: compact and
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
!publisher.hasRetryEvidence() &&
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(compaction.compact(compactionInput))).status === "completed"
)
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
// An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure records the assistant failure unless a provider error was
// already recorded from the stream. Terminal publication waits for owned tools.
if (overflowFailure) yield* publish(overflowFailure)
// An unrecovered held-back overflow becomes the step's durable provider error.
if (overflowFailure) yield* publisher.publish(overflowFailure)
// A thrown LLM failure not already recorded as the provider error either
// escapes as a scheduled retry or fails the assistant durably.
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure)
if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
// Step.Started must be durable before the failure escapes.
yield* serialized(publisher.startAssistant())
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
error,
step: currentStep,
})
}
yield* serialized(publisher.failAssistant(error))
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !publisher.record().outputStarted) {
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
// Step.Started must be durable before the failure escapes.
yield* publisher.startAssistant()
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
error: llmError,
step: currentStep,
})
}
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError()
if (llmError) yield* publisher.failAssistant(llmError)
// Settle every owned tool run: await all exits, not just the first failure,
// before publishing the terminal step event.
if (streamInterrupted) yield* interruptTools
const settled = yield* restore(
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
).pipe(Effect.exit)
if (settled._tag === "Failure") yield* interruptTools
const tools = classifyToolExits(
settled,
toolRuns.map((run) => run.call),
)
// A declined call settles durably with its reason before the generic sweeps.
// Close every unsettled call with the reason it could not settle truthfully,
// and fail the assistant when the step itself cannot complete. A declined call
// settles with its own reason before the generic sweeps.
for (const decline of tools.declines)
yield* serialized(
publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
}),
)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
if (tools.declines.length > 0 || streamInterrupted || tools.interrupted) {
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
yield* publisher.failAssistant(STEP_INTERRUPTED)
}
if (tools.failure !== undefined) {
const error = toSessionError(tools.infraError ?? Cause.squash(tools.failure))
yield* serialized(publisher.failUnsettledTools(error))
if (tools.infraError !== undefined) yield* serialized(publisher.failAssistant(error))
yield* publisher.failUnsettledTools(error)
if (tools.infraError !== undefined) yield* publisher.failAssistant(error)
}
// Fail unresolved calls before the terminal step event. Local calls have joined, so
// these sweeps only close calls that could not produce a truthful settlement.
if (providerFailed)
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
const resultMissing = {
type: "tool.result-missing",
message: "Provider did not return a tool result",
} as const
if (llmFailure && !providerFailed) yield* serialized(publisher.failUnsettledTools(resultMissing, "hosted"))
// Local calls have joined, so the remaining sweeps only close hosted calls the
// provider promised but never resolved.
if (publisher.record().providerFailed) yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
if (llmError) yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
// A clean stream that still left hosted calls unresolved fails the step itself.
if (stream._tag === "Success" && !providerFailed) {
const hostedResultMissing = yield* serialized(publisher.failUnsettledTools(resultMissing, "hosted"))
if (hostedResultMissing && !publisher.stepSettlement())
yield* serialized(publisher.failAssistant(resultMissing))
if (stream._tag === "Success" && !publisher.record().providerFailed) {
const hostedResultMissing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (hostedResultMissing && !publisher.record().finish) yield* publisher.failAssistant(RESULT_MISSING)
}
const stepFailure = publisher.stepFailure()
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
if (stepFailure) {
// One terminal event: Step.Ended on a clean finish, Step.Failed otherwise.
const record = publisher.record()
if (record.finish && !record.failure) yield* publishStepEnd(record.finish)
if (record.failure) {
const end = yield* captureStepEnd()
yield* serialized(
publisher.publishStepFailure({
...(stepSettlement ? stepUsage(stepSettlement) : {}),
...end,
}),
)
yield* publisher.publishStepFailure({
...(record.finish ? stepUsage(record.finish) : {}),
...end,
})
}
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if ((tools.interrupted || tools.infraError !== undefined) && tools.failure)
return yield* Effect.failCause(tools.failure)
if (tools.interrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
return CallOutcome.Completed({ needsContinuation, step: currentStep })
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return CallOutcome.Completed({
// A local call or malformed tool input requires another model step, unless
// this step already exhausted the agent's allowance.
needsContinuation:
!prepared.stepLimitReached &&
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
step: currentStep,
})
}),
)
}, Effect.scoped)
@@ -23,9 +23,30 @@ type Input = {
readonly assistantMessageID: SessionMessage.ID
}
const record = (value: unknown): Record<string, unknown> =>
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
/** Immutable fold of the durable facts a step's writer has recorded so far. */
export interface StepRecord {
/** The model produced visible output this attempt, which bars transparent retries and overflow recovery. */
readonly outputStarted: boolean
readonly providerFailed: boolean
/** The step's recorded assistant failure, if any. */
readonly failure?: SessionError.Error
/** Present once the provider finished the step normally. */
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly calls: ReadonlyArray<{
readonly id: string
readonly name: string
readonly called: boolean
readonly settled: boolean
readonly providerExecuted: boolean
}>
}
/** Derives canonical model content from a provider-hosted tool result. */
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
if (result.type === "content") {
@@ -35,7 +56,17 @@ const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
return [{ type: "text", text: Tool.stringify(result.value) }]
}
/** Persist one step without executing tools or starting a continuation step. */
/**
* Persist one step without executing tools or starting a continuation step.
*
* Concurrency invariant: the provider loop and each owned tool fiber call these methods
* concurrently without a lock. Two rules keep that safe, and every method must preserve
* them. (1) Commit state marks synchronously before the first await: never a yield
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
* order: each publishing fiber is sequential, so per-source order holds by construction,
* and consumers fold by callID/ordinal rather than global position.
*/
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
const tools = new Map<
string,
@@ -54,14 +85,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
let stepStarted = false
let stepFailed = false
let providerFailed = false
let retryEvidence = false
let outputStarted = false
let stepFailure: SessionError.Error | undefined
let stepSettlement:
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
| undefined
let stepSettlement: StepRecord["finish"]
const startAssistant = Effect.fnUntraced(function* () {
if (stepStarted) return assistantMessageID
@@ -123,13 +149,14 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const text = fragments(
"text",
(_textID, value, ordinal) =>
(_textID, value, ordinal, state) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
text: value,
state,
})
}),
true,
@@ -299,8 +326,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
yield* startAssistant()
return
case "text-start":
retryEvidence = true
const startedTextOrdinal = yield* text.start(event.id)
outputStarted = true
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
@@ -308,7 +335,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
})
return
case "text-delta":
const deltaTextOrdinal = yield* text.append(event.id, event.text)
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
@@ -317,10 +344,10 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
})
return
case "text-end":
yield* text.end(event.id)
yield* text.end(event.id, providerState(event.providerMetadata))
return
case "reasoning-start":
retryEvidence = true
outputStarted = true
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
@@ -346,7 +373,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
yield* reasoning.end(event.id, providerState(event.providerMetadata))
return
case "tool-input-start":
retryEvidence = true
outputStarted = true
yield* startToolInput(event)
return
case "tool-input-delta": {
@@ -368,11 +395,11 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
yield* endToolInput(event)
return
case "tool-input-error":
retryEvidence = true
outputStarted = true
yield* failMalformedToolInput(event)
return
case "tool-call": {
retryEvidence = true
outputStarted = true
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)!
if (toolInput.has(event.id)) yield* endToolInput(event)
@@ -385,7 +412,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
input: record(event.input),
input: asRecord(event.input),
executed: tool.providerExecuted,
state: providerState(event.providerMetadata),
})
@@ -535,9 +562,20 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
publishStepFailure,
failUnsettledTools,
hasProviderError: () => providerFailed,
hasRetryEvidence: () => retryEvidence,
stepFailure: () => stepFailure,
stepSettlement: () => stepSettlement,
/** Immutable snapshot of everything recorded for this step so far. */
record: (): StepRecord => ({
outputStarted,
providerFailed,
failure: stepFailure,
finish: stepSettlement,
calls: Array.from(tools, ([id, tool]) => ({
id,
name: tool.name,
called: tool.called,
settled: tool.settled,
providerExecuted: tool.providerExecuted,
})),
}),
startAssistant,
assistantMessageID: assistantMessageIDForTool,
}
@@ -113,7 +113,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
const sameModel = sameProvider && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "text")
return [
{
type: "text",
text: item.text,
providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined,
},
]
if (item.type === "reasoning")
return reuseProviderMetadata
? [
+2 -1
View File
@@ -36,7 +36,8 @@ type CollectedFiles = {
const description = [
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
'Call Code Mode tools through `tools` using only exact paths and signatures from the current catalog or `search`. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
].join("\n")
+7 -8
View File
@@ -67,9 +67,7 @@ describe("CodeModeInstructions.render", () => {
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
expect(instructions).not.toContain("## Search")
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
expect(instructions).toContain(
"`tools` contains only the tools shown below; surrounding top-level agent tools are not available and must not be called from the code.",
)
expect(instructions).not.toContain("surrounding top-level agent tools")
})
test("adds search guidance when the catalog exceeds the budget", () => {
@@ -78,9 +76,7 @@ describe("CodeModeInstructions.render", () => {
expect(partial).toContain("- orders (1 tool, none shown)")
expect(partial).toContain("## Search")
expect(partial).toContain("The Code Mode tool catalog below is partial.")
expect(partial).toContain(
"`tools` contains only the tools shown below or returned by `search`; surrounding top-level agent tools are not available and must not be called from the code.",
)
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).not.toContain("tools.orders.lookup(input:")
@@ -117,7 +113,9 @@ describe("CodeModeInstructions.render", () => {
})
test("renders only the no-tools notice for an empty catalog", () => {
expect(render([])).toBe("No tools are currently available.")
expect(render([])).toBe(
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
)
})
})
@@ -127,7 +125,8 @@ describe("CodeModeInstructions.update", () => {
test("renders additions, changes, and removals as a compact semantic delta", () => {
const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>" }
const added = entry("notes.list", "List notes")
const text = update([echo, lookup], [changed, added])
const unchanged = Array.from({ length: 5 }, (_, index) => entry(`stable.tool${index}`, `Stable ${index}`))
const text = update([echo, lookup, ...unchanged], [changed, added, ...unchanged])
expect(text).toContain("The Code Mode tool catalog has changed.")
expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`)
expect(text).toContain(
@@ -21,6 +21,25 @@ const lookup: CodeModeCatalog.Entry = {
}
describe("CodeModeInstructions", () => {
it.effect("instructs the model not to call execute while the catalog is empty", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([]))
expect(initialized.text).toBe(
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
)
const added = yield* readUpdate(CodeModeInstructions.make([echo]), initialized)
expect(added.text).toContain("New tools are available in addition to those previously listed:")
expect(added.text).toContain(echo.signature)
expect(yield* readUpdate(CodeModeInstructions.make([]), { values: added.values })).toMatchObject({
text:
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\n" +
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
})
}),
)
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
+16
View File
@@ -32,6 +32,22 @@ export function waitForTool(
})
}
export function waitForCodeModeTool(
registry: ToolRegistry.Interface,
path: string,
remaining = 1000,
): Effect.Effect<ToolRegistry.ToolSet, Error> {
return Effect.gen(function* () {
const toolSet = yield* registry.snapshot()
if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet
if (remaining === 0) {
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
}
yield* Effect.promise(() => Bun.sleep(1))
return yield* waitForCodeModeTool(registry, path, remaining - 1)
})
}
/**
* Registers a core tool plugin's tools against the real registry without booting the
* full plugin host. Only the tool domain is live; focused tool tests exercise
+14 -8
View File
@@ -33,7 +33,7 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
@@ -802,10 +802,16 @@ test("serializes concurrent MCP lifecycle operations", async () => {
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const definitions = yield* toolDefinitions(registry)
const execute = definitions.find((tool) => tool.name === "execute")
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
"direct_fail",
"direct_lookup",
"direct_media",
"execute",
])
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
expect(execute?.description).not.toContain("tools.demo.search")
}),
)
@@ -873,9 +879,9 @@ it.effect("waits for permission before calling an MCP tool", () =>
const permission = yield* Deferred.make<void>()
decision = Deferred.await(permission)
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const fiber = yield* executeTool(registry, {
const fiber = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_mcp_permission"),
...toolIdentity,
call: {
@@ -912,9 +918,9 @@ it.effect("does not call MCP when permission is blocked", () =>
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const execution = yield* executeTool(registry, {
const execution = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
...toolIdentity,
call: {
@@ -767,4 +767,35 @@ Recent work
},
])
})
test("preserves assistant text provider state across same-provider model changes and failures", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-phase"),
type: "assistant",
agent: build,
model: { id: ModelV2.ID.make("old"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
state: { phase: "commentary" },
}),
],
error: { type: "provider.unknown", message: "Interrupted after commentary" },
time: { created, completed: created },
}),
],
ModelV2.Ref.make({ id: ModelV2.ID.make("new"), providerID: ProviderV2.ID.make("provider") }),
)
expect(messages[0]?.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: { provider: { phase: "commentary" } },
},
])
})
})
@@ -186,6 +186,7 @@ describe("SessionRunnerLLM recorded", () => {
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
}),
)
const pluginHost = host({
@@ -259,7 +259,7 @@ test("step finish records settlement without publishing step ended", async () =>
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
})
test("content-filter finish retains failure evidence until step closeout", async () => {
@@ -280,7 +280,7 @@ test("content-filter finish retains failure evidence until step closeout", async
)
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
const settlement = publisher.stepSettlement()
const settlement = publisher.record().finish
expect(settlement).toMatchObject({
finish: "content-filter",
tokens: { input: 8, output: 2, reasoning: 1 },
@@ -88,7 +88,7 @@ describe("ToolRegistry", () => {
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.snapshot()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -102,7 +102,7 @@ describe("ToolRegistry", () => {
.register({ "echo.tool": make(), echo_tool: make() }, { codemode: false })
.pipe(Effect.flip)
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
expect((yield* service.snapshot()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -117,7 +117,7 @@ describe("ToolRegistry", () => {
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.snapshot()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -148,6 +148,20 @@ describe("ToolRegistry", () => {
}),
)
it.effect("keeps execute available without Code Mode tools unless explicitly denied", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const available = yield* service.snapshot()
expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(available.codeModeCatalog).toEqual([])
const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }])
expect(denied.definitions).toEqual([])
expect(denied.codeModeCatalog).toBeUndefined()
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@@ -156,7 +170,12 @@ describe("ToolRegistry", () => {
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"])
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash",
"edit",
"write",
"execute",
])
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
@@ -169,7 +188,11 @@ describe("ToolRegistry", () => {
{ action: "*", resource: "*", effect: "deny" },
]),
).toEqual([])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["bash", "question"])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([
"bash",
"question",
"execute",
])
}),
)
@@ -182,7 +205,7 @@ describe("ToolRegistry", () => {
expect(
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name),
).toEqual(["first"])
).toEqual(["first", "execute"])
}),
)
@@ -191,9 +214,9 @@ describe("ToolRegistry", () => {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -213,9 +236,9 @@ describe("ToolRegistry", () => {
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
}),
)
+81
View File
@@ -880,6 +880,46 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("advertises execute and durable guidance for an empty Code Mode catalog", () =>
Effect.gen(function* () {
const session = yield* setup
const empty = {
catalog: [],
tool: Tool.make({
description: "Execute Code Mode",
input: Schema.Struct({ code: Schema.String }),
output: Schema.String,
execute: () => Effect.succeed({ output: "unused" }),
}),
}
codeModeMaterializations = [empty, empty, {}]
yield* admit(session, "Continue without Code Mode tools")
response = reply.stop()
yield* session.resume(sessionID)
yield* admit(session, "Still no Code Mode tools")
yield* session.resume(sessionID)
yield* admit(session, "Code Mode denied")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
expect(requests[0]?.system.some((part) => part.text.includes("Do not call `execute`"))).toBe(true)
expect(requests[1]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
expect(requests[1]?.messages.filter((message) => message.role === "system")).toEqual([])
expect(requests[2]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail"])
expect(
requests[2]?.messages.some(
(message) =>
message.role === "system" &&
message.content.some(
(part) => part.type === "text" && part.text.includes("Code Mode tools are no longer available"),
),
),
).toBe(true)
}),
)
it.effect("applies session context hooks without exposing unavailable tools", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -2632,6 +2672,47 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("restores durable text provider metadata in the next request", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Check first")
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
LLMEvent.textEnd({
id: "commentary",
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
}),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
]
yield* session.resume(sessionID)
yield* replaySessionProjection(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Check first" },
{
type: "assistant",
content: [{ type: "text", text: "Checking.", state: { phase: "commentary" } }],
},
])
yield* admit(session, "Continue")
response = []
yield* session.resume(sessionID)
expect(requests[1]?.messages[1]?.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } },
},
])
}),
)
it.effect("replays durable provider-executed tool results inline in the next request", () =>
Effect.gen(function* () {
const session = yield* setup
+6 -4
View File
@@ -137,10 +137,12 @@ describe("EditTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"])
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
[],
)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit", "execute"])
expect(
(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).toEqual(["execute"])
const settled = yield* executeTool(
registry,
call({ path: "hello.txt", oldString: "before", newString: "after" }),
+2 -1
View File
@@ -19,7 +19,8 @@ test("execute describes invariant Code Mode behavior", () => {
[
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
'Call Code Mode tools through `tools` using only exact paths and signatures from the current catalog or `search`. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
].join("\n"),
+1 -1
View File
@@ -181,7 +181,7 @@ describe("PatchTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch", "execute"])
const settled = yield* executeTool(
registry,
call(
+6 -2
View File
@@ -97,7 +97,11 @@ describe("QuestionTool", () => {
deny = true
const registry = yield* ToolRegistry.Service
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(
(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).toEqual(["execute"])
expect(
yield* executeTool(registry, {
sessionID,
@@ -142,7 +146,7 @@ describe("QuestionTool", () => {
},
]
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question", "execute"])
expect(
yield* executeTool(registry, {
sessionID,
+6 -2
View File
@@ -197,8 +197,12 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["read", "execute"])
expect(
(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).toEqual(["execute"])
const execution = yield* executeTool(registry, {
sessionID,
...toolIdentity,
+1 -1
View File
@@ -92,7 +92,7 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = "http://example.com/public"
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch", "execute"])
expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
status: "completed",
output: { url, contentType: "text/plain", format: "text", output: "hello" },
+1 -1
View File
@@ -154,7 +154,7 @@ describe("WebSearchTool registration", () => {
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
expect(
yield* executeTool(registry, {
sessionID,
+1 -1
View File
@@ -118,7 +118,7 @@ describe("WriteTool", () => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"])
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
status: "completed",
+1
View File
@@ -314,6 +314,7 @@ export namespace Text {
assistantMessageID: SessionMessage.ID,
ordinal: NonNegativeInt,
text: Schema.String,
state: SessionMessage.ProviderState.pipe(optional),
},
})
export type Ended = typeof Ended.Type
+1
View File
@@ -155,6 +155,7 @@ export interface AssistantText extends Schema.Schema.Type<typeof AssistantText>
export const AssistantText = Schema.Struct({
type: Schema.tag("text"),
text: Schema.String,
state: ProviderState.pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Text" })
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
+16 -4
View File
@@ -1,8 +1,9 @@
import { RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
const { themeV2 } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
return (
<box
@@ -12,12 +13,23 @@ export function Reconnecting() {
right={0}
bottom={0}
left={0}
backgroundColor={themeV2.background.default}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
alignItems="center"
justifyContent="center"
>
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<Spinner color={themeV2.text.subdued}>Waiting for background service...</Spinner>
<box
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={themeV2.background.default}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
</box>
</box>
)