feat(ai): preserve raw finish reasons (#38423)

This commit is contained in:
Aiden Cline 2026-07-23 18:16:11 -05:00 committed by GitHub
parent e7ecee5df2
commit 6401eeaea0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 409 additions and 132 deletions

View file

@ -78,7 +78,10 @@ const streamText = LLM.stream(request).pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
if (event.type === "finish")
process.stdout.write(
`\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`,
)
}),
),
Stream.runDrain,
@ -194,7 +197,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
event: Schema.String,
initial: () => undefined,
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
onHalt: () => [{ type: "finish", reason: "stop" }],
onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }],
},
})

View file

@ -601,7 +601,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// =============================================================================
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
if (reason === "max_tokens") return "length"
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
if (reason === "tool_use") return "tool-calls"
if (reason === "refusal") return "content-filter"
return "unknown"
@ -836,7 +836,10 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
const usage = mergeUsage(state.usage, mapUsage(event.usage))
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: mapFinishReason(event.delta?.stop_reason),
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
usage,
providerMetadata: event.delta?.stop_sequence
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })

View file

@ -8,6 +8,7 @@ import {
Usage,
type CacheHint,
type FinishReason,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type ModelToolSchemaCompatibility,
@ -435,9 +436,10 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// =============================================================================
const mapFinishReason = (reason: string): FinishReason => {
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
if (reason === "max_tokens") return "length"
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
if (reason === "tool_use") return "tool-calls"
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
return "unknown"
}
@ -466,7 +468,7 @@ interface ParserState {
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
@ -583,7 +585,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage },
pendingFinish: {
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
},
},
[],
] as const
@ -591,7 +599,16 @@ const step = (state: ParserState, event: BedrockEvent) =>
if (event.metadata) {
const usage = mapUsage(event.metadata.usage)
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
return [
{
...state,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
},
[],
] as const
}
const exception = (
@ -624,8 +641,13 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
? (() => {
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason:
state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
reason: {
...state.pendingFinish.reason,
normalized:
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
? "tool-calls"
: state.pendingFinish.reason.normalized,
},
usage: state.pendingFinish.usage,
})
return events

View file

@ -382,10 +382,22 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
finishReason === "SAFETY" ||
finishReason === "BLOCKLIST" ||
finishReason === "PROHIBITED_CONTENT" ||
finishReason === "SPII"
finishReason === "SPII" ||
finishReason === "MODEL_ARMOR" ||
finishReason === "IMAGE_PROHIBITED_CONTENT" ||
finishReason === "IMAGE_RECITATION" ||
finishReason === "LANGUAGE"
)
return "content-filter"
if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
if (
finishReason === "MALFORMED_FUNCTION_CALL" ||
finishReason === "UNEXPECTED_TOOL_CALL" ||
finishReason === "NO_IMAGE" ||
finishReason === "TOO_MANY_TOOL_CALLS" ||
finishReason === "MISSING_THOUGHT_SIGNATURE" ||
finishReason === "MALFORMED_RESPONSE"
)
return "error"
return "unknown"
}
@ -402,7 +414,10 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
)
: state.lifecycle
Lifecycle.finish(lifecycle, events, {
reason: mapFinishReason(state.finishReason, state.hasToolCalls),
reason: {
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
raw: state.finishReason,
},
usage: state.usage,
})
return events

View file

