Compare commits

...
Author SHA1 Message Date
Kit Langton b346503924 refactor(ai): simplify OpenAI Chat content part parsing 2026-10-06 23:47:12 -07:00
Kit Langton d3b3d5c2bd fix(ai): accept content part arrays in OpenAI Chat streams
Mistral-family models served through OpenAI-compatible gateways stream delta.content as typed parts ({type: "text"} and {type: "thinking", thinking: [...]}) rather than a string, which failed stream decoding on the first chunk. Map text parts to text deltas and thinking parts to reasoning deltas in order, and skip other part types.
2026-10-06 23:24:54 -07:00
Aiden Cline 34cf183c4c feat(ai): support between_tools thinking in Anthropic Messages (#53601) 2026-10-06 23:57:20 -05:00
opencode-agent[bot]andBrendonovich fd99516e51 fix(session-ui): keep Markdown text selectable (#53665)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 04:33:34 +00:00
10 changed files with 223 additions and 18 deletions

No files matched your search

@@ -285,7 +285,7 @@ const AnthropicThinkingEnabled = Schema.Struct({
...AnthropicThinkingFields,
})
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.Literals(["disabled", "between_tools"]) })
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
type AnthropicThinking = typeof AnthropicThinking.Type
@@ -1001,18 +1001,21 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
// TODO: Move per-model capability heuristics (`supportsEffortUpdates`, `supportsNativeSystemUpdates`,
// `supportsThinkingBlockBinding`) into explicit model/provider `compatibility` metadata so the protocol
// only reads `request.model.compatibility`.
const isThinkingOff = Schema.is(AnthropicThinkingDisabled)
// Per-turn effort started with Claude Opus 5 and every Claude 5.1 model; later versions of any family inherit it.
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
const supportsEffortUpdates = (request: LLMRequest) => {
if (isThinkingOff(request.providerOptions?.thinking)) return false
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(model.id)
const version = claudeVersion(request.model.id)
if (version === undefined) return false
if (version.family === "opus" && version.major >= 5) return true
return version.major > 5 || (version.major === 5 && version.minor >= 1)
}
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
if (thinking?.type === "disabled") return thinking
if (isThinkingOff(thinking)) return thinking
if (!supportsThinkingBlockBinding(model)) return thinking
return {
...(thinking ?? { type: "adaptive" as const }),
@@ -1607,7 +1610,7 @@ export const protocol = Protocol.make({
}),
step,
},
supportsEffortUpdates: (request) => supportsEffortUpdates(request.model),
supportsEffortUpdates,
})
export const transport = <
@@ -1653,7 +1656,7 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
betas.push("mid-conversation-output-config-2026-07-01")
const thinking = body.thinking
if (thinking && thinking.type !== "disabled" && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
if (thinking && !isThinkingOff(thinking) && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
return betas
}
@@ -476,7 +476,9 @@ const MIN_THINKING_BUDGET = 1_024
const isThinkingDisabled = Schema.is(
Schema.Struct({
additionalModelRequestFields: Schema.Struct({ thinking: Schema.Struct({ type: Schema.Literal("disabled") }) }),
additionalModelRequestFields: Schema.Struct({
thinking: Schema.Struct({ type: Schema.Literals(["disabled", "between_tools"]) }),
}),
}),
)
+55 -5
View File
@@ -246,9 +246,23 @@ export const OpenAIChatToolCallDelta = Schema.Struct({
})
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
// Mistral-style models stream `content` as typed parts instead of a string:
// `text` parts carry output, and `thinking` parts nest their own text units.
// Other part types (references, media) carry nothing renderable and are skipped.
const OpenAIChatThinkingText = Schema.StructWithRest(Schema.Struct({ text: optionalNull(Schema.String) }), [JsonObject])
const OpenAIChatContentPart = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
text: optionalNull(Schema.String),
thinking: optionalNull(Schema.Union([Schema.String, Schema.Array(OpenAIChatThinkingText)])),
}),
[JsonObject],
)
export const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
content: optionalNull(Schema.Union([Schema.String, Schema.Array(OpenAIChatContentPart)])),
refusal: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
@@ -953,6 +967,37 @@ const reasoningDelta = (
return undefined
}
interface ContentDelta {
readonly type: "text" | "reasoning"
readonly text: string
}
// Flattens string or part-array content into ordered text and reasoning deltas.
const contentDeltas = Effect.fnUntraced(function* (content: Schema.Schema.Type<typeof OpenAIChatDelta>["content"]) {
if (!content) return []
if (typeof content === "string") return [{ type: "text" as const, text: content }]
const deltas: ContentDelta[] = []
const skipped: string[] = []
for (const part of content) {
if (part.type === "text") {
if (part.text) deltas.push({ type: "text", text: part.text })
} else if (part.type === "thinking") {
const text =
typeof part.thinking === "string"
? part.thinking
: (part.thinking ?? []).map((unit) => unit.text ?? "").join("")
if (text) deltas.push({ type: "reasoning", text })
} else {
skipped.push(part.type)
}
}
if (skipped.length > 0)
yield* Effect.logDebug("openai-chat.content_parts_skipped").pipe(
Effect.annotateLogs({ types: skipped.join(",") }),
)
return deltas
})
const detailText = (details: ReadonlyArray<ReasoningDetail>, hideKimiSummary: boolean) => {
const text = details.flatMap((detail) => {
if (detail.type === "reasoning.text") return detail.text ? [detail.text] : []
@@ -1078,8 +1123,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta, state.reasoningField)
const content = yield* contentDeltas(delta?.content)
const hasLateContent =
Boolean(delta?.content) ||
content.length > 0 ||
Boolean(delta?.refusal) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
@@ -1109,17 +1155,21 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
(content.length > 0 || Boolean(delta?.refusal) || toolDeltas.length > 0)
)
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
// Reasoning is one response-wide channel: it stays open alongside text and
// refusal output so late reasoning deltas and details join the same block,
// and `finishEvents` closes it once with the complete metadata.
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
for (const part of content)
lifecycle =
part.type === "text"
? Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
: Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", part.text, deltaMetadata)
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
@@ -65,8 +65,8 @@ const messagesRoute = Route.make({
...AnthropicMessages.protocol,
// Mantle rejects mid-conversation `output_config` on Opus 5.0; support starts at 5.1+.
supportsEffortUpdates: (request) => {
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
if (!(AnthropicMessages.protocol.supportsEffortUpdates?.(request) ?? false)) return false
if (request.model.compatibility?.supportsEffortUpdates !== undefined) return true
const version = claudeVersion(request.model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
},
+16
View File
@@ -268,6 +268,22 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("strips markers when thinking is disabled or between_tools", () =>
Effect.gen(function* () {
const sonnet = anthropic("claude-sonnet-5-5")
const betweenTools = yield* compileRequest(
LLM.request({
model: sonnet,
messages: conversation,
providerOptions: { thinking: { type: "between_tools" }, effort: "low" },
}),
)
expect(systemMessages(betweenTools.body)).toHaveLength(0)
expect(betweenTools.body.output_config).toEqual({ effort: "low" })
}),
)
it.effect("strips markers for Opus 5.0 on Bedrock Mantle Messages while lowering Opus 5.5", () =>
Effect.gen(function* () {
const mantle = AmazonBedrockMantle.configure({ apiKey: "test", region: "us-east-1" })
@@ -44,6 +44,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade
Effect.gen(function* () {
for (const thinking of [
{ type: "disabled" },
{ type: "between_tools" },
{ type: "adaptive", block_binding: { prefix_mismatch_behavior: "error" } },
] as const) {
const request = LLM.request({
@@ -55,7 +56,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
expect(compiled.body.thinking).toEqual(thinking)
expect(prepared.request.headers["anthropic-beta"]).toBe(
thinking.type === "disabled"
thinking.type === "disabled" || thinking.type === "between_tools"
? "interleaved-thinking-2025-05-14,compact-2026-01-12"
: "interleaved-thinking-2025-05-14,compact-2026-01-12,thinking-binding-controls-2026-08-01",
)
@@ -605,4 +605,123 @@ describe("OpenAI-compatible Chat route", () => {
})
}),
)
describe("content part arrays", () => {
const custom = LLMRequest.update(request, {
model: OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "example-model" }),
})
// Shape recorded from a Mistral-family model served through an
// OpenAI-compatible gateway.
const partChunk = (content: unknown, finishReason: string | null = null) => ({
id: "chunk_fixture",
object: "chat.completion.chunk",
created: 1791353545,
model: "example-model",
choices: [{ index: 0, finish_reason: finishReason, logprobs: null, delta: { content } }],
})
const thinking = (text: string) => ({ type: "thinking", thinking: [{ type: "text", text }] })
const generate = (...chunks: ReadonlyArray<unknown>) =>
LLMClient.generate(custom).pipe(Effect.provide(fixedResponse(sseEvents(...chunks))))
it.effect("streams thinking parts as reasoning and text parts as text", () =>
Effect.gen(function* () {
const response = yield* generate(
partChunk([thinking("Let")]),
partChunk([thinking(" me think.")]),
partChunk([{ type: "text", text: "Hello" }]),
partChunk([{ type: "text", text: "!" }]),
partChunk(null, "stop"),
)
expect(response.reasoning).toBe("Let me think.")
expect(response.text).toBe("Hello!")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
expect(
response.events.filter((event) => event.type === "reasoning-delta" || event.type === "text-delta"),
).toMatchObject([
{ type: "reasoning-delta", id: "reasoning-0", text: "Let" },
{ type: "reasoning-delta", id: "reasoning-0", text: " me think." },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
])
const replay = yield* compileRequest(LLM.request({ model: custom.model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello!", reasoning_content: "Let me think." },
])
}),
)
it.effect("keeps part order within one chunk", () =>
Effect.gen(function* () {
const response = yield* generate(
partChunk([
thinking("Plan"),
{ type: "thinking", thinking: "ned." },
{ type: "text", text: "Done" },
{ type: "text", text: "." },
]),
partChunk([], "stop"),
)
expect(response.reasoning).toBe("Planned.")
expect(response.text).toBe("Done.")
expect(
response.events.filter((event) => event.type === "reasoning-delta" || event.type === "text-delta"),
).toMatchObject([
{ type: "reasoning-delta", text: "Plan" },
{ type: "reasoning-delta", text: "ned." },
{ type: "text-delta", text: "Done" },
{ type: "text-delta", text: "." },
])
}),
)
it.effect("skips unknown part types and empty parts", () =>
Effect.gen(function* () {
const response = yield* generate(
partChunk([{ type: "reference", reference_ids: [1, 2] }]),
partChunk([
{
type: "thinking",
thinking: [
{ type: "reference", reference_ids: [3] },
{ type: "text", text: "Hm" },
],
},
]),
partChunk([
{ type: "text", text: "" },
{ type: "image_url", image_url: { url: "https://x.test/a.png" } },
]),
partChunk([{ type: "text", text: "Hi" }]),
partChunk(null, "stop"),
)
expect(response.reasoning).toBe("Hm")
expect(response.text).toBe("Hi")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
}),
)
it.effect("accepts empty part arrays after the finish reason", () =>
Effect.gen(function* () {
const response = yield* generate(partChunk([{ type: "text", text: "Hi" }], "stop"), partChunk([]))
expect(response.text).toBe("Hi")
}),
)
it.effect("rejects part content after the finish reason", () =>
Effect.gen(function* () {
const error = yield* Effect.flip(
generate(partChunk("Hi", "stop"), partChunk([{ type: "text", text: " late" }])),
)
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
}),
)
})
})
@@ -444,7 +444,7 @@ function ArtifactMarkdown(props: { session: MountedSession; path: string; text:
openLocalFile={(href) => void links.open({ href, base: dir(), session: props.session })}
>
<div class="mx-auto w-full max-w-3xl px-8 py-6">
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
<Markdown text={props.text} cacheKey={props.cacheKey} />
</div>
</MarkdownProvider>
)
@@ -454,7 +454,7 @@ function ArtifactMarkdown(props: { session: MountedSession; path: string; text:
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
return (
<div class="mx-auto w-full max-w-4xl px-8 py-6">
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} />
</div>
)
}
@@ -36,6 +36,17 @@ story("renders small completed Markdown immediately without skipping sanitizatio
await expect(harness.locator("pre.shiki")).toBeVisible()
})
story("keeps Markdown text selectable inside a container that disables selection", async ({ page }) => {
await page.evaluate(async (fixture) => {
document.body.style.userSelect = "none"
const { mountMarkdown } = await import(fixture)
await mountMarkdown({ text: "Selectable answer text" })
}, fixture)
await page.getByTestId("markdown-fixture").getByText("Selectable answer text").click({ clickCount: 3 })
expect(await page.evaluate(() => getSelection()?.toString().trim())).toBe("Selectable answer text")
})
story("sanitizes raw HTML while preserving supported Markdown markup", async ({ page }) => {
const result = await page.evaluate(async (fixture) => {
const { sanitizeMarkdown } = await import(fixture)
@@ -15,6 +15,9 @@
font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */
line-height: 160%;
/* Shells such as the app's turn off selection for chrome; Markdown is content, so it stays selectable. */
-webkit-user-select: text;
user-select: text;
[data-markdown-word] {
display: inline;