diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 8f73761c819..8fd6106c9f9 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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 diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index b44f15d4b55..d1a5548a8d8 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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" }] }, ]) }),