Skip to content

Commit 03b8a31

Browse files
committed
fix(session): retry empty stream truncations and discard partial parts
Detect provider stream truncation (finish reason "unknown" with zero output tokens) and retry it as a transient failure, capped at 3 attempts. On an EmptyOther retry — and when the retry cap is hit — discard the parts the failed attempt persisted (everything created after a per-call part floor) so the message reflects only the final attempt instead of accumulating an orphan step-start / partial text or reasoning. The discard is scoped to truncations; other retryable errors (rate limits, 5xx) retry untouched. Surface APIError instances through MessageV2.fromError so the TUI receives the structured message and metadata. Refs #14108
1 parent a136caa commit 03b8a31

5 files changed

Lines changed: 188 additions & 3 deletions

File tree

packages/opencode/src/session/message-v2.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,11 @@ export function fromError(
711711
},
712712
{ cause: e },
713713
).toObject()
714+
// Convert APIError class instances thrown via `Effect.fail(new APIError(...))`
715+
// to their wire form so the TUI receives the structured message and metadata
716+
// instead of being wrapped by the generic Error fallback below.
717+
case APIError.isInstance(e):
718+
return e instanceof Error ? e.toObject() : e
714719
case e instanceof Error:
715720
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
716721
default:

packages/opencode/src/session/processor.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ interface ProcessorContext extends Input {
8686
currentTextID: string | undefined
8787
reasoningMap: Record<string, SessionV1.ReasoningPart>
8888
v2AssistantMessageID: SessionMessage.ID | undefined
89+
// Part id created just before the current attempt begins; parts with a
90+
// greater id were produced by the attempt and are discarded when it is
91+
// retried after a stream truncation.
92+
partFloor: PartID
8993
}
9094

9195
type StreamEvent = LLMEvent
@@ -128,6 +132,7 @@ export const layer = Layer.effect(
128132
currentTextID: undefined,
129133
reasoningMap: {},
130134
v2AssistantMessageID: undefined,
135+
partFloor: PartID.ascending(),
131136
}
132137
const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary
133138
let aborted = false
@@ -395,7 +400,7 @@ export const layer = Layer.effect(
395400
time: { start: Date.now() },
396401
metadata: value.providerMetadata,
397402
}
398-
yield* session.updatePart(ctx.reasoningMap[value.id])
403+
yield* session.updatePart(ctx.reasoningMap[value.id])
399404
return
400405

401406
case "reasoning-delta":
@@ -701,6 +706,20 @@ export const layer = Layer.effect(
701706
usage: value.usage ?? new Usage({}),
702707
metadata: value.providerMetadata,
703708
})
709+
// Detect stream truncation: the AI SDK reports the unmapped
710+
// fallback reason when the upstream provider stream ends without a
711+
// proper stop_reason. No usage and no output means the connection
712+
// was cut mid-generation, which is a transient failure that should
713+
// be retried.
714+
if (value.reason === "unknown" && usage.tokens.output === 0) {
715+
return yield* Effect.fail(
716+
new SessionV1.APIError({
717+
message: "Provider stream ended without a stop reason",
718+
isRetryable: true,
719+
metadata: { code: "EmptyOther" },
720+
}),
721+
)
722+
}
704723
if (!ctx.assistantMessage.summary) {
705724
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
706725
if (mirrorAssistant) {
@@ -846,6 +865,27 @@ export const layer = Layer.effect(
846865
}
847866
})
848867

