Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
9 changes: 7 additions & 2 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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"

export type Err = ReturnType<NamedError["toObject"]>

export class EmptyResponseError extends Schema.TaggedErrorClass<EmptyResponseError>()("SessionEmptyResponseError", {
message: Schema.String,
}) {}

export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
export const GO_UPSELL_URL = "https://opencode.ai/go"
export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {})
Expand Down Expand Up @@ -180,7 +184,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)
Expand Down
62 changes: 62 additions & 0 deletions packages/opencode/test/session/processor-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 retry OpenAI-compatible midstream server errors", () =>
provideTmpdirServer(
({ dir, llm }) =>
Expand Down
Loading