fix(core): recover partial provider failures

This commit is contained in:
Kit Langton
2026-08-21 18:27:42 -04:00
parent 7c6ecaaca8
commit 28488a4e35
2 changed files with 92 additions and 16 deletions
+9 -6
View File
@@ -61,10 +61,13 @@ const isDecline = (
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
const isInterruptedStream = (failure: AIError) => {
if (failure.reason._tag === "InvalidProviderOutput")
return failure.reason.classification === "incomplete-stream"
const shouldContinueAfterFailure = (failure: AIError, record: StepRecord) => {
if (failure.reason._tag === "InvalidProviderOutput") return failure.reason.classification === "incomplete-stream"
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
// A fresh generation can continue partial output, but not cross a tool boundary
// whose provider-side effects may be ambiguous.
if (failure.reason._tag === "ProviderInternal")
return SessionRunnerRetry.isRetryable(failure) && record.calls.length === 0
return false
}
@@ -120,7 +123,7 @@ const classifyToolExits = (
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
const CONTINUE_AFTER_INCOMPLETE_STREAM =
const CONTINUE_AFTER_PARTIAL_FAILURE =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
const layer = Layer.effect(
@@ -302,7 +305,7 @@ const layer = Layer.effect(
).pipe(Pull.catchDone(() => Effect.fail(outcome.cause)))
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
text: CONTINUE_AFTER_PARTIAL_FAILURE,
})
assistantMessageID = SessionMessage.ID.create()
}
@@ -597,7 +600,7 @@ const layer = Layer.effect(
if (
llmFailure &&
llmError &&
isInterruptedStream(llmFailure) &&
shouldContinueAfterFailure(llmFailure, record) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
+83 -10
View File
@@ -10,6 +10,7 @@ import {
TransportReason,
InvalidProviderOutputReason,
InvalidRequestReason,
ProviderInternalReason,
RateLimitReason,
} from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
@@ -572,6 +573,13 @@ const streamDisconnected = () =>
}),
})
const providerInternal = () =>
new AIError({
module: "test",
method: "stream",
reason: new ProviderInternalReason({ message: "The provider failed while generating a response" }),
})
const continuationRejected = (recovery: "retry-full" | "rotate-and-retry-full") =>
new AIError({
module: "test",
@@ -596,7 +604,7 @@ const incompleteStream = () =>
}),
})
const INCOMPLETE_STREAM_CONTINUATION =
const PARTIAL_FAILURE_CONTINUATION =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
const invalidRequest = () =>
@@ -4591,7 +4599,7 @@ describe("SessionRunnerLLM", () => {
content: [
{
type: "text",
text: INCOMPLETE_STREAM_CONTINUATION,
text: PARTIAL_FAILURE_CONTINUATION,
},
],
})
@@ -4606,7 +4614,7 @@ describe("SessionRunnerLLM", () => {
},
{
type: "synthetic",
text: INCOMPLETE_STREAM_CONTINUATION,
text: PARTIAL_FAILURE_CONTINUATION,
},
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
])
@@ -4641,12 +4649,12 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }],
})
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "error", content: [{ type: "text", text: "Partial" }] },
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
])
}),
@@ -4680,7 +4688,7 @@ describe("SessionRunnerLLM", () => {
content: [
{
type: "text",
text: INCOMPLETE_STREAM_CONTINUATION,
text: PARTIAL_FAILURE_CONTINUATION,
},
],
})
@@ -4720,7 +4728,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
expect(requests[1]?.messages.slice(-2)).toMatchObject([
{ role: "user", content: [{ type: "text", text: "Recover disconnected reasoning" }] },
{ role: "user", content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }] },
{ role: "user", content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }] },
])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
@@ -4735,12 +4743,77 @@ describe("SessionRunnerLLM", () => {
},
],
},
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
}),
)
it.effect("continues a provider failure after reasoning without tool activity", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Recover failed reasoning")
const failure = providerInternal()
yield* TestLLM.push(
TestLLM.failAfter(
failure,
LLMEvent.stepStart({ index: 0 }),
LLMEvent.reasoningStart({ id: "failed-reasoning" }),
LLMEvent.reasoningEnd({
id: "failed-reasoning",
providerMetadata: {
openai: { itemId: "rs_failed", reasoningEncryptedContent: "failed-encrypted-state" },
},
}),
),
)
yield* TestLLM.push(TestLLM.text("Recovered", "provider-failure-recovery"))
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(requests[1]?.messages).toHaveLength(2)
expect(requests[1]?.messages.slice(-2)).toMatchObject([
{ role: "user", content: [{ type: "text", text: "Recover failed reasoning" }] },
{ role: "user", content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }] },
])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{
type: "assistant",
finish: "error",
error: { type: "provider.internal" },
content: [{ type: "reasoning", text: "" }],
},
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
}),
)
it.effect("does not continue a provider failure after tool activity", () =>
Effect.gen(function* () {
const session = yield* setup
const failure = providerInternal()
yield* TestLLM.push(
TestLLM.failAfter(
failure,
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-before-provider-failure", name: "echo", input: { text: "settled" } }),
),
)
expect(yield* runPrompt(session, "Do not repeat tool activity").pipe(Effect.flip)).toBe(failure)
expect(requests).toHaveLength(1)
expect(executions).toEqual(["settled"])
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
}),
)
it.effect("continues an incomplete stream after settling a local tool", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -4772,7 +4845,7 @@ describe("SessionRunnerLLM", () => {
content: [
{
type: "text",
text: INCOMPLETE_STREAM_CONTINUATION,
text: PARTIAL_FAILURE_CONTINUATION,
},
],
},
@@ -4812,7 +4885,7 @@ describe("SessionRunnerLLM", () => {
},
],
},
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
}),