@ -5,9 +5,11 @@ import { Endpoint } from "../route/endpoint"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMError,
LLMEvent,
Usage,
type FinishReason,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type MediaPart,
@ -17,6 +19,7 @@ import {
type ToolDefinition,
type ToolContent,
} from "../schema"
import { classifyProviderFailure } from "../provider-error"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
@ -164,11 +167,18 @@ const OpenAIChatDelta = Schema.StructWithRest(
const OpenAIChatChoice = Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
native_finish_reason: optionalNull(Schema.String),
})
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
})
export const OpenAIChatEvent = Schema.Struct({
choices: Schema.Array(OpenAIChatChoice),
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
})
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
@ -184,7 +194,7 @@ export interface ParserState {
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly finishReason?: FinishReasonDetails
readonly lifecycle: Lifecycle.State
readonly reasoningField?: string
readonly reasoningDetails: Array<unknown>
@ -439,6 +449,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "length") return "length"
if (reason === "content_filter") return "content-filter"
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
if (reason === "error") return "error"
return "unknown"
}
@ -532,10 +543,22 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
if (event.error)
return yield* new LLMError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message: event.error.message,
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
status: typeof event.error.code === "number" ? event.error.code : undefined,
}),
})
const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices[0]
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
const choice = event.choices?.[0]
const finishReason = choice?.finish_reason
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
: state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
@ -627,7 +650,13 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
const reason = state.finishReason
? {
...state.finishReason,
normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: undefined
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,

View file

@ -979,7 +979,10 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: mapFinishReason(event, state.hasFunctionCall),
reason: {
normalized: mapFinishReason(event, state.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage),
providerMetadata:
event.response?.id || event.response?.service_tier

View file

@ -1,4 +1,4 @@
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
export interface State {
readonly stepStarted: boolean
@ -81,7 +81,7 @@ export const finish = (
state: State,
events: LLMEvent[],
input: {
readonly reason: FinishReason
readonly reason: FinishReasonDetails
readonly usage?: Usage
readonly providerMetadata?: ProviderMetadata
},

View file

@ -191,10 +191,16 @@ export const ToolError = Schema.Struct({
}).annotate({ identifier: "LLM.Event.ToolError" })
export type ToolError = Schema.Schema.Type<typeof ToolError>
export const FinishReasonDetails = Schema.Struct({
normalized: FinishReason,
raw: Schema.optional(Schema.String),
}).annotate({ identifier: "LLM.FinishReasonDetails" })
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
export const StepFinish = Schema.Struct({
type: Schema.tag("step-finish"),
index: Schema.Number,
reason: FinishReason,
reason: FinishReasonDetails,
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.StepFinish" })
@ -202,7 +208,7 @@ export type StepFinish = Schema.Schema.Type<typeof StepFinish>
export const Finish = Schema.Struct({
type: Schema.tag("finish"),
reason: FinishReason,
reason: FinishReasonDetails,
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.Finish" })
@ -365,7 +371,7 @@ interface ResponseState {
readonly events: ReadonlyArray<LLMEvent>
readonly message: Message
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly finishReason?: FinishReasonDetails
readonly textParts: Readonly<Record<string, ContentAssembly>>
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
@ -393,7 +399,7 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
return {
...state,
events,
finishReason: state.finishReason ?? "error",
finishReason: state.finishReason ?? { normalized: "error" },
}
}
return {
@ -580,7 +586,7 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
message: Message,
events: Schema.Array(LLMEvent),
usage: Schema.optional(Usage),
finishReason: FinishReason,
finishReason: FinishReasonDetails,
}) {
/** Concatenated assistant text assembled from streamed `text-delta` events. */
get text() {

View file

@ -40,7 +40,7 @@ const fakeFraming: FramingDef<FakeEvent> = {
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
event.type === "finish"
? { type: "finish", reason: event.reason }
? { type: "finish", reason: { normalized: event.reason } }
: { type: "text-delta", id: "text-0", text: event.text }
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({

View file

@ -83,7 +83,7 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
const stepState = (events: ReadonlyArray<LLMEvent>) => {
const assistantContent: ContentPart[] = []
const toolCalls: ToolCallPart[] = []
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = { normalized: "unknown" }
let usage: Usage | undefined
let providerMetadata: ProviderMetadata | undefined

View file

@ -191,7 +191,7 @@ describe("llm constructors", () => {
LLMResponse.text({
events: [
{ type: "text-delta", id: "text-0", text: "hi" },
{ type: "finish", reason: "stop" },
{ type: "finish", reason: { normalized: "stop" } },
],
}),
).toBe("hi")

View file

@ -448,12 +448,50 @@ describe("Anthropic Messages route", () => {
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: "stop",
reason: { normalized: "stop", raw: "end_turn" },
providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
})
}),
)
it.effect("maps context-window truncation to length", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "message_delta",
delta: { stop_reason: "model_context_window_exceeded" },
usage: { output_tokens: 1 },
},
),
),
),
)
expect(response.finishReason).toEqual({ normalized: "length", raw: "model_context_window_exceeded" })
}),
)
it.effect("preserves pause_turn while normalizing it to stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
),
),
),
)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "pause_turn" })
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = sseEvents(
@ -503,10 +541,16 @@ describe("Anthropic Messages route", () => {
providerExecuted: undefined,
providerMetadata: undefined,
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: "tool_use" },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "tool-calls",
reason: { normalized: "tool-calls", raw: "tool_use" },
providerMetadata: undefined,
usage,
},
@ -674,7 +718,10 @@ describe("Anthropic Messages route", () => {
},
})
expect(response.text).toBe("Found it.")
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
})
}),
)