868+
// Discards every part the failed attempt persisted (anything created
869+
// after partFloor) so a successful retry replaces rather than appends to
870+
// the truncated content. The assistant message is created fresh per
871+
// process() call, so the floor scopes removal to this attempt's output.
872+
const discardAttempt = Effect.fn("SessionProcessor.discardAttempt")(function* () {
873+
const existing = yield* MessageV2.parts(ctx.assistantMessage.id).pipe(
874+
Effect.provideService(Database.Service, database),
875+
)
876+
for (const part of existing) {
877+
if (part.id <= ctx.partFloor) continue
878+
yield* session.removePart({
879+
sessionID: ctx.sessionID,
880+
messageID: ctx.assistantMessage.id,
881+
partID: part.id,
882+
})
883+
}
884+
ctx.currentText = undefined
885+
ctx.reasoningMap = {}
886+
ctx.toolcalls = {}
887+
})
888+
849889
const cleanup = Effect.fn("SessionProcessor.cleanup")(function* () {
850890
if (ctx.snapshot) {
851891
const patch = yield* snapshot.patch(ctx.snapshot)
@@ -933,6 +973,11 @@ export const layer = Layer.effect(
933973
yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
934974
return
935975
}
976+
// Retries are exhausted: drop the truncated attempt's partial parts so
977+
// the failed message doesn't keep an orphan step-start / partial text.
978+
if (SessionV1.APIError.isInstance(error) && error.data.metadata?.code === "EmptyOther") {
979+
yield* discardAttempt()
980+
}
936981
if (!ctx.assistantMessage.summary) {
937982
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
938983
if (mirrorAssistant) {
@@ -959,6 +1004,9 @@ export const layer = Layer.effect(
9591004
slog.info("process")
9601005
ctx.needsCompaction = false
9611006
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
1007+
// Record the high-water mark before any attempt persists parts so a
1008+
// truncation retry can discard exactly this call's output.
1009+
ctx.partFloor = PartID.ascending()
9621010

9631011
return yield* Effect.gen(function* () {
9641012
yield* Effect.gen(function* () {
@@ -1003,7 +1051,12 @@ export const layer = Layer.effect(
10031051
timestamp: DateTime.makeUnsafe(Date.now()),
10041052
})
10051053
: Effect.void
1006-
return flushV2Fragments().pipe(
1054+
// Only stream truncations leave partial parts worth discarding;
1055+
// other retryable errors (rate limits, 5xx) retry untouched.
1056+
const truncated =
1057+
SessionV1.APIError.isInstance(info.error) && info.error.data.metadata?.code === "EmptyOther"
1058+
return (truncated ? discardAttempt() : Effect.void).pipe(
1059+
Effect.andThen(flushV2Fragments()),
10071060
Effect.andThen(event),
10081061
Effect.andThen(
10091062
status.set(ctx.sessionID, {

packages/opencode/src/session/retry.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,30 @@ function parseJSON(value: unknown) {
176176
export function policy(opts: {
177177
provider: string
178178
parse: (error: unknown) => Err
179-
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
179+
set: (input: {
180+
attempt: number
181+
message: string
182+
action?: Retryable["action"]
183+
next: number
184+
error: Err
185+
}) => Effect.Effect<void>
180186
}) {
181187
return Schedule.fromStepWithMetadata(
182188
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
183189
const error = opts.parse(meta.input)
184190
const retry = retryable(error, opts.provider)
185191
if (!retry) return Cause.done(meta.attempt)
192+
// Cap empty-other stream-truncation retries to avoid infinite loops if a
193+
// provider keeps closing streams without a stop_reason. Other retryable
194+
// classifications (rate limits, 5xx, ZlibError, etc.) keep their existing
195+
// unbounded behaviour.
196+
if (
197+
SessionV1.APIError.isInstance(error) &&
198+
error.data.metadata?.code === "EmptyOther" &&
199+
meta.attempt >= 3
200+
) {
201+
return Cause.done(meta.attempt)
202+
}
186203
return Effect.gen(function* () {
187204
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
188205
const now = yield* Clock.currentTimeMillis
@@ -191,6 +208,7 @@ export function policy(opts: {
191208
message: retry.message,
192209
action: retry.action,
193210
next: now + wait,
211+
error,
194212
})
195213
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
196214
})

packages/opencode/test/session/prompt.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2316,3 +2316,35 @@ noLLMServer.instance(
23162316
}),
23172317
30_000,
23182318
)
2319+
2320+
it.instance("retry discards in-flight parts from the failed attempt", () =>
2321+
Effect.gen(function* () {
2322+
const { llm } = yield* useServerConfig(providerCfg)
2323+
const prompt = yield* SessionPrompt.Service
2324+
const sessions = yield* Session.Service
2325+
const chat = yield* sessions.create({
2326+
title: "Discard test",
2327+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
2328+
})
2329+
yield* prompt.prompt({
2330+
sessionID: chat.id,
2331+
agent: "build",
2332+
noReply: true,
2333+
parts: [{ type: "text", text: "hello" }],
2334+
})
2335+
// Attempt 1: emit partial text but never a finish_reason. The AI SDK
2336+
// flushes with finishReason="other" and usage.outputTokens=0, which the
2337+
// processor catches as EmptyOther and triggers a retry.
2338+
yield* llm.push(reply().text("partial first attempt").item())
2339+
yield* llm.push(reply().text("final answer").stop().item())
2340+
2341+
const result = yield* prompt.loop({ sessionID: chat.id })
2342+
2343+
expect(yield* llm.hits).toHaveLength(2)
2344+
const texts = result.parts.filter((p) => p.type === "text").map((p) => (p as SessionV1.TextPart).text)
2345+
expect(texts).toEqual(["final answer"])
2346+
// The discarded attempt's step-start must be removed too, otherwise the
2347+
// message keeps an orphan step-start per retry.
2348+
expect(result.parts.filter((p) => p.type === "step-start")).toHaveLength(1)
2349+
}),
2350+
)

packages/opencode/test/session/retry.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ProviderError } from "../../src/provider/error"
1111
import { SessionID } from "../../src/session/schema"
1212
import { SessionStatus } from "../../src/session/status"
1313
import { testEffect } from "../lib/effect"
14+
import { provideTmpdirInstance } from "../fixture/fixture"
1415
import { ProviderV2 } from "@opencode-ai/core/provider"
1516

1617
const providerID = ProviderV2.ID.make("test")
@@ -343,6 +344,63 @@ describe("session.retry.retryable", () => {
343344
"Usage limit reached. It will reset in 15 minutes. To continue using this model now, enable usage from your available balance",
344345
)
345346
})
347+
348+
test("retries EmptyOther stream truncation failures", () => {
349+
const error = new SessionV1.APIError({
350+
message: "Provider stream ended without a stop reason",
351+
isRetryable: true,
352+
metadata: { code: "EmptyOther" },
353+
}).toObject() as SessionV1.APIError
354+
355+
expect(SessionRetry.retryable(error, retryProvider)).toEqual({
356+
message: "Provider stream ended without a stop reason",
357+
})
358+
})
359+
360+
it.live("policy stops retrying EmptyOther after 3 attempts", () =>
361+
provideTmpdirInstance(() =>
362+
Effect.gen(function* () {
363+
const sessionID = SessionID.make("session-empty-other-test")
364+
// retry-after-ms=0 keeps the test fast; the cap is driven by metadata.code.
365+
const error = new SessionV1.APIError({
366+
message: "Provider stream ended without a stop reason",
367+
isRetryable: true,
368+
metadata: { code: "EmptyOther" },
369+
responseHeaders: { "retry-after-ms": "0" },
370+
}).toObject() as SessionV1.APIError
371+
const status = yield* SessionStatus.Service
372+
373+
const step = yield* Schedule.toStepWithMetadata(
374+
SessionRetry.policy({
375+
provider: retryProvider,
376+
parse: (err) => err as SessionV1.APIError,
377+
set: (info) =>
378+
status.set(sessionID, {
379+
type: "retry",
380+
attempt: info.attempt,
381+
message: info.message,
382+
next: info.next,
383+
}),
384+
}),
385+
)
386+
// attempt=1 and attempt=2 run normally and update status.
387+
yield* step(error)
388+
yield* step(error)
389+
// attempt=3 hits the EmptyOther cap and signals Cause.done.
390+
// Effect.exit captures the schedule termination so it doesn't
391+
// leak as an unhandled failure.
392+
const thirdExit = yield* Effect.exit(step(error))
393+
394+
expect(thirdExit._tag).toBe("Failure")
395+
396+
expect(yield* status.get(sessionID)).toMatchObject({
397+
type: "retry",
398+
attempt: 2,
399+
message: "Provider stream ended without a stop reason",
400+
})
401+
}),
402+
),
403+
)
346404
})
347405

348406
describe("session.message-v2.fromError", () => {
@@ -397,6 +455,25 @@ describe("session.message-v2.fromError", () => {
397455
expect(retryable).toEqual({ message: "Connection reset by server" })
398456
})
399457

458+
test("converts APIError class instances to wire form for storage", () => {
459+
// The processor throws via `yield* new SessionV1.APIError(...)`; fromError
460+
// must convert the class instance to its wire form so the TUI renders the
461+
// structured message and metadata rather than a JSON-stringified
462+
// UnknownError wrapper.
463+
const thrown = new SessionV1.APIError({
464+
message: "Provider stream ended without a stop reason",
465+
isRetryable: true,
466+
metadata: { code: "EmptyOther" },
467+
})
468+
469+
const result = MessageV2.fromError(thrown, { providerID })
470+
471+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
472+
expect((result as SessionV1.APIError).data.message).toBe("Provider stream ended without a stop reason")
473+
expect((result as SessionV1.APIError).data.metadata?.code).toBe("EmptyOther")
474+
expect((result as { name: string }).name).toBe("APIError")
475+
})
476+
400477
test("marks OpenAI 404 status codes as retryable", () => {
401478
const error = new APICallError({
402479
message: "boom",

0 commit comments

Comments
 (0)