Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline 5562652f63 test(opencode): expect empty response retry 2026-08-04 23:13:23 +00:00
Aiden Cline 031510e1c3 fix(opencode): retry empty unknown responses 2026-08-04 22:52:01 +00:00
4 changed files with 99 additions and 10 deletions
+17 -1
View File
@@ -636,14 +636,30 @@ const layer = Layer.effect(
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.reasoningMap = {}
let generated = false
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput)
yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Stream.tap((event) => {
if (
(event.type === "text-delta" && event.text.length > 0) ||
(event.type === "reasoning-delta" && event.text.length > 0) ||
event.type === "tool-input-start" ||
event.type === "tool-call"
) {
generated = true
}
return handleEvent(event)
}),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.runDrain,
)
if (ctx.assistantMessage.finish === "unknown" && !generated) {
yield* new SessionRetry.EmptyResponseError({
message: "The model returned an empty response with an unknown finish reason",
})
}
}).pipe(
Effect.onInterrupt(() =>
Effect.gen(function* () {
+7 -2
View File
@@ -1,6 +1,6 @@
import type { NamedError } from "@opencode-ai/core/util/error"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { Cause, Clock, Duration, Effect, Schedule, Schema } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"
@@ -23,6 +23,10 @@ export type Retryable = {
}
}
export class EmptyResponseError extends Schema.TaggedErrorClass<EmptyResponseError>()("SessionEmptyResponseError", {
message: Schema.String,
}) {}
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
@@ -181,7 +185,8 @@ export function policy(opts: {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
const retry =
meta.input instanceof EmptyResponseError ? { message: meta.input.message } : retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
@@ -81,11 +81,11 @@ describe("opencode run (non-interactive subprocess)", () => {
30_000,
)
// The test provider's SSE error item is interpreted by the SDK as an unknown
// finish, not a fatal provider/session error. Lock that distinction in so it
// is not accidentally used as the failure compatibility oracle.
// The test provider's SSE error item is interpreted by the SDK as an empty
// response with an unknown finish. That attempt should retry while preserving
// output from the preceding tool-call step.
cliIt.concurrent(
"unknown stream finish preserves partial output and exits 0",
"empty unknown stream finish retries and preserves partial output",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
@@ -95,9 +95,10 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
)
yield* llm.fail("upstream provider exploded mid-stream")
yield* llm.text("recovered response")
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe("partial response\n")
expect(result.stdout).toBe("partial response\nrecovered response\n")
expect(result.stderr).not.toContain("upstream provider exploded mid-stream")
}),
60_000,
@@ -213,7 +214,7 @@ describe("opencode run (non-interactive subprocess)", () => {
)
cliIt.concurrent(
"--format json records partial output for an unknown stream finish",
"--format json records an empty unknown stream retry",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
@@ -223,6 +224,7 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
)
yield* llm.fail("provider failed")
yield* llm.text("recovered json")
const result = yield* opencode.run("fail after output", { format: "json" })
const events = opencode.parseJsonEvents(result.stdout)
@@ -234,9 +236,13 @@ describe("opencode run (non-interactive subprocess)", () => {
"step_finish",
"step_start",
"step_finish",
"step_start",
"text",
"step_finish",
])
expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" }))
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" }))
expect(events.at(-2)?.part).toEqual(expect.objectContaining({ type: "text", text: "recovered json" }))
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "stop" }))
}),
60_000,
)
@@ -604,6 +604,68 @@ it.live("session.processor effect tests retry recognized structured json errors"
),
)
it.live("session.processor effect tests retry empty responses with unknown finish reasons", () =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()
yield* llm.push(
raw({
chunks: [
{
id: "chatcmpl-test",
object: "chat.completion.chunk",
choices: [{ delta: { role: "assistant" }, finish_reason: null }],
},
{
id: "chatcmpl-test",
object: "chat.completion.chunk",
choices: [{ delta: {}, finish_reason: "unknown_reason" }],
},
],
}),
reply().text("after").stop(),
)
const chat = yield* session.create({})
const parent = yield* user(chat.id, "retry empty")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: mdl,
})
const value = yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies SessionV1.User,
sessionID: chat.id,
model: mdl,
agent: agent(),
system: [],
messages: [{ role: "user", content: "retry empty" }],
tools: {},
})
const parts = yield* MessageV2.parts(msg.id)
expect(value).toBe("continue")
expect(yield* llm.calls).toBe(2)
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
expect(handle.message.error).toBeUndefined()
}),
{ config: (url) => providerCfg(url) },
),
)
it.live("session.processor effect tests publish retry status updates", () =>
provideTmpdirServer(
({ dir, llm }) =>