Compare commits

...

7 Commits

Author SHA1 Message Date
Aiden Cline 2254e5be54 fix(ai): replay refusals as text 2026-08-19 00:10:53 -05:00
Aiden Cline e33e7d8837 refactor(ai): validate refusal replay state 2026-08-18 23:14:26 -05:00
Aiden Cline 7ad9ccf5e6 feat(ai): preserve streamed refusals 2026-08-18 22:59:17 -05:00
Aiden Cline 6baad7fc3e feat(ai): support Responses truncation policy (#43339) 2026-08-18 22:54:37 -05:00
Dax 0762d63b6a fix(core): identify user-initiated web fetches (#43330) 2026-08-18 23:51:52 -04:00
Aiden Cline 4df0591025 feat(core): retain provider finish details (#43332) 2026-08-18 22:40:13 -05:00
Aiden Cline 30cb420900 feat(ai): lower system updates as developer (#43326) 2026-08-18 22:20:42 -05:00
22 changed files with 552 additions and 54 deletions
+1 -1
View File
@@ -193,7 +193,7 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i
`LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only.
Native chronological system messages are route/model-specific. Anthropic Messages lowers them natively for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
Native chronological system messages are route/model-specific. Open Responses lowers them to standard `developer` messages, while Anthropic Messages lowers them to native system messages for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
```text
<system-update>
+65 -17
View File
@@ -90,6 +90,7 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("assistant"),
@@ -153,6 +154,7 @@ export const coreFields = {
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String),
include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema),
@@ -252,7 +254,10 @@ export const Event = Schema.StructWithRest(
type: Schema.String,
delta: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
refusal: 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(
@@ -439,14 +444,10 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
for (const message of request.messages) {
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user")
input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
}
else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
})
continue
}
@@ -580,6 +581,7 @@ const lowerOptions = (request: LLMRequest) => {
: {}),
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
@@ -689,6 +691,35 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
const refusalID = (event: Event) => `refusal:${event.item_id}:${event.content_index}`
const messageMetadata = (
state: ParserState,
itemID: string,
phase: MessagePhase | null | undefined = state.messagePhases[itemID],
) => (phase === undefined ? undefined : providerMetadata(state, { phase }))
const onRefusalDelta = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || !event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const id = refusalID(event)
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, messageMetadata(state, event.item_id))
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
const onRefusalDone = (state: ParserState, event: Event): StepResult => {
if (!event.item_id) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const id = refusalID(event)
const metadata = messageMetadata(state, event.item_id)
const started =
state.lifecycle.text.has(id) || event.refusal === undefined
? state.lifecycle
: Lifecycle.textDelta(Lifecycle.textStart(state.lifecycle, events, id, metadata), events, id, event.refusal)
if (state.messageItems.has(event.item_id)) return [{ ...state, lifecycle: started }, events]
return [{ ...state, lifecycle: Lifecycle.textEnd(started, events, id, metadata) }, events]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
@@ -891,21 +922,24 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
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 phase = itemPhase === undefined ? state.messagePhases[itemID] : itemPhase
const events: LLMEvent[] = []
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Array.from(state.lifecycle.text)
.filter((id) => id.startsWith(`refusal:${itemID}:`))
.reduce(
(lifecycle, id) => Lifecycle.textEnd(lifecycle, events, id, messageMetadata(state, itemID, phase)),
Lifecycle.textEnd(state.lifecycle, events, itemID, metadata),
)
const messageItems = new Set(state.messageItems)
messageItems.delete(item.id)
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
messageItems.delete(itemID)
const { [itemID]: _phase, ...messagePhases } = state.messagePhases
return [
{
...state,
lifecycle: Lifecycle.textEnd(
state.lifecycle,
events,
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
lifecycle,
messageItems,
messagePhases,
},
@@ -1027,6 +1061,20 @@ export const step = (state: ParserState, event: Event) => {
: onOutputTextDone(state, event, event.item_id),
)
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.output_index === undefined)
return ProviderShared.eventError(state.id, `${event.type} is missing output_index`)
if (event.content_index === undefined)
return ProviderShared.eventError(state.id, `${event.type} is missing content_index`)
if (event.type === "response.refusal.delta" && event.delta === undefined)
return ProviderShared.eventError(state.id, `${event.type} is missing delta`)
if (event.type === "response.refusal.done" && event.refusal === undefined)
return ProviderShared.eventError(state.id, `${event.type} is missing refusal`)
return Effect.succeed(
event.type === "response.refusal.delta" ? onRefusalDelta(state, event) : onRefusalDone(state, event),
)
}
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))
+14 -2
View File
@@ -28,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "openai-chat"
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions"
@@ -194,6 +194,7 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
refusal: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
@@ -709,6 +710,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent =
Boolean(delta?.content) ||
Boolean(delta?.refusal) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
@@ -728,7 +730,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || toolDeltas.length > 0)
(Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
)
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
@@ -743,6 +745,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
for (const [position, tool] of toolDeltas.entries()) {
@@ -16,19 +16,25 @@ export type ResponseIncludable = (typeof ResponseIncludables)[number]
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number]
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(ResponseIncludables)
const SERVICE_TIERS = new Set<string>(ServiceTiers)
const TRUNCATIONS = new Set<string>(Truncations)
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
const isTruncation = (value: unknown): value is Truncation => typeof value === "string" && TRUNCATIONS.has(value)
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export const TruncationSchema = Schema.Literals(Truncations)
export interface Resolved {
readonly instructions?: string
@@ -38,6 +44,7 @@ export interface Resolved {
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
readonly serviceTier?: ServiceTier
readonly truncation?: Truncation
}
export const resolve = (request: LLMRequest): Resolved => {
@@ -57,6 +64,7 @@ export const resolve = (request: LLMRequest): Resolved => {
include: include.length > 0 ? include : undefined,
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
truncation: isTruncation(input?.truncation) ? input.truncation : undefined,
}
}
@@ -1,4 +1,4 @@
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options.js"
import type { ResponseIncludable, ServiceTier, Truncation } from "../protocols/utils/open-responses-options.js"
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema/index.js"
export interface OpenResponsesOptionsInput {
@@ -10,6 +10,7 @@ export interface OpenResponsesOptionsInput {
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: ServiceTier
readonly truncation?: Truncation
}
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
+13 -3
View File
@@ -7,6 +7,7 @@ import {
type FinishReasonDetails,
type AIError,
type LLMRequest,
type ProviderMetadata,
type UsageInput,
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
@@ -33,13 +34,22 @@ export interface LayerOptions {
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
export const complete = (
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
options: {
readonly reason: FinishReasonDetails
readonly usage?: UsageInput
readonly providerMetadata?: ProviderMetadata
},
...events: readonly LLMEvent[]
) => [
LLMEvent.stepStart({ index: 0 }),
...events,
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
LLMEvent.finish({ reason: options.reason }),
LLMEvent.stepFinish({
index: 0,
reason: options.reason,
usage: options.usage,
providerMetadata: options.providerMetadata,
}),
LLMEvent.finish({ reason: options.reason, providerMetadata: options.providerMetadata }),
]
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
@@ -664,6 +664,74 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves streamed refusals as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ role: "assistant", refusal: "I can't" }),
deltaChunk({ refusal: " help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }])
}),
)
it.effect("orders metadata-only reasoning before refusal output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
deltaChunk({ refusal: "I can't help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: [] } } },
{
type: "text",
text: "I can't help with that.",
},
])
}),
)
it.effect("joins content and refusal deltas into ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ refusal: "No." }),
deltaChunk({ content: " Alternative." }),
deltaChunk({ refusal: " Still no." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("No. Alternative. Still no.")
expect(response.events.filter(LLMEvent.is.textStart).map((event) => event.id)).toEqual(["text-0"])
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.id)).toEqual(["text-0"])
}),
)
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
Effect.gen(function* () {
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
@@ -56,6 +56,28 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("lowers chronological system updates as standard developer messages", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () {
const model = configure({
@@ -96,18 +118,66 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
refusal: "I can't help with that.",
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{ role: "assistant", content: [{ type: "output_text", text: "I can't help with that." }] },
])
}),
)
it.effect("reads standard options from the Open Responses namespace", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
providerOptions: { openresponses: { reasoningEffort: "low", store: true, truncation: "auto" } },
}).model("example-model")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
truncation: "auto",
})
}),
)
@@ -241,27 +241,22 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
it.effect("lowers chronological system updates to developer messages in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Before."),
Message.system("Treat </system-update> literally."),
Message.system("Operator update."),
Message.assistant("After."),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [
{ type: "input_text", text: "Before." },
{ type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
],
},
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
@@ -1288,6 +1283,7 @@ describe("OpenAI Responses route", () => {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
truncation: "disabled",
},
},
}),
@@ -1298,6 +1294,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.truncation).toBe("disabled")
}),
)
@@ -1484,6 +1481,108 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.content_part.added",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "" },
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: "I can't",
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: " help with that.",
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
refusal: "I can't help with that.",
},
{
type: "response.content_part.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "I can't help with that." },
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
phase: "final_answer",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: undefined })
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openai: { phase: "final_answer" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
])
}),
)
it.effect("rejects refusal events without standard content coordinates", () =>
Effect.gen(function* () {
const events = [
{ type: "response.refusal.delta", output_index: 0, content_index: 0, delta: "missing item" },
{ type: "response.refusal.delta", item_id: "msg_1", content_index: 0, delta: "missing output" },
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, delta: "missing content" },
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, content_index: 0 },
{ type: "response.refusal.done", item_id: "msg_1", output_index: 0, content_index: 0 },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}
}),
)
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
+5
View File
@@ -579,6 +579,8 @@ export type Endpoint5_31Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly cost: number & Brand.Brand<"Money.USD">
readonly tokens: {
readonly input: number
@@ -601,6 +603,9 @@ export type Endpoint5_31Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly finish?: "content-filter" | undefined
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -1108,6 +1108,8 @@ export type SessionStepEnded = {
sessionID: string
assistantMessageID: string
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost: MoneyUSD
tokens: TokenUsageInfo
snapshot?: string
@@ -1145,6 +1147,9 @@ export type SessionStepFailed = {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
finish?: "content-filter"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost?: MoneyUSD
tokens?: TokenUsageInfo
snapshot?: string
@@ -1921,6 +1926,8 @@ export type SessionMessageAssistant = {
content: Array<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool>
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -2691,6 +2698,8 @@ export type SessionImportInput = {
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -2958,6 +2967,8 @@ export type SessionImportInput = {
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3225,6 +3236,8 @@ export type SessionImportInput = {
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
+7 -1
View File
@@ -601,6 +601,8 @@ export function createData(config: CreateDataInput) {
existing.retry = undefined
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
return
@@ -628,6 +630,8 @@ export function createData(config: CreateDataInput) {
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
if (event.data.snapshot)
@@ -640,7 +644,9 @@ export function createData(config: CreateDataInput) {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = "error"
currentAssistant.finish = event.data.finish ?? "error"
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.error = event.data.error
currentAssistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
+7 -1
View File
@@ -195,6 +195,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.retry = undefined
draft.error = undefined
draft.finish = undefined
draft.rawFinish = undefined
draft.providerState = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
}),
@@ -228,6 +230,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
draft.finish = event.data.finish
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.cost = event.data.cost
draft.tokens = event.data.tokens
if (event.data.snapshot || event.data.files)
@@ -241,7 +245,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
draft.finish = "error"
draft.finish = event.data.finish ?? "error"
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
+2
View File
@@ -326,6 +326,8 @@ const layer = Layer.effect(
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: finish.finish,
rawFinish: finish.rawFinish,
providerState: finish.providerState,
...stepUsage(finish),
...end,
})
@@ -35,6 +35,8 @@ export interface StepRecord {
/** Present once the provider finished the step normally. */
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly rawFinish?: string
readonly providerState?: SessionMessage.ProviderState
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly calls: ReadonlyArray<{
@@ -364,6 +366,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
sessionID: input.sessionID,
assistantMessageID,
error: stepFailure,
finish: stepSettlement?.finish === "content-filter" ? stepSettlement.finish : undefined,
rawFinish: stepSettlement?.rawFinish,
providerState: stepSettlement?.providerState,
...details,
})
})
@@ -517,7 +522,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
stepSettlement = {
finish: event.reason.normalized,
rawFinish: event.reason.raw,
providerState: providerState(event.providerMetadata),
tokens: SessionUsage.tokens(event.usage),
}
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+4 -4
View File
@@ -56,8 +56,8 @@ const headers = (format: Format, userAgent: string) => ({
"Accept-Language": "en-US,en;q=0.9",
})
const browserUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
const openCodeUserAgent =
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
const isCloudflareChallenge = (error: unknown) => {
if (!error || typeof error !== "object" || !("reason" in error)) return false
@@ -74,14 +74,14 @@ const isCloudflareChallenge = (error: unknown) => {
return response.status === 403 && response.headers["cf-mitigated"] === "challenge"
}
const request = (url: string, format: Format, userAgent = browserUserAgent) =>
const request = (url: string, format: Format, userAgent = openCodeUserAgent) =>
HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers(format, userAgent)))
const assertHttpUrl = (url: URL) => {
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http:// or https://")
}
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = openCodeUserAgent) =>
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
@@ -353,7 +353,12 @@ test("content-filter finish retains failure evidence until step closeout", async
publisher.publish(
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "content-filter" },
reason: { normalized: "content-filter", raw: "refusal" },
providerMetadata: {
anthropic: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
},
usage: {
nonCachedInputTokens: 8,
outputTokens: 3,
@@ -367,6 +372,10 @@ test("content-filter finish retains failure evidence until step closeout", async
const settlement = publisher.record().finish
expect(settlement).toMatchObject({
finish: "content-filter",
rawFinish: "refusal",
providerState: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
tokens: { input: 8, output: 2, reasoning: 1 },
})
if (!settlement) throw new Error("Expected content-filter settlement")
@@ -381,6 +390,11 @@ test("content-filter finish retains failure evidence until step closeout", async
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
expect(published.at(-1)?.data).toMatchObject({
error: { type: "provider.content-filter", message: "Provider blocked the response" },
finish: "content-filter",
rawFinish: "refusal",
providerState: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
cost: 1.25,
tokens: { input: 8, output: 2, reasoning: 1 },
snapshot: "tree-end",
+43 -2
View File
@@ -4161,13 +4161,49 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("persists raw finish reasons and provider state", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(
TestLLM.complete(
{
reason: { normalized: "stop", raw: "end_turn" },
providerMetadata: { openai: { responseId: "response-1", serviceTier: "priority" } },
},
LLMEvent.textStart({ id: "answer" }),
LLMEvent.textDelta({ id: "answer", text: "Complete" }),
LLMEvent.textEnd({ id: "answer" }),
),
)
yield* runPrompt(session, "Keep provider finish details")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{
type: "assistant",
finish: "stop",
rawFinish: "end_turn",
providerState: { responseId: "response-1", serviceTier: "priority" },
content: [{ type: "text", text: "Complete" }],
},
])
}),
)
it.effect("projects content-filter finishes as visible terminal failures", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(
TestLLM.complete(
{
reason: { normalized: "content-filter" },
reason: { normalized: "content-filter", raw: "SAFETY" },
providerMetadata: {
openai: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
},
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
},
LLMEvent.textStart({ id: "partial" }),
@@ -4182,7 +4218,12 @@ describe("SessionRunnerLLM", () => {
{ type: "user" },
{
type: "assistant",
finish: "error",
finish: "content-filter",
rawFinish: "SAFETY",
providerState: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
error: { type: "provider.content-filter" },
cost: 0,
tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } },
+38 -10
View File
@@ -23,6 +23,8 @@ const webFetchToolNode = makeLocationNode({
})
const sessionID = Session.ID.make("ses_webfetch_test")
const webFetchUserAgent =
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
const assertions: Permission.AssertInput[] = []
let respond = (_request: HttpClientRequest.HttpClientRequest) =>
@@ -376,7 +378,17 @@ describe("WebFetchTool registration", () => {
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
])
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
expect(requests).toMatchObject([
{
url,
headers: {
accept: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1",
"accept-language": "en-US,en;q=0.9",
"user-agent": webFetchUserAgent,
},
},
])
expect(requests[0]?.headers).not.toHaveProperty("sec-fetch-mode")
}),
)
@@ -397,15 +409,23 @@ describe("WebFetchTool registration", () => {
}),
)
live.effect("follows redirects while approving only the requested URL", () =>
Effect.acquireUseRelease(
live.effect("follows redirects while approving only the requested URL", () => {
const received: Array<Record<string, string | null>> = []
return Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) =>
new URL(request.url).pathname === "/redirect"
? new Response("", { status: 302, headers: { location: "/target" } })
: new Response("redirected", { headers: { "content-type": "text/plain" } }),
fetch: (request) => {
received.push({
accept: request.headers.get("accept"),
"accept-language": request.headers.get("accept-language"),
"sec-fetch-mode": request.headers.get("sec-fetch-mode"),
"user-agent": request.headers.get("user-agent"),
})
if (new URL(request.url).pathname === "/redirect")
return new Response("", { status: 302, headers: { location: "/target" } })
return new Response("redirected", { headers: { "content-type": "text/plain" } })
},
}),
),
(server) =>
@@ -421,10 +441,18 @@ describe("WebFetchTool registration", () => {
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
expect(received).toEqual(
Array.from({ length: 2 }, () => ({
accept: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1",
"accept-language": "en-US,en;q=0.9",
"sec-fetch-mode": null,
"user-agent": webFetchUserAgent,
})),
)
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
)
})
it.effect("rejects non-HTTP schemes before permission or transport", () =>
Effect.gen(function* () {
@@ -549,7 +577,7 @@ describe("WebFetchTool registration", () => {
content: [{ type: "text", text: "ok" }],
})
expect(requests).toHaveLength(2)
expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
expect(requests[0]?.headers["user-agent"]).toBe(webFetchUserAgent)
expect(requests[1]?.headers["user-agent"]).toBe("opencode")
}),
)
+5
View File
@@ -298,6 +298,8 @@ export namespace Step {
...Base,
assistantMessageID: SessionMessage.ID,
finish: FinishReason,
rawFinish: Schema.String.pipe(optional),
providerState: SessionMessage.ProviderState.pipe(optional),
cost: Money.USD,
tokens: TokenUsage.Info,
snapshot: Snapshot.ID.pipe(optional),
@@ -313,6 +315,9 @@ export namespace Step {
...Base,
assistantMessageID: SessionMessage.ID,
error: SessionError.Error,
finish: Schema.Literals(["content-filter"]).pipe(optional),
rawFinish: Schema.String.pipe(optional),
providerState: SessionMessage.ProviderState.pipe(optional),
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
snapshot: Snapshot.ID.pipe(optional),
+2
View File
@@ -215,6 +215,8 @@ export const Assistant = Schema.Struct({
files: Schema.Array(RelativePath).pipe(optional),
}).pipe(optional),
finish: FinishReason.pipe(optional),
rawFinish: Schema.String.pipe(optional),
providerState: ProviderState.pipe(optional),
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
error: SessionError.Error.pipe(optional),
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { SessionEvent } from "../src/session-event.js"
import { SessionMessage } from "../src/session-message.js"
const assistant = {
id: "msg_terminal",
type: "assistant" as const,
agent: "build",
model: { providerID: "openai", id: "gpt-test" },
content: [],
time: { created: 0 },
}
test("assistant terminal diagnostics remain optional and round trip", () => {
const decode = Schema.decodeUnknownSync(SessionMessage.Assistant)
const encode = Schema.encodeSync(SessionMessage.Assistant)
expect(encode(decode(assistant))).toEqual(assistant)
expect(
encode(
decode({
...assistant,
finish: "content-filter",
rawFinish: "SAFETY",
providerState: { promptFeedback: { blockReason: "SAFETY" } },
}),
),
).toMatchObject({
finish: "content-filter",
rawFinish: "SAFETY",
providerState: { promptFeedback: { blockReason: "SAFETY" } },
})
})
test("failed steps only override the assistant finish for content filters", () => {
const decode = Schema.decodeUnknownSync(SessionEvent.Step.Failed.data)
const input = {
sessionID: "ses_terminal",
assistantMessageID: "msg_terminal",
error: { type: "provider.content-filter", message: "Blocked" },
}
expect(decode(input)).toMatchObject(input)
expect(decode({ ...input, finish: "content-filter", rawFinish: "SAFETY" })).toMatchObject({
finish: "content-filter",
rawFinish: "SAFETY",
})
expect(() => decode({ ...input, finish: "stop" })).toThrow()
})