From 9c13fe11768d23a1b742a834256870ce34dec32b Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Sat, 19 Sep 2026 18:13:39 -0700 Subject: [PATCH] fix(coding-agent): compact before model requests --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/compaction.md | 10 +- .../coding-agent/src/core/agent-session.ts | 149 ++++- .../src/core/compaction/compaction.ts | 22 +- .../agent-session-request-compaction.test.ts | 611 ++++++++++++++++++ 5 files changed, 768 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/test/suite/agent-session-request-compaction.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9fd0d8011..9cc5e0f2f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Check the reserve budget before every normal model request, preserving pending inputs and complete tool batches through durable compaction without replaying the run. - Bound unsuccessful proactive compaction to one operation per logical run, warn before continuing, and preserve independent cancellation, manual compaction, and failed-response recovery. - Unified pre-prompt, post-answer, and displayed context estimates, using local estimates when provider usage no longer applies after compaction or context changes. - Show compaction warnings alongside valid summaries or specific cancellation reasons, and write plain text warnings to stderr without changing successful answer output. diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 4bd6bd78b..aa5fc4659 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -38,15 +38,19 @@ You can also trigger manually with `/compact [instructions]`, where optional ins ### Automatic Attempt Lifecycle -Pre-prompt and successful-final-answer checks each allow one eligible proactive operation, including the configured transient summarization retries. Afterward, Diffex estimates context again. A failure, independent cancellation, unavailable compaction, or result still above the reserve threshold warns and suppresses further proactive attempts for that logical run. Queued input and automatic continuations share that suppression. The next separately admitted idle prompt resets it before preflight; an idle custom-message run also resets it. +Pre-prompt, every normal model request after input and tool-result admission, and successful-final-answer checks each allow one eligible proactive operation, including the configured transient summarization retries. Afterward, Diffex estimates context again. A failure, independent cancellation, unavailable compaction, or result still above the reserve threshold warns and suppresses further proactive attempts for that logical run. Queued input and automatic continuations share that suppression. The next separately admitted idle prompt resets it before preflight; an idle custom-message run also resets it. A valid summary remains committed even when it does not reduce context below the threshold. A successful reduction to or below the threshold permits another attempt after renewed growth. These are best-effort limits: local estimates and continuing after a warning do not guarantee a provider token limit. Cancelling the prompt or session stops pending work and cancels compaction, including authentication and extension hooks. Cancelling compaction alone continues the pending work. Cancellable preflight retains its existing rollback behavior. Successful final answers, including answers exceeding the configured context window, are never retried merely because proactive compaction ran. Manual compaction and bounded failed/truncated-response recovery remain independently available. +Pending user and custom messages (including images), and the complete latest tool-call/result batch, survive proactive compaction exactly once. This also protects custom messages appended while idle before their first request. Standalone compaction and branch-summary requests bypass normal request scheduling. + +An existing request-preparation hook runs once before the budget check. Its effective system prompt and tools remain in use; compaction callbacks can update them. If its messages cannot be matched to selected session history, compaction warns and continues with that effective context unchanged, suppressing further proactive attempts for the run. Unsafe extension cut points are rejected before saving. + ### Current Context Accounting -Pre-prompt and post-answer checks and the context display share one estimate of the normal model-visible context. It includes system instructions, tool definitions, images, summaries, custom messages, and included bash output. Request-only extension transforms are not rerun to calculate it. +Pre-prompt, request-time, and post-answer checks and the context display share one estimate of the normal model-visible context. It includes system instructions, tool definitions, images, summaries, custom messages, and included bash output. Request-only extension transforms are not rerun to calculate it. Provider usage is used only when an in-memory request baseline still matches the selected branch, model configuration, system prompt, tools, and preceding messages. Later messages are estimated locally; system instructions and tools already covered by usage are not added again. After compaction, retained older responses cannot supply usage, regardless of their timestamps. Loaded sessions and changed contexts without a matching baseline use local estimates until an applicable response arrives. Session token and cost totals still include all billed history. @@ -60,7 +64,7 @@ Compaction warnings appear in the interactive transcript, including when a valid 2. **Extract messages**: Collect messages from the previous kept boundary (or session start) up to the cut point 3. **Generate summary**: Call LLM to summarize with structured format, passing the previous summary as iterative context when present 4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId` -5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards +5. **Rebuild active context**: The current run continues with the saved summary + messages from `firstKeptEntryId` onwards ``` Before compaction: diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1a30ba6fb..df4719d24 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -15,6 +15,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { basename, dirname } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { type Agent, type AgentContext, @@ -135,6 +136,7 @@ import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader, + sessionEntryToContextMessages, } from "./session-manager.ts"; import type { SettingsManager } from "./settings-manager.ts"; import { @@ -424,6 +426,12 @@ interface SubagentCoordination { waitStarted: boolean; } +interface ProactiveCompactionContext { + branchIds: string[]; + messagesByEntryId: Map; + firstPendingEntryId: string | undefined; +} + type AutoCompactionOutcome = | { status: "compacted"; result: CompactionResult; continueRun: boolean } | { status: "failed" | "cancelled" | "unavailable"; errorMessage: string }; @@ -793,16 +801,38 @@ export class AgentSession { private readonly _contextEstimator = new SessionContextEstimator(); private _selectedRequestModel: string | undefined; + private _pendingRequestContextWindow: number | undefined; private _installAgentRequestAccounting(): void { const previousPrepareRequest = this.agent.prepareRequest; this.agent.prepareRequest = async (context, signal) => { const replacement = await previousPrepareRequest?.(context, signal); + signal?.throwIfAborted(); + const effective = replacement ?? context; + this.agent.state.systemPrompt = effective.systemPrompt; + this.agent.state.tools = effective.tools ?? []; + this.agent.state.messages = effective.messages; + const selectedModel = this._selectedRequestModel + ? (JSON.parse(this._selectedRequestModel) as Model) + : this.model; + this._pendingRequestContextWindow = selectedModel?.contextWindow; + try { + await this._checkProactiveCompaction("threshold", signal); + } finally { + this._pendingRequestContextWindow = undefined; + } + signal?.throwIfAborted(); + const prepared = { + ...effective, + systemPrompt: this.agent.state.systemPrompt, + tools: this.agent.state.tools, + messages: this.agent.state.messages, + }; // Admission events and hooks can change public state after the loop selected // its model. Such a response cannot prove the new configuration's usage. const model = this._selectedRequestModel === JSON.stringify(this.model) ? this.model : undefined; - this._contextEstimator.capture(replacement ?? context, model); - return replacement; + this._contextEstimator.capture(prepared, model); + return prepared; }; } @@ -810,6 +840,40 @@ export class AgentSession { return this._contextEstimator.estimate(context, this.model, this.sessionManager.getBranch()); } + /** Match complete session semantics before rebuilding durable context. */ + private _getProactiveCompactionContext(): ProactiveCompactionContext | undefined { + const messagesByEntryId = new Map(); + let messageIndex = 0; + let firstPendingEntryId: string | undefined; + for (const entry of this.sessionManager.buildContextEntries()) { + const projected = sessionEntryToContextMessages(entry)[0]; + if (!projected) continue; + const message = this.agent.state.messages[messageIndex++]; + if (!message) return undefined; + if (entry.type === "message") { + if (!isDeepStrictEqual(message, projected)) return undefined; + } else { + // These timestamps are reconstructed from entry admission rather than the original message. + const { timestamp: _messageTimestamp, ...messageContent } = message; + const { timestamp: _entryTimestamp, ...entryContent } = projected; + if (!isDeepStrictEqual(messageContent, entryContent)) return undefined; + } + messagesByEntryId.set(entry.id, message); + // A completed response has observed earlier input; its pending tool batch remains indivisible. + if (message.role === "assistant") { + firstPendingEntryId = message.content.some((content) => content.type === "toolCall") ? entry.id : undefined; + } else if (message.role !== "compactionSummary" && message.role !== "branchSummary") { + firstPendingEntryId ??= entry.id; + } + } + if (messageIndex !== this.agent.state.messages.length) return undefined; + return { + branchIds: this.sessionManager.getBranch().map((entry) => entry.id), + messagesByEntryId, + firstPendingEntryId, + }; + } + private _installAgentNextTurnRefresh(): void { const previousPrepareNextTurnWithContext = this.agent.prepareNextTurnWithContext ?? @@ -3473,6 +3537,7 @@ export class AgentSession { willRetry: boolean; signal?: AbortSignal; deferCommit?: (transaction: CompactionAppendTransaction) => void; + preserveMessages?: Map; }): Promise { const transaction = appendCompactionTransaction( this.sessionManager, @@ -3483,8 +3548,10 @@ export class AgentSession { options.fromExtension, options.usage, ); - const sessionContext = this.sessionManager.buildSessionContext(); - this.agent.state.messages = sessionContext.messages; + this.agent.state.messages = this.sessionManager.buildContextEntries().flatMap((entry) => { + const preserved = options.preserveMessages?.get(entry.id); + return preserved ? [preserved] : sessionEntryToContextMessages(entry); + }); const estimatedTokensAfter = this._estimateCurrentContext(); if (this._extensionRunner) { @@ -3583,10 +3650,7 @@ export class AgentSession { ) { const willRetry = assistantMessage.stopReason !== "stop"; - if (!willRetry) { - if (this._proactiveCompactionSuppressed) return false; - return await this._runAutoCompaction("overflow", false, signal, deferCommit); - } + if (!willRetry) return await this._checkProactiveCompaction("overflow", signal, deferCommit); if (this._overflowRecoveryAttempted) { this._emit({ @@ -3611,17 +3675,24 @@ export class AgentSession { return await this._runAutoCompaction("overflow", willRetry, signal, deferCommit); } - // Case 2: Threshold uses the same normal-context estimate as the footer and preflight. + const queuedContinuation = await this._checkProactiveCompaction("threshold", signal, deferCommit); + // Proactive compaction does not recover a provider failure. + return assistantMessage?.stopReason !== "error" && queuedContinuation; + } + + private async _checkProactiveCompaction( + reason: "threshold" | "overflow", + signal?: AbortSignal, + deferCommit?: (transaction: CompactionAppendTransaction) => void, + ): Promise { + const contextWindow = this._pendingRequestContextWindow ?? this.model?.contextWindow ?? 0; if ( - !this._proactiveCompactionSuppressed && - contextWindow > 0 && - shouldCompact(contextTokens, contextWindow, settings) - ) { - const queuedContinuation = await this._runAutoCompaction("threshold", false, signal, deferCommit); - // Threshold compaction does not recover a provider failure. - return assistantMessage?.stopReason !== "error" && queuedContinuation; - } - return false; + this._proactiveCompactionSuppressed || + contextWindow <= 0 || + !shouldCompact(this._estimateCurrentContext(), contextWindow, this.settingsManager.getCompactionSettings()) + ) + return false; + return this._runAutoCompaction(reason, false, signal, deferCommit); } /** @@ -3641,7 +3712,7 @@ export class AgentSession { const result = outcome.status === "compacted" ? outcome.result : undefined; let errorMessage = outcome.status === "compacted" ? undefined : outcome.errorMessage; if (!willRetry) { - const contextWindow = this.model?.contextWindow ?? 0; + const contextWindow = this._pendingRequestContextWindow ?? this.model?.contextWindow ?? 0; const tokens = this._estimateCurrentContext(); if (result) result.estimatedTokensAfter = tokens; const stillAboveThreshold = @@ -3694,8 +3765,14 @@ export class AgentSession { if (this._disposePromise) throw new Error("Compaction cancelled"); const pathEntries = this.sessionManager.getBranch(); - - const preparation = prepareCompaction(pathEntries, settings); + const proactiveContext = willRetry ? undefined : this._getProactiveCompactionContext(); + if (!willRetry && !proactiveContext) { + return { + status: "unavailable", + errorMessage: "Auto-compaction unavailable: active messages do not match the selected session history.", + }; + } + const preparation = prepareCompaction(pathEntries, settings, proactiveContext?.firstPendingEntryId); if (!preparation) { return { status: "unavailable", @@ -3729,6 +3806,7 @@ export class AgentSession { } } + controller.signal.throwIfAborted(); let summary: string; let firstKeptEntryId: string; let tokensBefore: number; @@ -3767,6 +3845,34 @@ export class AgentSession { controller.signal.throwIfAborted(); + let preserveMessages: Map | undefined; + if (proactiveContext) { + const current = this._getProactiveCompactionContext(); + const branch = this.sessionManager.getBranch(); + if ( + !current || + proactiveContext.branchIds.some((id, index) => current.branchIds[index] !== id) || + branch.slice(proactiveContext.branchIds.length).some((entry) => entry.type === "compaction") + ) { + throw new Error("Selected session context changed during compaction."); + } + const keptIndex = pathEntries.findIndex((entry) => entry.id === firstKeptEntryId); + const protectedIndex = + current.firstPendingEntryId === undefined + ? branch.length + : branch.findIndex((entry) => entry.id === current.firstPendingEntryId); + const firstKeptMessage = branch.slice(keptIndex).flatMap(sessionEntryToContextMessages)[0]; + if ( + keptIndex < 0 || + pathEntries[keptIndex]?.type === "compaction" || + keptIndex > protectedIndex || + firstKeptMessage?.role === "toolResult" || + !this.sessionManager.buildContextEntries().some((entry) => entry.id === firstKeptEntryId) + ) { + throw new Error("Compaction cut would discard pending input or separate a tool result from its call."); + } + preserveMessages = current.messagesByEntryId; + } const result = await this._recordSuccessfulCompaction({ reason, summary, @@ -3778,6 +3884,7 @@ export class AgentSession { willRetry, signal: controller.signal, deferCommit, + preserveMessages, }); if (willRetry) { diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index dce3fc5b5..36b390f12 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -769,6 +769,7 @@ export interface CompactionPreparation { export function prepareCompaction( pathEntries: SessionEntry[], settings: CompactionSettings, + firstPendingEntryId?: string, ): CompactionPreparation | undefined { if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") { return undefined; @@ -794,7 +795,26 @@ export function prepareCompaction( const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; - const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + let cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + const pendingIndex = + firstPendingEntryId === undefined ? -1 : pathEntries.findIndex((entry) => entry.id === firstPendingEntryId); + // A large trailing batch may have no valid cut after its results; retain its call frontier. + const pendingTokens = + pendingIndex < 0 + ? 0 + : pathEntries + .slice(pendingIndex) + .flatMap(sessionEntryToContextMessages) + .reduce((sum, message) => sum + estimateTokens(message), 0); + if ( + pendingIndex >= boundaryStart && + (pendingIndex < cutPoint.firstKeptEntryIndex || pendingTokens >= settings.keepRecentTokens) + ) { + const turnStartIndex = isTurnStartEntry(pathEntries[pendingIndex]) + ? -1 + : findTurnStartIndex(pathEntries, pendingIndex, boundaryStart); + cutPoint = { firstKeptEntryIndex: pendingIndex, turnStartIndex, isSplitTurn: turnStartIndex !== -1 }; + } // Get UUID of first kept entry const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; diff --git a/packages/coding-agent/test/suite/agent-session-request-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-request-compaction.test.ts new file mode 100644 index 000000000..d1b8a5a60 --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-request-compaction.test.ts @@ -0,0 +1,611 @@ +import type { AgentContext, AgentTool } from "@diffexai/diffex-agent-core"; +import { + calculateContextTokens, + estimateMessageTokens, + estimateTextTokens, + fauxAssistantMessage, + fauxToolCall, +} from "@diffexai/diffex-ai"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { convertToLlm } from "../../src/core/messages.ts"; +import { createHarness, getMessageText, type Harness } from "./harness.ts"; + +function localTokens(context: AgentContext): number { + const tools = context.tools?.map(({ name, description, parameters }) => ({ name, description, parameters })) ?? []; + return ( + convertToLlm(context.messages).reduce((sum, message) => sum + estimateMessageTokens(message), 0) + + estimateTextTokens(context.systemPrompt) + + (tools.length ? estimateTextTokens(JSON.stringify(tools)) : 0) + ); +} + +describe("compaction before normal model requests", () => { + const harnesses: Harness[] = []; + afterEach(async () => { + vi.restoreAllMocks(); + // Faux converts callback assertion failures into error responses; surface them after settlement. + const errors = harnesses.flatMap((harness) => + harness.session.messages.filter((message) => message.role === "assistant" && message.stopReason === "error"), + ); + while (harnesses.length) await harnesses.pop()?.cleanup(); + expect(errors).toEqual([]); + }); + async function seed(options: Parameters[0] = {}): Promise { + const harness = await createHarness({ + tools: [], + models: [{ id: "faux-1", contextWindow: 30000 }], + settings: { compaction: { enabled: false, keepRecentTokens: 1 }, retry: { enabled: false } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", (event) => ({ + compaction: { + summary: "checkpoint", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + }, + })); + }, + ], + ...options, + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("old answer")]); + await harness.session.prompt("x".repeat(40000)); + harness.settingsManager.applyOverrides({ compaction: { enabled: true } }); + harness.events.length = 0; + return harness; + } + + it.each(["valid", "zero", "missing"] as const)( + "budgets complete tool batches with %s usage and recompacts only after renewed growth", + async (usage) => { + let executions = 0; + const measured: Array<[number | null | undefined, number]> = []; + const tool: AgentTool = { + name: "large", + label: "Large", + description: "large result", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "t".repeat(++executions <= 2 ? 10000 : 20000) }], + details: {}, + }), + }; + const harness = await seed({ + tools: [tool], + extensionFactories: [ + (pi) => { + pi.on("turn_end", (event) => { + if (event.message.role !== "assistant" || event.message.stopReason !== "toolUse") return; + if (usage === "missing") Reflect.deleteProperty(event.message, "usage"); + if (usage === "zero") event.message.usage = fauxAssistantMessage("").usage; + measured.push([ + harness.session.getContextUsage()?.tokens, + usage === "valid" + ? calculateContextTokens(event.message.usage) + + event.toolResults.reduce((sum, message) => sum + estimateMessageTokens(message), 0) + : localTokens(harness.session.state), + ]); + }); + pi.on("session_before_compact", (event) => ({ + compaction: { + summary: "checkpoint", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + }, + })); + }, + ], + }); + const originalUsage = harness.session.getSessionStats().tokens.total; + // Invalidate the older seed baseline without changing its billed usage. + if (usage !== "valid") harness.session.agent.state.model = { ...harness.getModel(), contextWindow: 30001 }; + const order: string[] = []; + harness.session.subscribe((event) => { + if (event.type === "compaction_end") { + order.push("compact"); + } + }); + const batch = () => + fauxAssistantMessage([fauxToolCall("large", {}), fauxToolCall("large", {})], { stopReason: "toolUse" }); + harness.setResponses([ + () => { + order.push("request"); + return batch(); + }, + (context) => { + order.push("request"); + expect(getMessageText(context.messages[0])).toContain("checkpoint"); + const calls = context.messages.flatMap((message) => + message.role === "assistant" + ? message.content.filter((block) => block.type === "toolCall").map((block) => block.id) + : [], + ); + const results = context.messages.filter((message) => message.role === "toolResult"); + expect(results.map((message) => message.toolCallId)).toEqual(calls); + expect(results).toHaveLength(2); + expect(harness.session.getContextUsage()?.tokens).toBe(localTokens(harness.session.state)); + expect(harness.session.messages).toEqual(harness.sessionManager.buildSessionContext().messages); + return batch(); + }, + (context) => { + order.push("request"); + expect(context.messages.filter((message) => message.role === "toolResult")).toHaveLength(2); + return fauxAssistantMessage("finished"); + }, + ]); + await harness.session.prompt("run tools"); + expect(harness.eventsOfType("compaction_end").map((event) => event.errorMessage)).toEqual([ + undefined, + undefined, + ]); + expect(order).toEqual(["request", "compact", "request", "compact", "request"]); + expect(executions).toBe(4); + expect(measured).toHaveLength(2); + for (const [actual, expected] of measured) expect(actual).toBe(expected); + if (usage !== "missing") { + const billed = harness.sessionManager + .getEntries() + .reduce( + (sum, entry) => + sum + + (entry.type === "message" && entry.message.role === "assistant" + ? calculateContextTokens(entry.message.usage) + : 0), + 0, + ); + expect(harness.session.getSessionStats().tokens.total).toBe(billed); + expect(billed).toBeGreaterThan(originalUsage); + } + expect(harness.eventsOfType("agent_start")).toHaveLength(1); + expect(harness.session.getLastAssistantText()).toBe("finished"); + }, + ); + + it.each([false, true])( + "preserves idle customs, fresh images and multiple admitted customs (preflight: %s)", + async (preflight) => { + const harness = await seed(); + harness.settingsManager.applyOverrides({ compaction: { reserveTokens: preflight ? 25000 : 15000 } }); + for (const text of ["idle one", "idle two"]) + await harness.session.sendCustomMessage({ + customType: text, + content: text, + display: false, + details: { text }, + }); + for (const text of ["next one", "next two"]) + await harness.session.sendCustomMessage( + { customType: text, content: text, display: false }, + { deliverAs: "nextTurn" }, + ); + const prompt = `fresh ${"p".repeat(preflight ? 1000 : 20000)}`; + harness.setResponses([ + (context) => { + for (const text of ["idle one", "idle two", "next one", "next two", prompt]) + expect(context.messages.filter((message) => getMessageText(message) === text)).toHaveLength(1); + expect( + context.messages + .flatMap((message) => + message.role === "user" && Array.isArray(message.content) ? message.content : [], + ) + .filter((part) => part.type === "image"), + ).toHaveLength(1); + expect(harness.eventsOfType("compaction_end")).toHaveLength(1); + return fauxAssistantMessage("done"); + }, + ]); + await harness.session.prompt(prompt, { images: [{ type: "image", data: "AA==", mimeType: "image/png" }] }); + const firstCompact = harness.events.findIndex((event) => event.type === "compaction_start"); + const admission = harness.events.findIndex((event) => event.type === "agent_start"); + expect(firstCompact < admission).toBe(preflight); + expect(harness.session.messages.map(({ timestamp, ...message }) => message)).toEqual( + harness.sessionManager.buildSessionContext().messages.map(({ timestamp, ...message }) => message), + ); + }, + ); + + it.each(["steer", "followUp"] as const)( + "checks delivered %s input before its request without duplicate delivery", + async (delivery) => { + const harness = await seed(); + const text = `queued ${"q".repeat(20000)}`; + harness.setResponses([ + async () => { + await harness.session[delivery](text, [{ type: "image", data: "AA==", mimeType: "image/png" }]); + return fauxAssistantMessage("first"); + }, + (context) => { + expect(harness.eventsOfType("compaction_end")).toHaveLength(1); + expect(context.messages.filter((message) => getMessageText(message) === text)).toHaveLength(1); + expect(context.messages.filter((message) => getMessageText(message) === text)).toEqual([ + expect.objectContaining({ + content: [ + { type: "text", text }, + { type: "image", data: "AA==", mimeType: "image/png" }, + ], + }), + ]); + return fauxAssistantMessage("second"); + }, + ]); + await harness.session.prompt("start"); + expect(harness.eventsOfType("agent_start")).toHaveLength(1); + expect(harness.session.pendingMessageCount).toBe(0); + }, + ); + + it.each([undefined, 100])("uses strict request threshold with reserve %s", async (reserveOverride) => { + for (const delta of [-1, 0, 1, "disabled", "zero", "negative"] as const) { + const harness = await createHarness({ + tools: [], + settings: { compaction: { ...(reserveOverride === undefined ? {} : { reserveTokens: reserveOverride }) } }, + }); + harnesses.push(harness); + const prompt = "p".repeat(2000); + const tokens = localTokens({ + ...harness.session.state, + messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: 0 }], + }); + const reserve = harness.settingsManager.getCompactionSettings().reserveTokens; + expect(reserve).toBe(reserveOverride ?? 16384); + harness.session.agent.state.model = { + ...harness.getModel(), + contextWindow: + delta === "zero" + ? 0 + : delta === "negative" + ? -1 + : tokens + reserve - (typeof delta === "number" ? delta : 1), + }; + if (delta === "disabled") harness.session.setAutoCompactionEnabled(false); + harness.setResponses([ + () => { + expect(harness.eventsOfType("compaction_end")).toHaveLength(delta === 1 ? 1 : 0); + expect(harness.session.getContextUsage()?.tokens).toBe( + delta === "zero" || delta === "negative" ? undefined : tokens, + ); + harness.session.setAutoCompactionEnabled(false); + return fauxAssistantMessage("done"); + }, + ]); + await harness.session.prompt(prompt); + } + }); + + it("uses the loop-selected smaller window for eligibility and insufficient reduction", async () => { + const harness = await seed(); + harness.session.agent.state.model = { ...harness.getModel(), contextWindow: 20000 }; + harness.settingsManager.applyOverrides({ compaction: { reserveTokens: 0 } }); + harness.session.subscribe((event) => { + if (event.type === "agent_start") + harness.session.agent.state.model = { ...harness.getModel(), contextWindow: 200000 }; + }); + harness.setResponses([ + (context, _options, _state, model) => { + expect(model.contextWindow).toBe(20000); + expect(context.messages.filter((message) => getMessageText(message) === "p".repeat(90000))).toHaveLength(1); + expect(context.messages.some((message) => getMessageText(message).includes("checkpoint"))).toBe(true); + expect(harness.eventsOfType("compaction_end")[0].errorMessage).toContain("remains above"); + expect(harness.session.getContextUsage()?.contextWindow).toBe(200000); + return fauxAssistantMessage("done"); + }, + ]); + await harness.session.prompt("p".repeat(90000)); + expect(harness.eventsOfType("compaction_end")).toHaveLength(1); + }); + it.each([false, true])( + "composes a previous hook once and preserves effective callback configuration (callback: %s)", + async (callback) => { + let enabled = false; + let calls = 0; + let pending: AgentContext["messages"][number] | undefined; + const tool: AgentTool = { + name: "replacement", + label: "Replacement", + description: "d".repeat(6000), + parameters: Type.Object({}), + execute: async () => ({ content: [], details: {} }), + }; + const harness = await seed({ + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", (event) => ({ + compaction: { + summary: "checkpoint", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + }, + })); + pi.on("session_compact", () => { + if (callback) { + pi.setActiveTools([]); + harness.session.agent.state.systemPrompt = "callback instructions"; + } + }); + }, + ], + prepareRequest: (context) => { + calls++; + if (!enabled) return; + const messages = structuredClone(context.messages); + pending = messages.at(-1); + return { + ...context, + messages, + systemPrompt: `replacement instructions ${"s".repeat(12000)}`, + tools: [tool], + }; + }, + }); + enabled = true; + harness.setResponses([ + (context) => { + expect(calls).toBe(2); + expect(context.systemPrompt).toContain(callback ? "callback instructions" : "replacement instructions"); + expect(context.tools?.map((item) => item.name)).toEqual(callback ? [] : ["replacement"]); + expect(harness.session.messages.at(-1)).toBe(pending); + expect(harness.session.getContextUsage()?.tokens).toBe(localTokens(harness.session.state)); + expect(harness.eventsOfType("compaction_end")).toEqual([ + expect.objectContaining({ result: expect.any(Object) }), + ]); + expect(harness.eventsOfType("compaction_end")[0].errorMessage).toBeUndefined(); + return fauxAssistantMessage("done"); + }, + ]); + await harness.session.prompt("new input"); + expect(harness.session.state.errorMessage).toBeUndefined(); + expect(harness.session.getLastAssistantText()).toBe("done"); + expect(harness.eventsOfType("agent_start")).toHaveLength(1); + }, + ); + + it.each(["add", "remove", "reorder", "custom details", "excluded bash"] as const)( + "warns and preserves an unmappable %s hook replacement across later proactive sites", + async (kind) => { + let enabled = false; + let changed: AgentContext["messages"] | undefined; + const harness = await seed({ + prepareRequest: (context) => { + if (!enabled || changed) return; + const messages = structuredClone(context.messages); + if (kind === "add") messages.push({ role: "user", content: "hook-only input", timestamp: 1 }); + if (kind === "remove") messages.splice(1, 1); + if (kind === "reorder") [messages[0], messages[1]] = [messages[1], messages[0]]; + if (kind === "custom details") + for (const message of messages) if (message.role === "custom") message.details = { modified: true }; + if (kind === "excluded bash") + for (const message of messages) + if (message.role === "bashExecution") message.output = "modified hidden output"; + changed = messages; + return { ...context, messages }; + }, + }); + await harness.session.sendCustomMessage({ + customType: "metadata", + content: "custom", + display: false, + details: { original: true }, + }); + const bash = { + role: "bashExecution" as const, + command: "echo", + output: "original", + exitCode: 0, + cancelled: false, + truncated: false, + excludeFromContext: true, + timestamp: 1, + }; + harness.sessionManager.appendMessage(bash); + harness.session.agent.state.messages.push(bash); + enabled = true; + harness.setResponses([ + async (context) => { + expect(harness.eventsOfType("compaction_end")[0].errorMessage).toContain("do not match"); + expect(context.messages).toEqual(convertToLlm(changed!)); + await harness.session.followUp("queued continuation"); + return fauxAssistantMessage("first"); + }, + () => { + expect(harness.eventsOfType("compaction_end")).toHaveLength(1); + return fauxAssistantMessage("second"); + }, + ]); + await harness.session.prompt("p".repeat(20000)); + expect(harness.session.messages.slice(0, changed!.length)).toEqual(changed); + expect(harness.sessionManager.getEntries().some((entry) => entry.type === "compaction")).toBe(false); + harness.setResponses([ + () => { + expect(harness.eventsOfType("compaction_end")).toHaveLength(2); + return fauxAssistantMessage("next idle"); + }, + ]); + await harness.session.prompt("idle"); + expect(harness.eventsOfType("compaction_end").map((event) => event.errorMessage)).toEqual([ + expect.stringContaining("do not match"), + expect.stringContaining("do not match"), + ]); + }, + ); + + it.each(["failure", "independent", "parent", "unsafe tool cut"] as const)( + "bounds request-time %s and preserves cancellation ownership", + async (kind) => { + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + let normal = 0; + const tool: AgentTool = { + name: "large", + label: "Large", + description: "large result", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "t".repeat(20000) }], details: {} }), + }; + const harness = await seed({ + tools: [tool], + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => { + started(); + if (kind === "unsafe tool cut") + return { + compaction: { + summary: "invalid cut", + firstKeptEntryId: event.branchEntries.at(-1)!.id, + tokensBefore: event.preparation.tokensBefore, + }, + }; + if (kind === "parent" || kind === "independent") + await new Promise((resolve) => + event.signal.addEventListener("abort", () => resolve(), { once: true }), + ); + }); + }, + ], + }); + const batch = () => { + normal++; + return fauxAssistantMessage([fauxToolCall("large", {})], { stopReason: "toolUse" }); + }; + harness.setResponses([ + batch, + ...(kind === "failure" + ? [fauxAssistantMessage("", { stopReason: "error", errorMessage: "summary failed" })] + : []), + batch, + async () => { + normal++; + await harness.session.followUp("queued after failed compaction"); + return fauxAssistantMessage("final"); + }, + () => { + normal++; + return fauxAssistantMessage("queued answer"); + }, + ]); + const prompt = harness.session.prompt("run"); + await ready; + if (kind === "parent") await harness.session.abort(); + if (kind === "independent") harness.session.abortCompaction(); + await prompt; + expect(normal).toBe(kind === "parent" ? 1 : 4); + expect(harness.eventsOfType("compaction_end")).toHaveLength(1); + expect(harness.eventsOfType("compaction_end")[0]).toMatchObject({ + result: undefined, + aborted: kind === "parent" || kind === "independent", + errorMessage: expect.any(String), + }); + expect(harness.eventsOfType("agent_start")).toHaveLength(1); + expect(harness.session.isIdle).toBe(true); + }, + ); + + it("bypasses the request hook during standalone summaries and delivers input queued during summarization once", async () => { + let hookCalls = 0; + const harness = await seed({ + extensionFactories: [], + prepareRequest: () => { + hookCalls++; + return undefined; + }, + }); + harness.setResponses([ + async () => { + expect(hookCalls).toBe(2); + expect(harness.session.isCompacting).toBe(true); + await harness.session.followUp("during summary"); + return fauxAssistantMessage("standalone checkpoint"); + }, + (context) => { + expect(hookCalls).toBe(2); + expect(getMessageText(context.messages[0])).toContain("standalone checkpoint"); + expect(context.messages.some((message) => getMessageText(message) === "during summary")).toBe(false); + return fauxAssistantMessage("first"); + }, + (context) => { + expect(hookCalls).toBe(3); + expect(context.messages.filter((message) => getMessageText(message) === "during summary")).toHaveLength(1); + return fauxAssistantMessage("second"); + }, + ]); + await harness.session.prompt("p".repeat(20000)); + expect(harness.eventsOfType("compaction_end")).toHaveLength(1); + expect(harness.eventsOfType("agent_start")).toHaveLength(1); + expect(harness.faux.state.callCount).toBe(4); + }); + + it.each(["tail", "branch", "compaction", "unsafe cut"] as const)( + "validates selected history during asynchronous preparation: %s", + async (kind) => { + const harness = await seed({ + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => { + await Promise.resolve(); + const manager = harness.sessionManager; + if (kind === "tail") { + manager.appendCustomMessageEntry("tail", "appended tail", false, { retained: true }); + harness.session.agent.state.messages.push({ + role: "custom", + customType: "tail", + content: "appended tail", + display: false, + details: { retained: true }, + timestamp: 1, + }); + } + if (kind === "branch") { + manager.branch(manager.getBranch()[0].id); + harness.session.agent.state.messages = manager.buildSessionContext().messages; + } + if (kind === "compaction") { + manager.appendCompaction("concurrent checkpoint", event.preparation.firstKeptEntryId, 1); + harness.session.agent.state.messages = manager.buildSessionContext().messages; + } + return { + compaction: { + summary: "scheduled checkpoint", + firstKeptEntryId: + kind === "unsafe cut" + ? event.branchEntries.at(-1)!.id + : event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + }, + }; + }); + }, + ], + }); + await harness.session.sendCustomMessage({ customType: "pending", content: "pending custom", display: false }); + harness.setResponses([ + (context) => { + const event = harness.eventsOfType("compaction_end")[0]; + if (kind === "tail") { + expect(event.errorMessage).toBeUndefined(); + expect( + context.messages.filter((message) => getMessageText(message) === "appended tail"), + ).toHaveLength(1); + } else + expect(event.errorMessage).toContain( + kind === "unsafe cut" ? "discard pending input" : "Selected session context changed", + ); + if (kind !== "branch") + expect( + context.messages.filter((message) => getMessageText(message) === "pending custom"), + ).toHaveLength(1); + return fauxAssistantMessage("done"); + }, + ]); + await harness.session.prompt("p".repeat(20000)); + expect(harness.session.state.errorMessage).toBeUndefined(); + expect(harness.session.getLastAssistantText()).toBe("done"); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength( + kind === "tail" || kind === "compaction" ? 1 : 0, + ); + }, + ); +});