View file

@ -290,7 +290,10 @@ describe("Bedrock Converse route", () => {
// `metadata` (carries usage). We consolidate them into a single
// terminal `finish` event with both.
expect(finishes).toHaveLength(1)
expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
expect(finishes[0]).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
})
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
@ -299,6 +302,23 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("maps truncation and malformed output stop reasons", () =>
Effect.gen(function* () {
const reasons = [
["model_context_window_exceeded", "length"],
["malformed_model_output", "error"],
["malformed_tool_use", "error"],
] as const
for (const [raw, normalized] of reasons) {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
)
expect(response.finishReason).toEqual({ normalized, raw })
}
}),
)
it.effect("adds cache reads and writes to Bedrock input usage", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@ -362,7 +382,10 @@ describe("Bedrock Converse route", () => {
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
])
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "tool_use" },
})
}),
)
@ -388,7 +411,7 @@ describe("Bedrock Converse route", () => {
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
)

View file

@ -373,10 +373,16 @@ describe("Gemini route", () => {
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "stop", raw: "STOP" },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "stop",
reason: { normalized: "stop", raw: "STOP" },
usage,
},
])
@ -529,10 +535,16 @@ describe("Gemini route", () => {
providerExecuted: undefined,
providerMetadata: undefined,
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: "STOP" },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "tool-calls",
reason: { normalized: "tool-calls", raw: "STOP" },
usage,
},
])
@ -571,7 +583,10 @@ describe("Gemini route", () => {
},
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" },
})
}),
)
@ -591,9 +606,41 @@ describe("Gemini route", () => {
)
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
expect(length.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "length", raw: "MAX_TOKENS" },
})
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
expect(filtered.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "content-filter", raw: "SAFETY" },
})
}),
)
it.effect("maps current blocking and invalid-output finish reasons", () =>
Effect.gen(function* () {
const reasons = [
["MODEL_ARMOR", "content-filter"],
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
["IMAGE_RECITATION", "content-filter"],
["LANGUAGE", "content-filter"],
["UNEXPECTED_TOOL_CALL", "error"],
["NO_IMAGE", "error"],
["IMAGE_OTHER", "unknown"],
["TOO_MANY_TOOL_CALLS", "error"],
["MISSING_THOUGHT_SIGNATURE", "error"],
["MALFORMED_RESPONSE", "error"],
] as const
for (const [raw, normalized] of reasons) {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: raw }] })),
),
)
expect(response.finishReason).toEqual({ normalized, raw })
}
}),
)

View file

@ -569,10 +569,16 @@ describe("OpenAI Chat route", () => {
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "stop", raw: "stop" },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "stop",
reason: { normalized: "stop", raw: "stop" },
usage,
},
])
@ -1037,8 +1043,14 @@ describe("OpenAI Chat route", () => {
providerExecuted: undefined,
providerMetadata: undefined,
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
{ type: "finish", reason: "tool-calls", usage: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: "tool_calls" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "tool-calls", raw: "tool_calls" }, usage: undefined },
])
}),
)

View file

@ -232,7 +232,10 @@ describe("OpenAI-compatible Chat route", () => {
expect(response.text).toBe("Hello!")
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "stop" },
})
}),
)
})

View file

