mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 13:06:13 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87c734624 | ||
|
|
59b4a58324 | ||
|
|
40fd50f112 | ||
|
|
6999ea8b50 |
@@ -287,6 +287,7 @@ export const Event = Schema.StructWithRest(
|
||||
delta: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
output_index: Schema.optional(Schema.Number),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(StreamItem),
|
||||
response: Schema.optional(
|
||||
@@ -297,6 +298,7 @@ export const Event = Schema.StructWithRest(
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
|
||||
usage: optionalNull(OpenResponsesUsage),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
output: Schema.optional(Schema.Array(StreamItem)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
@@ -342,13 +344,14 @@ export interface ParserState {
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
readonly reasoningIndexes: Readonly<Record<number, string>>
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
type ReasoningSummaryStatus = "active" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly encryptedContent: string | null | undefined
|
||||
readonly blockIDs?: ReadonlyArray<string>
|
||||
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
// and matches the wire field.
|
||||
@@ -857,6 +860,10 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
...state.reasoningItems,
|
||||
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
||||
},
|
||||
reasoningIndexes:
|
||||
event.output_index === undefined
|
||||
? state.reasoningIndexes
|
||||
: { ...state.reasoningIndexes, [event.output_index]: item.id },
|
||||
},
|
||||
events,
|
||||
]
|
||||
@@ -890,23 +897,11 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
if (event.summary_index === 0) return [state, NO_EVENTS]
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const closed = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${entry[0]}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
closed,
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
@@ -916,11 +911,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
|
||||
),
|
||||
),
|
||||
...item.summaryParts,
|
||||
[event.summary_index]: "active",
|
||||
},
|
||||
},
|
||||
@@ -938,22 +929,19 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle:
|
||||
state.store !== false
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
)
|
||||
: state.lifecycle,
|
||||
lifecycle: Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...item.summaryParts,
|
||||
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
|
||||
[event.summary_index]: "concluded",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1039,27 +1027,74 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
|
||||
if (isReasoningItem(item)) {
|
||||
if (
|
||||
event.output_index !== undefined &&
|
||||
state.reasoningIndexes[event.output_index] !== undefined &&
|
||||
state.reasoningIndexes[event.output_index] !== item.id
|
||||
)
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
const reasoningIndexes =
|
||||
event.output_index === undefined
|
||||
? state.reasoningIndexes
|
||||
: Object.fromEntries(
|
||||
Object.entries(state.reasoningIndexes).filter((entry) => entry[0] !== `${event.output_index}`),
|
||||
)
|
||||
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,
|
||||
)
|
||||
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
||||
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
||||
const openParts = Object.entries(reasoningItem.summaryParts).filter((entry) => entry[1] === "active")
|
||||
const lifecycle = openParts.reduce(
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
if (typeof item.encrypted_content === "string" && openParts.length === 0) {
|
||||
const blockID = Object.keys(reasoningItem.summaryParts)
|
||||
.map((index) => `${item.id}:${index}`)
|
||||
.at(-1)
|
||||
if (blockID) events.push(LLMEvent.reasoningMetadata({ id: blockID, providerMetadata: metadata }))
|
||||
}
|
||||
const reasoningItems =
|
||||
typeof item.encrypted_content === "string"
|
||||
? Object.fromEntries(Object.entries(state.reasoningItems).filter((entry) => entry[0] !== item.id))
|
||||
: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
...reasoningItem,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: Object.fromEntries(
|
||||
Object.keys(reasoningItem.summaryParts).map((index) => [index, "concluded" as const]),
|
||||
),
|
||||
},
|
||||
}
|
||||
return [{ ...state, lifecycle, reasoningItems, reasoningIndexes }, events] satisfies StepResult
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.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 [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningIndexes,
|
||||
reasoningItems:
|
||||
typeof item.encrypted_content === "string"
|
||||
? state.reasoningItems
|
||||
: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: { encryptedContent: item.encrypted_content, blockIDs: [item.id], summaryParts: {} },
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata),
|
||||
reasoningIndexes,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
@@ -1077,7 +1112,27 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasFunctionCall
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
const terminalReasoning = new Map(
|
||||
(event.response?.output ?? []).filter(isReasoningItem).map((item) => [item.id, item]),
|
||||
)
|
||||
const reasoningLifecycle = Object.entries(state.reasoningItems).reduce((lifecycle, [id, item]) => {
|
||||
const terminal = terminalReasoning.get(id)
|
||||
if (!terminal) return lifecycle
|
||||
const metadata = providerMetadata(state, {
|
||||
itemId: id,
|
||||
reasoningEncryptedContent: terminal.encrypted_content ?? null,
|
||||
})
|
||||
const blockID =
|
||||
item.blockIDs?.at(-1) ??
|
||||
Object.keys(item.summaryParts)
|
||||
.map((index) => `${id}:${index}`)
|
||||
.at(-1)
|
||||
if (!blockID) return lifecycle
|
||||
if (lifecycle.reasoning.has(blockID)) return Lifecycle.reasoningEnd(lifecycle, events, blockID, metadata)
|
||||
events.push(LLMEvent.reasoningMetadata({ id: blockID, providerMetadata: metadata }))
|
||||
return lifecycle
|
||||
}, state.lifecycle)
|
||||
const lifecycle = Lifecycle.finish(reasoningLifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event, hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
@@ -1091,7 +1146,10 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
return [
|
||||
{ ...state, lifecycle, hasFunctionCall, tools: pending.tools, reasoningItems: {}, reasoningIndexes: {} },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
@@ -1210,7 +1268,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
messageItems: new Set<string>(),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
reasoningIndexes: {},
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
|
||||
@@ -138,6 +138,13 @@ export const ReasoningEnd = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||
|
||||
export const ReasoningMetadata = Schema.Struct({
|
||||
type: Schema.tag("reasoning-metadata"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: ProviderMetadata,
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningMetadata" })
|
||||
export type ReasoningMetadata = Schema.Schema.Type<typeof ReasoningMetadata>
|
||||
|
||||
export const ToolInputStart = Schema.Struct({
|
||||
type: Schema.tag("tool-input-start"),
|
||||
id: ToolCallID,
|
||||
@@ -242,6 +249,7 @@ const llmEventTagged = Schema.Union([
|
||||
ReasoningStart,
|
||||
ReasoningDelta,
|
||||
ReasoningEnd,
|
||||
ReasoningMetadata,
|
||||
ToolInputStart,
|
||||
ToolInputDelta,
|
||||
ToolInputEnd,
|
||||
@@ -278,6 +286,8 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
|
||||
ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningMetadata: (input: WithID<ReasoningMetadata, ContentBlockID>) =>
|
||||
ReasoningMetadata.make({ ...input, id: contentBlockID(input.id) }),
|
||||
toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
|
||||
ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||
@@ -312,6 +322,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
reasoningStart: llmEventTagged.guards["reasoning-start"],
|
||||
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
|
||||
reasoningEnd: llmEventTagged.guards["reasoning-end"],
|
||||
reasoningMetadata: llmEventTagged.guards["reasoning-metadata"],
|
||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||
@@ -483,6 +494,18 @@ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): Response
|
||||
}
|
||||
}
|
||||
|
||||
const reduceReasoningMetadata = (state: ResponseState, event: ReasoningMetadata): ResponseState => {
|
||||
const current = state.reasoningParts[event.id]
|
||||
if (!current) return state
|
||||
return {
|
||||
...replaceContent(state, current.contentIndex, reasoningContent(current.text, event.providerMetadata)),
|
||||
reasoningParts: {
|
||||
...state.reasoningParts,
|
||||
[event.id]: { ...current, providerMetadata: event.providerMetadata },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): ResponseState => ({
|
||||
...state,
|
||||
toolInputs: {
|
||||
@@ -552,6 +575,8 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
||||
return reduceReasoningDelta(next, event)
|
||||
case "reasoning-end":
|
||||
return reduceReasoningEnd(next, event)
|
||||
case "reasoning-metadata":
|
||||
return reduceReasoningMetadata(next, event)
|
||||
case "tool-input-start":
|
||||
return reduceToolInputStart(next, event)
|
||||
case "tool-input-delta":
|
||||
|
||||
@@ -82,16 +82,25 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
|
||||
|
||||
const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const assistantContent: ContentPart[] = []
|
||||
const reasoningIndexes = new Map<string, number>()
|
||||
const toolCalls: ToolCallPart[] = []
|
||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = { normalized: "unknown" }
|
||||
let usage: Usage | undefined
|
||||
let providerMetadata: ProviderMetadata | undefined
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
||||
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
|
||||
} else if (event.type === "text-end" || event.type === "reasoning-end") {
|
||||
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
|
||||
if (event.type === "text-delta") {
|
||||
appendText(assistantContent, "text", event.text)
|
||||
} else if (event.type === "reasoning-delta") {
|
||||
appendReasoning(assistantContent, reasoningIndexes, event.id, event.text, event.providerMetadata)
|
||||
} else if (event.type === "text-end") {
|
||||
appendText(assistantContent, "text", "", event.providerMetadata)
|
||||
} else if (event.type === "reasoning-end") {
|
||||
appendReasoning(assistantContent, reasoningIndexes, event.id, "", event.providerMetadata)
|
||||
} else if (event.type === "reasoning-metadata") {
|
||||
const index = reasoningIndexes.get(event.id)
|
||||
const reasoning = index === undefined ? undefined : assistantContent[index]
|
||||
if (reasoning?.type === "reasoning") reasoning.providerMetadata = event.providerMetadata
|
||||
} else if (event.type === "tool-call") {
|
||||
assistantContent.push(event)
|
||||
if (!event.providerExecuted) toolCalls.push(event)
|
||||
@@ -114,6 +123,28 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
return { assistantContent, toolCalls, reason, usage, providerMetadata }
|
||||
}
|
||||
|
||||
const appendReasoning = (
|
||||
content: ContentPart[],
|
||||
indexes: Map<string, number>,
|
||||
id: string,
|
||||
text: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
) => {
|
||||
const index = indexes.get(id)
|
||||
if (index === undefined) {
|
||||
indexes.set(id, content.length)
|
||||
content.push({ type: "reasoning", text, providerMetadata })
|
||||
return
|
||||
}
|
||||
const current = content[index]
|
||||
if (current?.type !== "reasoning") return
|
||||
content[index] = {
|
||||
...current,
|
||||
text: `${current.text}${text}`,
|
||||
providerMetadata: providerMetadata ?? current.providerMetadata,
|
||||
}
|
||||
}
|
||||
|
||||
const appendText = (
|
||||
content: ContentPart[],
|
||||
type: "text" | "reasoning",
|
||||
|
||||
@@ -2141,6 +2141,281 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("backfills encrypted reasoning from the terminal response", () =>
|
||||
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.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" }],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
])
|
||||
expect(response.events).toContainEqual({
|
||||
type: "reasoning-metadata",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
})
|
||||
expect(response.message.content).toContainEqual({
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "thinking" }],
|
||||
encrypted_content: "terminal-state",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("backfills done-only reasoning from the terminal response", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_done", encrypted_content: null },
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "reasoning", id: "rs_done", encrypted_content: "terminal-state" }],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { openai: { itemId: "rs_done", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ends reasoning before later tool and text output", () =>
|
||||
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.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"Effect"}',
|
||||
},
|
||||
},
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Found it" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" }],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const reasoningEnd = response.events.findIndex((event) => event.type === "reasoning-end")
|
||||
expect(reasoningEnd).toBeLessThan(response.events.findIndex((event) => event.type === "tool-input-start"))
|
||||
expect(reasoningEnd).toBeLessThan(response.events.findIndex((event) => event.type === "text-start"))
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
|
||||
expect(response.message.content[0]).toEqual({
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses terminal reasoning instead of output_item.added encryption", () =>
|
||||
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", encrypted_content: "provisional-state" },
|
||||
},
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" }],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("backfills reasoning from an incomplete terminal response", () =>
|
||||
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.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{
|
||||
type: "response.incomplete",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
incomplete_details: { reason: "max_output_tokens" },
|
||||
output: [{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" }],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "length", raw: "max_output_tokens" })
|
||||
expect(response.message.content[0]).toMatchObject({
|
||||
providerMetadata: { openai: { reasoningEncryptedContent: "terminal-state" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not backfill reasoning from a mismatched terminal item", () =>
|
||||
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.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "reasoning", id: "rs_other", encrypted_content: "other-state" }],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.some((event) => event.type === "reasoning-metadata")).toBe(false)
|
||||
expect(response.message.content[0]).toMatchObject({
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores a reasoning done item that mismatches its output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_1" },
|
||||
},
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_other", encrypted_content: "wrong-state" },
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" }],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
@@ -2188,6 +2463,11 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning-metadata",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
@@ -2196,7 +2476,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
it.effect("backfills terminal reasoning when storage is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
|
||||
Effect.provide(
|
||||
@@ -2216,7 +2496,13 @@ describe("OpenAI Responses route", () => {
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" }],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -2224,8 +2510,17 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
])
|
||||
expect(response.events).toContainEqual({
|
||||
type: "reasoning-metadata",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -709,6 +709,20 @@ export type SessionLogOutput =
|
||||
readonly state?: SessionMessage.ProviderState | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.reasoning.state.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly state: SessionMessage.ProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
|
||||
@@ -1263,6 +1263,16 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionReasoningStateUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.reasoning.state.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; state: SessionMessageProviderState1 }
|
||||
}
|
||||
|
||||
export type ToolContent1 = ToolTextContent | ToolFileContent1
|
||||
|
||||
export type ModelCompatibility = {
|
||||
@@ -1975,6 +1985,7 @@ export type SessionEventDurable =
|
||||
| SessionTextEnded
|
||||
| SessionReasoningStarted
|
||||
| SessionReasoningEnded
|
||||
| SessionReasoningStateUpdated
|
||||
| SessionToolInputStarted
|
||||
| SessionToolInputEnded
|
||||
| SessionToolCalled
|
||||
@@ -2069,6 +2080,7 @@ export type V2Event =
|
||||
| SessionReasoningStarted
|
||||
| SessionReasoningDelta
|
||||
| SessionReasoningEnded
|
||||
| SessionReasoningStateUpdated
|
||||
| SessionToolInputStarted
|
||||
| SessionToolInputDelta
|
||||
| SessionToolInputEnded
|
||||
|
||||
@@ -904,6 +904,14 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
})
|
||||
return
|
||||
case "session.reasoning.state.updated":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message
|
||||
.assistant(draft, index, event.data.assistantMessageID)
|
||||
?.content.filter((item) => item.type === "reasoning")[event.data.ordinal]
|
||||
if (match?.type === "reasoning") match.state = event.data.state
|
||||
})
|
||||
return
|
||||
case "session.retry.scheduled":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
|
||||
@@ -373,6 +373,14 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.reasoning.state.updated": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = draft.content.filter((item): item is DraftReasoning => item.type === "reasoning")[
|
||||
event.data.ordinal
|
||||
]
|
||||
if (match) match.state = event.data.state
|
||||
})
|
||||
},
|
||||
"session.retry.scheduled": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.retry = {
|
||||
|
||||
@@ -661,6 +661,7 @@ const layer = Layer.effectDiscard(
|
||||
yield* bus.project(SessionEvent.Tool.Failed, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Reasoning.StateUpdated, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.RetryScheduled, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Compaction.Started, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Compaction.Ended, (event) =>
|
||||
|
||||
@@ -181,7 +181,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
state === undefined ? current.state : { ...current.state, ...state },
|
||||
)
|
||||
chunks.delete(id)
|
||||
return undefined
|
||||
return current.ordinal
|
||||
})
|
||||
const flush = Effect.fnUntraced(function* () {
|
||||
for (const id of Array.from(chunks.keys())) yield* end(id)
|
||||
@@ -235,6 +235,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}),
|
||||
true,
|
||||
)
|
||||
const completedReasoning = new Map<string, number>()
|
||||
const toolInput = fragments("tool input", (id, value) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = tools.get(id)
|
||||
@@ -413,8 +414,21 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* reasoning.append(event.id, event.text, providerState(event.providerMetadata))
|
||||
return
|
||||
case "reasoning-end":
|
||||
yield* reasoning.end(event.id, providerState(event.providerMetadata))
|
||||
completedReasoning.set(event.id, yield* reasoning.end(event.id, providerState(event.providerMetadata)))
|
||||
return
|
||||
case "reasoning-metadata": {
|
||||
const ordinal = completedReasoning.get(event.id)
|
||||
if (ordinal === undefined) return yield* Effect.die(new Error(`Reasoning metadata before end: ${event.id}`))
|
||||
const state = providerState(event.providerMetadata)
|
||||
if (state === undefined) return
|
||||
yield* bus.publish(SessionEvent.Reasoning.StateUpdated, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
ordinal,
|
||||
state,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "tool-input-start":
|
||||
outputStarted = true
|
||||
yield* startToolInput(event)
|
||||
|
||||
@@ -203,6 +203,53 @@ test("reasoning state from start, empty delta, and end is merged", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("reasoning metadata updates completed reasoning state", async () => {
|
||||
const { published, publisher } = capture("openai")
|
||||
await Effect.runPromise(
|
||||
Effect.forEach(
|
||||
[
|
||||
LLMEvent.reasoningStart({ id: "reasoning" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
}),
|
||||
LLMEvent.reasoningMetadata({
|
||||
id: "reasoning",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
}),
|
||||
],
|
||||
publisher.publish,
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.slice(-2).map((event) => event.type)).toEqual([
|
||||
"session.reasoning.ended.1",
|
||||
"session.reasoning.state.updated.1",
|
||||
])
|
||||
expect(published.at(-1)?.data).toMatchObject({
|
||||
ordinal: 0,
|
||||
state: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" },
|
||||
})
|
||||
})
|
||||
|
||||
test("reasoning metadata ignores unrelated provider state", async () => {
|
||||
const { published, publisher } = capture("openai")
|
||||
await Effect.runPromise(
|
||||
Effect.forEach(
|
||||
[
|
||||
LLMEvent.reasoningStart({ id: "reasoning" }),
|
||||
LLMEvent.reasoningEnd({ id: "reasoning" }),
|
||||
LLMEvent.reasoningMetadata({ id: "reasoning", providerMetadata: { anthropic: { signature: "ignored" } } }),
|
||||
],
|
||||
publisher.publish,
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.some((event) => event.type === "session.reasoning.state.updated.1")).toBe(false)
|
||||
})
|
||||
|
||||
it.effect("batches text deltas and flushes pending text before the terminal event", () =>
|
||||
Effect.gen(function* () {
|
||||
const { published, publisher } = capture()
|
||||
|
||||
@@ -2903,6 +2903,13 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: {
|
||||
openai: { itemId: "rs_1", reasoningEncryptedContent: null },
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.reasoningMetadata({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: {
|
||||
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
|
||||
@@ -415,6 +415,18 @@ export namespace Reasoning {
|
||||
},
|
||||
})
|
||||
export type Ended = typeof Ended.Type
|
||||
|
||||
export const StateUpdated = Event.durable({
|
||||
type: "session.reasoning.state.updated",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
ordinal: NonNegativeInt,
|
||||
state: SessionMessage.ProviderState,
|
||||
},
|
||||
})
|
||||
export type StateUpdated = typeof StateUpdated.Type
|
||||
}
|
||||
|
||||
export namespace Tool {
|
||||
@@ -624,6 +636,7 @@ export const Definitions = Event.inventory(
|
||||
Reasoning.Started,
|
||||
Reasoning.Delta,
|
||||
Reasoning.Ended,
|
||||
Reasoning.StateUpdated,
|
||||
Tool.Input.Started,
|
||||
Tool.Input.Delta,
|
||||
Tool.Input.Ended,
|
||||
|
||||
@@ -111,6 +111,7 @@ describe("public event manifest", () => {
|
||||
"session.tool.failed.2",
|
||||
"session.reasoning.started.1",
|
||||
"session.reasoning.ended.1",
|
||||
"session.reasoning.state.updated.1",
|
||||
"session.retry.scheduled.1",
|
||||
"session.compaction.started.1",
|
||||
"session.compaction.ended.1",
|
||||
|
||||
Reference in New Issue
Block a user