diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index f2e06876c14..089ae304642 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -8,7 +8,6 @@ import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; import { contentText } from "@earendil-works/pi-ai"; import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai/compat"; -import { completeSimple } from "@earendil-works/pi-ai/compat"; import { convertToLlm, createBranchSummaryMessage, @@ -17,6 +16,7 @@ import { } from "../messages.ts"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts"; import { estimateTokens } from "./compaction.ts"; +import { completeSummaryRequest } from "./summary-request.ts"; import { computeFileLists, createFileOps, @@ -340,9 +340,7 @@ export async function generateBranchSummary( // without running through agent state/events. const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }; const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 }; - const response = streamFn - ? await (await streamFn(model, context, requestOptions)).result() - : await completeSimple(model, context, requestOptions); + const response = await completeSummaryRequest(model, context, requestOptions, streamFn); // Check if aborted or errored if (response.stopReason === "aborted") { diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 5c71c74dc52..527689dcdde 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -7,8 +7,7 @@ import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; import { contentText } from "@earendil-works/pi-ai"; -import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; -import { completeSimple } from "@earendil-works/pi-ai/compat"; +import type { AssistantMessage, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; import { convertToLlm } from "../messages.ts"; import { buildSessionContext, @@ -16,6 +15,7 @@ import { type SessionEntry, sessionEntryToContextMessages, } from "../session-manager.ts"; +import { completeSummaryRequest } from "./summary-request.ts"; import { computeFileLists, createFileOps, @@ -527,19 +527,6 @@ function createSummarizationOptions( return options; } -async function completeSummarization( - model: Model, - context: Context, - options: SimpleStreamOptions, - streamFn?: StreamFn, -): Promise { - if (!streamFn) { - return completeSimple(model, context, options); - } - const stream = await streamFn(model, context, options); - return stream.result(); -} - /** * Generate a summary of the conversation using the LLM. * If previousSummary is provided, uses the update prompt to merge. @@ -590,7 +577,7 @@ export async function generateSummary( const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel); - const response = await completeSummarization( + const response = await completeSummaryRequest( model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, completionOptions, @@ -852,7 +839,7 @@ async function generateTurnPrefixSummary( }, ]; - const response = await completeSummarization( + const response = await completeSummaryRequest( model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), diff --git a/packages/coding-agent/src/core/compaction/summary-request.ts b/packages/coding-agent/src/core/compaction/summary-request.ts new file mode 100644 index 00000000000..57cb130e787 --- /dev/null +++ b/packages/coding-agent/src/core/compaction/summary-request.ts @@ -0,0 +1,32 @@ +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai/compat"; +import { completeSimple, isRetryableAssistantError } from "@earendil-works/pi-ai/compat"; + +const MAX_SUMMARY_RETRIES = 1; + +/** + * Complete a summary request, retrying one retryable stream failure. + * Summary requests have no side effects until their final response is used, so + * a fresh request is safe after a transient transport or provider failure. + */ +export async function completeSummaryRequest( + model: Model, + context: Context, + options: SimpleStreamOptions, + streamFn?: StreamFn, +): Promise { + let response = streamFn + ? await (await streamFn(model, context, options)).result() + : await completeSimple(model, context, options); + + for (let attempt = 0; attempt < MAX_SUMMARY_RETRIES; attempt++) { + if (options.signal?.aborted || !isRetryableAssistantError(response)) { + break; + } + response = streamFn + ? await (await streamFn(model, context, options)).result() + : await completeSimple(model, context, options); + } + + return response; +} diff --git a/packages/coding-agent/test/suite/regressions/6647-compaction-summary-retry.test.ts b/packages/coding-agent/test/suite/regressions/6647-compaction-summary-retry.test.ts new file mode 100644 index 00000000000..f3997c5e293 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6647-compaction-summary-retry.test.ts @@ -0,0 +1,75 @@ +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, type Harness } from "../harness.ts"; + +describe("issue #6647 compaction summary retry", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("retries a transient split-turn summary failure", async () => { + const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } } }); + harnesses.push(harness); + + const now = Date.now(); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "A request large enough to split during compaction." }], + timestamp: now - 1000, + }); + harness.sessionManager.appendMessage({ + ...fauxAssistantMessage("An assistant response that should be retained."), + timestamp: now, + }); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; + + harness.setResponses([ + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "OpenAI Responses stream ended before a terminal response event", + }), + fauxAssistantMessage("## Original Request\nRecovered split-turn summary"), + ]); + + await expect(harness.session.compact()).resolves.toMatchObject({ + summary: expect.stringContaining("Recovered split-turn summary"), + }); + expect(harness.faux.state.callCount).toBe(2); + }); + + it("retries a transient branch summary failure", async () => { + const harness = await createHarness(); + harnesses.push(harness); + + const targetId = harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "Start a branch." }], + timestamp: Date.now() - 1000, + }); + harness.sessionManager.appendMessage(fauxAssistantMessage("Initial branch reply.")); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "Abandoned branch work." }], + timestamp: Date.now(), + }); + harness.sessionManager.appendMessage(fauxAssistantMessage("Abandoned branch reply.")); + + harness.setResponses([ + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "OpenAI Responses stream ended before a terminal response event", + }), + fauxAssistantMessage("## Goal\nRecovered branch summary"), + ]); + + const result = await harness.session.navigateTree(targetId, { summarize: true }); + + expect(result.cancelled).toBe(false); + expect(result.summaryEntry?.summary).toContain("Recovered branch summary"); + expect(harness.faux.state.callCount).toBe(2); + }); +});