@ -856,13 +856,13 @@ describe("OpenAI Responses route", () => {
{
type: "step-finish",
index: 0,
reason: "stop",
reason: { normalized: "stop", raw: undefined },
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage,
},
{
type: "finish",
reason: "stop",
reason: { normalized: "stop", raw: undefined },
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage,
},
@ -887,11 +887,18 @@ describe("OpenAI Responses route", () => {
const length = yield* generate({ reason: "max_output_tokens" })
const contentFilter = yield* generate({ reason: "content_filter" })
const unknown = yield* generate({})
const custom = yield* generate({ reason: "provider_limit" })
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
"length",
"content-filter",
"unknown",
expect([
length.finishReason,
contentFilter.finishReason,
unknown.finishReason,
custom.finishReason,
]).toEqual([
{ normalized: "length", raw: "max_output_tokens" },
{ normalized: "content-filter", raw: "content_filter" },
{ normalized: "unknown", raw: undefined },
{ normalized: "unknown", raw: "provider_limit" },
])
}),
)
@ -946,8 +953,8 @@ describe("OpenAI Responses route", () => {
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
@ -1038,8 +1045,8 @@ describe("OpenAI Responses route", () => {
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
}),
)
@ -1422,10 +1429,16 @@ describe("OpenAI Responses route", () => {
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: undefined },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "tool-calls",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: undefined,
usage,
},
@ -1465,7 +1478,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
expect(response.finishReason.normalized).toBe("tool-calls")
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}),
)
@ -1492,7 +1505,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)

View file

@ -4,6 +4,8 @@ import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
describe("OpenRouter", () => {
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
@ -54,6 +56,42 @@ describe("OpenRouter", () => {
}),
)
it.effect("preserves the upstream provider finish reason", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }],
}),
),
),
)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("fails on a mid-stream provider error", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
error: { code: 502, message: "Provider disconnected" },
}),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.message).toContain("Provider disconnected")
}),
)
it.effect("preserves manually supplied reasoning details", () =>
Effect.gen(function* () {
const details = [

View file

@ -106,7 +106,7 @@ const readPdfRuntime = Tool.make({
})
const expectCode = (response: LLMResponse) => {
expect(response.finishReason).toBe("stop")
expect(response.finishReason.normalized).toBe("stop")
expect(response.text.toUpperCase()).toContain(CODE)
}
@ -166,7 +166,7 @@ describe("PDF recorded", () => {
tools: { read_pdf: readPdfRuntime },
}).pipe(Stream.runCollect),
)
expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "stop" } })
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
return
}

View file

@ -125,8 +125,8 @@ const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
export const expectFinish = (
events: ReadonlyArray<LLMEvent>,
reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
reason: FinishReason,
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
export const expectWeatherToolCall = (response: LLMResponse) =>
expect(response.toolCalls).toMatchObject([
@ -136,10 +136,10 @@ export const expectWeatherToolCall = (response: LLMResponse) =>
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
const finishes = events.filter(LLMEvent.is.finish)
expect(finishes).toHaveLength(1)
expect(finishes[0]?.reason).toBe("stop")
expect(finishes[0]?.reason.normalized).toBe("stop")
const stepFinishes = events.filter(LLMEvent.is.stepFinish)
expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
expect(stepFinishes.map((event) => event.reason.normalized)).toEqual(["tool-calls", "stop"])
const toolCalls = events.filter(LLMEvent.is.toolCall)
expect(toolCalls).toHaveLength(1)
@ -503,7 +503,7 @@ export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
continue
}
if (event.type === "finish") {
summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
summary.push({ type: "finish", reason: event.reason.normalized, usage: usageSummary(event.usage) })
}
}
return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))

View file

