Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline 3e3910bc84 fix(ai): derive Anthropic tool finish reason 2026-08-05 01:06:17 +00:00
Aiden Cline 4ed343706d fix: retry empty incomplete streams (#40535) 2026-08-04 19:38:56 -05:00
opencode-agent[bot] 6d2eb2240a fix(app): finish tool call ID rename (#40539)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 19:26:45 -05:00
27 changed files with 200 additions and 241 deletions
@@ -303,6 +303,7 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly tools: ToolStream.State<number>
readonly hasLocalToolCalls: boolean
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
readonly pendingFinish?: {
@@ -668,7 +669,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// =============================================================================
// Stream Parsing
// =============================================================================
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
const mapFinishReason = (reason: string | null | undefined, hasLocalToolCalls: boolean): FinishReason => {
if (
hasLocalToolCalls &&
(reason === undefined || reason === null || reason === "end_turn" || reason === "stop_sequence")
)
return "tool-calls"
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
if (reason === "tool_use") return "tool-calls"
@@ -931,7 +937,17 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
delete reasoningSignatures[event.index]
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
return [
{
...state,
hasLocalToolCalls:
state.hasLocalToolCalls || resultEvents.some((item) => item.type === "tool-call" && !item.providerExecuted),
lifecycle,
tools: result.tools,
reasoningSignatures,
},
events,
] satisfies StepResult
})
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
@@ -942,7 +958,7 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
usage,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
normalized: mapFinishReason(event.delta?.stop_reason, state.hasLocalToolCalls),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
@@ -1013,6 +1029,7 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({
tools: ToolStream.empty<number>(),
hasLocalToolCalls: false,
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
}),
+14 -5
View File
@@ -20,6 +20,7 @@ import {
LanguageModel,
LanguageModelLimits,
LLMEvent,
InvalidProviderOutputReason,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -231,6 +232,17 @@ const streamError = (route: string, message: string, cause: Cause.Cause<unknown>
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
const incompleteStreamError = (route: string) =>
new AIError({
module: "LLMClient",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
route,
}),
})
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
Stream.suspend(() => {
let terminal = false
@@ -247,7 +259,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
Effect.suspend(() =>
terminal
? Effect.void
: Effect.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
: Effect.fail(incompleteStreamError(route)),
),
),
)
@@ -416,10 +428,7 @@ const generateWith = (stream: Interface["stream"]) =>
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
"Provider stream ended without a terminal finish event",
)
return yield* incompleteStreamError(`${request.model.provider}/${request.model.route.id}`)
})
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
+1
View File
@@ -105,6 +105,7 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
)({
_tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String,
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
+2 -2
View File
@@ -133,8 +133,8 @@ describe("llm route", () => {
Effect.gen(function* () {
const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Provider stream ended without a terminal finish event")
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
expect(error.message).toContain("The provider response ended unexpectedly.")
}),
)
@@ -538,7 +538,8 @@ describe("Anthropic Messages route", () => {
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Provider stream ended without a terminal finish event",
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
})
}),
)
@@ -914,6 +915,30 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("maps a local tool call with end_turn as tool-calls", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } },
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
)
it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -1136,9 +1136,12 @@ describe("OpenAI Chat route", () => {
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
expect(error.message).toContain("Provider stream ended without a terminal finish event")
expect(streamError.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
})
expect(streamError.message).toContain("The provider response ended unexpectedly.")
expect(error.message).toContain("The provider response ended unexpectedly.")
}),
)
@@ -53,7 +53,7 @@ describe("normalizePermissionRequest", () => {
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", callID: "call-1" },
source: { type: "tool", messageID: "message-1", id: "call-1" },
}),
).toEqual({
id: "permission-1",
@@ -48,7 +48,7 @@ export function normalizePermissionRequest(input: PermissionRequest | LegacyPerm
always: input.save ?? [],
metadata: input.metadata ?? {},
tool:
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
}
}
+34 -2
View File
@@ -21,12 +21,24 @@ describe("adaptServerEvent", () => {
id: "evt_1",
created: 1,
type: "permission.asked",
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
properties: {
id: "perm_1",
sessionID: "ses_1",
permission: "read",
patterns: ["src/**"],
tool: { messageID: "msg_1", callID: "call_1" },
},
current,
})
})
@@ -70,6 +82,26 @@ describe("coalesceServerEvents", () => {
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("coalesces current tool input deltas by tool ID", () => {
const current = (eventID: string, id: string, delta: string) =>
adaptServerEvent({
id: eventID,
created: 1,
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
])
expect(result).toHaveLength(2)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
+2 -2
View File
@@ -39,7 +39,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
? { messageID: event.data.source.messageID, callID: event.data.source.id }
: undefined,
},
current: event,
@@ -142,7 +142,7 @@ function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefine
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
@@ -92,19 +92,19 @@ describe("v2 session reducer", () => {
...base,
id: "evt_tool_start",
type: "session.tool.input.started",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", name: "bash" },
})
apply({
...base,
id: "evt_tool_delta",
type: "session.tool.input.delta",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", delta: "{}" },
})
apply({
...base,
id: "evt_tool_called",
type: "session.tool.called",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", input: {}, executed: true },
})
apply({
...base,
@@ -113,7 +113,7 @@ describe("v2 session reducer", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
callID: "call_1",
id: "call_1",
metadata: {},
content: [{ type: "text", text: "done" }],
executed: true,
@@ -241,13 +241,13 @@ export function createV2SessionReducer() {
case "session.tool.input.started":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
content: item.content.some((content) => content.type === "tool" && content.id === event.data.id)
? item.content
: [
...item.content,
{
type: "tool",
id: event.data.callID,
id: event.data.id,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
@@ -255,17 +255,17 @@ export function createV2SessionReducer() {
],
}))
case "session.tool.input.delta":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
tool.state.status === "streaming"
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
: tool,
)
case "session.tool.input.ended":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
)
case "session.tool.called":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => ({
...tool,
executed: event.data.executed,
providerState: event.data.state,
@@ -274,7 +274,7 @@ export function createV2SessionReducer() {
time: { ...tool.time, ran: event.created },
}))
case "session.tool.progress":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
tool.state.status === "running"
? {
...tool,
@@ -284,7 +284,7 @@ export function createV2SessionReducer() {
: tool,
)
case "session.tool.success":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
if (tool.state.status !== "running") return tool
return {
...tool,
@@ -302,7 +302,7 @@ export function createV2SessionReducer() {
}
})
case "session.tool.failed":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
return {
...tool,
+1 -1
View File
@@ -470,7 +470,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.step.failed") {
if (
input.compatibility === "v1" &&
event.data.error.message === "Provider stream ended without a terminal finish event"
event.data.error.message === "The provider response ended unexpectedly."
) {
pendingStep = undefined
v1InvalidOutput = true
+2 -2
View File
@@ -503,8 +503,8 @@ describe("runNonInteractivePrompt", () => {
turn: (messageID) => [
prompted(messageID),
stepStarted(),
stepFailed("Provider stream ended without a terminal finish event"),
executionFailed("Provider stream ended without a terminal finish event"),
stepFailed("The provider response ended unexpectedly."),
executionFailed("The provider response ended unexpectedly."),
],
})
-8
View File
@@ -764,14 +764,6 @@ export type Endpoint5_26Output =
readonly reason: "auto" | "manual"
readonly text: string
readonly recent: string
readonly media?:
| ReadonlyArray<{
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | undefined
}>
| undefined
}
}
| {
+21 -22
View File
@@ -121,6 +121,17 @@ export type SessionMessageCompactionRunning = {
recent: string
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
}
export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
@@ -766,6 +777,16 @@ export type SessionCompactionStarted = {
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
}
export type SessionRevertCleared = {
id: string
created: number
@@ -1206,18 +1227,6 @@ export type SessionMessageAssistantReasoning = {
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
media?: Array<ToolFileContent>
}
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
export type SessionMessageCompactionFailed = {
@@ -1380,16 +1389,6 @@ export type SessionToolCalled = {
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string; media?: Array<ToolFileContent1> }
}
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
export type ModelCost = {
+7 -61
View File
@@ -2,7 +2,6 @@ export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Tool } from "@opencode-ai/schema/tool"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
import { Bus } from "../bus"
@@ -20,10 +19,9 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const MEDIA_TOKEN_ESTIMATE = 1_500
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Objective
@@ -92,7 +90,6 @@ type Plan = {
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
readonly recent: string
readonly media: readonly Tool.FileContent[]
readonly inputID?: SessionMessage.ID
}
@@ -111,15 +108,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
const isMedia = (mime: string) => {
const value = mime.toLowerCase()
return (
value.startsWith("image/") ||
value.startsWith("audio/") ||
value.startsWith("video/") ||
value === "application/pdf"
)
}
export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
content
.map((item) =>
@@ -127,24 +115,6 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
)
.join("\n")
const isEstimatedMedia = (mime: string) =>
mime.toLowerCase().startsWith("image/") || mime.toLowerCase() === "application/pdf"
export const estimateMediaTokens = (message: SessionMessage.Info) => {
if (message.type === "user")
return (message.files?.filter((file) => isEstimatedMedia(file.mime)).length ?? 0) * MEDIA_TOKEN_ESTIMATE
if (message.type !== "assistant") return 0
return (
message.content
.flatMap((part) =>
part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")
? (part.state.content ?? [])
: [],
)
.filter((content) => content.type === "file" && isEstimatedMedia(content.mime)).length * MEDIA_TOKEN_ESTIMATE
)
}
const serialize = (message: SessionMessage.Info) => {
if (message.type === "user") {
const files =
@@ -192,11 +162,7 @@ const settings = (documents: readonly Config.Entry[]) => {
const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
): {
readonly head: string
readonly recent: string
readonly media: readonly Tool.FileContent[]
} | undefined => {
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = messages
.filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => {
@@ -207,7 +173,7 @@ const select = (
let total = 0
let split = conversation.length
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index].text) + estimateMediaTokens(conversation[index].message)
const next = total + Token.estimate(conversation[index].text)
if (split < conversation.length && next > tokens) break
total = next
split = index
@@ -217,33 +183,15 @@ const select = (
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
if (latestUser > 0) split = latestUser
}
const tail = conversation.slice(split)
return {
head: conversation
.slice(0, split)
.map((item) => item.text)
.join("\n\n"),
recent: tail.map((item) => item.text).join("\n\n"),
media: tail.flatMap((item) => {
if (item.message.type === "user")
return (
item.message.files
?.filter((file) => isMedia(file.mime))
.map((file) => ({
type: "file" as const,
uri: `data:${file.mime};base64,${file.data}`,
mime: file.mime,
name: file.name,
})) ?? []
)
if (item.message.type !== "assistant") return []
return item.message.content.flatMap((part) => {
if (part.type !== "tool" || (part.state.status !== "completed" && part.state.status !== "error")) return []
return (part.state.content ?? []).flatMap((content) =>
content.type === "file" && isMedia(content.mime) ? [content] : [],
)
})
}),
recent: conversation
.slice(split)
.map((item) => item.text)
.join("\n\n"),
}
}
@@ -271,7 +219,6 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
}),
recent: summarizeRecent ? "" : selected.recent,
media: summarizeRecent ? [] : selected.media,
}
}
@@ -371,7 +318,6 @@ const make = (dependencies: Dependencies) => {
reason: plan.reason,
text: summary,
recent: plan.recent,
media: plan.media,
})
return { status: "completed" as const }
})
@@ -480,7 +480,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
media: event.data.media,
})
return
}
@@ -493,7 +492,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
media: event.data.media,
time: { created: event.created },
}),
)
+2 -1
View File
@@ -20,10 +20,11 @@ export function isRetryable(error: AIError) {
case "ProviderInternal":
case "Transport":
return true
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
@@ -222,8 +222,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
Message.make({
id: message.id,
role: "user",
content: [
Message.text(`<conversation-checkpoint>
content: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
@@ -233,14 +232,7 @@ ${message.summary}
<recent-context>
${message.recent}
</recent-context>
</conversation-checkpoint>`),
...(message.media ?? []).map((media) => ({
type: "media" as const,
mediaType: media.mime,
data: media.uri,
filename: media.name,
})),
],
</conversation-checkpoint>`,
metadata: message.metadata,
}),
]
+5 -71
View File
@@ -22,7 +22,6 @@ import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Base64, FileAttachment } from "@opencode-ai/schema/prompt"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
@@ -114,24 +113,6 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64)
})
test("compaction estimates media context without counting base64", () => {
const image = FileAttachment.make({
data: Base64.make("a".repeat(10_000)),
mime: "image/png",
source: { type: "inline" },
name: "image.png",
})
const message = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compare these images.",
files: [image, image, FileAttachment.make({ ...image, mime: "application/pdf" })],
time: { created: DateTime.makeUnsafe(0) },
})
expect(SessionCompaction.estimateMediaTokens(message)).toBe(4_500)
})
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
@@ -197,7 +178,7 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
}),
)
it.effect("manual compaction preserves ordered media in the retained tail", () =>
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
@@ -209,35 +190,9 @@ it.effect("manual compaction preserves ordered media in the retained tail", () =
const userMessage = {
id: SessionMessage.ID.create(),
type: "user" as const,
text: `Manual compaction should include this older conversation. ${"older context ".repeat(4_500)}`,
text: "Manual compaction should include this short conversation.",
time: { created: DateTime.makeUnsafe(0) },
}
const recentMessage = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compare the retained media.",
files: [
FileAttachment.make({
data: Base64.make("aW1hZ2U="),
mime: "application/pdf",
source: { type: "inline" },
name: "prompt.pdf",
}),
FileAttachment.make({
data: Base64.make("aW1hZ2U="),
mime: "image/png",
source: { type: "inline" },
name: "prompt.png",
}),
],
time: { created: DateTime.makeUnsafe(1) },
})
const latestMessage = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Newest text after the retained media.",
time: { created: DateTime.makeUnsafe(2) },
})
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
@@ -273,7 +228,7 @@ it.effect("manual compaction preserves ordered media in the retained tail", () =
expect(
yield* compaction.compactManual({
session,
messages: [userMessage, recentMessage, latestMessage],
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
@@ -290,30 +245,9 @@ it.effect("manual compaction preserves ordered media in the retained tail", () =
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this older conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(yield* store.context(sessionID)).toMatchObject([
{
type: "compaction",
reason: "manual",
summary: "manual summary",
recent: expect.stringMatching(
/\[User\]: Compare the retained media\.\n\[Attached application\/pdf: prompt\.pdf\]\n\[Attached image\/png: prompt\.png\]\n\n\[User\]: Newest text after the retained media\./,
),
media: [
{
type: "file",
uri: "data:application/pdf;base64,aW1hZ2U=",
mime: "application/pdf",
name: "prompt.pdf",
},
{
type: "file",
uri: "data:image/png;base64,aW1hZ2U=",
mime: "image/png",
name: "prompt.png",
},
],
},
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
])
expect(yield* store.get(sessionID)).toMatchObject({
cost: 0.0000233,
@@ -102,15 +102,7 @@ describe("toLLMMessages", () => {
status: "completed",
reason: "auto",
summary: "Earlier work",
recent: "Recent work\n[Attached image/png: retained.png]",
media: [
{
type: "file",
uri: "data:image/png;base64,aGVsbG8=",
mime: "image/png",
name: "retained.png",
},
],
recent: "Recent work",
time: { created },
}),
],
@@ -150,16 +142,9 @@ Earlier work
<recent-context>
Recent work
[Attached image/png: retained.png]
</recent-context>
</conversation-checkpoint>`,
},
{
type: "media",
mediaType: "image/png",
data: "data:image/png;base64,aGVsbG8=",
filename: "retained.png",
},
],
])
})
+32 -2
View File
@@ -513,6 +513,16 @@ const providerUnavailable = () =>
reason: new TransportReason({ message: "Provider unavailable" }),
})
const incompleteStream = () =>
new AIError({
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
}),
})
const invalidRequest = () =>
new AIError({
module: "test",
@@ -3949,6 +3959,26 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries an incomplete stream before output", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry incomplete stream")
yield* TestLLM.push(Stream.fail(incompleteStream()))
yield* TestLLM.push(TestLLM.text("Recovered", "incomplete-stream-success"))
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
}),
)
it.effect("uses a larger provider retry-after delay", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -3969,7 +3999,7 @@ describe("SessionRunnerLLM", () => {
it.effect("does not retry eligible failures after observable output", () =>
Effect.gen(function* () {
const session = yield* setup
const failure = rateLimited()
const failure = incompleteStream()
yield* TestLLM.push(
TestLLM.failAfter(
failure,
@@ -3987,7 +4017,7 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "error",
error: { type: "provider.rate-limit" },
error: { type: "provider.invalid-output" },
content: [{ type: "text", text: "Partial" }],
},
])
+1 -2
View File
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { optional } from "./schema.js"
import { Event } from "./event.js"
import { FinishReason } from "./llm.js"
import { Content, FileContent } from "./tool.js"
import { Content } from "./tool.js"
import { Model } from "./model.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { FileAttachment } from "./prompt.js"
@@ -515,7 +515,6 @@ export namespace Compaction {
reason: Started.data.fields.reason,
text: Schema.String,
recent: Schema.String,
media: Schema.Array(FileContent).pipe(optional),
},
})
export type Ended = typeof Ended.Type
+1 -2
View File
@@ -2,7 +2,7 @@ export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Content, FileContent } from "./tool.js"
import { Content } from "./tool.js"
import { Model } from "./model.js"
import { Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
@@ -222,7 +222,6 @@ export const CompactionCompleted = Schema.Struct({
reason: Schema.Literals(["auto", "manual"]),
summary: Schema.String,
recent: Schema.String,
media: Schema.Array(FileContent).pipe(optional),
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
@@ -83,7 +83,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"auto": true,
"prune": false,
"keep": {
"tokens": 15000
"tokens": 8000
},
"buffer": 20000
}
@@ -94,7 +94,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
@@ -110,12 +110,9 @@ and relevant files.
The newest serialized context up to `keep.tokens` is retained separately. This
is not a byte-for-byte transcript: tool output is limited to 2000 characters,
and non-media attachments become textual descriptors. Media in the retained
context is attached to the checkpoint in the same order as its descriptors.
Tail selection budgets 1500 additional tokens per image or PDF as a
provider-neutral planning estimate; it does not count base64 request bytes as
text tokens. On later compactions, V2 updates the previous summary and carries
forward its retained recent context before selecting a new tail.
and file or media attachments become textual descriptors rather than embedded
data. On later compactions, V2 updates the previous summary and carries forward
its retained recent context before selecting a new tail.
The completed compaction is presented to the model as historical conversation
context, explicitly not as new instructions. Running and failed compactions are
+1 -1
View File
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
"compaction": {
"auto": true,
"keep": {
"tokens": 15000
"tokens": 8000
},
"buffer": 20000
}