Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/external-summary-compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { buildCompressSystemPrompt, parseCompressInput, type ParsedRange } from
import { messageTimestampSections } from "./external-summary-surface.js";
import { configuredSummaryPlan } from "./external-summary-runtime.js";
import type { ResolvedKernelConfig } from "./compress-settings.js";
import { applyRanges, noVisibleContextRefusal, normalizeRangeOrder, recoverEvictedView, type RewriteCtx } from "./stream.js";
import { applyRanges, noVisibleContextRefusal, noopCompressReceipt, normalizeRangeOrder, recoverEvictedView, type RewriteCtx } from "./stream.js";
import { compressResult, type ProxyToolResult } from "./proxy-tool-result.js";
import { imagePlaceholders } from "./image-note.js";
import { describeExternalSummaryFailure, summaryLengthBudgetClause, type SummaryWork } from "./external-summary.js";
Expand Down Expand Up @@ -95,7 +95,16 @@ export async function applyConfiguredCompression(input: unknown, ctx: RewriteCtx
instructions: `${ctx.session.meta.summaryInstructions ?? buildCompressSystemPrompt(undefined, messageTimestampSections(undefined))}\nWrite a faithful summary of the selected range ${range.startRef}-${range.endRef}. Preserve task status, constraints, decisions, exact code identifiers, paths and errors, and unfinished objectives with their source message refs. Submitted summary hints are non-authoritative. Never summarize or change the read-only reference.`
+ summaryLengthBudgetClause(maxSummaryChars, ctx.config.compress.minSummaryLength) });
}
if (work.length === 0) return compressResult("[Compression FAILED: no selected range passed the existing kernel protection checks. Nothing compressed.]", "refused", 0);
if (work.length === 0) {
// #5558: every submitted range was skipped by the probe (protected or
// already covered) — no kernel error means an idempotent no-op, so reuse
// the normal path's success-shaped receipt instead of a fresh failure.
if (preview.result.errors.length === 0) {
const detail = parsed.ranges.map((rg) => `${rg.startRef}–${rg.endRef}`).join(", ");
return noopCompressReceipt(ctx, detail, preview.result.warnings);
}
return compressResult("[Compression FAILED: no selected range passed the existing kernel protection checks. Nothing compressed.]", "refused", 0);
}
const batch = await plan.summarize(work, signal);
if (signal?.aborted || ctx.session.state !== state || ctx.session.revisionEpoch !== revision) return compressResult("[Compression FAILED: cancelled or session changed while generating external summaries. Nothing compressed.]", "refused", 0);
const successful: ParsedRange[] = [];
Expand Down
23 changes: 12 additions & 11 deletions src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ export function compressibleSpanHint(ctx: RewriteCtx): string {
return compressionRecovery(ctx).hint;
}

// #5558/#4521: the idempotent no-op receipt, owned once so applyRanges and the
// external-summary lane emit byte-identical text. Nothing was folded because every
// range was already covered or protected — NOT a failure: success-shaped, reason
// "noop", no breaker arm, plus the live recovery hint so the model stops resending.
export function noopCompressReceipt(ctx: RewriteCtx, detail: string, warnings: readonly string[]): ProxyToolResult {
const warnText = warnings.length > 0 ? warnings.join(" ") : "every requested range was already folded or protected.";
ctx.log(`[acp-proxy: compress no-op ${detail} → 0 blocks (nothing left to fold; nothing failed)]`);
const recovery = compressionRecovery(ctx);
return compressResult(`[Compression no-op: nothing was folded and nothing failed — ${warnText}${recovery.hint} Continue your current task.]`, "applied", 0, "noop");
}

const M_REF_NUM_RE = /^m0*(\d{1,7})$/i;

