From 83fa3ce993333ad4deff5d4a50b94817c8e91893 Mon Sep 17 00:00:00 2001 From: chengzhichao-xydt Date: Tue, 28 Jul 2026 08:13:45 +0800 Subject: [PATCH] fix(agents): honor abort signal during compaction summary retry backoff (#110114) * fix(agents): honor abort signal during compaction summary retry backoff * test(agents): cover compaction abort over real HTTP --------- Co-authored-by: Peter Steinberger --- src/agents/compaction.abort-http.test.ts | 108 ++++++++++++++++++ .../compaction.summarize-fallback.test.ts | 33 ++++++ src/agents/compaction.ts | 4 + 3 files changed, 145 insertions(+) create mode 100644 src/agents/compaction.abort-http.test.ts diff --git a/src/agents/compaction.abort-http.test.ts b/src/agents/compaction.abort-http.test.ts new file mode 100644 index 000000000000..0f13078e9caa --- /dev/null +++ b/src/agents/compaction.abort-http.test.ts @@ -0,0 +1,108 @@ +import { createServer } from "node:http"; +import { performance } from "node:perf_hooks"; +import { describe, expect, it } from "vitest"; +import { summarizeInStages } from "./compaction.js"; +import type { AgentMessage } from "./runtime/index.js"; +import type { ExtensionContext } from "./sessions/index.js"; + +describe("compaction retry backoff over real HTTP", () => { + it("aborts after one provider response without changing the source history", async () => { + const controller = new AbortController(); + const requests: Array<{ method: string; path: string }> = []; + let responseCompletedAt: number | undefined; + let abortedAt: number | undefined; + let abortTimer: ReturnType | undefined; + + const server = createServer((request, response) => { + request.resume(); + requests.push({ + method: request.method ?? "", + path: request.url ?? "", + }); + response.writeHead(400, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + error: { + message: "Transient loopback provider rejection", + type: "invalid_request_error", + code: "loopback_compaction_retry", + }, + }), + () => { + // Start cancellation only after the real provider response can put + // production compaction into its minimum 500 ms retry backoff. + if (requests.length === 1) { + responseCompletedAt = performance.now(); + abortTimer = setTimeout(() => { + abortedAt = performance.now(); + controller.abort(); + }, 200); + } + }, + ); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Compaction loopback server did not expose a TCP port"); + } + const model = { + id: "loopback-compaction-model", + name: "Loopback compaction model", + api: "openai-completions", + provider: "openai", + baseUrl: `http://127.0.0.1:${address.port}/v1`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + } satisfies NonNullable; + + const messages: AgentMessage[] = [ + { + role: "user", + content: "Keep this original conversation and opaque ID 5cf86ba9 unchanged.", + timestamp: 1, + }, + ]; + const originalHistory = Buffer.from(JSON.stringify(messages)); + + const summary = summarizeInStages({ + messages, + model, + apiKey: "loopback-test-key", // pragma: allowlist secret + signal: controller.signal, + reserveTokens: 1_000, + maxChunkTokens: 50_000, + contextWindow: model.contextWindow, + parts: 1, + }); + + await expect(summary).rejects.toThrow(/abort/i); + if (responseCompletedAt === undefined || abortedAt === undefined) { + throw new Error("Compaction did not abort after the real loopback response"); + } + expect(performance.now() - abortedAt).toBeLessThan(400); + expect(performance.now() - responseCompletedAt).toBeLessThan(400); + expect(requests).toEqual([{ method: "POST", path: "/v1/chat/completions" }]); + expect(Buffer.from(JSON.stringify(messages)).equals(originalHistory)).toBe(true); + } finally { + if (abortTimer !== undefined) { + clearTimeout(abortTimer); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/src/agents/compaction.summarize-fallback.test.ts b/src/agents/compaction.summarize-fallback.test.ts index 1d5aeefeab7f..5861cea690ba 100644 --- a/src/agents/compaction.summarize-fallback.test.ts +++ b/src/agents/compaction.summarize-fallback.test.ts @@ -138,6 +138,39 @@ describe("summarizeWithFallback", () => { expect(agentSessionMocks.generateSummary).toHaveBeenCalledTimes(1); }); + it("stops retry backoff promptly when the caller aborts mid-sleep", async () => { + // The first attempt fails with a retryable error, then the caller aborts + // while retryAsync sits in its backoff sleep (>= 500ms minDelay). The + // sleep must reject on abort instead of riding out the full delay. + const controller = new AbortController(); + agentSessionMocks.generateSummary.mockRejectedValueOnce(new Error("transient rate limit")); + + const startedAt = Date.now(); + const promise = summarizeWithFallback({ + messages: [ + { + role: "user", + content: "hello", + timestamp: 1, + } satisfies UserMessage, + ], + model: testModel, + apiKey: "test-key", // pragma: allowlist secret + signal: controller.signal, + reserveTokens: 1000, + maxChunkTokens: 50_000, + contextWindow: 200_000, + }); + const rejection = expect(promise).rejects.toThrow("aborted"); + setTimeout(() => controller.abort(), 50); + await rejection; + const elapsedMs = Date.now() - startedAt; + + // Well under the 500ms minimum backoff — the abort interrupted the sleep. + expect(elapsedMs).toBeLessThan(400); + expect(agentSessionMocks.generateSummary).toHaveBeenCalledTimes(1); + }); + it("still attempts partial summarization when oversized messages were excluded", async () => { // Oversized-message fallback tries the safe subset so a huge attachment or // tool output does not prevent summarizing the rest of the transcript. diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index cf0bb3d7bee8..0767491b5067 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -3,6 +3,7 @@ */ import type { AgentCompactionIdentifierPolicy } from "../config/types.agent-defaults.js"; import { isAbortError } from "../infra/abort-signal.js"; +import { sleepWithAbort } from "../infra/backoff.js"; import { formatErrorMessage } from "../infra/errors.js"; import { retryAsync } from "../infra/retry.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -156,6 +157,9 @@ async function summarizeChunks(params: { maxDelayMs: 5000, jitter: 0.2, label: "compaction/generateSummary", + // Backoff must honor caller cancellation; otherwise an abort during + // the sleep would stall compaction until the full delay elapses. + sleep: (ms) => sleepWithAbort(ms, params.signal), shouldRetry: (err) => { // Stop retrying when the caller explicitly cancelled. if (params.signal.aborted) {