|
|
@@ -6,6 +6,7 @@ import {
|
|
|
Model,
|
|
|
Tool,
|
|
|
TransportReason,
|
|
|
+ InvalidRequestReason,
|
|
|
type LLMClientShape,
|
|
|
type LLMRequest,
|
|
|
} from "@opencode-ai/llm"
|
|
|
@@ -103,6 +104,11 @@ const compactModel = Model.make({
|
|
|
provider: "fake",
|
|
|
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
|
|
|
})
|
|
|
+const recoveryModel = Model.make({
|
|
|
+ id: "recovery",
|
|
|
+ provider: "fake",
|
|
|
+ route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
|
|
|
+})
|
|
|
const authorizations: ToolRegistry.AuthorizeInput[] = []
|
|
|
const executions: string[] = []
|
|
|
const permission = Layer.succeed(
|
|
|
@@ -1456,6 +1462,119 @@ describe("SessionRunnerLLM", () => {
|
|
|
}),
|
|
|
)
|
|
|
|
|
|
+ it.effect("forces one compaction and retries after provider context overflow", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* setup
|
|
|
+ const session = yield* SessionV2.Service
|
|
|
+ response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
|
|
|
+ yield* session.prompt({
|
|
|
+ sessionID,
|
|
|
+ prompt: new Prompt({ text: "Earlier question ".repeat(700) }),
|
|
|
+ resume: false,
|
|
|
+ })
|
|
|
+ yield* session.resume(sessionID)
|
|
|
+
|
|
|
+ currentModel = recoveryModel
|
|
|
+ requests.length = 0
|
|
|
+ responses = [
|
|
|
+ [
|
|
|
+ LLMEvent.stepStart({ index: 0 }),
|
|
|
+ LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
|
|
+ ],
|
|
|
+ fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents,
|
|
|
+ fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
|
|
|
+ ]
|
|
|
+ yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
|
|
|
+ yield* session.resume(sessionID)
|
|
|
+
|
|
|
+ expect(requests).toHaveLength(3)
|
|
|
+ expect(userTexts(requests[1])[0]).toContain("## Goal")
|
|
|
+ expect(userTexts(requests[2])[0]).toContain("<summary>\n## Goal\n- Recover overflow\n</summary>")
|
|
|
+ expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
+ { type: "compaction", summary: "## Goal\n- Recover overflow" },
|
|
|
+ { type: "assistant", finish: "stop" },
|
|
|
+ ])
|
|
|
+ yield* replaySessionProjection(sessionID)
|
|
|
+ expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
+ { type: "compaction" },
|
|
|
+ { type: "assistant", finish: "stop" },
|
|
|
+ ])
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.effect("persists a second context overflow after one recovery", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* setup
|
|
|
+ const session = yield* SessionV2.Service
|
|
|
+ response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
|
|
|
+ yield* session.prompt({
|
|
|
+ sessionID,
|
|
|
+ prompt: new Prompt({ text: "Earlier question ".repeat(700) }),
|
|
|
+ resume: false,
|
|
|
+ })
|
|
|
+ yield* session.resume(sessionID)
|
|
|
+
|
|
|
+ currentModel = recoveryModel
|
|
|
+ requests.length = 0
|
|
|
+ const overflow = () => [
|
|
|
+ LLMEvent.stepStart({ index: 0 }),
|
|
|
+ LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
|
|
+ ]
|
|
|
+ responses = [
|
|
|
+ overflow(),
|
|
|
+ fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents,
|
|
|
+ overflow(),
|
|
|
+ ]
|
|
|
+ yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
|
|
|
+ yield* session.resume(sessionID)
|
|
|
+
|
|
|
+ expect(requests).toHaveLength(3)
|
|
|
+ expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
+ { type: "compaction" },
|
|
|
+ { type: "assistant", finish: "error", error: { message: "prompt too long" } },
|
|
|
+ ])
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.effect("recovers once from a raw context overflow failure", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* setup
|
|
|
+ const session = yield* SessionV2.Service
|
|
|
+ response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
|
|
|
+ yield* session.prompt({
|
|
|
+ sessionID,
|
|
|
+ prompt: new Prompt({ text: "Earlier question ".repeat(700) }),
|
|
|
+ resume: false,
|
|
|
+ })
|
|
|
+ yield* session.resume(sessionID)
|
|
|
+
|
|
|
+ currentModel = recoveryModel
|
|
|
+ requests.length = 0
|
|
|
+ responseStream = Stream.fail(
|
|
|
+ new LLMError({
|
|
|
+ module: "test",
|
|
|
+ method: "stream",
|
|
|
+ reason: new InvalidRequestReason({
|
|
|
+ message: "prompt too long",
|
|
|
+ classification: "context-overflow",
|
|
|
+ }),
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ responses = [
|
|
|
+ fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents,
|
|
|
+ fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
|
|
|
+ ]
|
|
|
+ yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
|
|
|
+ yield* session.resume(sessionID)
|
|
|
+
|
|
|
+ expect(requests).toHaveLength(3)
|
|
|
+ expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
+ { type: "compaction", summary: "## Goal\n- Recover raw overflow" },
|
|
|
+ { type: "assistant", finish: "stop" },
|
|
|
+ ])
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
it.effect("preserves effective System updates while compaction replacement is blocked", () =>
|
|
|
Effect.gen(function* () {
|
|
|
yield* setup
|
|
|
@@ -3108,6 +3227,35 @@ describe("SessionRunnerLLM", () => {
|
|
|
}),
|
|
|
)
|
|
|
|
|
|
+ it.effect("does not recover context overflow after durable assistant output", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* setup
|
|
|
+ const session = yield* SessionV2.Service
|
|
|
+ yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail after output" }), resume: false })
|
|
|
+
|
|
|
+ requests.length = 0
|
|
|
+ response = [
|
|
|
+ LLMEvent.stepStart({ index: 0 }),
|
|
|
+ LLMEvent.textStart({ id: "text-partial" }),
|
|
|
+ LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
|
|
+ LLMEvent.textEnd({ id: "text-partial" }),
|
|
|
+ LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
|
|
+ ]
|
|
|
+ yield* session.resume(sessionID)
|
|
|
+
|
|
|
+ expect(requests).toHaveLength(1)
|
|
|
+ expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
+ { type: "user", text: "Fail after output" },
|
|
|
+ {
|
|
|
+ type: "assistant",
|
|
|
+ finish: "error",
|
|
|
+ error: { message: "prompt too long" },
|
|
|
+ content: [{ type: "text", text: "Partial" }],
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
it.effect("projects raw provider stream failures as terminal assistant step failures", () =>
|
|
|
Effect.gen(function* () {
|
|
|
yield* setup
|