fix(coding-agent): stop auto-compaction thrash when a single turn exceeds the threshold
prepareCompaction keeps the most-recent turn verbatim (findCutPoint never cuts at tool results), so when that single turn already exceeds the compaction threshold the rewritten context stays above threshold. The context-full / snapcompact auto-compaction success tail scheduled the agent-authored auto-continue (and the overflow/incomplete retry) unconditionally, so the next agent_end re-entered #checkCompaction over the same oversized tail and re-fired forever. This is the residual loop left after #3247 capped snapcompact's own frame projection: once #computeSnapcompactMaxFrames drops the frame cap below one frame, snapcompact is skipped and the context-full summarizer path still creates no headroom, so the loop persists on that path. - #runAutoCompaction now gates the continuation and the overflow/ incomplete retry on a post-maintenance headroom check (#compactionCreatedHeadroom), reusing the shake recovery-band hysteresis from #2275 (consolidated as the shared COMPACTION_RECOVERY_BAND). When a pass frees too little it pauses automatic maintenance and emits a single warning instead of looping. - The post-turn threshold check ignores an assistant's stale pre-compaction usage so the scheduled auto-continue can't re-trip on the kept assistant's old high token count. Adds a regression test covering the no-headroom (pause + warn, no continuation) and headroom (auto-continue, no warn) paths; both assertions are mutation-verified against the guard. (cherry picked from commit 6fcbbe2b3075827ee621acaee8efa2c586eedd3e)
This commit is contained in:
committed by
can1357
parent
4e3a0a0f46
commit
6a55205b68
@@ -117,6 +117,7 @@
|
||||
|
||||
- Fixed `omp install` of legacy pi extensions failing with `Cannot find module '/$bunfs/root/packages/coding-agent/src/extensibility/typebox.js'` on every released `omp-<platform>-<arch>` binary. Commit `dc5c93462f` removed worker entrypoints from `scripts/ci-release-build-binaries.ts`; the inline comment then claimed the legacy-shim and package-barrel entrypoints (`typebox.ts`, `legacy-pi-{ai,coding-agent}-shim.ts`, `packages/{agent,natives,tui,utils}/...`) were "still" passed to `bun build --compile`, but they had never been re-added. The release binaries shipped without those files in bunfs, so `legacy-pi-compat.ts` redirected `typebox` imports to a bunfs path that didn't exist. `__resolveTypeBoxShimPath` now mirrors `__validateLegacyPiPackageRootOverrides` (#2168) by dropping the override when the shim file is missing, so a missing shim falls through to native `node_modules` resolution instead of emitting a dead bunfs URL ([#3414](https://github.com/can1357/oh-my-pi/issues/3414)).
|
||||
- Fixed every legacy `@(scope)/pi-*` and `@sinclair/typebox` import failing to load on the `omp-darwin-arm64` release binary (and any other `omp` built with Bun 1.3.14). `__validateLegacyPiPackageRootOverrides` and the `rewriteLegacyPiImports` emit path both depended on `--compile` extras being reachable as `/$bunfs/root/...` filesystem entries, but Bun 1.3.14 stopped exposing them through every API (`fs.existsSync`, `Bun.file().exists()`, `Bun.resolveSync`, `await import()` on the bunfs path or its `file://` URL all fail; only `/$bunfs/root/<binary-name>` itself answers). `legacy-pi-compat.ts` now keeps a JS-heap reference to every bundled pi-* surface in a lazy-loaded sibling `legacy-pi-bundled-registry.ts` and serves them through an `omp-legacy-pi-bundled:` virtual namespace whose `Bun.plugin().onLoad` returns synthetic re-exports — no bunfs path ever leaves the module in compiled mode, and dev / source-link / installed-package modes keep the historical `file://` rewrite. The matching `--compile` extras in `scripts/build-binary.ts`, the shared `scripts/binary-entrypoints.ts` list, and the dead `BUNFS_PACKAGE_ROOT` / `bunfsPath` / `__computeBunfsPackageRoot` / `__joinBunfsPath` helpers are gone. ([#3423](https://github.com/can1357/oh-my-pi/issues/3423))
|
||||
- Fixed auto-compaction thrashing on a session whose single most-recent kept turn already exceeds the compaction threshold. `prepareCompaction` keeps that turn verbatim (`findCutPoint` never cuts at tool results), so the rewritten context stays above threshold; the context-full / snapcompact success tail scheduled the agent-authored auto-continue (and the overflow/incomplete retry) unconditionally, so the next `agent_end` re-entered `#checkCompaction` over the same oversized tail and re-fired forever. This is the residual loop left after #3247 capped snapcompact's own frame projection — once the frame cap drops below one frame, snapcompact is skipped and the context-full summarizer path still made no headroom. `#runAutoCompaction` now gates the continuation/retry on a post-maintenance headroom check (`#compactionCreatedHeadroom`, sharing shake's `COMPACTION_RECOVERY_BAND` hysteresis from #2275); when a pass frees too little it pauses automatic maintenance and emits a single warning instead of looping. The post-turn threshold check also ignores an assistant's stale pre-compaction `usage` so the scheduled auto-continue cannot re-trip on the kept assistant's old high token count.
|
||||
|
||||
## [16.1.17] - 2026-06-24
|
||||
|
||||
|
||||
@@ -420,14 +420,17 @@ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "la
|
||||
|
||||
const RETRY_BACKOFF_JITTER_RATIO = 0.25;
|
||||
/**
|
||||
* Hysteresis band for the post-shake "did we actually create headroom?" check.
|
||||
* Shake counts as having resolved threshold pressure only when residual context
|
||||
* lands at or below `SHAKE_RECOVERY_BAND × threshold`. Re-checking against the
|
||||
* raw threshold lets shake keep reclaiming a trickle of the previous turn's
|
||||
* output and land just under the line every turn, sustaining the auto-continue
|
||||
* dead loop reported in #2275.
|
||||
* Hysteresis band for the post-maintenance "did we actually create headroom?"
|
||||
* check shared by the shake tail and the context-full / snapcompact tail. A
|
||||
* pass counts as having resolved threshold pressure only when residual context
|
||||
* lands at or below `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against
|
||||
* the raw threshold lets a pass keep reclaiming a trickle of the previous
|
||||
* turn's output and land just under the line every turn, sustaining the
|
||||
* auto-continue dead loop reported in #2275; the same band stops the
|
||||
* context-full / snapcompact tail from re-firing on a history whose single
|
||||
* most-recent kept turn already exceeds the threshold (the snapcompact thrash).
|
||||
*/
|
||||
const SHAKE_RECOVERY_BAND = 0.8;
|
||||
const COMPACTION_RECOVERY_BAND = 0.8;
|
||||
|
||||
function calculateRetryBackoffDelayMs(baseDelayMs: number, attempt: number): number {
|
||||
const cappedDelayMs = Math.min(Math.max(0, baseDelayMs) * 2 ** Math.max(0, attempt - 1), RETRY_BACKOFF_MAX_DELAY_MS);
|
||||
@@ -8862,7 +8865,15 @@ export class AgentSession {
|
||||
if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
|
||||
const pruneResult = await this.#pruneToolOutputs();
|
||||
const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
|
||||
const assistantUsageContextTokens = calculateContextTokens(assistantMessage.usage);
|
||||
// An assistant that predates the latest compaction carries stale, pre-rewrite
|
||||
// `usage`: the scheduled auto-continue re-enters this check with the kept
|
||||
// assistant (#promptWithMessage → #checkCompaction), and its old high prompt
|
||||
// count would re-trip the threshold on a freshly compacted history. Drop the
|
||||
// stale provider number for those messages and let the live stored estimate
|
||||
// (the floor applied below) drive the decision instead.
|
||||
const assistantUsageContextTokens = errorIsFromBeforeCompaction
|
||||
? 0
|
||||
: calculateContextTokens(assistantMessage.usage);
|
||||
const storedContextTokens = this.#estimateStoredContextTokens();
|
||||
// Pruning frees bytes for the NEXT prompt; it does not change the size of
|
||||
// the prompt the LLM just billed for. Earlier revisions subtracted the
|
||||
@@ -10112,6 +10123,46 @@ export class AgentSession {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-maintenance progress check for the context-full / snapcompact tail.
|
||||
*
|
||||
* After `appendCompaction` rewrote history and `replaceMessages` swapped in the
|
||||
* compacted context, measure the residual context off the live message set and
|
||||
* decide whether maintenance actually created headroom. Mirrors the shake
|
||||
* recovery-band logic (#2275): a session whose single most-recent turn already
|
||||
* blows the threshold cannot be reduced by compaction (findCutPoint keeps that
|
||||
* turn verbatim), so re-firing on the next agent_end just thrashes. We only
|
||||
* report progress when residual context lands at or below
|
||||
* `COMPACTION_RECOVERY_BAND × threshold`.
|
||||
*
|
||||
* When the model/window is unknown we cannot evaluate the band, so we
|
||||
* optimistically allow the continuation (preserving prior behavior).
|
||||
*/
|
||||
#compactionCreatedHeadroom(triggerContextTokens?: number): boolean {
|
||||
const contextWindow = this.model?.contextWindow ?? 0;
|
||||
if (contextWindow <= 0) return true;
|
||||
const compactionSettings = this.settings.getGroup("compaction");
|
||||
const residualTokens = compactionContextTokens(
|
||||
this.getContextUsage({ contextWindow })?.tokens ?? 0,
|
||||
this.#estimateStoredContextTokens(),
|
||||
);
|
||||
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
|
||||
const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
|
||||
// A genuine reduction past the band always counts as progress. The
|
||||
// triggerContextTokens comparison is a secondary guard: if residual context
|
||||
// is not meaningfully smaller than what triggered this pass, treat it as a
|
||||
// no-op even when it nominally sits under the band.
|
||||
if (residualTokens > recoveryBand) return false;
|
||||
if (
|
||||
typeof triggerContextTokens === "number" &&
|
||||
Number.isFinite(triggerContextTokens) &&
|
||||
triggerContextTokens > 0
|
||||
) {
|
||||
return residualTokens < triggerContextTokens;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Run auto-compaction with events.
|
||||
*
|
||||
@@ -10601,11 +10652,17 @@ export class AgentSession {
|
||||
};
|
||||
await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
|
||||
|
||||
// Post-maintenance progress guard. Snapcompact can project over budget and
|
||||
// fall back to a context-full summary; the summarizer keeps `keepRecentTokens`
|
||||
// of recent history verbatim and findCutPoint can only cut at turn
|
||||
// boundaries (never tool results), so a single oversized recent turn (e.g. a
|
||||
// huge tool result) leaves the rewritten context still above threshold.
|
||||
// Scheduling the auto-continue regardless means the next agent_end re-enters
|
||||
// #checkCompaction over the same oversized tail and re-fires forever
|
||||
// (the snapcompact thrash). Mirror the shake recovery-band check: only
|
||||
// auto-continue when compaction actually created headroom.
|
||||
let continuationScheduled = false;
|
||||
if (!willRetry && reason !== "idle" && shouldAutoContinue) {
|
||||
this.#scheduleAutoContinuePrompt(generation);
|
||||
continuationScheduled = true;
|
||||
}
|
||||
const madeProgress = this.#compactionCreatedHeadroom(options.triggerContextTokens);
|
||||
|
||||
if (willRetry) {
|
||||
const messages = this.agent.state.messages;
|
||||
@@ -10624,7 +10681,27 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
||||
// Only retry when maintenance actually created headroom. An
|
||||
// overflow/incomplete recovery whose kept recent turn alone still
|
||||
// exceeds the window would retry straight back into the same
|
||||
// overflow/length failure — the recovery-side twin of the threshold
|
||||
// auto-continue thrash. Pause and surface the dead-end instead.
|
||||
if (madeProgress) {
|
||||
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
||||
continuationScheduled = true;
|
||||
}
|
||||
} else if (reason !== "idle" && shouldAutoContinue && madeProgress) {
|
||||
// Post-maintenance progress guard. Snapcompact can project over budget
|
||||
// and fall back to a context-full summary; the summarizer keeps
|
||||
// `keepRecentTokens` of recent history verbatim and findCutPoint can
|
||||
// only cut at turn boundaries (never tool results), so a single
|
||||
// oversized recent turn (e.g. a huge tool result) leaves the rewritten
|
||||
// context still above threshold. Scheduling the auto-continue regardless
|
||||
// means the next agent_end re-enters #checkCompaction over the same
|
||||
// oversized tail and re-fires forever (the snapcompact thrash). Mirror
|
||||
// the shake recovery-band check: only auto-continue when compaction
|
||||
// actually created headroom.
|
||||
this.#scheduleAutoContinuePrompt(generation);
|
||||
continuationScheduled = true;
|
||||
} else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
|
||||
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
|
||||
@@ -10636,6 +10713,17 @@ export class AgentSession {
|
||||
});
|
||||
continuationScheduled = true;
|
||||
}
|
||||
|
||||
// A non-idle pass that wanted to continue (auto-continue or retry) but
|
||||
// made no headroom is a dead-end: warn once so the user understands why
|
||||
// maintenance paused instead of silently looping.
|
||||
if (!madeProgress && reason !== "idle" && (willRetry || shouldAutoContinue)) {
|
||||
this.emitNotice(
|
||||
"warning",
|
||||
"Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; shrink it (e.g. clear large tool output) or switch to a larger-context model.",
|
||||
"compaction",
|
||||
);
|
||||
}
|
||||
return continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE;
|
||||
} catch (error) {
|
||||
if (autoCompactionSignal.aborted) {
|
||||
@@ -10734,7 +10822,7 @@ export class AgentSession {
|
||||
if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) {
|
||||
const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed);
|
||||
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
|
||||
const recoveryBand = Math.floor(thresholdTokens * SHAKE_RECOVERY_BAND);
|
||||
const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
|
||||
stillOverThreshold = correctedTokens > recoveryBand;
|
||||
} else {
|
||||
const postShakeTokens = this.getContextUsage({ contextWindow })?.tokens ?? 0;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
|
||||
import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
|
||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/**
|
||||
* Regression test for the auto-compaction thrash loop.
|
||||
*
|
||||
* When the most-recent kept turn alone exceeds the compaction threshold,
|
||||
* `prepareCompaction` keeps it verbatim (findCutPoint never cuts at tool
|
||||
* results), so a "successful" compaction leaves context still above threshold.
|
||||
* The snapcompact strategy makes this visible: it projects over budget, falls
|
||||
* back to a context-full summary ("could not bring the context under the
|
||||
* limit"), and the success tail used to schedule the auto-continue regardless —
|
||||
* the next agent_end re-entered #checkCompaction over the same oversized tail and
|
||||
* re-fired forever.
|
||||
*
|
||||
* The fix gates the auto-continue (and the overflow/incomplete retry) on a
|
||||
* post-maintenance headroom check; with no headroom it pauses and emits a single
|
||||
* warning notice instead of looping.
|
||||
*/
|
||||
describe("AgentSession auto-compaction progress guard", () => {
|
||||
let tempDir: TempDir;
|
||||
let session: AgentSession;
|
||||
let sessionManager: SessionManager;
|
||||
let authStorage: AuthStorage;
|
||||
let modelRegistry: ModelRegistry;
|
||||
|
||||
const NOTICE_SOURCE = "compaction";
|
||||
const NO_PROGRESS_FRAGMENT = "Compaction freed too little context to make progress";
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = TempDir.createSync("@pi-auto-compaction-progress-");
|
||||
|
||||
// Short-circuit the actual summarization so the test makes no LLM call: the
|
||||
// hook supplies the compaction result, then the production tail (events,
|
||||
// progress guard, continuation scheduling) runs exactly as in a real pass.
|
||||
const extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions");
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts");
|
||||
fs.writeFileSync(
|
||||
extensionPath,
|
||||
[
|
||||
"export default function(pi) {",
|
||||
'\tpi.on("session_before_compact", async (event) => {',
|
||||
"\t\treturn {",
|
||||
"\t\t\tcompaction: {",
|
||||
'\t\t\t\tsummary: "compacted",',
|
||||
"\t\t\t\tshortSummary: undefined,",
|
||||
"\t\t\t\tfirstKeptEntryId: event.preparation.firstKeptEntryId,",
|
||||
"\t\t\t\ttokensBefore: event.preparation.tokensBefore,",
|
||||
"\t\t\t\tdetails: {},",
|
||||
"\t\t\t},",
|
||||
"\t\t};",
|
||||
"\t});",
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
modelRegistry = new ModelRegistry(authStorage);
|
||||
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
|
||||
|
||||
const extensionsResult = await loadExtensions([extensionPath], tempDir.path());
|
||||
const extensionRunner = new ExtensionRunner(
|
||||
extensionsResult.extensions,
|
||||
extensionsResult.runtime,
|
||||
tempDir.path(),
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
);
|
||||
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) {
|
||||
throw new Error("Expected built-in anthropic model to exist");
|
||||
}
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: ["Test"],
|
||||
tools: [],
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Seed a minimal branch so prepareCompaction() returns a preparation.
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: "hello",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settings: Settings.isolated({
|
||||
// Auto-continue ON so the guarded auto-continue path is exercised.
|
||||
"compaction.autoContinue": true,
|
||||
}),
|
||||
modelRegistry,
|
||||
extensionRunner,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await session?.dispose();
|
||||
} finally {
|
||||
authStorage?.close();
|
||||
await tempDir?.remove();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
/** Build a threshold-tripping assistant turn (contextWindow 200k, ~80% threshold). */
|
||||
function highUsageAssistant() {
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text: "Done." }],
|
||||
api: "anthropic-messages" as const,
|
||||
provider: "anthropic" as const,
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop" as const,
|
||||
usage: {
|
||||
input: 190000,
|
||||
output: 1000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 191000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function collectNotices() {
|
||||
const notices: { level: string; message: string; source?: string }[] = [];
|
||||
session.subscribe(event => {
|
||||
if (event.type === "notice") {
|
||||
notices.push({ level: event.level, message: event.message, source: event.source });
|
||||
}
|
||||
});
|
||||
return notices;
|
||||
}
|
||||
|
||||
function countCompactionStarts() {
|
||||
let starts = 0;
|
||||
session.subscribe(event => {
|
||||
if (event.type === "auto_compaction_start") starts++;
|
||||
});
|
||||
return () => starts;
|
||||
}
|
||||
|
||||
it("pauses (no continuation, single warning) when compaction creates no headroom", async () => {
|
||||
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
|
||||
// Auto-continue runs through agent.prompt (#promptWithMessage), not
|
||||
// agent.continue — spy both so "no continuation" is actually proven.
|
||||
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
|
||||
// Residual context stays above the recovery band after the rewrite: the most
|
||||
// recent turn alone is too large to reduce.
|
||||
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 });
|
||||
|
||||
const notices = collectNotices();
|
||||
const startCount = countCompactionStarts();
|
||||
|
||||
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
|
||||
session.subscribe(event => {
|
||||
if (event.type === "auto_compaction_end") onCompactionDone();
|
||||
});
|
||||
|
||||
const assistantMsg = highUsageAssistant();
|
||||
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
|
||||
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
|
||||
|
||||
await compactionDone;
|
||||
await session.waitForIdle();
|
||||
|
||||
// Compaction ran exactly once and did not schedule a continuation turn
|
||||
// (neither the auto-continue prompt nor a queued-message continue).
|
||||
expect(startCount()).toBe(1);
|
||||
expect(promptSpy).not.toHaveBeenCalled();
|
||||
expect(continueSpy).not.toHaveBeenCalled();
|
||||
expect(session.isStreaming).toBe(false);
|
||||
|
||||
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
|
||||
expect(noProgress.length).toBe(1);
|
||||
expect(noProgress[0].level).toBe("warning");
|
||||
});
|
||||
|
||||
it("auto-continues (no warning) when compaction creates headroom", async () => {
|
||||
// The auto-continue path runs #scheduleAutoContinuePrompt → #promptWithMessage
|
||||
// → agent.prompt. Stub both prompt and continue so no real agent loop runs.
|
||||
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
|
||||
vi.spyOn(session.agent, "continue").mockResolvedValue();
|
||||
// Residual context drops well under the threshold: real reduction happened.
|
||||
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 1000, contextWindow: 200000, percent: 0.5 });
|
||||
|
||||
const notices = collectNotices();
|
||||
|
||||
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
|
||||
session.subscribe(event => {
|
||||
if (event.type === "auto_compaction_end") onCompactionDone();
|
||||
});
|
||||
|
||||
const assistantMsg = highUsageAssistant();
|
||||
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
|
||||
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
|
||||
|
||||
await compactionDone;
|
||||
await session.waitForIdle();
|
||||
|
||||
// Headroom was created, so the guard scheduled the agent-authored
|
||||
// continuation prompt and stayed silent.
|
||||
expect(promptSpy).toHaveBeenCalledTimes(1);
|
||||
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
|
||||
expect(noProgress.length).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user