// #1001: after a client history rewrite, ref numbers are no longer monotonic
Expand Down Expand Up @@ -1232,17 +1243,7 @@ export function applyRanges(parsed: ReturnType<typeof parseCompressInput>, ctx:
// driver on long sessions (live incident: session 01a18a4d, 4
// consecutive refusals 20:09 over ranges that were 100% legal).
if (r.errors.length === 0) {
const warnText = r.warnings.length > 0 ? r.warnings.join(" ") : "every requested range was already folded or protected.";
ctx.log(`[acp-proxy: compress no-op ${detail} → 0 blocks (nothing left to fold; nothing failed)]`);
// Reuse the failure-path recovery hint verbatim: a model that
// keeps aiming at covered/protected ranges needs the live
// pointer (eligible ranges, distillation target, or the explicit
// stop wording) — without it the no-op receipt says "do not
// resend" but never says what TO do, which is how the retry
// loops persisted (#4521). eligibilityFailure stays guarded by
// compressionRecovery itself (never a guessed span).
const recovery = compressionRecovery(ctx);
return compressResult(`[Compression no-op: nothing was folded and nothing failed — ${warnText}${recovery.hint} Continue your current task.]`, "applied", 0, "noop");
return noopCompressReceipt(ctx, detail, r.warnings);
}
const errs = r.errors.join("; ") || "no blocks created";
// #2362: machine-readable failure class for the [plugin] execution
Expand Down
5 changes: 4 additions & 1 deletion tests/external-summary-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,10 @@ test("protected ranges cause no paid call and no fold", async () => {
ctx.config.preserveRecentMessages = 10;
ctx.config.preserveRecentTokens = 100_000;
const result = await applyConfiguredCompression(args(ctx), ctx);
assert.equal(result.outcome, "refused");
// #5558: a fully-protected range is an idempotent no-op, not a failure —
// success-shaped receipt, zero blocks, and (the point of the test) no payer.
assert.equal(result.outcome, "applied");
assert.equal(result.reason, "noop");
assert.equal(ctx.session.state.blocks.length, 0);
assert.equal(calls, 0);
}, (_req, res) => { calls++; success(res); });
Expand Down
110 changes: 110 additions & 0 deletions tests/issue5558-ext-summary-noop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// #5558: with external-summary configured, a compress request whose ranges are
// ALL protected or already covered by an active block used to fall through to a
// generic "Compression FAILED ... protection checks" refused receipt — mislabeling
// an idempotent no-op as a fresh failure so a replaying model kept retrying. Now
// the external lane reuses the normal path's success-shaped no-op receipt (#4521):
// outcome "applied", reason "noop", zero blocks, NO payer call. Genuine kernel
// errors (too small / cannot anchor) keep the original refused receipt untouched.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { CoreMessage } from "acp-kernel";
import { createCore, createInitialState, defaultConfig, assignRefs, emptyRefMap } from "acp-kernel";
import { parseCompressInput } from "../src/compress-tool.ts";
import { applyRanges, type RewriteCtx } from "../src/stream.ts";
import { applyConfiguredCompression } from "../src/external-summary-compress.ts";

type Ctx = Omit<RewriteCtx, "log"> & { log: (m: string) => void; logs: string[] };

const MESSAGES = Array.from({ length: 80 }, (_, i) => ({
id: `raw${i}`,
role: (i % 2 ? "assistant" : "user") as "assistant" | "user",
text: "lorem ipsum dolor sit amet conseq ".repeat(8) + i,
})) as unknown as CoreMessage[];

function makeCtx(preserveRecentMessages: number): Ctx {
const logs: string[] = [];
const ctx: Ctx = {
core: createCore(),
config: defaultConfig(200000, { preserveRecentMessages, preserveRecentTokens: 0 }),
messages: MESSAGES,
session: {
id: "issue5558-test",
meta: {},
stats: { requests: 0, tokensSaved: 0, inputTokens: 0, cachedTokens: 0, outputTokens: 0, cacheSamples: 0, lastInputTokens: 0, compressCreditTokens: 0, contextTokens: 0, retrieveCalls: 0, retrieveHits: 0, retrieveMisses: 0, storedBytes: 0, storeBytesSaved: 0, rangeRestores: 0 },
metadata: {},
state: createInitialState(),
createdAt: Date.now(),
lastSeen: Date.now(),
blockContents: new Map(),
inFlight: 0,
persisted: false,
pendingRetrievals: [],
},
log: (m: string) => { logs.push(m); },
logs,
};
ctx.session.state.messageRefs = assignRefs(MESSAGES, { existing: emptyRefMap(), nextIndex: 0 }).map;
return ctx;
}

function withExternalSummary(ctx: Ctx, preserveRecentMessages: number): Ctx {
// summary.example.test never resolves: if the no-op path ever dispatched a
// payer call the batch would fail and the outcome could not be "applied".
ctx.config = {
...defaultConfig(200000, { preserveRecentMessages, preserveRecentTokens: 0 }),
externalSummary: { enabled: true, targets: [{ name: "sum-sm", protocol: "openai", url: "https://summary.example.test/v1/chat/completions", model: "sm", apiKey: "sk-test-5558", contextWindow: 128000, outputTokens: 8192, stream: false }] },
} as typeof ctx.config;
return ctx;
}

// >= minSummaryLength (50): the normal path validates summary length, the ext
// path treats it as an ignored hint — one value satisfies both lanes.
const RANGE = () => ({ content: [{ startId: "m00010", endId: "m00040", summary: "y".repeat(100) }] });

test("#5558: fully protected range -> ext lane returns an applied no-op, not a failure", async () => {
const ctx = withExternalSummary(makeCtx(75), 75); // last 75 protected => m00010..m00040 fully inside
const res = await applyConfiguredCompression(RANGE(), ctx);
assert.equal(res.outcome, "applied", `expected idempotent no-op, got: ${res.text}`);
assert.equal(res.reason, "noop");
assert.equal(res.blocksCreated, 0);
assert.equal(ctx.session.state.blocks.length, 0, "no block created");
assert.ok(res.text.startsWith("[Compression no-op"), `success-shaped receipt: ${res.text}`);
assert.ok(res.text.includes("protected zone"), `receipt names the cause: ${res.text}`);
// The whole point: no retry re-arm (breaker/streak) on a legal no-op.
assert.equal(ctx.session.metadata["compressFailStreak"], undefined, "no failure streak recorded");
});

test("#5558: replaying an already-covered range -> ext lane reports the covering block as a no-op", async () => {
const ctx = makeCtx(5);
const setup = applyRanges(parseCompressInput(RANGE()), ctx);
assert.equal(setup.outcome, "applied", `setup fold lands: ${setup.text}`);
assert.equal(ctx.session.state.blocks.length, 1);
const res = await applyConfiguredCompression(RANGE(), withExternalSummary(ctx, 5));
assert.equal(res.outcome, "applied", `expected idempotent no-op, got: ${res.text}`);
assert.equal(res.reason, "noop");
assert.equal(res.blocksCreated, 0);
assert.equal(ctx.session.state.blocks.length, 1, "existing covering block untouched, none added");
assert.ok(res.text.startsWith("[Compression no-op"), `success-shaped receipt: ${res.text}`);
assert.ok(res.text.includes("already covered"), `receipt names the covering block: ${res.text}`);
assert.equal(ctx.session.metadata["compressFailStreak"], undefined, "no failure streak recorded");
});

test("#5558: a genuinely uncompressible range (too small) keeps the refused receipt", async () => {
const ctx = makeCtx(5);
ctx.messages = [{ id: "rawA", role: "user", text: "hi" }] as unknown as CoreMessage[];
ctx.session.state.messageRefs = assignRefs(ctx.messages, { existing: emptyRefMap(), nextIndex: 0 }).map;
withExternalSummary(ctx, 5);
const res = await applyConfiguredCompression({ content: [{ startId: "m00001", endId: "m00001", summary: "hint" }] }, ctx);
assert.equal(res.outcome, "refused", `genuine kernel error must stay refused: ${res.text}`);
assert.equal(res.blocksCreated, 0);
assert.ok(res.text.startsWith("[Compression FAILED"), `original genuine-error receipt preserved: ${res.text}`);
});

test("#5558: the shared no-op receipt did not change the normal path (applyRanges)", async () => {
const ctx = makeCtx(5);
applyRanges(parseCompressInput(RANGE()), ctx); // establish coverage
const res = applyRanges(parseCompressInput(RANGE()), ctx);
assert.equal(res.outcome, "applied", `normal path no-op intact: ${res.text}`);
assert.equal(res.reason, "noop");
assert.ok(res.text.startsWith("[Compression no-op"), `normal path uses the same receipt owner: ${res.text}`);
});
Loading