From b0709438e2933b22c34b845d07d1f04cb7c3a7ef Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sat, 1 Aug 2026 23:36:05 -0700 Subject: [PATCH 1/5] fix(opencode): retry output-limit responses --- packages/opencode/src/session/processor.ts | 24 ++++++ packages/opencode/src/session/retry.ts | 1 + .../test/session/processor-effect.test.ts | 86 +++++++++++++++++++ 3 files changed, 111 insertions(+) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 1bdc820b59..8f0d031e32 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -165,6 +165,26 @@ const layer = Layer.effect( return { call, part } }) + const resetOutputLimit = Effect.fn("SessionProcessor.resetOutputLimit")(function* () { + const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe( + Effect.provideService(Database.Service, database), + ) + // Replace the streamed partial before resampling the unchanged request. + // Keep step-finish so usage from the billed attempt remains accounted for. + yield* Effect.forEach( + parts.filter((part) => part.type !== "step-finish"), + (part) => + session.removePart({ + sessionID: part.sessionID, + messageID: part.messageID, + partID: part.id, + }), + { concurrency: "unbounded" }, + ) + ctx.assistantMessage.finish = undefined + yield* session.updateMessage(ctx.assistantMessage) + }) + const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* ( toolCallID: string, update: (part: SessionV1.ToolPart) => SessionV1.ToolPart, @@ -480,6 +500,7 @@ const layer = Layer.effect( cost: usage.cost, }) yield* session.updateMessage(ctx.assistantMessage) + if (value.reason === "length") throw new SessionV1.OutputLengthError({}) if (ctx.snapshot) { const patch = yield* snapshot.patch(ctx.snapshot) if (patch.files.length) { @@ -688,6 +709,9 @@ const layer = Layer.effect( (cause) => !Cause.hasInterruptsOnly(cause), (cause) => Effect.fail(Cause.squash(cause)), ), + Effect.tapError((error) => + SessionV1.OutputLengthError.isInstance(error) ? resetOutputLimit() : Effect.void, + ), Effect.retry( SessionRetry.policy({ provider: input.model.providerID, diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 33c373d82a..bd52ea6115 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -76,6 +76,7 @@ export function delay(attempt: number, error?: SessionV1.APIError) { } export function retryable(error: Err, provider: string) { + if (SessionV1.OutputLengthError.isInstance(error)) return { message: "Model hit its output limit" } // context overflow errors should not be retried if (SessionV1.ContextOverflowError.isInstance(error)) return undefined if (SessionV1.APIError.isInstance(error)) { diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 5287605436..485f5f46b7 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -41,6 +41,28 @@ const ref = { modelID: ModelV2.ID.make("test-model"), } +const outputRetryModel: Provider.Model = { + id: ref.modelID, + providerID: ref.providerID, + api: { id: "test-model", url: "https://example.com", npm: "@ai-sdk/openai" }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 100_000, input: 100_000, output: 10_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", +} + const cfg = { provider: { test: { @@ -226,6 +248,27 @@ const fragmentFailureLLM = Layer.succeed( const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]]) const itFragmentFailure = testEffect(fragmentFailureEnv) +const outputRetryInputs: LLM.StreamInput[] = [] +const outputRetryLLM = Layer.succeed( + LLM.Service, + LLM.Service.of({ + stream: (input) => { + outputRetryInputs.push(input) + const first = outputRetryInputs.length === 1 + return Stream.make( + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-1" }), + LLMEvent.textDelta({ id: "text-1", text: first ? "truncated" : "complete" }), + LLMEvent.textEnd({ id: "text-1" }), + LLMEvent.stepFinish({ index: 0, reason: first ? "length" : "stop" }), + LLMEvent.finish({ reason: first ? "length" : "stop" }), + ) + }, + }), +) +const outputRetryEnv = LayerNode.compile(root, [...replacements, [LLM.node, outputRetryLLM]]) +const itOutputRetry = testEffect(outputRetryEnv) + const boot = Effect.fn("test.boot")(function* () { const processors = yield* SessionProcessor.Service const session = yield* Session.Service @@ -514,6 +557,49 @@ it.live("session.processor effect tests reset reasoning state across retries", ( ), ) +itOutputRetry.live("session.processor effect tests resample the exact request after an output limit", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const { processors, session } = yield* boot() + outputRetryInputs.length = 0 + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "resample") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: outputRetryModel, + }) + + 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: outputRetryModel, + agent: agent(), + system: [], + messages: [{ role: "user", content: "resample" }], + tools: {}, + }) + + const parts = yield* MessageV2.parts(msg.id) + + expect(value).toBe("continue") + expect(outputRetryInputs).toHaveLength(2) + expect(outputRetryInputs[1]).toBe(outputRetryInputs[0]) + expect(parts.filter((part) => part.type === "text").map((part) => part.text)).toStrictEqual(["complete"]) + expect(handle.message.finish).toBe("stop") + }), + ), +) + it.live("session.processor effect tests do not retry unknown json errors", () => provideTmpdirServer( ({ dir, llm }) => From 9525fd8a3dcbb7a3e9443d856ee4622bc93b1502 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sat, 1 Aug 2026 23:47:01 -0700 Subject: [PATCH 2/5] fix(opencode): cap output retries --- packages/opencode/src/session/retry.ts | 5 ++++ packages/opencode/test/session/retry.test.ts | 24 +++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index bd52ea6115..2308d63bf4 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -37,6 +37,7 @@ export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout +export const OUTPUT_LENGTH_MAX_RETRIES = 3 function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) @@ -189,11 +190,15 @@ export function policy(opts: { parse: (error: unknown) => Err set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { + let outputLengthRetries = 0 return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) const retry = retryable(error, opts.provider) if (!retry) return Cause.done(meta.attempt) + if (SessionV1.OutputLengthError.isInstance(error) && ++outputLengthRetries > OUTPUT_LENGTH_MAX_RETRIES) { + return Cause.done(meta.attempt) + } return Effect.gen(function* () { const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined) const now = yield* Clock.currentTimeMillis diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index ea6d596a16..99eec1da15 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -4,7 +4,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Schedule, Schema } from "effect" +import { Effect, Exit, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -115,6 +115,28 @@ describe("session.retry.delay", () => { }) }), ) + + it.instance("policy caps output-length errors at three retries", () => + Effect.gen(function* () { + const error = new SessionV1.OutputLengthError({}).toObject() + const attempts: number[] = [] + const step = yield* Schedule.toStep( + SessionRetry.policy({ + provider: "test", + parse: () => error, + set: (info) => Effect.sync(() => attempts.push(info.attempt)), + }), + ) + + yield* step(0, error) + yield* step(0, error) + yield* step(0, error) + const fourth = yield* step(0, error).pipe(Effect.exit) + + expect(attempts).toStrictEqual([1, 2, 3]) + expect(Exit.isFailure(fourth)).toBe(true) + }), + ) }) describe("session.retry.retryable", () => { From b1a47a37c8198104e5f70e2e13079ae338d6a084 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 00:12:55 -0700 Subject: [PATCH 3/5] fix(opencode): limit output calls --- packages/opencode/src/session/retry.ts | 2 +- packages/opencode/test/session/retry.test.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 2308d63bf4..086da723c9 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -37,7 +37,7 @@ export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout -export const OUTPUT_LENGTH_MAX_RETRIES = 3 +export const OUTPUT_LENGTH_MAX_RETRIES = 2 function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 99eec1da15..62e7bc25db 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -116,7 +116,7 @@ describe("session.retry.delay", () => { }), ) - it.instance("policy caps output-length errors at three retries", () => + it.instance("policy caps output-length errors at two retries", () => Effect.gen(function* () { const error = new SessionV1.OutputLengthError({}).toObject() const attempts: number[] = [] @@ -130,11 +130,10 @@ describe("session.retry.delay", () => { yield* step(0, error) yield* step(0, error) - yield* step(0, error) - const fourth = yield* step(0, error).pipe(Effect.exit) + const third = yield* step(0, error).pipe(Effect.exit) - expect(attempts).toStrictEqual([1, 2, 3]) - expect(Exit.isFailure(fourth)).toBe(true) + expect(attempts).toStrictEqual([1, 2]) + expect(Exit.isFailure(third)).toBe(true) }), ) }) From 589072e29e6bd68a77da0b9cf02d8945b2b9fae1 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 00:17:05 -0700 Subject: [PATCH 4/5] fix(opencode): preserve retry usage --- packages/opencode/src/session/processor.ts | 38 ++++++++++++++----- packages/opencode/src/session/retry.ts | 2 + .../test/session/processor-effect.test.ts | 15 +++++++- packages/opencode/test/session/retry.test.ts | 5 ++- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 8f0d031e32..7051363c02 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -96,6 +96,7 @@ interface ProcessorContext extends Input { needsCompaction: boolean currentText: SessionV1.TextPart | undefined reasoningMap: Record + outputLimitUsage: Pick | undefined } type StreamEvent = LLMEvent @@ -135,6 +136,7 @@ const layer = Layer.effect( needsCompaction: false, currentText: undefined, reasoningMap: {}, + outputLimitUsage: undefined, } let aborted = false @@ -169,10 +171,10 @@ const layer = Layer.effect( const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe( Effect.provideService(Database.Service, database), ) - // Replace the streamed partial before resampling the unchanged request. - // Keep step-finish so usage from the billed attempt remains accounted for. + // Replace the streamed attempt before resampling the unchanged request. + // Its usage is carried into the next step-finish part. yield* Effect.forEach( - parts.filter((part) => part.type !== "step-finish"), + parts, (part) => session.removePart({ sessionID: part.sessionID, @@ -486,9 +488,28 @@ const layer = Layer.effect( usage: value.usage ?? new Usage({}), metadata: value.providerMetadata, }) + const previous = ctx.outputLimitUsage + const total = + previous?.tokens.total === undefined && usage.tokens.total === undefined + ? undefined + : (previous?.tokens.total ?? 0) + (usage.tokens.total ?? 0) + const accounted = { + cost: (previous?.cost ?? 0) + usage.cost, + tokens: { + ...(total === undefined ? {} : { total }), + input: (previous?.tokens.input ?? 0) + usage.tokens.input, + output: (previous?.tokens.output ?? 0) + usage.tokens.output, + reasoning: (previous?.tokens.reasoning ?? 0) + usage.tokens.reasoning, + cache: { + read: (previous?.tokens.cache.read ?? 0) + usage.tokens.cache.read, + write: (previous?.tokens.cache.write ?? 0) + usage.tokens.cache.write, + }, + }, + } + ctx.outputLimitUsage = value.reason === "length" ? accounted : undefined ctx.assistantMessage.finish = value.reason ctx.assistantMessage.cost += usage.cost - ctx.assistantMessage.tokens = usage.tokens + ctx.assistantMessage.tokens = accounted.tokens yield* session.updatePart({ id: PartID.ascending(), reason: value.reason, @@ -496,8 +517,8 @@ const layer = Layer.effect( messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "step-finish", - tokens: usage.tokens, - cost: usage.cost, + tokens: accounted.tokens, + cost: accounted.cost, }) yield* session.updateMessage(ctx.assistantMessage) if (value.reason === "length") throw new SessionV1.OutputLengthError({}) @@ -709,13 +730,12 @@ const layer = Layer.effect( (cause) => !Cause.hasInterruptsOnly(cause), (cause) => Effect.fail(Cause.squash(cause)), ), - Effect.tapError((error) => - SessionV1.OutputLengthError.isInstance(error) ? resetOutputLimit() : Effect.void, - ), Effect.retry( SessionRetry.policy({ provider: input.model.providerID, parse, + onRetry: (error) => + SessionV1.OutputLengthError.isInstance(error) ? resetOutputLimit() : Effect.void, set: (info) => { return status.set(ctx.sessionID, { type: "retry", diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 086da723c9..ab0fb56362 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -188,6 +188,7 @@ function parseJSON(value: unknown) { export function policy(opts: { provider: string parse: (error: unknown) => Err + onRetry?: (error: Err) => Effect.Effect set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { let outputLengthRetries = 0 @@ -200,6 +201,7 @@ export function policy(opts: { return Cause.done(meta.attempt) } return Effect.gen(function* () { + if (opts.onRetry) yield* opts.onRetry(error) const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined) const now = yield* Clock.currentTimeMillis yield* opts.set({ diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 485f5f46b7..c75c1e104a 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -55,7 +55,7 @@ const outputRetryModel: Provider.Model = { output: { text: true, audio: false, image: false, video: false, pdf: false }, interleaved: false, }, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, limit: { context: 100_000, input: 100_000, output: 10_000 }, status: "active", options: {}, @@ -260,7 +260,11 @@ const outputRetryLLM = Layer.succeed( LLMEvent.textStart({ id: "text-1" }), LLMEvent.textDelta({ id: "text-1", text: first ? "truncated" : "complete" }), LLMEvent.textEnd({ id: "text-1" }), - LLMEvent.stepFinish({ index: 0, reason: first ? "length" : "stop" }), + LLMEvent.stepFinish({ + index: 0, + reason: first ? "length" : "stop", + usage: first ? { inputTokens: 3, outputTokens: 5 } : { inputTokens: 7, outputTokens: 11 }, + }), LLMEvent.finish({ reason: first ? "length" : "stop" }), ) }, @@ -595,6 +599,13 @@ itOutputRetry.live("session.processor effect tests resample the exact request af expect(outputRetryInputs).toHaveLength(2) expect(outputRetryInputs[1]).toBe(outputRetryInputs[0]) expect(parts.filter((part) => part.type === "text").map((part) => part.text)).toStrictEqual(["complete"]) + const finishes = parts.filter((part) => part.type === "step-finish") + expect(finishes).toHaveLength(1) + expect(finishes[0]).toMatchObject({ + reason: "stop", + tokens: { input: 10, output: 16 }, + }) + expect(finishes[0]?.cost).toBeCloseTo(0.000026) expect(handle.message.finish).toBe("stop") }), ), diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 62e7bc25db..2d0deec7ca 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -116,14 +116,16 @@ describe("session.retry.delay", () => { }), ) - it.instance("policy caps output-length errors at two retries", () => + it.effect("policy caps output-length errors at two retries", () => Effect.gen(function* () { const error = new SessionV1.OutputLengthError({}).toObject() const attempts: number[] = [] + let retries = 0 const step = yield* Schedule.toStep( SessionRetry.policy({ provider: "test", parse: () => error, + onRetry: () => Effect.sync(() => retries++), set: (info) => Effect.sync(() => attempts.push(info.attempt)), }), ) @@ -133,6 +135,7 @@ describe("session.retry.delay", () => { const third = yield* step(0, error).pipe(Effect.exit) expect(attempts).toStrictEqual([1, 2]) + expect(retries).toBe(2) expect(Exit.isFailure(third)).toBe(true) }), ) From 290d419b2eccca05521308ef477dcdc7964f7f03 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 08:42:15 -0700 Subject: [PATCH 5/5] refactor(opencode): simplify output cap --- packages/opencode/src/session/processor.ts | 2 ++ packages/opencode/src/session/retry.ts | 11 +++++------ .../test/session/processor-effect.test.ts | 15 ++++++++++++--- packages/opencode/test/session/retry.test.ts | 2 +- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 7051363c02..15e74557c3 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -734,6 +734,8 @@ const layer = Layer.effect( SessionRetry.policy({ provider: input.model.providerID, parse, + // Only replace attempts that will be retried. Cloud intentionally + // returns the terminal partial next to the truncation error. onRetry: (error) => SessionV1.OutputLengthError.isInstance(error) ? resetOutputLimit() : Effect.void, set: (info) => { diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index ab0fb56362..8d47b73308 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -23,6 +23,7 @@ export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & { export type Retryable = { message: string + maxAttempts?: number action?: { reason: RetryReason provider: string @@ -37,7 +38,6 @@ export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout -export const OUTPUT_LENGTH_MAX_RETRIES = 2 function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) @@ -77,7 +77,9 @@ export function delay(attempt: number, error?: SessionV1.APIError) { } export function retryable(error: Err, provider: string) { - if (SessionV1.OutputLengthError.isInstance(error)) return { message: "Model hit its output limit" } + if (SessionV1.OutputLengthError.isInstance(error)) { + return { message: "Model hit its output limit", maxAttempts: 3 } + } // context overflow errors should not be retried if (SessionV1.ContextOverflowError.isInstance(error)) return undefined if (SessionV1.APIError.isInstance(error)) { @@ -191,15 +193,12 @@ export function policy(opts: { onRetry?: (error: Err) => Effect.Effect set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { - let outputLengthRetries = 0 return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) const retry = retryable(error, opts.provider) if (!retry) return Cause.done(meta.attempt) - if (SessionV1.OutputLengthError.isInstance(error) && ++outputLengthRetries > OUTPUT_LENGTH_MAX_RETRIES) { - return Cause.done(meta.attempt) - } + if (retry.maxAttempts !== undefined && meta.attempt >= retry.maxAttempts) return Cause.done(meta.attempt) return Effect.gen(function* () { if (opts.onRetry) yield* opts.onRetry(error) const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index c75c1e104a..09d108bf17 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -249,6 +249,10 @@ const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, const itFragmentFailure = testEffect(fragmentFailureEnv) const outputRetryInputs: LLM.StreamInput[] = [] +const outputRetryUsage = { + truncated: { input: 3, output: 5 }, + complete: { input: 7, output: 11 }, +} as const const outputRetryLLM = Layer.succeed( LLM.Service, LLM.Service.of({ @@ -263,7 +267,10 @@ const outputRetryLLM = Layer.succeed( LLMEvent.stepFinish({ index: 0, reason: first ? "length" : "stop", - usage: first ? { inputTokens: 3, outputTokens: 5 } : { inputTokens: 7, outputTokens: 11 }, + usage: { + inputTokens: first ? outputRetryUsage.truncated.input : outputRetryUsage.complete.input, + outputTokens: first ? outputRetryUsage.truncated.output : outputRetryUsage.complete.output, + }, }), LLMEvent.finish({ reason: first ? "length" : "stop" }), ) @@ -600,12 +607,14 @@ itOutputRetry.live("session.processor effect tests resample the exact request af expect(outputRetryInputs[1]).toBe(outputRetryInputs[0]) expect(parts.filter((part) => part.type === "text").map((part) => part.text)).toStrictEqual(["complete"]) const finishes = parts.filter((part) => part.type === "step-finish") + const input = outputRetryUsage.truncated.input + outputRetryUsage.complete.input + const output = outputRetryUsage.truncated.output + outputRetryUsage.complete.output expect(finishes).toHaveLength(1) expect(finishes[0]).toMatchObject({ reason: "stop", - tokens: { input: 10, output: 16 }, + tokens: { input, output }, }) - expect(finishes[0]?.cost).toBeCloseTo(0.000026) + expect(finishes[0]?.cost).toBeCloseTo((input + output) / 1_000_000) expect(handle.message.finish).toBe("stop") }), ), diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 2d0deec7ca..cbf43c459c 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -116,7 +116,7 @@ describe("session.retry.delay", () => { }), ) - it.effect("policy caps output-length errors at two retries", () => + it.effect("policy caps output-length errors at three total calls", () => Effect.gen(function* () { const error = new SessionV1.OutputLengthError({}).toObject() const attempts: number[] = []