@ -14,11 +14,11 @@ describe("LLMResponse reducer", () => {
LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }),
LLMEvent.textDelta({ id: "t1", text: "Answer" }),
LLMEvent.textEnd({ id: "t1" }),
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }),
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 5 } }),
]
const response = LLMResponse.fromEvents(events)
expect(response?.finishReason).toBe("stop")
expect(response?.finishReason).toEqual({ normalized: "stop" })
expect(response?.usage).toMatchObject({ outputTokens: 5 })
expect(response?.events).toEqual(events)
expect(response?.events.map((event) => event.type)).toEqual([
@ -62,18 +62,26 @@ describe("LLMResponse reducer", () => {
test("uses terminal usage when present and keeps prior usage when finish omits it", () => {
const withFinishUsage = LLMResponse.fromEvents([
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }),
])
const withoutFinishUsage = LLMResponse.fromEvents([
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 })
expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 })
})
test("preserves the raw finish reason", () => {
const response = LLMResponse.fromEvents([
LLMEvent.finish({ reason: { normalized: "unknown", raw: "provider_limit" } }),
])
expect(response?.finishReason).toEqual({ normalized: "unknown", raw: "provider_limit" })
})
test("assembles tool-call content only after the completed tool call event", () => {
const pending = reduce([
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
@ -88,7 +96,7 @@ describe("LLMResponse reducer", () => {
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }),
LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }),
LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
])
expect(response?.message.content).toEqual([

View file

@ -48,8 +48,12 @@ describe("llm schema", () => {
})
test("finish constructors accept usage input", () => {
expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage)
expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
expect(
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage,
).toBeInstanceOf(Usage)
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(
Usage,
)
})
test("content part tagged union exposes guards", () => {

View file

@ -659,12 +659,12 @@ function streamPartEvents(
return Effect.succeed([
LLMEvent.stepFinish({
index: state.step++,
reason: finishReason(event.finishReason),
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
usage: usage(event.usage),
providerMetadata: providerMetadata(event.providerMetadata),
}),
LLMEvent.finish({
reason: finishReason(event.finishReason),
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
usage: usage(event.usage),
providerMetadata: providerMetadata(event.providerMetadata),
}),

View file

@ -58,7 +58,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
let stepFailure: SessionError.Error | undefined
let stepSettlement:
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
| undefined
@ -449,8 +449,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
if (event.reason === "content-filter") {
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
return

View file

@ -299,6 +299,7 @@ it.effect("emits malformed AI SDK tool input without executing it", () =>
})
expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" })
}),
)

View file

@ -73,7 +73,7 @@ const client = Layer.mock(LLMClient.Service)({
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "OK" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
if (!response) throw new Error("Incomplete generate response")
return response

View file

@ -49,7 +49,7 @@ const client = Layer.mock(LLMClient.Service)({
LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
reason: { normalized: "stop" },
usage: {
inputTokens: 15,
outputTokens: 6,
@ -60,7 +60,7 @@ const client = Layer.mock(LLMClient.Service)({
},
}),
LLMEvent.finish({
reason: "stop",
reason: { normalized: "stop" },
}),
)
},

View file

@ -59,8 +59,8 @@ const client = Layer.mock(LLMClient.Service)({
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 100, outputTokens: 10 } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
if (!response) throw new Error("Incomplete generate response")
return response

View file

@ -255,7 +255,7 @@ test("success event data can carry provider-executed result state", () => {
test("step finish records settlement without publishing step ended", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
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" })
@ -268,7 +268,7 @@ test("content-filter finish retains failure evidence until step closeout", async
publisher.publish(
LLMEvent.stepFinish({
index: 0,
reason: "content-filter",
reason: { normalized: "content-filter" },
usage: {
nonCachedInputTokens: 8,
outputTokens: 3,
@ -311,7 +311,7 @@ test("content-filter finish preserves partial streamed text and never ends the s
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text" }),
LLMEvent.textDelta({ id: "text", text: "Partial" }),
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }),
],
(event) => publisher.publish(event),
{ discard: true },

View file

@ -119,8 +119,8 @@ const client = Layer.succeed(
const reply = {
stop: () => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
],
text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents,
textWithUsage: (text: string, id: string, inputTokens: number) =>
@ -136,8 +136,8 @@ const reply = {
tool: (id: string, name: string, input: unknown) => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id, name, input }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
],
}
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
@ -682,8 +682,8 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
completeEvents: [
...partialEvents,
LLMEvent.textEnd({ id }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
],
expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
expectedContent,
@ -702,8 +702,8 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
completeEvents: [
...partialEvents,
LLMEvent.reasoningEnd({ id }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
],
expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
expectedContent,
@ -999,8 +999,8 @@ describe("SessionRunnerLLM", () => {
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-reloaded", name: "reloaded", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
],
[],
]
@ -2377,7 +2377,7 @@ describe("SessionRunnerLLM", () => {
}),
LLMEvent.stepFinish({
index: 0,
reason: "tool-calls",
reason: { normalized: "tool-calls" },
usage: {
inputTokens: 10,
nonCachedInputTokens: 8,
@ -2386,7 +2386,7 @@ describe("SessionRunnerLLM", () => {
cacheReadInputTokens: 2,
},
}),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
]
yield* session.resume(sessionID)
@ -2535,8 +2535,8 @@ describe("SessionRunnerLLM", () => {
anthropic: { ignored: true },
},
}),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
]
yield* session.resume(sessionID)
yield* replaySessionProjection(sessionID)
@ -2600,8 +2600,8 @@ describe("SessionRunnerLLM", () => {
providerExecuted: true,
providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
}),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
]
yield* session.resume(sessionID)
yield* replaySessionProjection(sessionID)
@ -2648,8 +2648,8 @@ describe("SessionRunnerLLM", () => {
),
])
const final = Stream.fromIterable([
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
])
responseStream = Stream.concat(
initial,
@ -3605,7 +3605,7 @@ describe("SessionRunnerLLM", () => {
yield* admit(session, "Reject permission")
responses = [
reply.tool("call-permission", "permissionfail", {}),
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })],
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })],
]
yield* session.resume(sessionID)
@ -3954,10 +3954,10 @@ describe("SessionRunnerLLM", () => {
LLMEvent.textDelta({ id: "partial", text: "Partial" }),
LLMEvent.stepFinish({
index: 0,
reason: "content-filter",
reason: { normalized: "content-filter" },
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
}),
LLMEvent.finish({ reason: "content-filter" }),
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response")
@ -3990,8 +3990,8 @@ describe("SessionRunnerLLM", () => {
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }),
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
LLMEvent.finish({ reason: "content-filter" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }),
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
]
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
@ -4282,8 +4282,8 @@ describe("SessionRunnerLLM", () => {
name: "echo",
raw,
}),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
],
reply.stop(),
]
@ -4379,8 +4379,8 @@ describe("SessionRunnerLLM", () => {
name: "echo",
raw: '{"text":"partial',
}),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
],
reply.stop(),
]
@ -4421,8 +4421,8 @@ describe("SessionRunnerLLM", () => {
name: "echo",
raw: '{"text":"partial',
}),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
]
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
@ -4530,8 +4530,8 @@ describe("SessionRunnerLLM", () => {
name: "echo",
raw: '{"text":"partial',
}),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
]
responses = [
malformed("call-first"),
@ -4565,8 +4565,8 @@ describe("SessionRunnerLLM", () => {
name: "echo",
raw: '{"text":"partial',
}),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
]
responses = [malformed("call-first"), malformed("call-at-limit")]
@ -4727,8 +4727,8 @@ describe("SessionRunnerLLM", () => {
response = [
LLMEvent.stepStart({ index: 0 }),
hostedCall("call-hosted-clean-end", "effect"),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
]
yield* session.resume(sessionID)
@ -4852,8 +4852,8 @@ describe("SessionRunnerLLM", () => {
LLMEvent.textStart({ id: "text-2" }),
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
LLMEvent.textEnd({ id: "text-2" }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
]
yield* session.resume(sessionID)
@ -4906,8 +4906,8 @@ describe("SessionRunnerLLM", () => {
LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }),
LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }),
hostedCall("call-parsed", "hello"),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
]
yield* session.resume(sessionID)

View file

@ -47,7 +47,7 @@ const client = Layer.mock(LLMClient.Service)({
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
reason: { normalized: "stop" },
usage: {
inputTokens: 15,
outputTokens: 6,
@ -58,7 +58,7 @@ const client = Layer.mock(LLMClient.Service)({
},
}),
LLMEvent.finish({
reason: "stop",
reason: { normalized: "stop" },
}),
)
},