mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-19 06:30:30 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2254e5be54 | |||
| e33e7d8837 | |||
| 7ad9ccf5e6 | |||
| 6baad7fc3e | |||
| 0762d63b6a | |||
| 4df0591025 | |||
| 30cb420900 |
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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 </system-update> 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(
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useData } from "@/context/server"
|
||||
import { pluginLabels } from "@/utils/plugin"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
@@ -102,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return pluginLabels(projectPluginList.latest ?? []).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { pluginLabels } from "@/utils/plugin"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
@@ -45,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
|
||||
const plugins = createMemo<PluginRowItem[]>(() =>
|
||||
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { pluginLabels } from "@/utils/plugin"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -39,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import { pluginLabels } from "./plugin"
|
||||
|
||||
describe("pluginLabels", () => {
|
||||
test("omits built-in plugins", () => {
|
||||
const plugins: PluginInfo[] = [
|
||||
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
|
||||
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
|
||||
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
|
||||
]
|
||||
|
||||
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,3 @@ export function pluginLabel(plugin: PluginInfo) {
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
export function pluginLabels(plugins: readonly PluginInfo[]) {
|
||||
return plugins.filter((plugin) => plugin.source.type !== "builtin").map(pluginLabel)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user